diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a25e6231a1..9b62119367 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -22161,6 +22161,7 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.ChatClientType" }, "content": { + "description": "Content is the initial user message. It is optional: when\nempty, the chat is created idle with no initial user message\nand generation starts with the first message POSTed to\n/chats/{chat}/messages.", "type": "array", "items": { "$ref": "#/definitions/codersdk.ChatInputPart" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 26fb39f169..7289e202ae 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -20103,6 +20103,7 @@ "$ref": "#/definitions/codersdk.ChatClientType" }, "content": { + "description": "Content is the initial user message. It is optional: when\nempty, the chat is created idle with no initial user message\nand generation starts with the first message POSTed to\n/chats/{chat}/messages.", "type": "array", "items": { "$ref": "#/definitions/codersdk.ChatInputPart" diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 1bd1ed1ceb..f6c717eb68 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -1545,8 +1545,10 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) { // Kick off best-effort automatic title generation now that the // chat and its initial user message are persisted. It runs // detached so it never blocks the create response, and only acts - // on the first user turn. - api.chatDaemon.GenerateChatTitleAsync(ownerCtx, chat) + // on the first user turn. Empty creates title on their first send. + if len(contentBlocks) > 0 { + api.chatDaemon.GenerateChatTitleAsync(ownerCtx, chat) + } chatFiles := api.fetchChatFileMetadata(ownerCtx, chat.ID) response := db2sdk.Chat(chat, nil, chatFiles) @@ -2824,6 +2826,24 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } + // Chats created without an initial message have no stored effort, + // so apply the personal override postChats would have applied. + if reasoningEffort == nil && !chat.LastReasoningEffort.Valid { + overrideModelConfigID, overrideEffort, err := api.personalRootModelOverride(ctx, chat.OwnerID, chat.OrganizationID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to resolve chat model config.", + Detail: err.Error(), + }) + return + } + if overrideEffort != nil && + overrideModelConfigID == cmp.Or(modelConfigID, chat.LastModelConfigID) && + chatprovider.IsValidReasoningEffort(*overrideEffort) { + reasoningEffort = overrideEffort + } + } + sendResult, sendErr := api.chatDaemon.SendMessage( ctx, chatd.SendMessageOptions{ @@ -2894,6 +2914,17 @@ func (api *API) postChatMessages(rw http.ResponseWriter, r *http.Request) { return } + // Pass the inserted message as the snapshot: SendMessage published + // the ownership hint, so a fresh history read could already contain + // the assistant reply and disqualify title generation. + if sendResult.FirstUserTurn { + api.chatDaemon.GenerateChatTitleForMessagesAsync( + ctx, + sendResult.Chat, + []database.ChatMessage{sendResult.Message}, + ) + } + response := codersdk.CreateChatMessageResponse{Queued: sendResult.Queued} if sendResult.Queued { if sendResult.QueuedMessage != nil { @@ -4407,75 +4438,87 @@ func (api *API) resolveCreateChatModelConfigID( return *req.ModelConfigID, nil, 0, nil } - personalOverridesEnabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx) + overrideModelConfigID, overrideEffort, err := api.personalRootModelOverride(ctx, userID, req.OrganizationID) if err != nil { return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ Message: "Failed to resolve chat model config.", Detail: err.Error(), } } - if !personalOverridesEnabled { - id, status, resp := api.defaultCreateChatModelConfigID(ctx, req.OrganizationID) - return id, nil, status, resp - } - - override, err := api.Database.GetChatUserModelOverride(ctx, database.GetChatUserModelOverrideParams{ - UserID: userID, - OrganizationID: req.OrganizationID, - Context: string(codersdk.ChatPersonalModelOverrideContextRoot), - }) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ - Message: "Failed to resolve chat model config.", - Detail: err.Error(), - } - } - if err == nil { - switch codersdk.ChatPersonalModelOverrideMode(override.Mode) { - case codersdk.ChatPersonalModelOverrideModeChatDefault: - case codersdk.ChatPersonalModelOverrideModeModel: - if override.ModelConfigID.Valid { - _, reason, err := api.userCanUseChatModelConfig( - ctx, - userID, - req.OrganizationID, - override.ModelConfigID.UUID, - ) - if err != nil { - return uuid.Nil, nil, http.StatusInternalServerError, &codersdk.Response{ - Message: "Failed to resolve chat model config.", - Detail: err.Error(), - } - } - if reason == chatModelConfigAvailable { - var effort *string - if override.ReasoningEffort.Valid { - effort = &override.ReasoningEffort.String - } - return override.ModelConfigID.UUID, effort, 0, nil - } - api.Logger.Debug( - ctx, - "personal root model override is unavailable, using default model", - slog.F("user_id", userID), - slog.F("model_config_id", override.ModelConfigID.UUID), - slog.F("reason", reason), - ) - } - default: - api.Logger.Warn( - ctx, - "unsupported personal root model override mode, using default model", - slog.F("user_id", userID), - slog.F("mode", override.Mode), - ) - } + if overrideModelConfigID != uuid.Nil { + return overrideModelConfigID, overrideEffort, 0, nil } id, status, resp := api.defaultCreateChatModelConfigID(ctx, req.OrganizationID) return id, nil, status, resp } +// personalRootModelOverride returns the user's available personal root +// model override and its reasoning effort, or uuid.Nil when personal +// overrides are disabled or none applies. +func (api *API) personalRootModelOverride( + ctx context.Context, + userID uuid.UUID, + organizationID uuid.UUID, +) (uuid.UUID, *string, error) { + personalOverridesEnabled, err := api.Database.GetChatPersonalModelOverridesEnabled(ctx) + if err != nil { + return uuid.Nil, nil, err + } + if !personalOverridesEnabled { + return uuid.Nil, nil, nil + } + + override, err := api.Database.GetChatUserModelOverride(ctx, database.GetChatUserModelOverrideParams{ + UserID: userID, + OrganizationID: organizationID, + Context: string(codersdk.ChatPersonalModelOverrideContextRoot), + }) + if errors.Is(err, sql.ErrNoRows) { + return uuid.Nil, nil, nil + } + if err != nil { + return uuid.Nil, nil, err + } + switch codersdk.ChatPersonalModelOverrideMode(override.Mode) { + case codersdk.ChatPersonalModelOverrideModeChatDefault: + case codersdk.ChatPersonalModelOverrideModeModel: + if override.ModelConfigID.Valid { + _, reason, err := api.userCanUseChatModelConfig( + ctx, + userID, + organizationID, + override.ModelConfigID.UUID, + ) + if err != nil { + return uuid.Nil, nil, err + } + if reason == chatModelConfigAvailable { + var effort *string + if override.ReasoningEffort.Valid { + effort = &override.ReasoningEffort.String + } + return override.ModelConfigID.UUID, effort, nil + } + api.Logger.Debug( + ctx, + "personal root model override is unavailable, using default model", + slog.F("user_id", userID), + slog.F("model_config_id", override.ModelConfigID.UUID), + slog.F("reason", reason), + ) + } + default: + api.Logger.Warn( + ctx, + "unsupported personal root model override mode, using default model", + slog.F("user_id", userID), + slog.F("mode", override.Mode), + ) + } + return uuid.Nil, nil, nil +} + func (api *API) defaultCreateChatModelConfigID( ctx context.Context, organizationID uuid.UUID, @@ -7077,6 +7120,14 @@ func createChatInputFromRequest(ctx context.Context, db database.Store, req code string, *codersdk.Response, ) { + // Empty content creates an idle chat with no initial user + // message. Generation starts with the first message POSTed to + // /chats/{chat}/messages. This lets clients sequence work that + // needs the chat ID before the first turn, such as workspace + // file uploads. + if len(req.Content) == 0 { + return nil, "", nil + } // Chat creation has no chat ID yet, so workspace-file-reference // parts are rejected (they require an existing chat's uploads). content, pasteData, inputError := createChatInputFromParts(ctx, db, uuid.Nil, uuid.NullUUID{}, req.Content, "content") diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 321bbbe3d6..9a6ec3d47b 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -1669,14 +1669,16 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModel(t, client) - _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + // Empty content creates an idle chat; see + // TestPostChats_EmptyContent for the full behavior. + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, Content: nil, }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "Content is required.", sdkErr.Message) - require.Equal(t, "Content cannot be empty.", sdkErr.Detail) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusWaiting, chat.Status) }) t.Run("EmptyText", func(t *testing.T) { @@ -11069,6 +11071,148 @@ func TestPostChats_AutomaticTitleGenerationPasteOnly(t *testing.T) { coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) } +// TestPostChats_EmptyContent verifies chats can be created without an +// initial message. The chat starts idle in `waiting` with the +// placeholder title and only system messages, can be archived right +// away (the cleanup path for clients whose post-create work fails), +// and its first message inserts directly instead of queueing. +func TestPostChats_EmptyContent(t *testing.T) { + t.Parallel() + + t.Run("CreateIdleThenFirstMessage", 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, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusWaiting, chat.Status) + require.Equal(t, chatprompt.DefaultChatTitle, chat.Title) + + messagesResp, err := client.GetChatMessages(ctx, chat.ID, nil) + require.NoError(t, err) + for _, msg := range messagesResp.Messages { + require.NotEqual(t, codersdk.ChatMessageRoleUser, msg.Role, "an empty create must not insert a user message") + } + require.Empty(t, messagesResp.QueuedMessages) + + // The chat is idle, so the first message inserts directly + // (W -> R0) rather than queueing behind a running turn. + messageResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}}, + }) + require.NoError(t, err) + require.False(t, messageResp.Queued) + require.NotNil(t, messageResp.Message) + }) + + t.Run("ArchiveImmediately", 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, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusWaiting, chat.Status) + + // An idle chat archives without waiting for any generation, + // so clients can clean up when post-create work fails. + archived := true + err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: &archived}) + require.NoError(t, err) + + refreshed, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.True(t, refreshed.Archived) + }) +} + +// TestPostChatMessages_TitleGenerationForEmptyCreatedChat verifies +// that a chat created without an initial message gets its automatic +// title when the first message is posted instead of at create time. +func TestPostChatMessages_TitleGenerationForEmptyCreatedChat(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + titleRequested := make(chan struct{}, 1) + baseURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if req.Stream { + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("Hello from test server.")...) + } + if bytes.Contains(req.RawBody, []byte("propose_title")) { + select { + case titleRequested <- struct{}{}: + default: + } + } + return chattest.OpenAINonStreamingResponse(`{"title": "Generated Title"}`) + }) + + client, _, api := newChatClientWithoutAIBridge(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelWithBaseURL(t, client, baseURL) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + }) + require.NoError(t, err) + require.Equal(t, chatprompt.DefaultChatTitle, chat.Title, "empty creates carry the placeholder title") + + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "name this chat please"}}, + }) + require.NoError(t, err) + + select { + case <-titleRequested: + case <-ctx.Done(): + t.Fatal("timed out waiting for automatic title generation after the first message") + } + + coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) +} + +// TestPostChatMessages_FallbackTitleForEmptyCreatedChat verifies that +// the first message on an empty-created chat persists the fallback +// title even when automatic title generation fails. +func TestPostChatMessages_FallbackTitleForEmptyCreatedChat(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, _, 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, + }) + require.NoError(t, err) + + const text = "summarize the failing upload integration tests for me" + _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: text}}, + }) + require.NoError(t, err) + coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) + + refreshed, err := client.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chatprompt.FallbackTitle(text), refreshed.Title) +} + func TestGetChatDiffStatus(t *testing.T) { t.Parallel() @@ -15289,6 +15433,55 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { require.Equal(t, ptr.Ref("high"), chat.LastReasoningEffort) }) + t.Run("EmptyCreateFirstMessageUsesSavedReasoningEffort", func(t *testing.T) { + reasoningModel, err := adminClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{ + AIProviderID: &overrideProvider.ID, + Model: "claude-root-personal-empty-" + uuid.NewString(), + ContextLimit: &contextLimit, + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("medium"), + Max: ptr.Ref("high"), + }, + }, + }) + require.NoError(t, err) + err = adminClient.UpdateUserChatPersonalModelOverride(ctx, firstUser.OrganizationID, codersdk.Me, codersdk.ChatPersonalModelOverrideContextRoot, codersdk.UpdateUserChatPersonalModelOverrideRequest{ + Mode: codersdk.ChatPersonalModelOverrideModeModel, + ModelConfigID: reasoningModel.ID.String(), + ReasoningEffort: ptr.Ref("high"), + }) + require.NoError(t, err) + + sendFirstMessage := func(modelConfigID *uuid.UUID) database.Chat { + t.Helper() + chat, err := adminClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + }) + require.NoError(t, err) + require.Equal(t, reasoningModel.ID, chat.LastModelConfigID) + require.Nil(t, chat.LastReasoningEffort) + + _, err = adminClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "first message"}}, + ModelConfigID: modelConfigID, + }) + require.NoError(t, err) + storedChat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) + require.NoError(t, err) + return storedChat + } + + storedChat := sendFirstMessage(nil) + require.True(t, storedChat.LastReasoningEffort.Valid) + require.Equal(t, database.ChatReasoningEffortHigh, storedChat.LastReasoningEffort.ChatReasoningEffort) + + // The override effort belongs to its model, so a send that + // targets another model must not inherit it. + storedChat = sendFirstMessage(ptr.Ref(defaultModel.ID)) + require.False(t, storedChat.LastReasoningEffort.Valid) + }) + t.Run("CrossOrgRootModelIsUnrepresentable", func(t *testing.T) { org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) orgModel := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ diff --git a/coderd/exp_chats_workspace_file_test.go b/coderd/exp_chats_workspace_file_test.go index 774edcbe57..48d741dd38 100644 --- a/coderd/exp_chats_workspace_file_test.go +++ b/coderd/exp_chats_workspace_file_test.go @@ -571,6 +571,69 @@ func TestPostChatWorkspaceFile(t *testing.T) { require.NoError(t, err, "archive should not remove workspace files") }) + t.Run("EmptyCreatedChat", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db, api := newChatClientWithAPIAndDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModel(t, client) + + workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: firstUser.OrganizationID, + OwnerID: firstUser.UserID, + }).WithAgent().Do() + _ = startChatUploadAgent(t, client, workspaceBuild) + + // Uploads only need the chat ID and workspace binding, so a + // chat created idle with no messages accepts them. This is + // the sequencing used by the new-chat page: create empty, + // upload, then send the first message with the references. + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + WorkspaceID: &workspaceBuild.Workspace.ID, + }) + require.NoError(t, err) + require.Equal(t, codersdk.ChatStatusWaiting, chat.Status) + + payload := bytes.Repeat([]byte{0x50, 0x4b}, 16) + resp, err := client.UploadChatWorkspaceFile(ctx, chat.ID, "application/zip", "archive.zip", bytes.NewReader(payload)) + require.NoError(t, err) + require.Equal(t, workspaceBuild.Workspace.ID, resp.WorkspaceID) + + bytesOnDisk, err := os.ReadFile(resp.Path) + require.NoError(t, err) + require.Equal(t, payload, bytesOnDisk) + + // The first message carries text plus the uploaded reference + // and starts generation: the idle chat inserts it directly. + messageResp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + {Type: codersdk.ChatInputPartTypeText, Text: "inspect the archive"}, + { + Type: codersdk.ChatInputPartTypeWorkspaceFileReference, + WorkspaceFilePath: resp.Path, + WorkspaceFileName: resp.Name, + WorkspaceFileSize: resp.Size, + WorkspaceFileMediaType: resp.MediaType, + WorkspaceFileWorkspaceID: resp.WorkspaceID, + }, + }, + }) + require.NoError(t, err) + require.False(t, messageResp.Queued) + require.NotNil(t, messageResp.Message) + require.Contains(t, messageResp.Message.Content, codersdk.ChatMessageWorkspaceFileReference( + resp.WorkspaceID, + resp.Path, + resp.Name, + resp.Size, + resp.MediaType, + )) + + coderdtest.WaitForChatSettled(ctx, t, api, chat.ID) + }) + t.Run("ConcurrentSameName", func(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/ARCHITECTURE.md b/coderd/x/chatd/ARCHITECTURE.md index 533e354ab1..b8aa03f301 100644 --- a/coderd/x/chatd/ARCHITECTURE.md +++ b/coderd/x/chatd/ARCHITECTURE.md @@ -115,6 +115,7 @@ I don't recommend reading the rest of section thoroughly if this is your first t ### Transitions used by the HTTP endpoints - `Create(initialMessages)` creates a new chat, initializes `snapshot_version` to 1, inserts its initial history, and lands in `running`. The inserted initial history sets `history_version` to 1. Since the queue has not changed, `queue_version` remains 0. This transition is a special case: since the chat does not exist at the time it's run, the chat row cannot be locked before the transition is applied. +- TODO (#27111): `Create(initialMessages)` now lands in `waiting` instead of `running` when the initial history carries no user message (system messages only); such a chat enters `running` through its first `SendMessage`. The state diagram below needs the matching `N --> W: Create` edge. Describe this here. - `SetArchived(archived)` sets or clears the archived marker for one chat. - `SendMessage(m, busy_behavior)` inserts a user message directly when the chat is idle, or queues it when the chat is busy. `busy_behavior` must be either `queue` or `interrupt`. With `busy_behavior=interrupt`, it also requests interruption or cancels a pending dynamic-tool action as needed. - `EditMessage(k, replacement)` clears queued messages, cancels or obsoletes active work, marks the truncated active-history suffix as deleted, inserts the replacement turn followed by any caller-provided suffix messages, and lands in `running`. @@ -449,6 +450,8 @@ This endpoint uses `Create(initialMessages)`: - `N -> Create(initialMessages) -> R0` +TODO (#27111): a request with an empty `content` array now takes `N -> Create(initialMessages) -> W`: the chat is created idle with system messages only and no worker picks it up, so clients can use the chat ID (for example for workspace file uploads) before the first `POST /api/v2/chats/{chat}/messages` starts generation. Describe this here. + No other input states are supported. ### `PATCH /api/v2/chats/{chat}` diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 32270e7718..d7e2ba18ae 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1105,11 +1105,14 @@ type CreateOptions struct { PlanMode database.NullChatPlanMode ClientType database.ChatClientType SystemPrompt string - InitialUserContent []codersdk.ChatMessagePart - MCPServerIDs []uuid.UUID - InlineMCPServers []codersdk.InlineMCPServerRequest - Labels database.StringMap - DynamicTools json.RawMessage + // InitialUserContent is the first user message. When empty, the + // chat is created idle (`waiting`) with system messages only and + // no worker processes it until the first SendMessage. + InitialUserContent []codersdk.ChatMessagePart + MCPServerIDs []uuid.UUID + InlineMCPServers []codersdk.InlineMCPServerRequest + Labels database.StringMap + DynamicTools json.RawMessage } // SendMessageBusyBehavior controls what happens when a chat is already active. @@ -1149,6 +1152,11 @@ type SendMessageResult struct { // insert messages by promoting the previous queue head. InsertedMessages []database.ChatMessage Chat database.Chat + // FirstUserTurn reports that the send inserted the first + // user-visible message into a chat still carrying + // chatprompt.DefaultChatTitle. The send already persisted the + // fallback title, so callers only schedule the async title upgrade. + FirstUserTurn bool } // EditMessageOptions controls user message edits via soft-delete and re-insert. @@ -1266,8 +1274,10 @@ func (p *Server) applyRequestedMCPServerIDs(ctx context.Context, store database. } // CreateChat creates a chat with its initial history through -// chatstate.CreateChat. The new chat starts in `running` status per -// the chat execution state model. Ownership hints wake chat workers. +// chatstate.CreateChat. With initial user content the new chat starts +// in `running` status and ownership hints wake chat workers. Without +// initial user content the chat is created idle in `waiting` status +// with system messages only; the first SendMessage starts generation. func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.Chat, error) { if opts.OrganizationID == uuid.Nil { return database.Chat{}, xerrors.New("organization_id is required") @@ -1278,8 +1288,9 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C if strings.TrimSpace(opts.Title) == "" { return database.Chat{}, xerrors.New("title is required") } - if len(opts.InitialUserContent) == 0 { - return database.Chat{}, xerrors.New("initial user content is required") + initialStatus := database.ChatStatusWaiting + if len(opts.InitialUserContent) > 0 { + initialStatus = database.ChatStatusRunning } // Ensure MCPServerIDs is non-nil so pq.Array produces '{}' // instead of SQL NULL, which violates the NOT NULL column @@ -1320,7 +1331,9 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C chatID := uuid.New() contentParts := opts.InitialUserContent - if p.hooks.Enabled() { + // The prompt-submit hook fires with the first SendMessage when the + // chat is created without an initial user message. + if p.hooks.Enabled() && len(contentParts) > 0 { // Validate model admission before dispatch, matching the insert path. if err := validateCreateModelConfigID(ctx, p.db, opts.OrganizationID, opts.ModelConfigID); err != nil { return database.Chat{}, err @@ -1361,11 +1374,6 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C if err != nil { return database.Chat{}, xerrors.Errorf("marshal workspace awareness: %w", err) } - userContent, err := chatprompt.MarshalParts(contentParts) - if err != nil { - return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", err) - } - var initialMessages []chatstate.Message if deploymentPrompt != "" { deploymentContent, marshalErr := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ @@ -1386,7 +1394,13 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C initialMessages = append(initialMessages, systemMessage(userPromptContent, opts.ModelConfigID)) } initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, opts.ModelConfigID)) - initialMessages = append(initialMessages, userMessage(userContent, opts.ModelConfigID, cmp.Or(opts.CreatedBy, opts.OwnerID), opts.ReasoningEffort)) + if len(contentParts) > 0 { + userContent, marshalErr := chatprompt.MarshalParts(contentParts) + if marshalErr != nil { + return database.Chat{}, xerrors.Errorf("marshal initial user content: %w", marshalErr) + } + initialMessages = append(initialMessages, userMessage(userContent, opts.ModelConfigID, cmp.Or(opts.CreatedBy, opts.OwnerID), opts.ReasoningEffort)) + } result, err := chatstate.CreateChatWithID(ctx, p.db, p.pubsub, chatID, chatstate.CreateChatInput{ OrganizationID: opts.OrganizationID, @@ -1413,6 +1427,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C ClientType: opts.ClientType, InitialMessages: initialMessages, FileIDs: chatprompt.FileIDs(contentParts), + InitialStatus: initialStatus, }) if err != nil { return database.Chat{}, err @@ -1553,6 +1568,18 @@ func (p *Server) SendMessage( messageCreatedBy = lockedChat.OwnerID } + firstUserTurn := false + if lockedChat.Title == chatprompt.DefaultChatTitle { + visible, err := store.GetChatMessagesByChatIDAscPaginated(ctx, database.GetChatMessagesByChatIDAscPaginatedParams{ + ChatID: opts.ChatID, + LimitVal: 1, + }) + if err != nil { + return xerrors.Errorf("probe visible chat messages: %w", err) + } + firstUserTurn = len(visible) == 0 + } + // Queue capacity is enforced inside tx.SendMessage; this // wrapper only propagates the typed error. message := userMessage(content, modelConfigID, messageCreatedBy, opts.ReasoningEffort) @@ -1582,6 +1609,24 @@ func (p *Server) SendMessage( if err := chatstate.LinkFiles(ctx, store, opts.ChatID, chatprompt.FileIDs(contentParts)); err != nil { return err } + if firstUserTurn && !result.Queued { + result.FirstUserTurn = true + // Persist the fallback title with the message so a failed + // async title call cannot leave the placeholder forever. + firstMessage := []database.ChatMessage{result.Message} + pasteText, err := titlePasteText(ctx, store, firstMessage) + if err != nil { + return err + } + if titleText, ok := titleInput(lockedChat, firstMessage, pasteText); ok { + if _, err := store.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{ + ID: opts.ChatID, + Title: chatprompt.FallbackTitle(titleText), + }); err != nil { + return xerrors.Errorf("update fallback chat title: %w", err) + } + } + } // Capture the post-transition chat inside the same // transaction so the returned chat and the watch event // reflect the snapshot bump and status change produced by @@ -1600,6 +1645,9 @@ func (p *Server) SendMessage( // Sidebar watch event keeps the chat list in sync. Stream side // effects are handled by chat:update consumers. p.publishChatPubsubEvent(result.Chat, codersdk.ChatWatchEventKindStatusChange, nil) + if result.FirstUserTurn && result.Chat.Title != chatprompt.DefaultChatTitle { + p.publishChatPubsubEvent(result.Chat, codersdk.ChatWatchEventKindTitleChange, nil) + } return result, nil } diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index d21979d244..265e2eece2 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -788,8 +788,9 @@ func TestExploreChatUsesPersistedMCPSnapshot(t *testing.T) { ChatMode: database.ChatModeExplore, Valid: true, }, - MCPServerIDs: []uuid.UUID{mcpConfig.ID}, - ClientType: database.ChatClientTypeApi, + MCPServerIDs: []uuid.UUID{mcpConfig.ID}, + ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ { Role: database.ChatMessageRoleUser, @@ -1996,6 +1997,122 @@ func TestCreateChatInsertsWorkspaceAwarenessMessage(t *testing.T) { }) } +// TestCreateChatWithoutInitialUserContent verifies that chats created +// without initial user content start idle in `waiting` with system +// messages only, so no worker picks them up before the first message. +// APIKeyID is not required because no user message is inserted. +func TestCreateChatWithoutInitialUserContent(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + server := newTestServer(t, db, ps, uuid.New()) + + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "empty-create", + ModelConfigID: model.ID, + }) + require.NoError(t, err) + require.Equal(t, database.ChatStatusWaiting, chat.Status) + + messages, err := db.GetChatMessagesForPromptByChatID(ctx, chat.ID) + require.NoError(t, err) + require.NotEmpty(t, messages, "system messages are still inserted") + for _, msg := range messages { + require.Equal(t, database.ChatMessageRoleSystem, msg.Role, "no user message on an empty create") + } +} + +// TestSendMessageFirstUserTurnFallbackTitle verifies that the first +// message on a chat created without initial content persists the +// fallback title in the send itself, independent of title generation. +func TestSendMessageFirstUserTurnFallbackTitle(t *testing.T) { + t.Parallel() + + const text = "investigate the flaky workspace upload tests today" + + setup := func(t *testing.T, title string) (context.Context, database.Store, *chatd.Server, database.Chat) { + t.Helper() + db, ps := dbtestutil.NewDB(t) + server := newTestServer(t, db, ps, uuid.New()) + ctx := testutil.Context(t, testutil.WaitLong) + user, org, model := seedChatDependencies(t, db) + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: title, + ModelConfigID: model.ID, + }) + require.NoError(t, err) + return ctx, db, server, chat + } + send := func(ctx context.Context, t *testing.T, server *chatd.Server, chatID uuid.UUID) chatd.SendMessageResult { + t.Helper() + result, err := server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chatID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)}, + }) + require.NoError(t, err) + require.False(t, result.Queued) + return result + } + + t.Run("PlaceholderTitle", func(t *testing.T) { + t.Parallel() + ctx, db, server, chat := setup(t, chatprompt.DefaultChatTitle) + + result := send(ctx, t, server, chat.ID) + require.True(t, result.FirstUserTurn) + require.Equal(t, chatprompt.FallbackTitle(text), result.Chat.Title) + + stored, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chatprompt.FallbackTitle(text), stored.Title) + }) + + t.Run("ExistingTitle", func(t *testing.T) { + t.Parallel() + ctx, db, server, chat := setup(t, "custom title") + + result := send(ctx, t, server, chat.ID) + require.False(t, result.FirstUserTurn) + + stored, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, "custom title", stored.Title) + }) + + t.Run("PriorVisibleMessage", func(t *testing.T) { + t.Parallel() + ctx, db, server, chat := setup(t, chatprompt.DefaultChatTitle) + + send(ctx, t, server, chat.ID) + // Reset to an idle chat that still carries the placeholder + // title but already has a user-visible message. + _, err := db.UpdateChatTitleByID(ctx, database.UpdateChatTitleByIDParams{ + ID: chat.ID, + Title: chatprompt.DefaultChatTitle, + }) + require.NoError(t, err) + _, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ + ID: chat.ID, + Status: database.ChatStatusWaiting, + }) + require.NoError(t, err) + + result := send(ctx, t, server, chat.ID) + require.False(t, result.FirstUserTurn) + + stored, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.Equal(t, chatprompt.DefaultChatTitle, stored.Title) + }) +} + func TestAutoPromoteQueuedMessagesPreservesPerTurnModelOrder(t *testing.T) { t.Parallel() @@ -2561,6 +2678,7 @@ func TestRecoverStaleRequiresActionChat(t *testing.T) { Title: "stale-requires-action", DynamicTools: nullRawMessage(dynamicToolsJSON), ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ { Role: database.ChatMessageRoleUser, @@ -2652,6 +2770,7 @@ func TestNewReplicaRecoversStaleChatFromDeadReplica(t *testing.T) { LastModelConfigID: model.ID, Title: "orphaned-chat", ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ { Role: database.ChatMessageRoleUser, @@ -6973,6 +7092,7 @@ func TestActiveServer_ToolExecutionAndPolicy(t *testing.T) { Title: "provider-runner-replay-active", MCPServerIDs: []uuid.UUID{}, ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ userMessageForTest(t, "use provider runner", model.ID, user.ID, apiKey.ID), assistantMessageForTest(t, []codersdk.ChatMessagePart{computerCall}, model.ID), diff --git a/coderd/x/chatd/chatprompt/title.go b/coderd/x/chatd/chatprompt/title.go index 3e9f65df11..7d097d2c9a 100644 --- a/coderd/x/chatd/chatprompt/title.go +++ b/coderd/x/chatd/chatprompt/title.go @@ -96,16 +96,22 @@ func SyntheticPasteFileIDs(parts []codersdk.ChatMessagePart) []uuid.UUID { return ids } +// DefaultChatTitle is the placeholder title for chats created without +// any text to derive a title from (for example chats created with no +// initial user message). It is always considered replaceable by +// automatic title generation. +const DefaultChatTitle = "New Chat" + // FallbackTitle derives a deterministic chat title from title text: // the first six words, ellipsized when truncated, capped at 80 runes. -// Empty input yields "New Chat". +// Empty input yields DefaultChatTitle. func FallbackTitle(message string) string { const maxWords = 6 const maxRunes = 80 words := strings.Fields(message) if len(words) == 0 { - return "New Chat" + return DefaultChatTitle } truncated := false diff --git a/coderd/x/chatd/chatstate/machine_test.go b/coderd/x/chatd/chatstate/machine_test.go index c26f1475a5..489788a3df 100644 --- a/coderd/x/chatd/chatstate/machine_test.go +++ b/coderd/x/chatd/chatstate/machine_test.go @@ -153,6 +153,7 @@ func createTestChat(t *testing.T, f *testFixture) chatstate.CreateChatResult { LastModelConfigID: f.Model.ID, Title: "test", ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ userTextMessage("hello", f.User.ID, f.Model.ID), }, diff --git a/coderd/x/chatd/chatstate/transition.go b/coderd/x/chatd/chatstate/transition.go index bbd723b6a2..3333a8c956 100644 --- a/coderd/x/chatd/chatstate/transition.go +++ b/coderd/x/chatd/chatstate/transition.go @@ -46,7 +46,7 @@ func (t Transition) String() string { return string(t) } // included; they are orthogonal to execution state. var transitionMatrix = map[ExecutionState]map[Transition][]ExecutionState{ StateN: { - TransitionCreateChat: {StateR0}, + TransitionCreateChat: {StateR0, StateW}, }, StateW: { TransitionSetArchived: {StateXW}, diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 8e58d11703..3652a840cc 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -1,6 +1,7 @@ package chatstate import ( + "cmp" "context" "database/sql" "encoding/json" @@ -39,6 +40,12 @@ type CreateChatInput struct { InitialMessages []Message // FileIDs are linked atomically with the initial messages. FileIDs []uuid.UUID + // InitialStatus selects the chat's starting execution state: + // `running` (R0) when the initial history ends with a user turn + // the worker should process, or `waiting` (W) when the chat is + // created idle with no initial user message. Empty defaults to + // `running`. + InitialStatus database.ChatStatus } // CreateChatResult is the value returned by [CreateChat]. It carries @@ -54,11 +61,13 @@ type CreateChatResult struct { // // Validation: // - InitialMessages must be non-empty. +// - InitialStatus must be `waiting`, `running`, or empty (`running`). // // After commit CreateChat publishes a `chat:update` message describing -// the new chat snapshot. Because the new chat has no worker assigned, +// the new chat snapshot. When the new chat is runnable (`running`), // CreateChat also publishes an ownership hint so workers can race to -// acquire the runnable chat. +// acquire it. A `waiting` chat is idle, so no ownership hint is +// published until a later transition makes it runnable. func CreateChat( ctx context.Context, store database.Store, @@ -102,6 +111,13 @@ func insertChat( "initial messages must include at least one message", ) } + initialStatus := cmp.Or(input.InitialStatus, database.ChatStatusRunning) + if initialStatus != database.ChatStatusWaiting && initialStatus != database.ChatStatusRunning { + return CreateChatResult{}, newTransitionError( + TransitionCreateChat, StateN, + "initial status must be waiting or running", + ) + } var result CreateChatResult buffer := NewPublishBuffer(publisher) defer buffer.Discard() @@ -119,7 +135,7 @@ func insertChat( Title: input.Title, Mode: input.Mode, PlanMode: input.PlanMode, - Status: database.ChatStatusRunning, + Status: initialStatus, MCPServerIDs: input.MCPServerIDs, Labels: input.Labels, DynamicTools: input.DynamicTools, diff --git a/coderd/x/chatd/chatstate/transitions_helpers_test.go b/coderd/x/chatd/chatstate/transitions_helpers_test.go index 72ecb38e42..899fa8498e 100644 --- a/coderd/x/chatd/chatstate/transitions_helpers_test.go +++ b/coderd/x/chatd/chatstate/transitions_helpers_test.go @@ -124,6 +124,7 @@ func createTestChatWithDynamicTools(t *testing.T, f *testFixture, toolName strin RawMessage: dynamicToolJSON(toolName), Valid: true, }, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ userTextMessage("hello", f.User.ID, f.Model.ID), }, diff --git a/coderd/x/chatd/chatstate/transitions_test.go b/coderd/x/chatd/chatstate/transitions_test.go index 31dbe23459..42c9071e69 100644 --- a/coderd/x/chatd/chatstate/transitions_test.go +++ b/coderd/x/chatd/chatstate/transitions_test.go @@ -43,6 +43,9 @@ func TestTransitionCreate_NToR0(t *testing.T) { require.NotEmpty(t, res.InitialMessages) require.Equal(t, int64(1), res.InitialMessages[0].Revision) require.Equal(t, chatstate.StateR0, f.classify(ctx, t, res.Chat.ID)) + require.Contains(t, + chatstate.AllowedExecutionTransitionOutputs(chatstate.StateN, chatstate.TransitionCreateChat), + chatstate.StateR0, "transition matrix must admit the N -> R0 create path") require.True(t, f.Pub.hasOwnership(), "newly created chat is runnable and unowned") f.Pub.expectChatUpdate(t, res.Chat.ID, 1) } @@ -60,6 +63,7 @@ func TestCreateChat_RejectsEmptyInitialMessages(t *testing.T) { LastModelConfigID: f.Model.ID, ClientType: database.ChatClientTypeApi, Title: "t", + InitialStatus: database.ChatStatusRunning, InitialMessages: nil, }) require.Error(t, err) @@ -67,6 +71,61 @@ func TestCreateChat_RejectsEmptyInitialMessages(t *testing.T) { require.Empty(t, f.Pub.channels, "rejected create must not publish") } +// TestTransitionCreate_NToW verifies that CreateChat with +// InitialStatus waiting lands a fresh chat in W: the chat is created +// idle, so no ownership hint is published and no worker acquires it. +func TestTransitionCreate_NToW(t *testing.T) { + t.Parallel() + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + systemMsg := userTextMessage("system context", f.User.ID, f.Model.ID) + systemMsg.Role = database.ChatMessageRoleSystem + res, err := chatstate.CreateChat(ctx, f.DB, f.Pub, chatstate.CreateChatInput{ + OrganizationID: f.Org.ID, + OwnerID: f.User.ID, + LastModelConfigID: f.Model.ID, + Title: "t", + ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusWaiting, + InitialMessages: []chatstate.Message{systemMsg}, + }) + require.NoError(t, err) + require.Equal(t, database.ChatStatusWaiting, res.Chat.Status) + require.Equal(t, int64(1), res.Chat.SnapshotVersion) + require.Equal(t, chatstate.StateW, f.classify(ctx, t, res.Chat.ID)) + require.Contains(t, + chatstate.AllowedExecutionTransitionOutputs(chatstate.StateN, chatstate.TransitionCreateChat), + chatstate.StateW, "transition matrix must admit the N -> W create path") + require.False(t, f.Pub.hasOwnership(), "waiting chat is not runnable, must not publish an ownership hint") + f.Pub.expectChatUpdate(t, res.Chat.ID, 1) +} + +// TestCreateChat_RejectsInvalidInitialStatus verifies that CreateChat +// only accepts waiting or running as the initial status. The zero +// value defaults to running (see CreateChatInput), so only explicit +// non-initial statuses are rejected. +func TestCreateChat_RejectsInvalidInitialStatus(t *testing.T) { + t.Parallel() + for _, status := range []database.ChatStatus{database.ChatStatusError, database.ChatStatusInterrupting} { + f := newTestFixture(t) + ctx := testutil.Context(t, testutil.WaitShort) + _, err := chatstate.CreateChat(ctx, f.DB, f.Pub, chatstate.CreateChatInput{ + OrganizationID: f.Org.ID, + OwnerID: f.User.ID, + LastModelConfigID: f.Model.ID, + Title: "t", + ClientType: database.ChatClientTypeApi, + InitialStatus: status, + InitialMessages: []chatstate.Message{ + userTextMessage("hello", f.User.ID, f.Model.ID), + }, + }) + require.Error(t, err, "status %q", status) + require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed, "status %q", status) + require.Empty(t, f.Pub.channels, "rejected create must not publish") + } +} + func TestCreateChat_AllowsNoUserMessages(t *testing.T) { t.Parallel() f := newTestFixture(t) @@ -79,6 +138,7 @@ func TestCreateChat_AllowsNoUserMessages(t *testing.T) { LastModelConfigID: f.Model.ID, Title: "t", ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{assistant}, }) require.NoError(t, err) @@ -95,6 +155,7 @@ func TestCreateChat_AllowsNonFinalUserMessage(t *testing.T) { LastModelConfigID: f.Model.ID, Title: "t", ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ userTextMessage("context user", f.User.ID, f.Model.ID), userTextMessage("final user", f.User.ID, f.Model.ID), diff --git a/coderd/x/chatd/generation_preparer_internal_test.go b/coderd/x/chatd/generation_preparer_internal_test.go index 1ee588d362..f9af6a9f92 100644 --- a/coderd/x/chatd/generation_preparer_internal_test.go +++ b/coderd/x/chatd/generation_preparer_internal_test.go @@ -526,6 +526,7 @@ func TestDeriveFinalTurnRunResult(t *testing.T) { LastModelConfigID: modelCfg.ID, Title: "derive-chat", ClientType: database.ChatClientTypeUi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ { Role: database.ChatMessageRoleUser, @@ -678,6 +679,7 @@ func TestDeriveFinalTurnRunResult(t *testing.T) { LastModelConfigID: modelCfg.ID, Title: "derive-chat-error", ClientType: database.ChatClientTypeUi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ { Role: database.ChatMessageRoleUser, diff --git a/coderd/x/chatd/helpers_test.go b/coderd/x/chatd/helpers_test.go index 3c3e6cbf3f..53f458c8ce 100644 --- a/coderd/x/chatd/helpers_test.go +++ b/coderd/x/chatd/helpers_test.go @@ -141,6 +141,7 @@ func (f *workerTestFixture) createRunningChat(t *testing.T) database.Chat { LastModelConfigID: f.model.ID, Title: "test", ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ userTextMessage(t, "hello", f.user.ID, f.model.ID, f.apiKey.ID), }, @@ -188,6 +189,7 @@ func (f *workerTestFixture) createRequiresActionChat(t *testing.T) database.Chat RawMessage: dynamicTools, Valid: true, }, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ userTextMessage(t, "hello", f.user.ID, f.model.ID, f.apiKey.ID), }, diff --git a/coderd/x/chatd/quickgen.go b/coderd/x/chatd/quickgen.go index d0a3f424da..7296d00383 100644 --- a/coderd/x/chatd/quickgen.go +++ b/coderd/x/chatd/quickgen.go @@ -369,6 +369,20 @@ func (p *Server) GenerateChatTitleAsync(ctx context.Context, chat database.Chat) ) return } + p.GenerateChatTitleForMessagesAsync(ctx, chat, messages) +} + +// GenerateChatTitleForMessagesAsync is GenerateChatTitleAsync for +// callers that already hold a message snapshot taken before any worker +// could reply. The first-message endpoint publishes the ownership hint +// during the send, before it can schedule title generation, so a fresh +// history read there may already contain the assistant reply and would +// wrongly disqualify the first-user-turn eligibility check. +func (p *Server) GenerateChatTitleForMessagesAsync(ctx context.Context, chat database.Chat, messages []database.ChatMessage) { + logger := p.logger.With( + slog.F("chat_id", chat.ID), + slog.F("owner_id", chat.OwnerID), + ) pasteText, err := titlePasteText(ctx, p.db, messages) if err != nil { logger.Debug(ctx, "failed to load pasted-text attachments for automatic title generation", @@ -718,7 +732,7 @@ func titleInput( } currentTitle := strings.TrimSpace(chat.Title) - if currentTitle == "" { + if currentTitle == "" || currentTitle == chatprompt.DefaultChatTitle { return firstUserText, true } diff --git a/coderd/x/chatd/quickgen_internal_test.go b/coderd/x/chatd/quickgen_internal_test.go index 2f623421bb..48568654ca 100644 --- a/coderd/x/chatd/quickgen_internal_test.go +++ b/coderd/x/chatd/quickgen_internal_test.go @@ -1354,6 +1354,31 @@ func TestIsTemperatureRejectedError(t *testing.T) { } } +// Test_titleInput_DefaultTitleReplaceable verifies that the +// DefaultChatTitle placeholder is always replaceable. Chats created +// without an initial message keep the placeholder until their first +// message, whose text does not match the placeholder's fallback +// truncation. +func Test_titleInput_DefaultTitleReplaceable(t *testing.T) { + t.Parallel() + + message := mustChatMessage(t, database.ChatMessageRoleUser, database.ChatMessageVisibilityBoth, + codersdk.ChatMessageText("investigate flaky tests"), + ) + messages := []database.ChatMessage{message} + + text, ok := titleInput(database.Chat{Title: chatprompt.DefaultChatTitle}, messages, nil) + require.True(t, ok, "default placeholder title must be replaceable") + require.Equal(t, "investigate flaky tests", text) + + _, ok = titleInput(database.Chat{Title: "custom title"}, messages, nil) + require.False(t, ok, "user-set titles must not be replaced") + + text, ok = titleInput(database.Chat{Title: chatprompt.FallbackTitle("investigate flaky tests")}, messages, nil) + require.True(t, ok, "fallback truncation title remains replaceable") + require.Equal(t, "investigate flaky tests", text) +} + func mustChatMessage( t *testing.T, role database.ChatMessageRole, diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index 2cb22a1d99..d35ee12f84 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -1163,6 +1163,7 @@ func (p *Server) createChildSubagentChatWithOptions( DynamicTools: pqtype.NullRawMessage{}, ClientType: parent.ClientType, InitialMessages: initialMessages, + InitialStatus: database.ChatStatusRunning, }) if err != nil { return database.Chat{}, xerrors.Errorf("create child chat: %w", err) @@ -1524,7 +1525,7 @@ func subagentFallbackChatTitle(message string) string { words := strings.Fields(message) if len(words) == 0 { - return "New Chat" + return chatprompt.DefaultChatTitle } truncated := false diff --git a/coderd/x/chatd/tasks_test.go b/coderd/x/chatd/tasks_test.go index 1aebd0e2df..b474cb7ad5 100644 --- a/coderd/x/chatd/tasks_test.go +++ b/coderd/x/chatd/tasks_test.go @@ -1342,6 +1342,7 @@ func (f *taskTestFixture) createRunningChat(t *testing.T) database.Chat { LastModelConfigID: f.model.ID, Title: "test", ClientType: database.ChatClientTypeApi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{taskUserTextMessage(t, "hello", f.user.ID, f.model.ID, f.apiKey.ID)}, }) require.NoError(t, err) @@ -1365,6 +1366,7 @@ func (f *taskTestFixture) createRequiresActionChat(t *testing.T) database.Chat { Title: "test", ClientType: database.ChatClientTypeApi, DynamicTools: pqtype.NullRawMessage{RawMessage: dynamicTools, Valid: true}, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{taskUserTextMessage(t, "hello", f.user.ID, f.model.ID, f.apiKey.ID)}, }) require.NoError(t, err) diff --git a/coderd/x/chatd/turn_summary_internal_test.go b/coderd/x/chatd/turn_summary_internal_test.go index 7f133591c2..35324f3785 100644 --- a/coderd/x/chatd/turn_summary_internal_test.go +++ b/coderd/x/chatd/turn_summary_internal_test.go @@ -67,6 +67,7 @@ func TestUpdateLastTurnSummaryRejectsStaleWrites(t *testing.T) { LastModelConfigID: modelCfg.ID, Title: "summary-chat", ClientType: database.ChatClientTypeUi, + InitialStatus: database.ChatStatusRunning, InitialMessages: []chatstate.Message{ { Role: database.ChatMessageRoleUser, diff --git a/codersdk/chats.go b/codersdk/chats.go index 04cee17a71..1f6d1b6577 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -635,7 +635,11 @@ type CreateChatRequest struct { // OwnerID makes another user the chat owner. It defaults to the // caller. The chat runs with the owner's credentials, so setting it // requires site-wide authority over that user. - OwnerID *uuid.UUID `json:"owner_id,omitempty" format:"uuid"` + OwnerID *uuid.UUID `json:"owner_id,omitempty" format:"uuid"` + // Content is the initial user message. It is optional: when + // empty, the chat is created idle with no initial user message + // and generation starts with the first message POSTed to + // /chats/{chat}/messages. Content []ChatInputPart `json:"content"` SystemPrompt string `json:"system_prompt,omitempty"` WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index efa1b45520..a0323bfed4 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6423,22 +6423,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|-----------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | -| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | | -| `inline_mcp_servers` | array of [codersdk.InlineMCPServerRequest](#codersdkinlinemcpserverrequest) | false | | Inline mcp servers declares MCP servers by value on this chat, next to the org-configured servers selected by MCPServerIDs. Experimental. | -| `labels` | object | false | | | -| » `[any property]` | string | false | | | -| `mcp_server_ids` | array of string | false | | | -| `model_config_id` | string | false | | | -| `organization_id` | string | false | | | -| `owner_id` | string | false | | Owner ID makes another user the chat owner. It defaults to the caller. The chat runs with the owner's credentials, so setting it requires site-wide authority over that user. | -| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | -| `reasoning_effort` | string | false | | | -| `system_prompt` | string | false | | | -| `unsafe_dynamic_tools` | array of [codersdk.DynamicTool](#codersdkdynamictool) | false | | Unsafe dynamic tools declares client-executed tools that the LLM can invoke. This API is highly experimental and highly subject to change. | -| `workspace_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|-----------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | +| `content` | array of [codersdk.ChatInputPart](#codersdkchatinputpart) | false | | Content is the initial user message. It is optional: when empty, the chat is created idle with no initial user message and generation starts with the first message POSTed to /chats/{chat}/messages. | +| `inline_mcp_servers` | array of [codersdk.InlineMCPServerRequest](#codersdkinlinemcpserverrequest) | false | | Inline mcp servers declares MCP servers by value on this chat, next to the org-configured servers selected by MCPServerIDs. Experimental. | +| `labels` | object | false | | | +| » `[any property]` | string | false | | | +| `mcp_server_ids` | array of string | false | | | +| `model_config_id` | string | false | | | +| `organization_id` | string | false | | | +| `owner_id` | string | false | | Owner ID makes another user the chat owner. It defaults to the caller. The chat runs with the owner's credentials, so setting it requires site-wide authority over that user. | +| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | +| `reasoning_effort` | string | false | | | +| `system_prompt` | string | false | | | +| `unsafe_dynamic_tools` | array of [codersdk.DynamicTool](#codersdkdynamictool) | false | | Unsafe dynamic tools declares client-executed tools that the LLM can invoke. This API is highly experimental and highly subject to change. | +| `workspace_id` | string | false | | | ## codersdk.CreateFirstUserOnboardingInfo diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3cf701a300..cb23d0fbeb 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3989,6 +3989,12 @@ export interface CreateChatRequest { * requires site-wide authority over that user. */ readonly owner_id?: string; + /** + * Content is the initial user message. It is optional: when + * empty, the chat is created idle with no initial user message + * and generation starts with the first message POSTed to + * /chats/{chat}/messages. + */ readonly content: readonly ChatInputPart[]; readonly system_prompt?: string; readonly workspace_id?: string;