From 90d6322cb3d8b66cfdcb7795958ef00ad74ae892 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 19 Aug 2026 15:19:10 +0000 Subject: [PATCH 1/8] fix(coderd): avoid 500 when generating chat title from rename dialog Clicking Generate in the rename dialog (or Regenerate) runs manual chat title generation under an internal deadline. When the model call times out, the error ("context deadline exceeded") bubbled up to the propose and regenerate handlers and fell through to a raw 500 that leaked the wrapped error chain. Translate context.DeadlineExceeded to 504 with a friendly retry message and context.Canceled to 499 (client closed request) in both the propose-title and regenerate-title handlers, matching by errors.Is so the wrapped deadline error is detected. Genuine, unrelated failures keep their existing 500 surface. Generated by Coder Agents. --- coderd/exp_chats.go | 34 ++++++++++++++++++ coderd/exp_chats_internal_test.go | 57 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8b3f69ee5c0c4..13a67815fa034 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -146,6 +146,34 @@ func maybeWriteChatUsageLimitError(ctx context.Context, rw http.ResponseWriter, return true } +// statusClientClosedRequest is nginx's non-standard 499 status code, +// used here to distinguish a client-initiated cancel from a server- +// side failure when the manual title generation context is canceled. +const statusClientClosedRequest = 499 + +// maybeWriteManualTitleTimeoutErr translates context-cancel or +// context-deadline errors from the manual title pipeline into friendly +// 499/504 responses instead of a raw 500 that leaks the wrapped error +// chain. Manual title generation runs a model call under an internal +// deadline; when it expires (or the caller disconnects) the error +// bubbles up wrapped, so match with errors.Is. Returns true when a +// response was written. +func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool { + switch { + case errors.Is(err, context.Canceled): + httpapi.Write(ctx, rw, statusClientClosedRequest, codersdk.Response{ + Message: "Title generation was canceled.", + }) + return true + case errors.Is(err, context.DeadlineExceeded): + httpapi.Write(ctx, rw, http.StatusGatewayTimeout, codersdk.Response{ + Message: "Title generation timed out. Try again or rename manually.", + }) + return true + } + return false +} + // requireChatDaemon reports whether the chat daemon exists, writing a 503 // Service Unavailable with a remediation message when it does not. The // daemon is nil when the in-memory AI Gateway is disabled by deployment @@ -3592,6 +3620,9 @@ func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) { if maybeWriteChatUsageLimitError(ctx, rw, err) { return } + if maybeWriteManualTitleTimeoutErr(ctx, rw, err) { + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to regenerate chat title.", Detail: err.Error(), @@ -3641,6 +3672,9 @@ func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) { if maybeWriteChatUsageLimitError(ctx, rw, err) { return } + if maybeWriteManualTitleTimeoutErr(ctx, rw, err) { + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to generate chat title.", Detail: err.Error(), diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index da8ba38286c8a..a079821f77e81 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "encoding/json" "net/http" @@ -517,3 +518,59 @@ func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) { "isZeroChatModelCallConfig ignores field %s", field.Name) } } + +func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + wantWrote bool + wantStatus int + wantMessage string + }{ + { + // The deadline error is wrapped several layers deep by the + // title pipeline, so the handler must match with errors.Is. + name: "DeadlineExceededMapsTo504", + err: xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded), + wantWrote: true, + wantStatus: http.StatusGatewayTimeout, + wantMessage: "Title generation timed out. Try again or rename manually.", + }, + { + name: "CanceledMapsTo499", + err: xerrors.Errorf("generate manual title: %w", context.Canceled), + wantWrote: true, + wantStatus: statusClientClosedRequest, + wantMessage: "Title generation was canceled.", + }, + { + // Unrelated errors must fall through so the handler keeps + // its existing 500 surface for genuine failures. + name: "UnrelatedErrorFallsThrough", + err: xerrors.New("something else"), + wantWrote: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rw := httptest.NewRecorder() + wrote := maybeWriteManualTitleTimeoutErr(context.Background(), rw, tt.err) + require.Equal(t, tt.wantWrote, wrote) + if !tt.wantWrote { + require.Equal(t, http.StatusOK, rw.Code, "must not write a response when err is unrelated") + return + } + require.Equal(t, tt.wantStatus, rw.Code) + + var resp codersdk.Response + require.NoError(t, json.NewDecoder(rw.Body).Decode(&resp)) + require.Equal(t, tt.wantMessage, resp.Message) + require.Empty(t, resp.Detail, "translated copy must not leak the raw error detail") + }) + } +} From 3584ca05052198cfbc53e3fb2617636878641702 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 13:42:45 +0000 Subject: [PATCH 2/8] feat(coderd/x/chatd): fall back across models for manual title generation Manual chat title generation (rename dialog Generate and Regenerate) previously ran a single model under a 30s deadline. A slow provider hit the deadline and failed the request, which the handler now surfaces as a 504. Walk the enabled preferred short-text models the user has credentials for, then the chat's own model, bounded by an overall 90s budget with a 30s per-attempt deadline. Fall through to the next candidate only on per-attempt timeout or chatretry-classified transient errors; stop on non-retryable errors (auth, config) so real failures still surface. When the context is canceled after a candidate has already failed, the walker surfaces ctx.Err() instead of the stale candidate error, so the handler maps caller cancellation to 499 and overall-budget expiry to 504 rather than a stale 500. Fallback models resolve lazily so a run that succeeds on the first candidate never constructs the others. Replaces the single-model resolveManualTitleModel (and the now-unused selectPreferredConfiguredShortTextModelConfig) with resolveManualTitleCandidates. Generated by Coder Agents. --- coderd/x/chatd/chatd.go | 144 +++++++------ coderd/x/chatd/quickgen.go | 17 +- coderd/x/chatd/quickgen_internal_test.go | 18 +- .../x/chatd/title_override_internal_test.go | 50 +++-- coderd/x/chatd/titlewalk.go | 149 +++++++++++++ coderd/x/chatd/titlewalk_internal_test.go | 200 ++++++++++++++++++ 6 files changed, 483 insertions(+), 95 deletions(-) create mode 100644 coderd/x/chatd/titlewalk.go create mode 100644 coderd/x/chatd/titlewalk_internal_test.go diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 37e5fcc8570dd..98e9f9527328e 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2530,36 +2530,52 @@ func (p *Server) generateManualTitleCandidate( } modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID} - model, modelConfig, err := p.resolveManualTitleModel(ctx, store, chat, modelOpts) + candidates, err := p.resolveManualTitleCandidates(ctx, store, chat, modelOpts) if err != nil { return "", err } - titleCtx := ctx - titleModel := model - finishDebugRun := func(error) {} - if debugSvc := p.debugService(); debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID) { - titleCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun( - ctx, - debugSvc, - chat, - modelConfig, - modelOpts, + // Bound the whole candidate walk so a slow first provider cannot starve + // the fallbacks. Each attempt is separately bounded inside + // generateManualTitle (titleAttemptTimeout). + overallCtx, cancel := context.WithTimeout(ctx, titleOverallTimeout) + defer cancel() + + debugSvc := p.debugService() + debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, chat.ID, chat.OwnerID) + + attempt := func(attemptCtx context.Context, cand manualTitleCandidate, model chatprovider.Model) (string, error) { + titleModel := model + finishDebugRun := func(error) {} + if debugEnabled { + attemptCtx, titleModel, finishDebugRun = p.prepareManualTitleDebugRun( + attemptCtx, + debugSvc, + chat, + cand.config, + modelOpts, + messages, + model, + ) + } + + title, genErr := generateManualTitle( + attemptCtx, messages, - model, + pasteText, + titleModel.LanguageModel(), + p.titleGenerationProviderOptions(ctx, titleModel, cand.config), ) + finishDebugRun(genErr) + if genErr != nil { + return "", xerrors.Errorf("generate manual title: %w", genErr) + } + return title, nil } - title, err := generateManualTitle( - titleCtx, - messages, - pasteText, - titleModel.LanguageModel(), - p.titleGenerationProviderOptions(ctx, titleModel, modelConfig), - ) - finishDebugRun(err) + title, _, err := p.walkManualTitleCandidates(overallCtx, chat, candidates, attempt) if err != nil { - return "", xerrors.Errorf("generate manual title: %w", err) + return "", err } return title, nil @@ -2776,12 +2792,22 @@ func deriveChatDebugSeed(messages []database.ChatMessage) ( return triggerMessageID, historyTipMessageID, triggerLabel } -func (p *Server) resolveManualTitleModel( +// resolveManualTitleCandidates returns the ordered list of model candidates to +// try for manual title generation. When a deployment override pins the title +// model it is honored exclusively (matching the auto-title path). Otherwise the +// primary is the preferred short-text model the user has credentials for (or +// the chat's own model), followed by the remaining enabled preferred short-text +// models as lazy fallbacks, so a slow or unavailable provider does not fail the +// request. Fallback models are resolved lazily: the common case where the +// primary succeeds never constructs clients it does not use. +func (p *Server) resolveManualTitleCandidates( ctx context.Context, store database.Store, chat database.Chat, modelOpts modelBuildOptions, -) (chatprovider.Model, database.ChatModelConfig, error) { +) ([]manualTitleCandidate, error) { + // A deployment override pins the title model; honor it exclusively and do + // not fall through to other models. overrideConfig, overrideModel, _, overrideSet, overrideErr := p.resolveTitleGenerationModelOverride( ctx, chat, @@ -2789,7 +2815,7 @@ func (p *Server) resolveManualTitleModel( ) if overrideErr != nil { if overrideSet { - return chatprovider.Model{}, database.ChatModelConfig{}, xerrors.Errorf( + return nil, xerrors.Errorf( "resolve manual title generation model override: %w", overrideErr, ) @@ -2799,49 +2825,45 @@ func (p *Server) resolveManualTitleModel( slog.Error(overrideErr), ) } else if overrideSet { - return overrideModel, overrideConfig, nil - } - - configs, err := store.GetEnabledChatModelConfigs(ctx) - if err != nil { + return []manualTitleCandidate{ + newResolvedManualTitleCandidate(overrideConfig, overrideModel), + }, nil + } + + // Non-override: try every enabled preferred short-text model the user has + // credentials for, resolved lazily so a run that succeeds on the first + // candidate never constructs the others. A single GetEnabledChatModelConfigs + // lookup feeds the whole list. + var candidates []manualTitleCandidate + seen := make(map[uuid.UUID]bool) + if configs, cfgErr := store.GetEnabledChatModelConfigs(ctx); cfgErr != nil { p.logger.Debug(ctx, "failed to list manual title model configs", slog.F("chat_id", chat.ID), - slog.Error(err), + slog.Error(cfgErr), ) - return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) - } - - config, ok := selectPreferredConfiguredShortTextModelConfig(configs) - if !ok { - return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) + } else { + for _, config := range selectAllConfiguredShortTextModelConfigs(configs) { + if config.ID != uuid.Nil { + if seen[config.ID] { + continue + } + seen[config.ID] = true + } + candidates = append(candidates, p.newLazyManualTitleCandidate(chat, config, modelOpts)) + } } - route, err := p.resolveModelRouteForConfig(ctx, chat.OwnerID, config) - if err != nil { - p.logger.Debug(ctx, "manual title preferred model unavailable", - slog.F("chat_id", chat.ID), - slog.F("model", config.Model), - slog.Error(err), - ) - return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) - } - model, err := p.newModel(ctx, modelClientRequest{ - Chat: chat, - ModelName: config.Model, - UserAgent: chatprovider.UserAgent(), - ExtraHeaders: chatprovider.CoderHeaders(chat), - ConfigOptions: config.Options, - }, route, modelOpts) - if err != nil { - p.logger.Debug(ctx, "manual title preferred model unavailable", - slog.F("chat_id", chat.ID), - slog.F("model", config.Model), - slog.Error(err), - ) - return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) + // When no preferred model is configured, fall back to the chat's own model. + // Resolved eagerly so its error (e.g. ErrNoDefaultChatModelConfig) surfaces + // to the handler's existing status mapping instead of a generic walk error. + if len(candidates) == 0 { + fallbackModel, fallbackConfig, fallbackErr := p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) + if fallbackErr != nil { + return nil, fallbackErr + } + candidates = append(candidates, newResolvedManualTitleCandidate(fallbackConfig, fallbackModel)) } - - return model, config, nil + return candidates, nil } func (p *Server) resolveFallbackManualTitleModel( diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index ccc73b3ed30d6..917829cf5e312 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -146,9 +146,15 @@ type shortTextCandidate struct { configOptions json.RawMessage } -func selectPreferredConfiguredShortTextModelConfig( +// selectAllConfiguredShortTextModelConfigs returns every enabled model config +// that matches a preferredTitleModels entry, ordered by the preferredTitleModels +// priority list. Each preferred (provider, model) pair contributes at most one +// config. The manual title candidate walk uses this so a slow or unavailable +// provider can fall through to another configured preferred model. +func selectAllConfiguredShortTextModelConfigs( configs []database.GetEnabledChatModelConfigsRow, -) (database.ChatModelConfig, bool) { +) []database.ChatModelConfig { + out := make([]database.ChatModelConfig, 0, len(preferredTitleModels)) for _, preferred := range preferredTitleModels { for _, config := range configs { if chatprovider.NormalizeProvider(config.Provider) != preferred.provider { @@ -157,10 +163,11 @@ func selectPreferredConfiguredShortTextModelConfig( if !strings.EqualFold(strings.TrimSpace(config.ChatModelConfig.Model), preferred.model) { continue } - return config.ChatModelConfig, true + out = append(out, config.ChatModelConfig) + break } } - return database.ChatModelConfig{}, false + return out } func normalizeShortTextOutput(text string) string { @@ -946,7 +953,7 @@ func generateManualTitle( latestUserMsg, ) - titleCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + titleCtx, cancel := context.WithTimeout(ctx, titleAttemptTimeout) defer cancel() userInput := strings.TrimSpace(latestUserMsg) diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index 7d0bef5defc97..6aadfd16ae540 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -789,10 +789,10 @@ func Test_generateManualTitle_ErrorsOnEmptyNormalizedTitle(t *testing.T) { require.ErrorContains(t, err, "generated title was empty") } -func Test_selectPreferredConfiguredShortTextModelConfig(t *testing.T) { +func Test_selectAllConfiguredShortTextModelConfigs(t *testing.T) { t.Parallel() - t.Run("chooses the highest-priority configured lightweight model", func(t *testing.T) { + t.Run("returns preferred configs ordered by priority", func(t *testing.T) { t.Parallel() configs := []database.GetEnabledChatModelConfigsRow{ @@ -801,20 +801,20 @@ func Test_selectPreferredConfiguredShortTextModelConfig(t *testing.T) { {ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1"}, Provider: "openai"}, } - got, ok := selectPreferredConfiguredShortTextModelConfig(configs) - require.True(t, ok) - require.Equal(t, preferredTitleModels[1].model, got.Model) + got := selectAllConfiguredShortTextModelConfigs(configs) + require.Len(t, got, 2) + require.Equal(t, preferredTitleModels[1].model, got[0].Model) + require.Equal(t, preferredTitleModels[2].model, got[1].Model) }) - t.Run("returns false when no preferred lightweight model is configured", func(t *testing.T) { + t.Run("returns empty when no preferred lightweight model is configured", func(t *testing.T) { t.Parallel() - got, ok := selectPreferredConfiguredShortTextModelConfig([]database.GetEnabledChatModelConfigsRow{{ + got := selectAllConfiguredShortTextModelConfigs([]database.GetEnabledChatModelConfigsRow{{ ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1"}, Provider: "openai", }}) - require.False(t, ok) - require.Equal(t, database.ChatModelConfig{}, got) + require.Empty(t, got) }) } diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index e109ca7652d30..e8e42d4e27692 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -367,7 +367,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback( require.False(t, ok) } -func TestResolveManualTitleModel_TitleGenerationOverrideUnset(t *testing.T) { +func TestResolveManualTitleCandidates_TitleGenerationOverrideUnset(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -391,18 +391,21 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnset(t *testing.T) { db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() server := titleOverrideTestServer(db, logger) - model, gotConfig, err := server.resolveManualTitleModel( + candidates, err := server.resolveManualTitleCandidates( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) + require.NotEmpty(t, candidates) + model, err := candidates[0].resolve(ctx) + require.NoError(t, err) require.True(t, model.Valid()) - require.Equal(t, preferredConfig, gotConfig) + require.Equal(t, preferredConfig, candidates[0].config) } -func TestResolveManualTitleModel_TitleGenerationOverrideUnsetAIProvider(t *testing.T) { +func TestResolveManualTitleCandidates_TitleGenerationOverrideUnsetAIProvider(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -440,18 +443,21 @@ func TestResolveManualTitleModel_TitleGenerationOverrideUnsetAIProvider(t *testi }}, nil).AnyTimes() server := titleOverrideTestServer(db, logger) - model, gotConfig, err := server.resolveManualTitleModel( + candidates, err := server.resolveManualTitleCandidates( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) + require.NotEmpty(t, candidates) + model, err := candidates[0].resolve(ctx) + require.NoError(t, err) require.True(t, model.Valid()) - require.Equal(t, preferredConfig, gotConfig) + require.Equal(t, preferredConfig, candidates[0].config) } -func TestResolveManualTitleModel_TitleGenerationOverrideReadDBError(t *testing.T) { +func TestResolveManualTitleCandidates_TitleGenerationOverrideReadDBError(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -475,18 +481,21 @@ func TestResolveManualTitleModel_TitleGenerationOverrideReadDBError(t *testing.T db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() server := titleOverrideTestServer(db, logger) - model, gotConfig, err := server.resolveManualTitleModel( + candidates, err := server.resolveManualTitleCandidates( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) + require.NotEmpty(t, candidates) + model, err := candidates[0].resolve(ctx) + require.NoError(t, err) require.True(t, model.Valid()) - require.Equal(t, preferredConfig, gotConfig) + require.Equal(t, preferredConfig, candidates[0].config) } -func TestResolveManualTitleModel_TitleGenerationOverrideSetUsable(t *testing.T) { +func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUsable(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -507,18 +516,21 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUsable(t *testing.T) }}, nil).AnyTimes() server := titleOverrideTestServer(db, logger) - model, gotConfig, err := server.resolveManualTitleModel( + candidates, err := server.resolveManualTitleCandidates( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) + require.NotEmpty(t, candidates) + model, err := candidates[0].resolve(ctx) + require.NoError(t, err) require.True(t, model.Valid()) - require.Equal(t, overrideConfig, gotConfig) + require.Equal(t, overrideConfig, candidates[0].config) } -func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *testing.T) { +func TestResolveManualTitleCandidates_TitleGenerationOverrideMissingCredentials(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -541,7 +553,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return(nil, nil).AnyTimes() server := titleOverrideTestServer(db, logger) - model, gotConfig, err := server.resolveManualTitleModel( + candidates, err := server.resolveManualTitleCandidates( ctx, db, chat, @@ -550,8 +562,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *te require.Error(t, err) require.ErrorContains(t, err, "resolve manual title generation model override") require.ErrorContains(t, err, "credentials are unavailable") - require.False(t, model.Valid()) - require.Equal(t, database.ChatModelConfig{}, gotConfig) + require.Nil(t, candidates) } func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) { @@ -623,7 +634,7 @@ func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) { require.Equal(t, apiKeyID, testutil.RequireReceive(ctx, t, seenAPIKeyID)) } -func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T) { +func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUnusable(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -637,7 +648,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T db.EXPECT().GetChatModelConfigByID(gomock.Any(), overrideConfig.ID).Return(overrideConfig, nil) server := titleOverrideTestServer(db, logger) - model, gotConfig, err := server.resolveManualTitleModel( + candidates, err := server.resolveManualTitleCandidates( ctx, db, chat, @@ -646,8 +657,7 @@ func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T require.Error(t, err) require.ErrorContains(t, err, "resolve manual title generation model override") require.ErrorContains(t, err, "title generation model override is unavailable") - require.False(t, model.Valid()) - require.Equal(t, database.ChatModelConfig{}, gotConfig) + require.Nil(t, candidates) } func TestParseModelOverride(t *testing.T) { diff --git a/coderd/x/chatd/titlewalk.go b/coderd/x/chatd/titlewalk.go new file mode 100644 index 0000000000000..7a67a2e2015c5 --- /dev/null +++ b/coderd/x/chatd/titlewalk.go @@ -0,0 +1,149 @@ +package chatd + +import ( + "context" + "errors" + "time" + + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/coderd/x/chatd/chatretry" +) + +const ( + // titleAttemptTimeout bounds a single model call for manual title + // generation (applied inside generateManualTitle). A slow or hung + // provider is killed at this deadline so the candidate walk can fall + // through to the next model instead of burning the overall budget. + titleAttemptTimeout = 30 * time.Second + // titleOverallTimeout bounds the entire manual title candidate walk so a + // slow first provider cannot starve the fallbacks. Multiple per-attempt + // deadlines fit within it. + titleOverallTimeout = 90 * time.Second +) + +// manualTitleCandidate is one model the manual title walk can try. resolve +// builds the runnable model lazily so the common case (the first candidate +// succeeds) never constructs clients it does not use, and unit tests that +// only exercise the primary candidate do not force fallback resolution. +type manualTitleCandidate struct { + config database.ChatModelConfig + resolve func(ctx context.Context) (chatprovider.Model, error) +} + +// manualTitleFallThrough reports whether a failed manual title attempt should +// advance to the next candidate. Only per-attempt deadline expiry and +// chatretry-classified transient errors fall through; non-retryable errors +// (auth, config) stop the walk so the real failure surfaces instead of +// silently trying every provider until the overall budget is exhausted. +func manualTitleFallThrough(err error) bool { + if errors.Is(err, context.DeadlineExceeded) { + return true + } + return chatretry.IsRetryable(err) +} + +// walkManualTitleCandidates tries each candidate in order, falling through on +// transient or per-attempt-timeout failures per manualTitleFallThrough. It +// returns the first success along with the winning candidate's config. +// +// When ctx is canceled or its overall deadline expires, walkManualTitleCandidates +// surfaces ctx.Err() rather than the last candidate's (stale) error, so the +// handler maps caller cancellation to 499 and overall-budget expiry to 504 +// instead of leaking a wrapped provider 500. This includes the window where a +// candidate has already failed and ctx is canceled before the next attempt. +func (p *Server) walkManualTitleCandidates( + ctx context.Context, + chat database.Chat, + candidates []manualTitleCandidate, + attempt func(ctx context.Context, cand manualTitleCandidate, model chatprovider.Model) (string, error), +) (string, database.ChatModelConfig, error) { + var lastErr error + var lastConfig database.ChatModelConfig + for _, cand := range candidates { + // Overall budget exhausted or caller canceled between attempts. + if ctxErr := ctx.Err(); ctxErr != nil { + return "", lastConfig, ctxErr + } + + model, err := cand.resolve(ctx) + if err != nil { + // Model construction is best-effort: log and try the next + // candidate rather than failing the whole request. + p.logger.Debug(ctx, "manual title candidate unavailable", + slog.F("chat_id", chat.ID), + slog.F("model", cand.config.Model), + slog.Error(err), + ) + lastErr = err + lastConfig = cand.config + continue + } + + title, err := attempt(ctx, cand, model) + if err == nil { + return title, cand.config, nil + } + lastErr = err + lastConfig = cand.config + + // Caller-side cancellation or overall-budget expiry wins over the + // candidate's own error so the handler maps to 499/504 instead of a + // stale provider 500. Checked here (not only at the top of the loop) + // to cover cancellation in the window after this attempt failed. + if ctxErr := ctx.Err(); ctxErr != nil { + return "", lastConfig, ctxErr + } + if !manualTitleFallThrough(err) { + return "", lastConfig, lastErr + } + } + if lastErr == nil { + lastErr = xerrors.New("no manual title model candidates available") + } + return "", lastConfig, lastErr +} + +// newResolvedManualTitleCandidate wraps an already-resolved model as a +// candidate whose resolve step is a no-op. +func newResolvedManualTitleCandidate( + config database.ChatModelConfig, + model chatprovider.Model, +) manualTitleCandidate { + return manualTitleCandidate{ + config: config, + resolve: func(context.Context) (chatprovider.Model, error) { + return model, nil + }, + } +} + +// newLazyManualTitleCandidate builds a candidate whose model is constructed on +// first use, so fallback providers are only dialed when an earlier candidate +// fails. +func (p *Server) newLazyManualTitleCandidate( + chat database.Chat, + config database.ChatModelConfig, + modelOpts modelBuildOptions, +) manualTitleCandidate { + return manualTitleCandidate{ + config: config, + resolve: func(ctx context.Context) (chatprovider.Model, error) { + route, err := p.resolveModelRouteForConfig(ctx, chat.OwnerID, config) + if err != nil { + return chatprovider.Model{}, err + } + return p.newModel(ctx, modelClientRequest{ + Chat: chat, + ModelName: config.Model, + UserAgent: chatprovider.UserAgent(), + ExtraHeaders: chatprovider.CoderHeaders(chat), + ConfigOptions: config.Options, + }, route, modelOpts) + }, + } +} diff --git a/coderd/x/chatd/titlewalk_internal_test.go b/coderd/x/chatd/titlewalk_internal_test.go new file mode 100644 index 0000000000000..0b7f93c10c7df --- /dev/null +++ b/coderd/x/chatd/titlewalk_internal_test.go @@ -0,0 +1,200 @@ +package chatd + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3/sloggers/slogtest" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" +) + +func walkTestServer(t *testing.T) *Server { + t.Helper() + return &Server{ + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + } +} + +// resolvedCandidate builds a candidate whose resolve step always succeeds with +// an empty model; the walker tests exercise attempt behavior, not real model +// construction. +func resolvedCandidate(model string) manualTitleCandidate { + return manualTitleCandidate{ + config: database.ChatModelConfig{Model: model}, + resolve: func(context.Context) (chatprovider.Model, error) { + return chatprovider.Model{}, nil + }, + } +} + +func TestWalkManualTitleCandidates(t *testing.T) { + t.Parallel() + + t.Run("FirstCandidateWins", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + var calls int + title, config, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("a"), resolvedCandidate("b")}, + func(context.Context, manualTitleCandidate, chatprovider.Model) (string, error) { + calls++ + return "Title A", nil + }, + ) + require.NoError(t, err) + require.Equal(t, "Title A", title) + require.Equal(t, "a", config.Model) + require.Equal(t, 1, calls, "should stop after the first success") + }) + + t.Run("FallsThroughTimeoutToNextCandidate", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + var models []string + title, config, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("slow"), resolvedCandidate("fast")}, + func(_ context.Context, cand manualTitleCandidate, _ chatprovider.Model) (string, error) { + models = append(models, cand.config.Model) + if cand.config.Model == "slow" { + return "", xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded) + } + return "Title Fast", nil + }, + ) + require.NoError(t, err) + require.Equal(t, "Title Fast", title) + require.Equal(t, "fast", config.Model) + require.Equal(t, []string{"slow", "fast"}, models) + }) + + t.Run("StopsOnNonRetryableError", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + var calls int + sentinel := xerrors.New("bad api key") + _, config, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("first"), resolvedCandidate("second")}, + func(context.Context, manualTitleCandidate, chatprovider.Model) (string, error) { + calls++ + return "", sentinel + }, + ) + require.ErrorIs(t, err, sentinel) + require.Equal(t, "first", config.Model) + require.Equal(t, 1, calls, "non-retryable error must not fall through") + }) + + t.Run("SkipsCandidateThatFailsToResolve", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + var attempted []string + candidates := []manualTitleCandidate{ + { + config: database.ChatModelConfig{Model: "unavailable"}, + resolve: func(context.Context) (chatprovider.Model, error) { + return chatprovider.Model{}, xerrors.New("no credentials") + }, + }, + resolvedCandidate("available"), + } + title, config, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + candidates, + func(_ context.Context, cand manualTitleCandidate, _ chatprovider.Model) (string, error) { + attempted = append(attempted, cand.config.Model) + return "Title", nil + }, + ) + require.NoError(t, err) + require.Equal(t, "Title", title) + require.Equal(t, "available", config.Model) + require.Equal(t, []string{"available"}, attempted, "unresolvable candidate is skipped, not attempted") + }) + + t.Run("AllCandidatesTimeoutReturnsDeadlineExceeded", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + _, _, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("a"), resolvedCandidate("b")}, + func(context.Context, manualTitleCandidate, chatprovider.Model) (string, error) { + return "", xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded) + }, + ) + require.ErrorIs(t, err, context.DeadlineExceeded) + }) + + // Regression for the review finding: when the context is canceled after a + // candidate has already failed, the walker must surface ctx.Err() rather + // than the stale candidate error, so the handler maps it to 499/504 instead + // of a stale 500. + t.Run("CancellationAfterFailureSurfacesCtxErr", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + staleErr := xerrors.New("stale provider error") + var calls int + _, _, err := p.walkManualTitleCandidates( + ctx, + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("a"), resolvedCandidate("b")}, + func(context.Context, manualTitleCandidate, chatprovider.Model) (string, error) { + calls++ + // Simulate the caller disconnecting during this attempt. + cancel() + return "", staleErr + }, + ) + require.ErrorIs(t, err, context.Canceled) + require.False(t, errors.Is(err, staleErr), "must not surface the stale candidate error") + require.Equal(t, 1, calls, "must not try the next candidate after cancellation") + }) + + t.Run("PreCanceledContextSurfacesCtxErr", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var calls int + _, _, err := p.walkManualTitleCandidates( + ctx, + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("a")}, + func(context.Context, manualTitleCandidate, chatprovider.Model) (string, error) { + calls++ + return "Title", nil + }, + ) + require.ErrorIs(t, err, context.Canceled) + require.Zero(t, calls, "a pre-canceled context must not run any attempt") + }) + + t.Run("NoCandidates", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + _, _, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + nil, + func(context.Context, manualTitleCandidate, chatprovider.Model) (string, error) { + return "Title", nil + }, + ) + require.ErrorContains(t, err, "no manual title model candidates available") + }) +} From 1efc54acc2c1c9cdb309d303da18785d84d711de Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 17:35:16 +0000 Subject: [PATCH 3/8] fix(coderd): require canceled request context before mapping title error to 499 --- coderd/exp_chats.go | 8 +++++++- coderd/exp_chats_internal_test.go | 28 ++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 13a67815fa034..f37ba83a38bd9 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -158,9 +158,15 @@ const statusClientClosedRequest = 499 // deadline; when it expires (or the caller disconnects) the error // bubbles up wrapped, so match with errors.Is. Returns true when a // response was written. +// +// The 499 branch additionally requires the request context itself to be +// canceled. A provider error can wrap context.Canceled (for example an +// upstream 401) while the caller context is still active; without the +// ctx.Err() guard such a provider failure would be misreported as a +// client-closed request instead of surfacing through the 500 path. func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool { switch { - case errors.Is(err, context.Canceled): + case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled): httpapi.Write(ctx, rw, statusClientClosedRequest, codersdk.Response{ Message: "Title generation was canceled.", }) diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index a079821f77e81..0f01494645cd5 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -522,8 +522,17 @@ func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) { func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) { t.Parallel() + // canceledCtx returns a context whose Err reports context.Canceled, + // mirroring a request whose caller disconnected. + canceledCtx := func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + } + tests := []struct { name string + ctx context.Context err error wantWrote bool wantStatus int @@ -533,22 +542,37 @@ func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) { // The deadline error is wrapped several layers deep by the // title pipeline, so the handler must match with errors.Is. name: "DeadlineExceededMapsTo504", + ctx: context.Background(), err: xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded), wantWrote: true, wantStatus: http.StatusGatewayTimeout, wantMessage: "Title generation timed out. Try again or rename manually.", }, { - name: "CanceledMapsTo499", + // The caller disconnected, so ctx.Err() confirms the cancel + // and the handler reports a client-closed request. + name: "CanceledWithCanceledCtxMapsTo499", + ctx: canceledCtx(), err: xerrors.Errorf("generate manual title: %w", context.Canceled), wantWrote: true, wantStatus: statusClientClosedRequest, wantMessage: "Title generation was canceled.", }, + { + // A provider error can wrap context.Canceled (e.g. an + // upstream 401) while the request context is still active. + // Without a live cancel this must fall through to the 500 + // path instead of a misleading 499. + name: "CanceledWithLiveCtxFallsThrough", + ctx: context.Background(), + err: xerrors.Errorf("provider auth failed: %w", context.Canceled), + wantWrote: false, + }, { // Unrelated errors must fall through so the handler keeps // its existing 500 surface for genuine failures. name: "UnrelatedErrorFallsThrough", + ctx: context.Background(), err: xerrors.New("something else"), wantWrote: false, }, @@ -559,7 +583,7 @@ func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) { t.Parallel() rw := httptest.NewRecorder() - wrote := maybeWriteManualTitleTimeoutErr(context.Background(), rw, tt.err) + wrote := maybeWriteManualTitleTimeoutErr(tt.ctx, rw, tt.err) require.Equal(t, tt.wantWrote, wrote) if !tt.wantWrote { require.Equal(t, http.StatusOK, rw.Code, "must not write a response when err is unrelated") From 74c7c7884253d8571c557ee5167a1f4ae5729776 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 18:09:27 +0000 Subject: [PATCH 4/8] chore(coderd/x/chatd): fix import grouping in titlewalk files --- coderd/x/chatd/titlewalk.go | 1 - coderd/x/chatd/titlewalk_internal_test.go | 1 - 2 files changed, 2 deletions(-) diff --git a/coderd/x/chatd/titlewalk.go b/coderd/x/chatd/titlewalk.go index a120c8a2a55e7..8f19af5f9668b 100644 --- a/coderd/x/chatd/titlewalk.go +++ b/coderd/x/chatd/titlewalk.go @@ -8,7 +8,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatretry" ) diff --git a/coderd/x/chatd/titlewalk_internal_test.go b/coderd/x/chatd/titlewalk_internal_test.go index 6f8ef822e669d..203b3535c85cf 100644 --- a/coderd/x/chatd/titlewalk_internal_test.go +++ b/coderd/x/chatd/titlewalk_internal_test.go @@ -9,7 +9,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/database" ) From 8fe08d99251c1748a0d24fbe7cd1de31c0f3c31c Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 18:28:03 +0000 Subject: [PATCH 5/8] fix(coderd): gate title 504 on a real deadline and keep chat-model fallback Address two review findings: - Map manual title errors to 504 only when a title deadline actually expired, via the chatd.ErrManualTitleTimedOut sentinel, so provider failures that merely wrap a transport deadline keep the 500 surface. - Append the chat's own model as the final walk candidate when preferred short-text models exist, so the walk can still succeed when every preferred candidate fails. It skips itself when it duplicates an already-attempted preferred config. --- coderd/exp_chats.go | 16 ++-- coderd/exp_chats_internal_test.go | 26 +++++-- coderd/x/chatd/chatd.go | 8 +- coderd/x/chatd/quickgen.go | 8 ++ .../x/chatd/title_override_internal_test.go | 13 +++- coderd/x/chatd/titlewalk.go | 75 ++++++++++++++++++- coderd/x/chatd/titlewalk_internal_test.go | 55 ++++++++++++++ 7 files changed, 186 insertions(+), 15 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 5ecfdb6dfa1c6..cdd6e1b5ad63f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -152,18 +152,22 @@ func maybeWriteChatUsageLimitError(ctx context.Context, rw http.ResponseWriter, const statusClientClosedRequest = 499 // maybeWriteManualTitleTimeoutErr translates context-cancel or -// context-deadline errors from the manual title pipeline into friendly +// title-timeout errors from the manual title pipeline into friendly // 499/504 responses instead of a raw 500 that leaks the wrapped error -// chain. Manual title generation runs a model call under an internal -// deadline; when it expires (or the caller disconnects) the error -// bubbles up wrapped, so match with errors.Is. Returns true when a -// response was written. +// chain. The errors bubble up wrapped, so match with errors.Is. Returns +// true when a response was written. // // The 499 branch additionally requires the request context itself to be // canceled. A provider error can wrap context.Canceled (for example an // upstream 401) while the caller context is still active; without the // ctx.Err() guard such a provider failure would be misreported as a // client-closed request instead of surfacing through the 500 path. +// +// The 504 branch keys off chatd.ErrManualTitleTimedOut, which chatd +// attaches only when a title deadline (per-attempt or overall walk +// budget) actually expired. A provider failure whose chain merely +// contains an unrelated transport deadline is not tagged and keeps its +// provider-failure surface. func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool { switch { case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled): @@ -171,7 +175,7 @@ func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter Message: "Title generation was canceled.", }) return true - case errors.Is(err, context.DeadlineExceeded): + case errors.Is(err, chatd.ErrManualTitleTimedOut): httpapi.Write(ctx, rw, http.StatusGatewayTimeout, codersdk.Response{ Message: "Title generation timed out. Try again or rename manually.", }) diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index a6b4f2580448f..5f35e7e7aa5b0 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "net/http" "net/http/httptest" "reflect" @@ -22,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -586,15 +588,29 @@ func TestMaybeWriteManualTitleTimeoutErr(t *testing.T) { wantMessage string }{ { - // The deadline error is wrapped several layers deep by the - // title pipeline, so the handler must match with errors.Is. - name: "DeadlineExceededMapsTo504", - ctx: context.Background(), - err: xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded), + // A genuine title timeout is tagged with the chatd sentinel + // and wrapped several layers deep, so the handler must match + // with errors.Is. + name: "TitleTimeoutSentinelMapsTo504", + ctx: context.Background(), + err: xerrors.Errorf( + "generate manual title: %w", + errors.Join(chatd.ErrManualTitleTimedOut, context.DeadlineExceeded), + ), wantWrote: true, wantStatus: http.StatusGatewayTimeout, wantMessage: "Title generation timed out. Try again or rename manually.", }, + { + // A provider failure can wrap an unrelated transport deadline + // while the title deadline never expired. Without the chatd + // sentinel this must keep the 500 path instead of a + // misleading 504. + name: "BareDeadlineWithoutSentinelFallsThrough", + ctx: context.Background(), + err: xerrors.Errorf("provider call failed: %w", context.DeadlineExceeded), + wantWrote: false, + }, { // The caller disconnected, so ctx.Err() confirms the cancel // and the handler reports a client-closed request. diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 216574f407f57..87fb9cd4e2845 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2903,8 +2903,14 @@ func (p *Server) resolveManualTitleCandidates( if fallbackErr != nil { return nil, fallbackErr } - candidates = append(candidates, newResolvedManualTitleCandidate(fallbackResolved)) + return []manualTitleCandidate{newResolvedManualTitleCandidate(fallbackResolved)}, nil } + + // Keep the chat's own model as the final candidate so the walk can still + // succeed when every preferred model fails to resolve or is unavailable. + // Resolved lazily, and skipped when it duplicates a preferred candidate + // that was already attempted. + candidates = append(candidates, p.newChatModelFallbackManualTitleCandidate(chat, modelOpts, seen)) return candidates, nil } diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index dc468216e777f..2e2765596547c 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -881,6 +881,14 @@ func generateManualTitle( userInput, ) if err != nil { + // Tag genuine attempt-deadline expiry so the handler can map it to a + // friendly 504. A provider failure can wrap an unrelated transport + // deadline while titleCtx is still live; leave that untagged so it + // keeps its provider-failure surface. + if errors.Is(err, context.DeadlineExceeded) && + errors.Is(titleCtx.Err(), context.DeadlineExceeded) { + return "", markManualTitleTimeout(err) + } return "", err } diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 1e26c7cbd8f79..b490067091d65 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -342,12 +342,16 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideUnset(t *testing.T) Model: preferredTitleModels[1].model, Enabled: true, } + // Point the chat's own model at the preferred config so the appended + // chat-model fallback exercises its dedupe path. + chat.LastModelConfigID = preferredConfig.ID db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ {ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1", Enabled: true}, Provider: "openai"}, {ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider}, }, nil) + db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), preferredConfig.ID).Return(preferredConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() server := titleOverrideTestServer(db, logger) @@ -358,11 +362,18 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideUnset(t *testing.T) modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) - require.NotEmpty(t, candidates) + // One preferred short-text candidate plus the chat-model final fallback. + require.Len(t, candidates, 2) resolved, err := candidates[0].resolve(ctx) require.NoError(t, err) require.True(t, resolved.model.Valid()) require.Equal(t, preferredConfig, resolved.dbConfig) + + // The chat's own model resolves to the preferred config that was already + // attempted, so the fallback candidate skips itself instead of retrying + // the same model. + _, err = candidates[1].resolve(ctx) + require.ErrorIs(t, err, errManualTitleCandidateSkip) } func TestResolveManualTitleCandidates_NonDefaultOrgDoesNotUseDefaultOrgConfigs(t *testing.T) { diff --git a/coderd/x/chatd/titlewalk.go b/coderd/x/chatd/titlewalk.go index 8f19af5f9668b..6ec97164ca259 100644 --- a/coderd/x/chatd/titlewalk.go +++ b/coderd/x/chatd/titlewalk.go @@ -5,6 +5,7 @@ import ( "errors" "time" + "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -24,6 +25,28 @@ const ( titleOverallTimeout = 90 * time.Second ) +// ErrManualTitleTimedOut marks a manual title failure caused by an expired +// title deadline (the per-attempt timeout or the overall walk budget), as +// opposed to a provider error whose chain merely contains an unrelated +// transport deadline. The API handler maps this sentinel to a friendly 504. +var ErrManualTitleTimedOut = xerrors.New("manual title generation timed out") + +// errManualTitleCandidateSkip marks a candidate that turned out to be +// redundant at resolve time, for example the chat-model fallback resolving to +// a preferred config that was already attempted. The walker skips it without +// replacing an earlier attempt's more meaningful error. +var errManualTitleCandidateSkip = xerrors.New("manual title candidate skipped") + +// markManualTitleTimeout tags err with ErrManualTitleTimedOut when it stems +// from an expired context deadline, so the handler can distinguish a real +// title timeout from a provider failure that wraps one. +func markManualTitleTimeout(err error) error { + if err == nil || !errors.Is(err, context.DeadlineExceeded) { + return err + } + return errors.Join(ErrManualTitleTimedOut, err) +} + // manualTitleCandidate is one model the manual title walk can try. resolve // builds the runnable model lazily so the common case (the first candidate // succeeds) never constructs clients it does not use, and unit tests that @@ -65,7 +88,7 @@ func (p *Server) walkManualTitleCandidates( for _, cand := range candidates { // Overall budget exhausted or caller canceled between attempts. if ctxErr := ctx.Err(); ctxErr != nil { - return "", lastConfig, ctxErr + return "", lastConfig, markManualTitleTimeout(ctxErr) } resolved, err := cand.resolve(ctx) @@ -77,6 +100,11 @@ func (p *Server) walkManualTitleCandidates( slog.F("model", cand.config.Model), slog.Error(err), ) + if errors.Is(err, errManualTitleCandidateSkip) { + // Redundant candidate; keep the earlier, more + // meaningful error. + continue + } lastErr = err lastConfig = cand.config continue @@ -94,7 +122,7 @@ func (p *Server) walkManualTitleCandidates( // stale provider 500. Checked here (not only at the top of the loop) // to cover cancellation in the window after this attempt failed. if ctxErr := ctx.Err(); ctxErr != nil { - return "", lastConfig, ctxErr + return "", lastConfig, markManualTitleTimeout(ctxErr) } if !manualTitleFallThrough(err) { return "", lastConfig, lastErr @@ -117,6 +145,49 @@ func newResolvedManualTitleCandidate(resolved resolvedModelCall) manualTitleCand } } +// newChatModelFallbackManualTitleCandidate returns the chat's own model as a +// final walk candidate so the request can still succeed when every preferred +// short-text model fails to resolve or is unavailable. Resolution is lazy and +// skips itself (errManualTitleCandidateSkip) when the chat's model resolves to +// a config that was already attempted as a preferred candidate. +func (p *Server) newChatModelFallbackManualTitleCandidate( + chat database.Chat, + modelOpts modelBuildOptions, + attempted map[uuid.UUID]bool, +) manualTitleCandidate { + return manualTitleCandidate{ + resolve: func(ctx context.Context) (resolvedModelCall, error) { + config, err := p.resolveModelConfig(ctx, chat) + if err != nil { + return resolvedModelCall{}, xerrors.Errorf( + "resolve fallback manual title model config: %w", + err, + ) + } + if config.ID != uuid.Nil && attempted[config.ID] { + return resolvedModelCall{}, xerrors.Errorf( + "%w: chat model %q already attempted as a preferred candidate", + errManualTitleCandidateSkip, + config.Model, + ) + } + resolved, err := p.resolveModelCall(ctx, modelCallSpec{ + purpose: "title", + chat: chat, + explicitConfig: &config, + buildOptions: modelOpts, + }) + if err != nil { + return resolvedModelCall{}, xerrors.Errorf( + "create fallback manual title model: %w", + err, + ) + } + return resolved, nil + }, + } +} + // newLazyManualTitleCandidate builds a candidate whose model is constructed on // first use, so fallback providers are only dialed when an earlier candidate // fails. diff --git a/coderd/x/chatd/titlewalk_internal_test.go b/coderd/x/chatd/titlewalk_internal_test.go index 203b3535c85cf..89055446a989f 100644 --- a/coderd/x/chatd/titlewalk_internal_test.go +++ b/coderd/x/chatd/titlewalk_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -182,6 +183,60 @@ func TestWalkManualTitleCandidates(t *testing.T) { require.Zero(t, calls, "a pre-canceled context must not run any attempt") }) + // The overall walk budget expiring is a genuine title timeout, so the + // walker must tag it with ErrManualTitleTimedOut for the handler's 504 + // mapping. + t.Run("OverallDeadlineMarksTimeout", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + var calls int + _, _, err := p.walkManualTitleCandidates( + ctx, + database.Chat{}, + []manualTitleCandidate{resolvedCandidate("a")}, + func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { + calls++ + return "Title", nil + }, + ) + require.ErrorIs(t, err, ErrManualTitleTimedOut) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Zero(t, calls, "an expired overall budget must not run any attempt") + }) + + // A candidate that skips itself at resolve time (e.g. the chat-model + // fallback duplicating an already-attempted preferred candidate) must not + // replace the earlier attempt's more meaningful error. + t.Run("SkipCandidateKeepsEarlierError", func(t *testing.T) { + t.Parallel() + p := walkTestServer(t) + candidates := []manualTitleCandidate{ + resolvedCandidate("preferred"), + { + resolve: func(context.Context) (resolvedModelCall, error) { + return resolvedModelCall{}, xerrors.Errorf( + "%w: duplicate of preferred", + errManualTitleCandidateSkip, + ) + }, + }, + } + _, config, err := p.walkManualTitleCandidates( + context.Background(), + database.Chat{}, + candidates, + func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { + return "", xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded) + }, + ) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.False(t, errors.Is(err, errManualTitleCandidateSkip), + "skip sentinel must not replace the attempt error") + require.Equal(t, "preferred", config.Model) + }) + t.Run("NoCandidates", func(t *testing.T) { t.Parallel() p := walkTestServer(t) From 6f44075cf5403a3e4febf1e2ee729298fb1e1a8e Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 19:03:17 +0000 Subject: [PATCH 6/8] refactor: remove redundant chat title regenerate endpoint The frontend only uses POST /chats/{chat}/title/propose (rename dialog); nothing calls /title/regenerate anymore. Remove the endpoint, its codersdk client method, the chatd persist/broadcast path it alone used (persistManualTitle, regenerateChatTitleWithStore), and its tests. Port the paste-only-chat coverage to the propose suite and add the missing swagger annotations for the propose endpoint so it appears in the API reference. --- coderd/apidoc/docs.go | 16 +- coderd/apidoc/swagger.json | 16 +- coderd/coderd.go | 1 - coderd/exp_chats.go | 58 +--- coderd/exp_chats_test.go | 303 +--------------- coderd/x/chatd/chatd.go | 91 ----- coderd/x/chatd/chatd_internal_test.go | 322 ------------------ codersdk/chats.go | 15 - .../agents/tasks-to-chats-migration.md | 2 +- docs/reference/api/chats.md | 212 +----------- docs/reference/api/schemas.md | 14 + 11 files changed, 59 insertions(+), 991 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 434b2367d95d0..ca44e050a336f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1226,7 +1226,7 @@ const docTemplate = `{ } } }, - "/api/experimental/chats/{chat}/title/regenerate": { + "/api/experimental/chats/{chat}/title/propose": { "post": { "description": "Experimental: this endpoint is subject to change.", "produces": [ @@ -1235,8 +1235,8 @@ const docTemplate = `{ "tags": [ "Chats" ], - "summary": "Regenerate chat title", - "operationId": "regenerate-chat-title", + "summary": "Propose chat title", + "operationId": "propose-chat-title", "parameters": [ { "type": "string", @@ -1251,7 +1251,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Chat" + "$ref": "#/definitions/codersdk.ProposeChatTitleResponse" } } }, @@ -25191,6 +25191,14 @@ const docTemplate = `{ } } }, + "codersdk.ProposeChatTitleResponse": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + } + }, "codersdk.ProvisionerConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4a657b17c1483..eaf3a7c5d61b0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1089,13 +1089,13 @@ } } }, - "/api/experimental/chats/{chat}/title/regenerate": { + "/api/experimental/chats/{chat}/title/propose": { "post": { "description": "Experimental: this endpoint is subject to change.", "produces": ["application/json"], "tags": ["Chats"], - "summary": "Regenerate chat title", - "operationId": "regenerate-chat-title", + "summary": "Propose chat title", + "operationId": "propose-chat-title", "parameters": [ { "type": "string", @@ -1110,7 +1110,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Chat" + "$ref": "#/definitions/codersdk.ProposeChatTitleResponse" } } }, @@ -23116,6 +23116,14 @@ } } }, + "codersdk.ProposeChatTitleResponse": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + } + }, "codersdk.ProvisionerConfig": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 208b484e5ee7e..7199a8977d687 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1559,7 +1559,6 @@ func New(options *Options) *API { r.Post("/compact", api.compactChat) r.Post("/reconcile-invalid", api.reconcileInvalidChatState) r.Post("/tool-results", api.postChatToolResults) - r.Post("/title/regenerate", api.regenerateChatTitle) r.Post("/title/propose", api.proposeChatTitle) r.Get("/diff", api.getChatDiffContents) r.Put("/context", api.refreshChatContext) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index cdd6e1b5ad63f..8d6c69f9313cc 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -3419,66 +3419,16 @@ func (api *API) reconcileInvalidChatState(rw http.ResponseWriter, r *http.Reques // EXPERIMENTAL: this endpoint is experimental and is subject to change. // -// @Summary Regenerate chat title -// @ID regenerate-chat-title +// @Summary Propose chat title +// @ID propose-chat-title // @Security CoderSessionToken // @Tags Chats // @Produce json // @Param chat path string true "Chat ID" format(uuid) -// @Success 200 {object} codersdk.Chat -// @Router /api/experimental/chats/{chat}/title/regenerate [post] +// @Success 200 {object} codersdk.ProposeChatTitleResponse +// @Router /api/experimental/chats/{chat}/title/propose [post] // @Description Experimental: this endpoint is subject to change. // -//nolint:revive // HTTP handler writes to ResponseWriter. -func (api *API) regenerateChatTitle(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - apiKey := httpmw.APIKey(r) - chat := httpmw.ChatParam(r) - - if !api.requireChatDaemon(ctx, rw) { - return - } - - if !api.Authorize(r, policy.ActionUpdate, chat.RBACObject()) { - httpapi.ResourceNotFound(rw) - return - } - - // Only the chat owner may regenerate titles. See - // postChatMessages for the security rationale. - if apiKey.UserID != chat.OwnerID { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Only the chat owner may regenerate the title.", - }) - return - } - - updatedChat, err := api.chatDaemon.RegenerateChatTitle(ctx, chat) - if err != nil { - if errors.Is(err, chatd.ErrNoDefaultChatModelConfig) { - writeNoLocalChatModelResponse(ctx, rw) - return - } - if httpapi.Is404Error(err) { - httpapi.ResourceNotFound(rw) - return - } - if maybeWriteChatUsageLimitError(ctx, rw, err) { - return - } - if maybeWriteManualTitleTimeoutErr(ctx, rw, err) { - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to regenerate chat title.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Chat(updatedChat, nil, nil)) -} - //nolint:revive // HTTP handler writes to ResponseWriter. func (api *API) proposeChatTitle(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 71cbda7ae4684..ed5e8a238a33c 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -44,7 +44,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/rbac" @@ -11275,7 +11274,7 @@ func TestCompactChat(t *testing.T) { }) } -func TestRegenerateChatTitle(t *testing.T) { +func TestProposeChatTitle(t *testing.T) { t.Parallel() t.Run("ChatNotFound", func(t *testing.T) { @@ -11285,7 +11284,7 @@ func TestRegenerateChatTitle(t *testing.T) { client := newChatClient(t) _ = coderdtest.CreateFirstUser(t, client.Client) - _, err := client.RegenerateChatTitle(ctx, uuid.New()) + _, err := client.ProposeChatTitle(ctx, uuid.New()) requireSDKError(t, err, http.StatusNotFound) }) @@ -11317,32 +11316,8 @@ func TestRegenerateChatTitle(t *testing.T) { Title: "chat with update denied", }) - _, err := client.RegenerateChatTitle(ctx, chat.ID) - requireSDKError(t, err, http.StatusNotFound) - }) - - t.Run("NotFoundForDifferentUser", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModel(t, client) - - createdChat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{ - { - Type: codersdk.ChatInputPartTypeText, - Text: "private chat", - }, - }, - }) - require.NoError(t, err) + _, err := client.ProposeChatTitle(ctx, chat.ID) - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) - otherClient := codersdk.NewExperimentalClient(otherClientRaw) - _, err = otherClient.RegenerateChatTitle(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) }) @@ -11358,13 +11333,13 @@ func TestRegenerateChatTitle(t *testing.T) { OrganizationID: firstUser.OrganizationID, Content: []codersdk.ChatInputPart{{ Type: codersdk.ChatInputPartTypeText, - Text: "chat for unauthenticated regeneration", + Text: "chat for unauthenticated proposal", }}, }) require.NoError(t, err) unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL)) - _, err = unauthenticatedClient.RegenerateChatTitle(ctx, chat.ID) + _, err = unauthenticatedClient.ProposeChatTitle(ctx, chat.ID) requireSDKError(t, err, http.StatusUnauthorized) }) @@ -11388,243 +11363,9 @@ func TestRegenerateChatTitle(t *testing.T) { // attachment with no text parts. seedPasteOnlyTitleSourceMessage(ctx, t, db, chat, modelConfig.ID, "pasted stack trace for title") - updated, err := client.RegenerateChatTitle(ctx, chat.ID) - require.NoError(t, err) - require.Equal(t, "Test Chat", updated.Title) - }) - - t.Run("NoPubsubDelivery", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db, api := newChatClientWithoutAIBridge(t) - user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationChatModel(t, client) - - // Wire the daemon's reload subscription to a pubsub coderd never - // publishes to: gateway routes can then only come from the - // synchronous initial load. This guards the invariant the - // create-config-before-daemon pattern above relies on; if the - // initial load is removed or made asynchronous, this fails - // deterministically instead of reintroducing the startup race. - isolated := dbpubsub.NewInMemory() - t.Cleanup(func() { _ = isolated.Close() }) - aibridgedtest.StartTestAIBridgeDaemonWithPubsub(t.Context(), t, api, nil, isolated) - - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - LastModelConfigID: modelConfig.ID, - Title: "New Chat", - }) - seedManualTitleSourceMessage(t, db, chat, modelConfig.ID) - - updated, err := client.RegenerateChatTitle(ctx, chat.ID) - require.NoError(t, err) - require.Equal(t, "Test Chat", updated.Title) - }) - - t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db, api := newChatClientWithoutAIBridge(t) - user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createTitleGenerationChatModel(t, client) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - LastModelConfigID: modelConfig.ID, - Title: "history fence chat", - }) - seedManualTitleSourceMessage(t, db, chat, modelConfig.ID) - - // Leave history_version lagging snapshot_version, as when a - // generation task is in flight. A chat_messages write here would - // sync it and break that task's commit fence. - _, err := db.LockChatAndBumpSnapshotVersion(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - - before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.NotEqual(t, before.SnapshotVersion, before.HistoryVersion, - "setup must leave history_version lagging snapshot_version") - - updated, err := client.RegenerateChatTitle(ctx, chat.ID) - require.NoError(t, err) - require.Equal(t, "Test Chat", updated.Title) - - after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.Equal(t, before.HistoryVersion, after.HistoryVersion, - "manual title regeneration must not touch chat_messages") - }) - - t.Run("NoDefaultModelConfig", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - user := coderdtest.CreateFirstUser(t, client.Client) - chat := seedChatWithDeletedModelConfig(ctx, t, db, user) - - _, err := client.RegenerateChatTitle(ctx, chat.ID) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No chat model is available in this organization.", sdkErr.Message) - require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) - }) - - t.Run("RegenerationFailure", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db, api := newChatClientWithoutAIBridge(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelWithTitleFailure(t, client) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - - chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{ - { - Type: codersdk.ChatInputPartTypeText, - Text: "test chat", - }, - }, - }) - require.NoError(t, err) - - coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) - - _, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ - ID: chat.ID, - Status: database.ChatStatusWaiting, - WorkerID: uuid.NullUUID{}, - StartedAt: sql.NullTime{}, - HeartbeatAt: sql.NullTime{}, - LastError: pqtype.NullRawMessage{}, - }) - require.NoError(t, err) - - before, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - - _, err = client.RegenerateChatTitle(ctx, chat.ID) - requireSDKError(t, err, http.StatusInternalServerError) - - after, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.True(t, after.UpdatedAt.Equal(before.UpdatedAt)) - }) - - t.Run("UsageLimitExhausted", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db, api := newChatClientWithAPIAndDatabase(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModelWithTitleQuotaExhausted(t, client) - - chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{ - { - Type: codersdk.ChatInputPartTypeText, - Text: "test chat", - }, - }, - }) - require.NoError(t, err) - - coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) - - _, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ - ID: chat.ID, - Status: database.ChatStatusWaiting, - WorkerID: uuid.NullUUID{}, - StartedAt: sql.NullTime{}, - HeartbeatAt: sql.NullTime{}, - LastError: pqtype.NullRawMessage{}, - }) - require.NoError(t, err) - - _, err = client.RegenerateChatTitle(ctx, chat.ID) - sdkErr := requireSDKError(t, err, http.StatusConflict) - require.Equal(t, - "The AI usage limit has been exceeded. Contact an administrator or check the applicable budget and quota settings.", - sdkErr.Message) - }) -} - -func TestProposeChatTitle(t *testing.T) { - t.Parallel() - - t.Run("ChatNotFound", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) - _ = coderdtest.CreateFirstUser(t, client.Client) - - _, err := client.ProposeChatTitle(ctx, uuid.New()) - requireSDKError(t, err, http.StatusNotFound) - }) - - t.Run("UpdateDenied", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - clientRaw, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Authorizer: &coderdtest.FakeAuthorizer{ - ConditionalReturn: func(_ context.Context, _ rbac.Subject, action policy.Action, object rbac.Object) error { - if action == policy.ActionUpdate && object.Type == rbac.ResourceChat.Type { - return xerrors.New("denied") - } - return nil - }, - }, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - db := api.Database - client := codersdk.NewExperimentalClient(clientRaw) - user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModel(t, client) - - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - LastModelConfigID: modelConfig.ID, - Title: "chat with update denied", - }) - - _, err := client.ProposeChatTitle(ctx, chat.ID) - - requireSDKError(t, err, http.StatusNotFound) - }) - - t.Run("Unauthenticated", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client := newChatClient(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - _ = createChatModel(t, client) - - chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "chat for unauthenticated proposal", - }}, - }) + proposed, err := client.ProposeChatTitle(ctx, chat.ID) require.NoError(t, err) - - unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL)) - _, err = unauthenticatedClient.ProposeChatTitle(ctx, chat.ID) - requireSDKError(t, err, http.StatusUnauthorized) + require.Equal(t, "Test Chat", proposed.Title) }) t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) { @@ -11644,7 +11385,8 @@ func TestProposeChatTitle(t *testing.T) { }) seedManualTitleSourceMessage(t, db, chat, modelConfig.ID) - // See the matching TestRegenerateChatTitle subtest. + // Bump the snapshot version up front so the assertion below + // detects any further bump caused by the propose call. _, err := db.LockChatAndBumpSnapshotVersion(dbauthz.AsSystemRestricted(ctx), chat.ID) require.NoError(t, err) @@ -11779,13 +11521,6 @@ func TestManualTitleEndpointsPassOwnerSyntheticAPIKeyToAIGateway(t *testing.T) { name string call func(context.Context, *codersdk.ExperimentalClient, uuid.UUID) error }{ - { - name: "RegenerateChatTitle", - call: func(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID) error { - _, err := client.RegenerateChatTitle(ctx, chatID) - return err - }, - }, { name: "ProposeChatTitle", call: func(ctx context.Context, client *codersdk.ExperimentalClient, chatID uuid.UUID) error { @@ -18728,15 +18463,6 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) - t.Run("RegenerateChatTitle", func(t *testing.T) { - t.Parallel() - - ctx, _, sharedClient, chat, _ := setup(t) - _, err := sharedClient.RegenerateChatTitle(ctx, chat.ID) - - requireSDKError(t, err, http.StatusNotFound) - }) - t.Run("ProposeChatTitle", func(t *testing.T) { t.Parallel() @@ -18890,17 +18616,6 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) { require.Contains(t, sdkErr.Message, "Only the chat owner") }) - t.Run("RegenerateChatTitle", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - _, adminClient, chat, _ := setupOrgAdminAndOwnerChat(t) - - _, err := adminClient.RegenerateChatTitle(ctx, chat.ID) - sdkErr := requireSDKError(t, err, http.StatusForbidden) - require.Contains(t, sdkErr.Message, "Only the chat owner") - }) - t.Run("ProposeChatTitle", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 87fb9cd4e2845..b547b18acfc0c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2521,23 +2521,6 @@ func (t *generatedChatTitle) Load() (string, bool) { return t.title, true } -// RegenerateChatTitle regenerates a chat title from the chat's visible -// messages, persists it when it changes, and broadcasts the update. -func (p *Server) RegenerateChatTitle( - ctx context.Context, - chat database.Chat, -) (database.Chat, error) { - // Reuse chatd's scoped auth context for chat model config reads while - // keeping chat ownership authorization at the HTTP layer. - //nolint:gocritic // Non-admin users need chatd-scoped config reads here. - chatdCtx := dbauthz.AsChatd(ctx) - return p.regenerateChatTitleWithStore( - chatdCtx, - p.db, - chat, - ) -} - // RenameChatTitle persists a user-supplied chat title. func (p *Server) RenameChatTitle( ctx context.Context, @@ -2668,40 +2651,6 @@ func (p *Server) generateManualTitleCandidate( return title, nil } -func (p *Server) regenerateChatTitleWithStore( - ctx context.Context, - store database.Store, - chat database.Chat, -) (database.Chat, error) { - title, err := p.generateManualTitleCandidate(ctx, store, chat) - if err != nil { - return database.Chat{}, err - } - if title == "" { - return chat, nil - } - - // Generation already happened; don't let a client disconnect drop the - // title write. - persistCtx, persistCancel := context.WithTimeout(context.WithoutCancel(ctx), manualTitlePersistTimeout) - defer persistCancel() - - updatedChat, wroteTitle, err := persistManualTitle(persistCtx, store, chat, title) - if err != nil { - return database.Chat{}, xerrors.Errorf("update chat title: %w", err) - } - // Publish only when this regeneration wrote the title. When a - // concurrent rename won the race, the rename path already published - // the fresher title; re-publishing the re-read row here could - // deliver a stale title_change after an even newer rename. - if !wroteTitle { - return updatedChat, nil - } - - p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindTitleChange, nil) - return updatedChat, nil -} - func (p *Server) prepareManualTitleDebugRun( ctx context.Context, debugSvc *chatdebug.Service, @@ -2963,46 +2912,6 @@ func mergeManualTitleMessages( return merged } -// persistManualTitle writes newTitle only if the chat title still -// matches the caller's snapshot. The returned bool reports whether the -// title was actually written; it is false when a concurrent writer -// changed the title first or when newTitle matches the current title. -// Token usage for manual title generation is not recorded here; AI -// Gateway tracks it independently, and writing to chat_messages outside -// the chatstate state machine would break in-flight task fences. -func persistManualTitle( - ctx context.Context, - store database.Store, - chat database.Chat, - newTitle string, -) (database.Chat, bool, error) { - updatedChat := chat - wroteTitle := false - err := store.InTx(func(tx database.Store) error { - lockedChat, err := tx.GetChatByIDForUpdate(ctx, chat.ID) - if err != nil { - return xerrors.Errorf("lock chat for manual title persist: %w", err) - } - updatedChat = lockedChat - wroteTitle = false - if lockedChat.Title == chat.Title && newTitle != lockedChat.Title { - updatedChat, err = tx.UpdateChatByID(ctx, database.UpdateChatByIDParams{ - ID: chat.ID, - Title: newTitle, - }) - if err != nil { - return xerrors.Errorf("update chat title: %w", err) - } - wroteTitle = true - } - return nil - }, nil) - if err != nil { - return database.Chat{}, false, err - } - return updatedChat, wroteTitle, nil -} - type chatMessage struct { role database.ChatMessageRole content pqtype.NullRawMessage diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index d32455e8ee4e2..de66681f1deeb 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -4,9 +4,6 @@ import ( "context" "database/sql" "encoding/json" - "io" - "net/http" - "strconv" "strings" "sync" "testing" @@ -28,7 +25,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" - coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" @@ -989,324 +985,6 @@ func TestRenameChatTitle(t *testing.T) { }) } -// requireOutgoingRequestModel asserts that the outgoing request body -// requests wantModel. This is so that mock transports can still -// verify the outgoing request asked for the expected model. -func requireOutgoingRequestModel(t testing.TB, req *http.Request, wantModel string) { - t.Helper() - - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - req.Body = io.NopCloser(strings.NewReader(string(body))) - - var decoded struct { - Model string `json:"model"` - } - require.NoError(t, json.Unmarshal(body, &decoded)) - require.Equal(t, wantModel, decoded.Model) -} - -func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - usageTx := dbmock.NewMockStore(ctrl) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - pubsub := dbpubsub.NewInMemory() - clock := quartz.NewReal() - - ownerID := uuid.New() - chatID := uuid.New() - modelConfigID := uuid.New() - workerID := uuid.New() - userPrompt := "review pull request 23633 and fix review threads" - activeAPIKeyID := "key-" + uuid.NewString() - wantTitle := "Review PR 23633" - - organizationID := uuid.New() - chat := database.Chat{ - ID: chatID, - OwnerID: ownerID, - OrganizationID: organizationID, - LastModelConfigID: modelConfigID, - Status: database.ChatStatusRunning, - WorkerID: uuid.NullUUID{UUID: workerID, Valid: true}, - Title: chatprompt.FallbackTitle(userPrompt), - } - providerID := uuid.New() - modelConfig := database.ChatModelConfig{ - ID: modelConfigID, - Model: "gpt-4o-mini", - ContextLimit: 8192, - Enabled: true, - AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, - OrganizationID: organizationID, - } - updatedChat := chat - updatedChat.Title = wantTitle - - messageEvents := make(chan struct { - payload codersdk.ChatWatchEvent - err error - }, 1) - cancelSub, err := pubsub.SubscribeWithErr( - coderdpubsub.ChatWatchEventChannel(ownerID), - coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) { - messageEvents <- struct { - payload codersdk.ChatWatchEvent - err error - }{payload: payload, err: err} - }), - ) - require.NoError(t, err) - defer cancelSub() - - // Title generation routes through the transport factory, so the model - // response is synthesized by the RoundTripper (see aibridgeTestFactory). - factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) { - requireOutgoingRequestModel(t, req, modelConfig.Model) - text := strconv.Quote(`{"title":"` + wantTitle + `"}`) - body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4o-mini","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - })} - - server := &Server{ - db: db, - logger: logger, - pubsub: pubsub, - clock: quartz.NewReal(), - configCache: newChatConfigCache(context.Background(), db, clock), - aibridgeTransportFactory: aibridgeTestFactoryPointer(factory), - } - - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{ - ID: providerID, - Name: "primary-openai", - Type: database.AIProviderTypeOpenai, - Enabled: true, - }, nil).AnyTimes() - - db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{ - ID: providerID, - Name: "primary-openai", - Type: database.AIProviderTypeOpenai, - Enabled: true, - }}, nil).AnyTimes() - db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() - db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() - db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil) - db.EXPECT().GetChatMessagesByChatIDAscPaginated( - gomock.Any(), - database.GetChatMessagesByChatIDAscPaginatedParams{ - ChatID: chatID, - AfterID: 0, - LimitVal: manualTitleMessageWindowLimit, - }, - ).Return([]database.ChatMessage{ - mustChatMessage( - t, - database.ChatMessageRoleUser, - database.ChatMessageVisibilityBoth, - codersdk.ChatMessageText(userPrompt), - ), - mustChatMessage( - t, - database.ChatMessageRoleAssistant, - database.ChatMessageVisibilityBoth, - codersdk.ChatMessageText("checking the diff now"), - ), - }, nil) - db.EXPECT().GetChatMessagesByChatIDDescPaginated( - gomock.Any(), - database.GetChatMessagesByChatIDDescPaginatedParams{ - ChatID: chatID, - BeforeID: 0, - LimitVal: manualTitleMessageWindowLimit, - }, - ).Return(nil, nil) - db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) - db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), organizationID).Return(nil, nil) - - db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( - func(fn func(database.Store) error, opts *database.TxOptions) error { - require.Nil(t, opts) - return fn(usageTx) - }, - ) - - usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(chat, nil) - usageTx.EXPECT().UpdateChatByID(gomock.Any(), database.UpdateChatByIDParams{ - ID: chatID, - Title: wantTitle, - }).Return(updatedChat, nil) - - gotChat, err := server.RegenerateChatTitle(ctx, chat) - require.NoError(t, err) - require.Equal(t, updatedChat, gotChat) - - select { - case event := <-messageEvents: - require.NoError(t, event.err) - require.Equal(t, codersdk.ChatWatchEventKindTitleChange, event.payload.Kind) - require.Equal(t, chatID, event.payload.Chat.ID) - require.Equal(t, wantTitle, event.payload.Chat.Title) - case <-time.After(time.Second): - t.Fatal("timed out waiting for title change pubsub event") - } -} - -// With no request-level locking, persistManualTitle's re-read under -// GetChatByIDForUpdate is the only protection against clobbering a title -// that changed while the model call ran. The strict mock has no -// UpdateChatByID expectation, so any persist attempt fails the test. -// A skipped persist must also not publish a title_change event; the -// wroteTitle comment in regenerateChatTitleWithStore explains why. -func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - usageTx := dbmock.NewMockStore(ctrl) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - pubsub := dbpubsub.NewInMemory() - clock := quartz.NewReal() - - ownerID := uuid.New() - chatID := uuid.New() - modelConfigID := uuid.New() - providerID := uuid.New() - userPrompt := "review pull request 23633 and fix review threads" - activeAPIKeyID := "key-" + uuid.NewString() - generatedTitle := "Review PR 23633" - - organizationID := uuid.New() - chat := database.Chat{ - ID: chatID, - OwnerID: ownerID, - OrganizationID: organizationID, - LastModelConfigID: modelConfigID, - Status: database.ChatStatusWaiting, - Title: chatprompt.FallbackTitle(userPrompt), - } - modelConfig := database.ChatModelConfig{ - ID: modelConfigID, - Model: "gpt-4o-mini", - ContextLimit: 8192, - Enabled: true, - AIProviderID: uuid.NullUUID{UUID: providerID, Valid: true}, - OrganizationID: organizationID, - } - // Another writer (rename or a second regenerate) landed while the - // model call was in flight. - landedChat := chat - landedChat.Title = "landed-concurrently" - - titleEvents := make(chan codersdk.ChatWatchEvent, 1) - cancelSub, err := pubsub.SubscribeWithErr( - coderdpubsub.ChatWatchEventChannel(ownerID), - coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) { - require.NoError(t, err) - titleEvents <- payload - }), - ) - require.NoError(t, err) - defer cancelSub() - - factory := &aibridgeTestFactory{rt: roundTripFunc(func(req *http.Request) (*http.Response, error) { - requireOutgoingRequestModel(t, req, modelConfig.Model) - text := strconv.Quote(`{"title":"` + generatedTitle + `"}`) - body := `{"id":"resp_test","object":"response","created_at":0,"status":"completed","model":"gpt-4o-mini","output":[{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"output_text","text":` + text + `}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - })} - - server := &Server{ - db: db, - logger: logger, - pubsub: pubsub, - clock: quartz.NewReal(), - configCache: newChatConfigCache(context.Background(), db, clock), - aibridgeTransportFactory: aibridgeTestFactoryPointer(factory), - } - - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), modelConfigID).Return(modelConfig, nil) - db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(database.AIProvider{ - ID: providerID, - Name: "primary-openai", - Type: database.AIProviderTypeOpenai, - Enabled: true, - }, nil).AnyTimes() - db.EXPECT().GetAIProviders(gomock.Any(), gomock.Any()).Return([]database.AIProvider{{ - ID: providerID, - Name: "primary-openai", - Type: database.AIProviderTypeOpenai, - Enabled: true, - }}, nil).AnyTimes() - db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() - db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() - db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil) - db.EXPECT().GetChatMessagesByChatIDAscPaginated( - gomock.Any(), - database.GetChatMessagesByChatIDAscPaginatedParams{ - ChatID: chatID, - AfterID: 0, - LimitVal: manualTitleMessageWindowLimit, - }, - ).Return([]database.ChatMessage{ - mustChatMessage( - t, - database.ChatMessageRoleUser, - database.ChatMessageVisibilityBoth, - codersdk.ChatMessageText(userPrompt), - ), - }, nil) - db.EXPECT().GetChatMessagesByChatIDDescPaginated( - gomock.Any(), - database.GetChatMessagesByChatIDDescPaginatedParams{ - ChatID: chatID, - BeforeID: 0, - LimitVal: manualTitleMessageWindowLimit, - }, - ).Return(nil, nil) - db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) - db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), organizationID).Return(nil, nil) - - db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( - func(fn func(database.Store) error, _ *database.TxOptions) error { - return fn(usageTx) - }, - ) - - usageTx.EXPECT().GetChatByIDForUpdate(gomock.Any(), chatID).Return(landedChat, nil) - - gotChat, err := server.RegenerateChatTitle(ctx, chat) - require.NoError(t, err) - require.Equal(t, landedChat.Title, gotChat.Title, - "the concurrently landed title must survive; the generated title must not be persisted") - - // The in-memory pubsub delivers synchronously, so any event published - // during RegenerateChatTitle is already buffered by now. - select { - case event := <-titleEvents: - t.Fatalf("unexpected %s event published for skipped regeneration (title %q)", - event.Kind, event.Chat.Title) - default: - } -} - func TestResolveUserProviderAPIKeys_StripsDisabledFallbackKeys(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index 69bf87009aab2..b1c05172b7207 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -3178,21 +3178,6 @@ func (c *ExperimentalClient) ReconcileInvalidChatState(ctx context.Context, chat return chat, ReadBodyAsJSON(res, &chat) } -// RegenerateChatTitle requests the server to regenerate the chat's -// title using richer conversation context. -func (c *ExperimentalClient) RegenerateChatTitle(ctx context.Context, chatID uuid.UUID) (Chat, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/experimental/chats/%s/title/regenerate", chatID), nil) - if err != nil { - return Chat{}, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return Chat{}, ReadBodyAsError(res) - } - var chat Chat - return chat, ReadBodyAsJSON(res, &chat) -} - // ProposeChatTitleResponse is returned by the propose-title endpoint. type ProposeChatTitleResponse struct { Title string `json:"title"` diff --git a/docs/ai-coder/agents/tasks-to-chats-migration.md b/docs/ai-coder/agents/tasks-to-chats-migration.md index d22c2390ede66..426ff6f7b28bb 100644 --- a/docs/ai-coder/agents/tasks-to-chats-migration.md +++ b/docs/ai-coder/agents/tasks-to-chats-migration.md @@ -668,7 +668,7 @@ API: | **Labels** | Key-value metadata on chats for filtering (`label` query parameter) | | **Sub-agents** | Agent can spawn child agents for parallel work | | **Diff/PR tracking** | `GET /chats/{chat}/diff` returns change tracking and PR metadata | -| **Title regeneration** | `POST /chats/{chat}/title/regenerate` | +| **Title generation** | `POST /chats/{chat}/title/propose` returns a suggested title | | **Pinning** | Pin and reorder chats via the `pin_order` field | | **Automatic workspace provisioning** | No workspace needed for Q&A. Provisioned only when the agent needs to act | diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 420696a4e4f3a..dcd00f33d1845 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -2982,18 +2982,18 @@ Experimental: this endpoint is subject to change. To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Regenerate chat title +## Propose chat title ### Code samples ```sh # Example request using curl -curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/title/regenerate \ +curl -X POST http://coder-server:8080/api/experimental/chats/{chat}/title/propose \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /api/experimental/chats/{chat}/title/regenerate` +`POST /api/experimental/chats/{chat}/title/propose` Experimental: this endpoint is subject to change. @@ -3009,212 +3009,14 @@ Experimental: this endpoint is subject to change. ```json { - "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", - "archived": true, - "build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb", - "children": [ - { - "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", - "archived": true, - "build_id": "bfb1f3fa-bf7b-43a5-9e0b-26cc050e44cb", - "children": [], - "client_type": "ui", - "context": { - "dirty": true, - "dirty_since": "2019-08-24T14:15:22Z", - "error": "string", - "resources": [ - { - "error": "string", - "kind": "instruction_file", - "size_bytes": 0, - "skill_description": "string", - "skill_name": "string", - "source": "string", - "status": "ok", - "tools": [ - { - "description": "string", - "name": "string" - } - ] - } - ] - }, - "created_at": "2019-08-24T14:15:22Z", - "diff_status": { - "additions": 0, - "approved": true, - "author_avatar_url": "string", - "author_login": "string", - "base_branch": "string", - "changed_files": 0, - "changes_requested": true, - "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", - "commits": 0, - "deletions": 0, - "head_branch": "string", - "pr_number": 0, - "pull_request_draft": true, - "pull_request_state": "string", - "pull_request_title": "string", - "refreshed_at": "2019-08-24T14:15:22Z", - "reviewer_count": 0, - "stale_at": "2019-08-24T14:15:22Z", - "url": "string" - }, - "files": [ - { - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "mime_type": "string", - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "size_bytes": 0 - } - ], - "has_unread": true, - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "labels": { - "property1": "string", - "property2": "string" - }, - "last_error": { - "detail": "string", - "kind": "generic", - "message": "string", - "provider": "string", - "retryable": true, - "status_code": 0 - }, - "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", - "last_reasoning_effort": "string", - "last_turn_summary": "string", - "mcp_server_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "owner_name": "string", - "owner_username": "string", - "parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359", - "pin_order": 0, - "plan_mode": "plan", - "queued_for_capacity": true, - "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", - "shared": true, - "status": "waiting", - "summary": "string", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z", - "warnings": [ - "string" - ], - "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" - } - ], - "client_type": "ui", - "context": { - "dirty": true, - "dirty_since": "2019-08-24T14:15:22Z", - "error": "string", - "resources": [ - { - "error": "string", - "kind": "instruction_file", - "size_bytes": 0, - "skill_description": "string", - "skill_name": "string", - "source": "string", - "status": "ok", - "tools": [ - { - "description": "string", - "name": "string" - } - ] - } - ] - }, - "created_at": "2019-08-24T14:15:22Z", - "diff_status": { - "additions": 0, - "approved": true, - "author_avatar_url": "string", - "author_login": "string", - "base_branch": "string", - "changed_files": 0, - "changes_requested": true, - "chat_id": "efc9fe20-a1e5-4a8c-9c48-f1b30c1e4f86", - "commits": 0, - "deletions": 0, - "head_branch": "string", - "pr_number": 0, - "pull_request_draft": true, - "pull_request_state": "string", - "pull_request_title": "string", - "refreshed_at": "2019-08-24T14:15:22Z", - "reviewer_count": 0, - "stale_at": "2019-08-24T14:15:22Z", - "url": "string" - }, - "files": [ - { - "created_at": "2019-08-24T14:15:22Z", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "mime_type": "string", - "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "size_bytes": 0 - } - ], - "has_unread": true, - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "labels": { - "property1": "string", - "property2": "string" - }, - "last_error": { - "detail": "string", - "kind": "generic", - "message": "string", - "provider": "string", - "retryable": true, - "status_code": 0 - }, - "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", - "last_reasoning_effort": "string", - "last_turn_summary": "string", - "mcp_server_ids": [ - "497f6eca-6276-4993-bfeb-53cbbbba6f08" - ], - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", - "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", - "owner_name": "string", - "owner_username": "string", - "parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359", - "pin_order": 0, - "plan_mode": "plan", - "queued_for_capacity": true, - "root_chat_id": "2898031c-fdce-4e3e-8c53-4481dd42fcd7", - "shared": true, - "status": "waiting", - "summary": "string", - "title": "string", - "updated_at": "2019-08-24T14:15:22Z", - "warnings": [ - "string" - ], - "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + "title": "string" } ``` ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Chat](schemas.md#codersdkchat) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ProposeChatTitleResponse](schemas.md#codersdkproposechattitleresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 38a0ebde20e03..f517c9b79d3df 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12353,6 +12353,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith | `collect_db_metrics` | boolean | false | | | | `enable` | boolean | false | | | +## codersdk.ProposeChatTitleResponse + +```json +{ + "title": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------|--------|----------|--------------|-------------| +| `title` | string | false | | | + ## codersdk.ProvisionerConfig ```json From f4c383decde43d8a5a47a909eb908bcd79cd58de Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 19:05:03 +0000 Subject: [PATCH 7/8] chore(coderd/x/chatd): remove unused manualTitlePersistTimeout constant --- coderd/x/chatd/chatd.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b547b18acfc0c..0cc662263ed2f 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -70,7 +70,6 @@ const ( homeInstructionLookupTimeout = 5 * time.Second workspaceDialValidationDelay = 5 * time.Second turnStatusLabelWriteTimeout = 5 * time.Second - manualTitlePersistTimeout = 5 * time.Second // defaultDialTimeout matches the timeout used by ~8 other // server-side AgentConn callers. defaultDialTimeout = 30 * time.Second From a7b88c7e40d7bf0abd2d254ce4eeea9e1a2a6832 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Mon, 24 Aug 2026 20:27:45 +0000 Subject: [PATCH 8/8] refactor(coderd): remove multi-model fallback from manual title generation Restore the single-model manual title path (resolveManualTitleModel and selectPreferredConfiguredShortTextModelConfig) and delete the candidate walk (titlewalk.go). Keep the 30s generation deadline and the ErrManualTitleTimedOut sentinel so the handler still maps genuine title timeouts to a friendly 504 and caller cancellation to 499. Generated by Coder Agents. --- coderd/exp_chats.go | 7 +- coderd/x/chatd/chatd.go | 127 ++++----- coderd/x/chatd/quickgen.go | 37 ++- coderd/x/chatd/quickgen_internal_test.go | 18 +- .../x/chatd/title_override_internal_test.go | 57 ++-- coderd/x/chatd/titlewalk.go | 210 --------------- coderd/x/chatd/titlewalk_internal_test.go | 253 ------------------ 7 files changed, 99 insertions(+), 610 deletions(-) delete mode 100644 coderd/x/chatd/titlewalk.go delete mode 100644 coderd/x/chatd/titlewalk_internal_test.go diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8d6c69f9313cc..00f2d2d132f3e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -164,10 +164,9 @@ const statusClientClosedRequest = 499 // client-closed request instead of surfacing through the 500 path. // // The 504 branch keys off chatd.ErrManualTitleTimedOut, which chatd -// attaches only when a title deadline (per-attempt or overall walk -// budget) actually expired. A provider failure whose chain merely -// contains an unrelated transport deadline is not tagged and keeps its -// provider-failure surface. +// attaches only when the title-generation deadline actually expired. A +// provider failure whose chain merely contains an unrelated transport +// deadline is not tagged and keeps its provider-failure surface. func maybeWriteManualTitleTimeoutErr(ctx context.Context, rw http.ResponseWriter, err error) bool { switch { case errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled): diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 0cc662263ed2f..6e11785de096a 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -2604,47 +2604,33 @@ func (p *Server) generateManualTitleCandidate( } modelOpts := modelBuildOptions{ActiveAPIKeyID: apiKeyID} - candidates, err := p.resolveManualTitleCandidates(ctx, store, chat, modelOpts) + resolved, err := p.resolveManualTitleModel(ctx, store, chat, modelOpts) if err != nil { return "", err } - // Bound the whole candidate walk so a slow first provider cannot starve - // the fallbacks. Each attempt is separately bounded inside - // generateManualTitle (titleAttemptTimeout). - overallCtx, cancel := context.WithTimeout(ctx, titleOverallTimeout) - defer cancel() - - attempt := func(attemptCtx context.Context, _ manualTitleCandidate, resolved resolvedModelCall) (string, error) { - titleCtx := attemptCtx - finishDebugRun := func(error) {} - if resolved.debugEnabled { - titleCtx, finishDebugRun = p.prepareManualTitleDebugRun( - attemptCtx, - p.debugService(), - chat, - resolved, - messages, - ) - } - - title, genErr := generateManualTitle( - titleCtx, + titleCtx := ctx + finishDebugRun := func(error) {} + if resolved.debugEnabled { + titleCtx, finishDebugRun = p.prepareManualTitleDebugRun( + ctx, + p.debugService(), + chat, + resolved, messages, - pasteText, - resolved.model.LanguageModel(), - titleObjectCall(resolved), ) - finishDebugRun(genErr) - if genErr != nil { - return "", xerrors.Errorf("generate manual title: %w", genErr) - } - return title, nil } - title, _, err := p.walkManualTitleCandidates(overallCtx, chat, candidates, attempt) + title, err := generateManualTitle( + titleCtx, + messages, + pasteText, + resolved.model.LanguageModel(), + titleObjectCall(resolved), + ) + finishDebugRun(err) if err != nil { - return "", err + return "", xerrors.Errorf("generate manual title: %w", err) } return title, nil @@ -2778,22 +2764,12 @@ func deriveChatDebugSeed(messages []database.ChatMessage) ( return triggerMessageID, historyTipMessageID, triggerLabel } -// resolveManualTitleCandidates returns the ordered list of model candidates to -// try for manual title generation. When a deployment override pins the title -// model it is honored exclusively (matching the auto-title path). Otherwise the -// primary is the preferred short-text model the user has credentials for (or -// the chat's own model), followed by the remaining enabled preferred short-text -// models as lazy fallbacks, so a slow or unavailable provider does not fail the -// request. Fallback models are resolved lazily: the common case where the -// primary succeeds never constructs clients it does not use. -func (p *Server) resolveManualTitleCandidates( +func (p *Server) resolveManualTitleModel( ctx context.Context, store database.Store, chat database.Chat, modelOpts modelBuildOptions, -) ([]manualTitleCandidate, error) { - // A deployment override pins the title model; honor it exclusively and do - // not fall through to other models. +) (resolvedModelCall, error) { overrideResolved, overrideSet, overrideErr := p.resolveTitleGenerationModelOverride( ctx, chat, @@ -2801,7 +2777,7 @@ func (p *Server) resolveManualTitleCandidates( ) if overrideErr != nil { if overrideSet { - return nil, xerrors.Errorf( + return resolvedModelCall{}, xerrors.Errorf( "resolve manual title generation model override: %w", overrideErr, ) @@ -2811,55 +2787,42 @@ func (p *Server) resolveManualTitleCandidates( slog.Error(overrideErr), ) } else if overrideSet { - return []manualTitleCandidate{ - newResolvedManualTitleCandidate(overrideResolved), - }, nil + return overrideResolved, nil } - // Non-override: try every enabled preferred short-text model the user has - // credentials for, resolved lazily so a run that succeeds on the first - // candidate never constructs the others. A single organization-aware - // enabled-config lookup feeds the whole list. - var candidates []manualTitleCandidate - seen := make(map[uuid.UUID]bool) modelCtx, err := p.callerModelConfigContext(ctx, chat.OwnerID) if err != nil { - return nil, err + return resolvedModelCall{}, err } - if configs, cfgErr := enabledChatModelConfigsForOrganization(modelCtx, store, chat.OrganizationID); cfgErr != nil { + configs, err := enabledChatModelConfigsForOrganization(modelCtx, store, chat.OrganizationID) + if err != nil { p.logger.Debug(ctx, "failed to list manual title model configs", slog.F("chat_id", chat.ID), - slog.Error(cfgErr), + slog.Error(err), ) - } else { - for _, config := range selectAllConfiguredShortTextModelConfigs(configs) { - if config.ID != uuid.Nil { - if seen[config.ID] { - continue - } - seen[config.ID] = true - } - candidates = append(candidates, p.newLazyManualTitleCandidate(chat, config, modelOpts)) - } + return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) } - // When no preferred model is configured, fall back to the chat's own model. - // Resolved eagerly so its error (e.g. ErrNoDefaultChatModelConfig) surfaces - // to the handler's existing status mapping instead of a generic walk error. - if len(candidates) == 0 { - fallbackResolved, fallbackErr := p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) - if fallbackErr != nil { - return nil, fallbackErr - } - return []manualTitleCandidate{newResolvedManualTitleCandidate(fallbackResolved)}, nil + config, ok := selectPreferredConfiguredShortTextModelConfig(configs) + if !ok { + return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) } - // Keep the chat's own model as the final candidate so the walk can still - // succeed when every preferred model fails to resolve or is unavailable. - // Resolved lazily, and skipped when it duplicates a preferred candidate - // that was already attempted. - candidates = append(candidates, p.newChatModelFallbackManualTitleCandidate(chat, modelOpts, seen)) - return candidates, nil + resolved, err := p.resolveModelCall(ctx, modelCallSpec{ + purpose: "title", + chat: chat, + explicitConfig: &config, + buildOptions: modelOpts, + }) + if err != nil { + p.logger.Debug(ctx, "manual title preferred model unavailable", + slog.F("chat_id", chat.ID), + slog.F("model", config.Model), + slog.Error(err), + ) + return p.resolveFallbackManualTitleModel(ctx, chat, modelOpts) + } + return resolved, nil } func (p *Server) resolveFallbackManualTitleModel( diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 2e2765596547c..c93bc9b725917 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -144,15 +144,9 @@ type shortTextCandidate struct { resolved resolvedModelCall } -// selectAllConfiguredShortTextModelConfigs returns every enabled model config -// that matches a preferredTitleModels entry, ordered by the preferredTitleModels -// priority list. Each preferred (provider, model) pair contributes at most one -// config. The manual title candidate walk uses this so a slow or unavailable -// provider can fall through to another configured preferred model. -func selectAllConfiguredShortTextModelConfigs( +func selectPreferredConfiguredShortTextModelConfig( configs []database.GetEnabledChatModelConfigsByOrganizationRow, -) []database.ChatModelConfig { - out := make([]database.ChatModelConfig, 0, len(preferredTitleModels)) +) (database.ChatModelConfig, bool) { for _, preferred := range preferredTitleModels { for _, config := range configs { if chatprovider.NormalizeProvider(config.Provider) != preferred.provider { @@ -161,11 +155,10 @@ func selectAllConfiguredShortTextModelConfigs( if !strings.EqualFold(strings.TrimSpace(config.ChatModelConfig.Model), preferred.model) { continue } - out = append(out, config.ChatModelConfig) - break + return config.ChatModelConfig, true } } - return out + return database.ChatModelConfig{}, false } func normalizeShortTextOutput(text string) string { @@ -840,6 +833,26 @@ func renderManualTitlePrompt( return prompt.String() } +// manualTitleGenerationTimeout bounds the model call for manual title +// generation so a slow or hung provider cannot hold the rename dialog's +// Generate request indefinitely. +const manualTitleGenerationTimeout = 30 * time.Second + +// ErrManualTitleTimedOut marks a manual title failure caused by an expired +// title-generation deadline, as opposed to an unrelated error that merely +// wraps context.DeadlineExceeded. The handler maps it to a friendly 504. +var ErrManualTitleTimedOut = xerrors.New("manual title generation timed out") + +// markManualTitleTimeout tags err with ErrManualTitleTimedOut when it stems +// from a deadline so the handler can distinguish a genuine title timeout from +// other failures. +func markManualTitleTimeout(err error) error { + if err == nil { + return nil + } + return errors.Join(ErrManualTitleTimedOut, err) +} + func generateManualTitle( ctx context.Context, messages []database.ChatMessage, @@ -865,7 +878,7 @@ func generateManualTitle( latestUserMsg, ) - titleCtx, cancel := context.WithTimeout(ctx, titleAttemptTimeout) + titleCtx, cancel := context.WithTimeout(ctx, manualTitleGenerationTimeout) defer cancel() userInput := strings.TrimSpace(latestUserMsg) diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index a21ccb40a579a..fe67c2da4a4d5 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -794,10 +794,10 @@ func Test_generateManualTitle_ErrorsOnEmptyNormalizedTitle(t *testing.T) { require.ErrorContains(t, err, "generated title was empty") } -func Test_selectAllConfiguredShortTextModelConfigs(t *testing.T) { +func Test_selectPreferredConfiguredShortTextModelConfig(t *testing.T) { t.Parallel() - t.Run("returns preferred configs ordered by priority", func(t *testing.T) { + t.Run("chooses the highest-priority configured lightweight model", func(t *testing.T) { t.Parallel() configs := []database.GetEnabledChatModelConfigsByOrganizationRow{ @@ -806,20 +806,20 @@ func Test_selectAllConfiguredShortTextModelConfigs(t *testing.T) { {ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1"}, Provider: "openai"}, } - got := selectAllConfiguredShortTextModelConfigs(configs) - require.Len(t, got, 2) - require.Equal(t, preferredTitleModels[1].model, got[0].Model) - require.Equal(t, preferredTitleModels[2].model, got[1].Model) + got, ok := selectPreferredConfiguredShortTextModelConfig(configs) + require.True(t, ok) + require.Equal(t, preferredTitleModels[1].model, got.Model) }) - t.Run("returns empty when no preferred lightweight model is configured", func(t *testing.T) { + t.Run("returns false when no preferred lightweight model is configured", func(t *testing.T) { t.Parallel() - got := selectAllConfiguredShortTextModelConfigs([]database.GetEnabledChatModelConfigsByOrganizationRow{{ + got, ok := selectPreferredConfiguredShortTextModelConfig([]database.GetEnabledChatModelConfigsByOrganizationRow{{ ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1"}, Provider: "openai", }}) - require.Empty(t, got) + require.False(t, ok) + require.Equal(t, database.ChatModelConfig{}, got) }) } diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index b490067091d65..d483ac7d3b319 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -327,7 +327,7 @@ func TestMaybeGenerateChatTitle_TitleGenerationOverrideCallFailureSkipsFallback( require.False(t, ok) } -func TestResolveManualTitleCandidates_TitleGenerationOverrideUnset(t *testing.T) { +func TestResolveManualTitleModel_TitleGenerationOverrideUnset(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -342,41 +342,27 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideUnset(t *testing.T) Model: preferredTitleModels[1].model, Enabled: true, } - // Point the chat's own model at the preferred config so the appended - // chat-model fallback exercises its dedupe path. - chat.LastModelConfigID = preferredConfig.ID db.EXPECT().GetChatOrganizationModelOverride(gomock.Any(), titleGenerationOverrideParams(chat)).Return(database.ChatOrganizationModelOverride{}, sql.ErrNoRows) db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return([]database.GetEnabledChatModelConfigsByOrganizationRow{ {ChatModelConfig: database.ChatModelConfig{Model: "gpt-4.1", Enabled: true}, Provider: "openai"}, {ChatModelConfig: preferredConfig, Provider: preferredTitleModels[1].provider}, }, nil) - db.EXPECT().GetEnabledChatModelConfigByID(gomock.Any(), preferredConfig.ID).Return(preferredConfig, nil) db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) - // One preferred short-text candidate plus the chat-model final fallback. - require.Len(t, candidates, 2) - resolved, err := candidates[0].resolve(ctx) - require.NoError(t, err) require.True(t, resolved.model.Valid()) require.Equal(t, preferredConfig, resolved.dbConfig) - - // The chat's own model resolves to the preferred config that was already - // attempted, so the fallback candidate skips itself instead of retrying - // the same model. - _, err = candidates[1].resolve(ctx) - require.ErrorIs(t, err, errManualTitleCandidateSkip) } -func TestResolveManualTitleCandidates_NonDefaultOrgDoesNotUseDefaultOrgConfigs(t *testing.T) { +func TestResolveManualTitleModel_NonDefaultOrgDoesNotUseDefaultOrgConfigs(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -390,17 +376,17 @@ func TestResolveManualTitleCandidates_NonDefaultOrgDoesNotUseDefaultOrgConfigs(t db.EXPECT().GetEnabledChatModelConfigsByOrganization(gomock.Any(), chat.OrganizationID).Return(nil, nil).AnyTimes() server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.ErrorIs(t, err, ErrNoDefaultChatModelConfig) - require.Nil(t, candidates) + require.Equal(t, resolvedModelCall{}, resolved) } -func TestResolveManualTitleCandidates_TitleGenerationOverrideUnsetAIProvider(t *testing.T) { +func TestResolveManualTitleModel_TitleGenerationOverrideUnsetAIProvider(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -438,21 +424,18 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideUnsetAIProvider(t * }}, nil).AnyTimes() server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) - require.NotEmpty(t, candidates) - resolved, err := candidates[0].resolve(ctx) - require.NoError(t, err) require.True(t, resolved.model.Valid()) require.Equal(t, preferredConfig, resolved.dbConfig) } -func TestResolveManualTitleCandidates_TitleGenerationOverrideReadDBError(t *testing.T) { +func TestResolveManualTitleModel_TitleGenerationOverrideReadDBError(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -476,21 +459,18 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideReadDBError(t *test db.EXPECT().GetAIProviderByID(gomock.Any(), providerID).Return(aibridgeTestAIProvider(providerID, "primary-openai", database.AIProviderTypeOpenai), nil).AnyTimes() server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) - require.NotEmpty(t, candidates) - resolved, err := candidates[0].resolve(ctx) - require.NoError(t, err) require.True(t, resolved.model.Valid()) require.Equal(t, preferredConfig, resolved.dbConfig) } -func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUsable(t *testing.T) { +func TestResolveManualTitleModel_TitleGenerationOverrideSetUsable(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -511,21 +491,18 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUsable(t *testin }}, nil).AnyTimes() server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, modelBuildOptions{ActiveAPIKeyID: uuid.NewString()}, ) require.NoError(t, err) - require.NotEmpty(t, candidates) - resolved, err := candidates[0].resolve(ctx) - require.NoError(t, err) require.True(t, resolved.model.Valid()) require.Equal(t, overrideConfig, resolved.dbConfig) } -func TestResolveManualTitleCandidates_TitleGenerationOverrideMissingCredentials(t *testing.T) { +func TestResolveManualTitleModel_TitleGenerationOverrideMissingCredentials(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -549,7 +526,7 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideMissingCredentials( // Missing override credentials hard-fail the configured override. server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, @@ -557,7 +534,7 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideMissingCredentials( ) require.ErrorContains(t, err, "resolve manual title generation model override") require.ErrorContains(t, err, "credentials are unavailable") - require.Nil(t, candidates) + require.Equal(t, resolvedModelCall{}, resolved) } func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) { @@ -639,7 +616,7 @@ func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) { require.Equal(t, "title-options-sentinel", raw["user"]) } -func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUnusable(t *testing.T) { +func TestResolveManualTitleModel_TitleGenerationOverrideSetUnusable(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -654,7 +631,7 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUnusable(t *test // A disabled configured override is a hard failure. server := titleOverrideTestServer(db, logger) - candidates, err := server.resolveManualTitleCandidates( + resolved, err := server.resolveManualTitleModel( ctx, db, chat, @@ -662,7 +639,7 @@ func TestResolveManualTitleCandidates_TitleGenerationOverrideSetUnusable(t *test ) require.ErrorContains(t, err, "resolve manual title generation model override") require.ErrorContains(t, err, "model override is unavailable") - require.Nil(t, candidates) + require.Equal(t, resolvedModelCall{}, resolved) } func TestParseModelOverride(t *testing.T) { diff --git a/coderd/x/chatd/titlewalk.go b/coderd/x/chatd/titlewalk.go deleted file mode 100644 index 6ec97164ca259..0000000000000 --- a/coderd/x/chatd/titlewalk.go +++ /dev/null @@ -1,210 +0,0 @@ -package chatd - -import ( - "context" - "errors" - "time" - - "github.com/google/uuid" - "golang.org/x/xerrors" - - "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/x/chatd/chatretry" -) - -const ( - // titleAttemptTimeout bounds a single model call for manual title - // generation (applied inside generateManualTitle). A slow or hung - // provider is killed at this deadline so the candidate walk can fall - // through to the next model instead of burning the overall budget. - titleAttemptTimeout = 30 * time.Second - // titleOverallTimeout bounds the entire manual title candidate walk so a - // slow first provider cannot starve the fallbacks. Multiple per-attempt - // deadlines fit within it. - titleOverallTimeout = 90 * time.Second -) - -// ErrManualTitleTimedOut marks a manual title failure caused by an expired -// title deadline (the per-attempt timeout or the overall walk budget), as -// opposed to a provider error whose chain merely contains an unrelated -// transport deadline. The API handler maps this sentinel to a friendly 504. -var ErrManualTitleTimedOut = xerrors.New("manual title generation timed out") - -// errManualTitleCandidateSkip marks a candidate that turned out to be -// redundant at resolve time, for example the chat-model fallback resolving to -// a preferred config that was already attempted. The walker skips it without -// replacing an earlier attempt's more meaningful error. -var errManualTitleCandidateSkip = xerrors.New("manual title candidate skipped") - -// markManualTitleTimeout tags err with ErrManualTitleTimedOut when it stems -// from an expired context deadline, so the handler can distinguish a real -// title timeout from a provider failure that wraps one. -func markManualTitleTimeout(err error) error { - if err == nil || !errors.Is(err, context.DeadlineExceeded) { - return err - } - return errors.Join(ErrManualTitleTimedOut, err) -} - -// manualTitleCandidate is one model the manual title walk can try. resolve -// builds the runnable model lazily so the common case (the first candidate -// succeeds) never constructs clients it does not use, and unit tests that -// only exercise the primary candidate do not force fallback resolution. -type manualTitleCandidate struct { - config database.ChatModelConfig - resolve func(ctx context.Context) (resolvedModelCall, error) -} - -// manualTitleFallThrough reports whether a failed manual title attempt should -// advance to the next candidate. Only per-attempt deadline expiry and -// chatretry-classified transient errors fall through; non-retryable errors -// (auth, config) stop the walk so the real failure surfaces instead of -// silently trying every provider until the overall budget is exhausted. -func manualTitleFallThrough(err error) bool { - if errors.Is(err, context.DeadlineExceeded) { - return true - } - return chatretry.IsRetryable(err) -} - -// walkManualTitleCandidates tries each candidate in order, falling through on -// transient or per-attempt-timeout failures per manualTitleFallThrough. It -// returns the first success along with the winning candidate's config. -// -// When ctx is canceled or its overall deadline expires, walkManualTitleCandidates -// surfaces ctx.Err() rather than the last candidate's (stale) error, so the -// handler maps caller cancellation to 499 and overall-budget expiry to 504 -// instead of leaking a wrapped provider 500. This includes the window where a -// candidate has already failed and ctx is canceled before the next attempt. -func (p *Server) walkManualTitleCandidates( - ctx context.Context, - chat database.Chat, - candidates []manualTitleCandidate, - attempt func(ctx context.Context, cand manualTitleCandidate, resolved resolvedModelCall) (string, error), -) (string, database.ChatModelConfig, error) { - var lastErr error - var lastConfig database.ChatModelConfig - for _, cand := range candidates { - // Overall budget exhausted or caller canceled between attempts. - if ctxErr := ctx.Err(); ctxErr != nil { - return "", lastConfig, markManualTitleTimeout(ctxErr) - } - - resolved, err := cand.resolve(ctx) - if err != nil { - // Model construction is best-effort: log and try the next - // candidate rather than failing the whole request. - p.logger.Debug(ctx, "manual title candidate unavailable", - slog.F("chat_id", chat.ID), - slog.F("model", cand.config.Model), - slog.Error(err), - ) - if errors.Is(err, errManualTitleCandidateSkip) { - // Redundant candidate; keep the earlier, more - // meaningful error. - continue - } - lastErr = err - lastConfig = cand.config - continue - } - - title, err := attempt(ctx, cand, resolved) - if err == nil { - return title, cand.config, nil - } - lastErr = err - lastConfig = cand.config - - // Caller-side cancellation or overall-budget expiry wins over the - // candidate's own error so the handler maps to 499/504 instead of a - // stale provider 500. Checked here (not only at the top of the loop) - // to cover cancellation in the window after this attempt failed. - if ctxErr := ctx.Err(); ctxErr != nil { - return "", lastConfig, markManualTitleTimeout(ctxErr) - } - if !manualTitleFallThrough(err) { - return "", lastConfig, lastErr - } - } - if lastErr == nil { - lastErr = xerrors.New("no manual title model candidates available") - } - return "", lastConfig, lastErr -} - -// newResolvedManualTitleCandidate wraps an already-resolved model as a -// candidate whose resolve step is a no-op. -func newResolvedManualTitleCandidate(resolved resolvedModelCall) manualTitleCandidate { - return manualTitleCandidate{ - config: resolved.dbConfig, - resolve: func(context.Context) (resolvedModelCall, error) { - return resolved, nil - }, - } -} - -// newChatModelFallbackManualTitleCandidate returns the chat's own model as a -// final walk candidate so the request can still succeed when every preferred -// short-text model fails to resolve or is unavailable. Resolution is lazy and -// skips itself (errManualTitleCandidateSkip) when the chat's model resolves to -// a config that was already attempted as a preferred candidate. -func (p *Server) newChatModelFallbackManualTitleCandidate( - chat database.Chat, - modelOpts modelBuildOptions, - attempted map[uuid.UUID]bool, -) manualTitleCandidate { - return manualTitleCandidate{ - resolve: func(ctx context.Context) (resolvedModelCall, error) { - config, err := p.resolveModelConfig(ctx, chat) - if err != nil { - return resolvedModelCall{}, xerrors.Errorf( - "resolve fallback manual title model config: %w", - err, - ) - } - if config.ID != uuid.Nil && attempted[config.ID] { - return resolvedModelCall{}, xerrors.Errorf( - "%w: chat model %q already attempted as a preferred candidate", - errManualTitleCandidateSkip, - config.Model, - ) - } - resolved, err := p.resolveModelCall(ctx, modelCallSpec{ - purpose: "title", - chat: chat, - explicitConfig: &config, - buildOptions: modelOpts, - }) - if err != nil { - return resolvedModelCall{}, xerrors.Errorf( - "create fallback manual title model: %w", - err, - ) - } - return resolved, nil - }, - } -} - -// newLazyManualTitleCandidate builds a candidate whose model is constructed on -// first use, so fallback providers are only dialed when an earlier candidate -// fails. -func (p *Server) newLazyManualTitleCandidate( - chat database.Chat, - config database.ChatModelConfig, - modelOpts modelBuildOptions, -) manualTitleCandidate { - return manualTitleCandidate{ - config: config, - resolve: func(ctx context.Context) (resolvedModelCall, error) { - return p.resolveModelCall(ctx, modelCallSpec{ - purpose: "title", - chat: chat, - explicitConfig: &config, - buildOptions: modelOpts, - }) - }, - } -} diff --git a/coderd/x/chatd/titlewalk_internal_test.go b/coderd/x/chatd/titlewalk_internal_test.go deleted file mode 100644 index 89055446a989f..0000000000000 --- a/coderd/x/chatd/titlewalk_internal_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package chatd - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" - - "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/database" -) - -func walkTestServer(t *testing.T) *Server { - t.Helper() - return &Server{ - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - } -} - -// resolvedCandidate builds a candidate whose resolve step always succeeds with -// an empty model; the walker tests exercise attempt behavior, not real model -// construction. -func resolvedCandidate(model string) manualTitleCandidate { - return manualTitleCandidate{ - config: database.ChatModelConfig{Model: model}, - resolve: func(context.Context) (resolvedModelCall, error) { - return resolvedModelCall{}, nil - }, - } -} - -func TestWalkManualTitleCandidates(t *testing.T) { - t.Parallel() - - t.Run("FirstCandidateWins", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - var calls int - title, config, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("a"), resolvedCandidate("b")}, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - calls++ - return "Title A", nil - }, - ) - require.NoError(t, err) - require.Equal(t, "Title A", title) - require.Equal(t, "a", config.Model) - require.Equal(t, 1, calls, "should stop after the first success") - }) - - t.Run("FallsThroughTimeoutToNextCandidate", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - var models []string - title, config, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("slow"), resolvedCandidate("fast")}, - func(_ context.Context, cand manualTitleCandidate, _ resolvedModelCall) (string, error) { - models = append(models, cand.config.Model) - if cand.config.Model == "slow" { - return "", xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded) - } - return "Title Fast", nil - }, - ) - require.NoError(t, err) - require.Equal(t, "Title Fast", title) - require.Equal(t, "fast", config.Model) - require.Equal(t, []string{"slow", "fast"}, models) - }) - - t.Run("StopsOnNonRetryableError", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - var calls int - sentinel := xerrors.New("bad api key") - _, config, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("first"), resolvedCandidate("second")}, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - calls++ - return "", sentinel - }, - ) - require.ErrorIs(t, err, sentinel) - require.Equal(t, "first", config.Model) - require.Equal(t, 1, calls, "non-retryable error must not fall through") - }) - - t.Run("SkipsCandidateThatFailsToResolve", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - var attempted []string - candidates := []manualTitleCandidate{ - { - config: database.ChatModelConfig{Model: "unavailable"}, - resolve: func(context.Context) (resolvedModelCall, error) { - return resolvedModelCall{}, xerrors.New("no credentials") - }, - }, - resolvedCandidate("available"), - } - title, config, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - candidates, - func(_ context.Context, cand manualTitleCandidate, _ resolvedModelCall) (string, error) { - attempted = append(attempted, cand.config.Model) - return "Title", nil - }, - ) - require.NoError(t, err) - require.Equal(t, "Title", title) - require.Equal(t, "available", config.Model) - require.Equal(t, []string{"available"}, attempted, "unresolvable candidate is skipped, not attempted") - }) - - t.Run("AllCandidatesTimeoutReturnsDeadlineExceeded", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - _, _, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("a"), resolvedCandidate("b")}, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - return "", xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded) - }, - ) - require.ErrorIs(t, err, context.DeadlineExceeded) - }) - - // Regression for the review finding: when the context is canceled after a - // candidate has already failed, the walker must surface ctx.Err() rather - // than the stale candidate error, so the handler maps it to 499/504 instead - // of a stale 500. - t.Run("CancellationAfterFailureSurfacesCtxErr", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - staleErr := xerrors.New("stale provider error") - var calls int - _, _, err := p.walkManualTitleCandidates( - ctx, - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("a"), resolvedCandidate("b")}, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - calls++ - // Simulate the caller disconnecting during this attempt. - cancel() - return "", staleErr - }, - ) - require.ErrorIs(t, err, context.Canceled) - require.False(t, errors.Is(err, staleErr), "must not surface the stale candidate error") - require.Equal(t, 1, calls, "must not try the next candidate after cancellation") - }) - - t.Run("PreCanceledContextSurfacesCtxErr", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - var calls int - _, _, err := p.walkManualTitleCandidates( - ctx, - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("a")}, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - calls++ - return "Title", nil - }, - ) - require.ErrorIs(t, err, context.Canceled) - require.Zero(t, calls, "a pre-canceled context must not run any attempt") - }) - - // The overall walk budget expiring is a genuine title timeout, so the - // walker must tag it with ErrManualTitleTimedOut for the handler's 504 - // mapping. - t.Run("OverallDeadlineMarksTimeout", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) - defer cancel() - var calls int - _, _, err := p.walkManualTitleCandidates( - ctx, - database.Chat{}, - []manualTitleCandidate{resolvedCandidate("a")}, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - calls++ - return "Title", nil - }, - ) - require.ErrorIs(t, err, ErrManualTitleTimedOut) - require.ErrorIs(t, err, context.DeadlineExceeded) - require.Zero(t, calls, "an expired overall budget must not run any attempt") - }) - - // A candidate that skips itself at resolve time (e.g. the chat-model - // fallback duplicating an already-attempted preferred candidate) must not - // replace the earlier attempt's more meaningful error. - t.Run("SkipCandidateKeepsEarlierError", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - candidates := []manualTitleCandidate{ - resolvedCandidate("preferred"), - { - resolve: func(context.Context) (resolvedModelCall, error) { - return resolvedModelCall{}, xerrors.Errorf( - "%w: duplicate of preferred", - errManualTitleCandidateSkip, - ) - }, - }, - } - _, config, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - candidates, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - return "", xerrors.Errorf("generate manual title: %w", context.DeadlineExceeded) - }, - ) - require.ErrorIs(t, err, context.DeadlineExceeded) - require.False(t, errors.Is(err, errManualTitleCandidateSkip), - "skip sentinel must not replace the attempt error") - require.Equal(t, "preferred", config.Model) - }) - - t.Run("NoCandidates", func(t *testing.T) { - t.Parallel() - p := walkTestServer(t) - _, _, err := p.walkManualTitleCandidates( - context.Background(), - database.Chat{}, - nil, - func(context.Context, manualTitleCandidate, resolvedModelCall) (string, error) { - return "Title", nil - }, - ) - require.ErrorContains(t, err, "no manual title model candidates available") - }) -}