diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 65a2edb6978..d6b90f982e2 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" } } }, @@ -25194,6 +25194,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 89ad2a191a4..0cb630c4460 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" } } }, @@ -23119,6 +23119,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 208b484e5ee..7199a8977d6 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 4cbfa58e116..00f2d2d132f 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -146,6 +146,43 @@ 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 +// title-timeout errors from the manual title pipeline into friendly +// 499/504 responses instead of a raw 500 that leaks the wrapped error +// 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 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): + httpapi.Write(ctx, rw, statusClientClosedRequest, codersdk.Response{ + Message: "Title generation was canceled.", + }) + return true + case errors.Is(err, chatd.ErrManualTitleTimedOut): + 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 @@ -3381,63 +3418,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 - } - 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() @@ -3475,6 +3465,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 9b134a21b2e..5f35e7e7aa5 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -1,8 +1,10 @@ package coderd import ( + "context" "database/sql" "encoding/json" + "errors" "net/http" "net/http/httptest" "reflect" @@ -21,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" @@ -564,3 +567,97 @@ func TestIsZeroChatModelCallConfigCoversEveryField(t *testing.T) { "isZeroChatModelCallConfig ignores field %s", field.Name) } } + +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 + wantMessage string + }{ + { + // 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. + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rw := httptest.NewRecorder() + 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") + 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") + }) + } +} diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 71cbda7ae46..ed5e8a238a3 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,289 +11274,6 @@ func TestCompactChat(t *testing.T) { }) } -func TestRegenerateChatTitle(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.RegenerateChatTitle(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.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) - - 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) - }) - - 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 regeneration", - }}, - }) - require.NoError(t, err) - - unauthenticatedClient := codersdk.NewExperimentalClient(codersdk.New(client.URL)) - _, err = unauthenticatedClient.RegenerateChatTitle(ctx, chat.ID) - requireSDKError(t, err, http.StatusUnauthorized) - }) - - t.Run("PasteOnlyChat", 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: "New Chat", - Status: database.ChatStatusWaiting, - }) - // The chat's only user message is a synthetic pasted-text - // 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() @@ -11627,6 +11343,31 @@ func TestProposeChatTitle(t *testing.T) { requireSDKError(t, err, http.StatusUnauthorized) }) + t.Run("PasteOnlyChat", 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: "New Chat", + Status: database.ChatStatusWaiting, + }) + // The chat's only user message is a synthetic pasted-text + // attachment with no text parts. + seedPasteOnlyTitleSourceMessage(ctx, t, db, chat, modelConfig.ID, "pasted stack trace for title") + + proposed, err := client.ProposeChatTitle(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, "Test Chat", proposed.Title) + }) + t.Run("DoesNotBumpHistoryVersion", func(t *testing.T) { t.Parallel() @@ -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 21a50a99a30..54b62474b5c 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 @@ -2524,23 +2523,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, @@ -2657,40 +2639,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, @@ -2929,46 +2877,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 d32455e8ee4..de66681f1de 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/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index 5aa47a9015f..c93bc9b7259 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -833,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, @@ -858,7 +878,7 @@ func generateManualTitle( latestUserMsg, ) - titleCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + titleCtx, cancel := context.WithTimeout(ctx, manualTitleGenerationTimeout) defer cancel() userInput := strings.TrimSpace(latestUserMsg) @@ -874,6 +894,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/codersdk/chats.go b/codersdk/chats.go index 69bf87009aa..b1c05172b72 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 d22c2390ede..426ff6f7b28 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 420696a4e4f..dcd00f33d18 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 2320d4ff0d4..04849e8cfc6 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -12356,6 +12356,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