fix: avoid 500 when generating chat title from rename dialog and remove unused endpoint (#28306)

This commit is contained in:
Garrett Delfosse
2026-08-25 06:52:45 -04:00
committed by GitHub
parent 511dcc0293
commit eedc0ece47
13 changed files with 243 additions and 1008 deletions
+12 -4
View File
@@ -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": {
+12 -4
View File
@@ -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": {
-1
View File
@@ -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)
+44 -51
View File
@@ -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(),
+97
View File
@@ -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")
})
}
}
+27 -312
View File
@@ -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()
-92
View File
@@ -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
-322
View File
@@ -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()
+29 -1
View File
@@ -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
}
-15
View File
@@ -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"`
@@ -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 |
+7 -205
View File
@@ -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).
+14
View File
@@ -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