mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
feat: allow creating chats without an initial message (#27111)
> Written by Mux on behalf of Mike.
## What
`POST /api/v2/chats` now accepts an empty `content` array. The chat is
created idle (`waiting`) with system messages only: no user message is
inserted, no ownership hint is published, and no chat worker picks it
up. Generation starts when the first message is POSTed to
`/chats/{chat}/messages`.
## Why
The new-chat page needs to upload files into the chat's workspace before
the first turn, and `POST /chats/{chat}/workspace-files` requires a chat
ID. There was previously no way to sequence "create chat, upload files,
send first message": creation required non-empty content and started
generation immediately. This PR adds the enabling primitive; the UI
lands in the next PR of the stack.
## How
- `chatstate.CreateChat` takes an explicit `InitialStatus` (`waiting` or
`running`, validated). The existing `IsRunnable()` gate keeps ownership
hints suppressed for `waiting` chats.
- `chatd.CreateChat` derives the status from `InitialUserContent`: empty
content means `waiting` with system messages only, and `APIKeyID` is
only required when a user message is inserted.
- `createChatInputFromRequest` returns empty blocks for empty content
instead of rejecting; the `/messages` path still rejects empty sends.
- Chats created this way carry the `New Chat` placeholder title. Title
generation now treats that placeholder as replaceable. When the first
message lands on such a chat, `chatd.SendMessage` detects the first user
turn inside its transaction (under the chat row lock), writes the
fallback title in the same transaction, and reports `FirstUserTurn`;
`postChatMessages` then fires `GenerateChatTitleForMessagesAsync` to
upgrade it. A failed title call therefore still leaves a meaningful
title.
- `ARCHITECTURE.md` carries `TODO (#27111)` items in the transition list
and the `POST /api/v2/chats` section for the new `N -> Create -> W`
transition (the doc text and the diagram edge are left for the author
per the chatd guardrail).
## Follow-ups
- Automatic title generation (both the create path and the first-message
path added here) writes the generated title unconditionally, so a user
rename that lands while the title model call is in flight is
overwritten; a compare-and-set title update would close that
pre-existing window.
- `chats.last_reasoning_effort` is derived from inserted messages, so an
idle chat created with `reasoning_effort` does not persist it until its
first message; the first message applies the owner's personal
reasoning-effort override (when it targets the same model) if the
request omits an effort, matching `POST /chats` with content.
## Testing
- chatstate: `N -> W` lands in `StateW` with no ownership hint; invalid
initial statuses are rejected.
- chatd: empty create produces a `waiting` chat with system messages
only.
- HTTP: empty create + first message (`W -> R0`, inserts directly, not
queued), immediate archive of an idle chat, workspace file upload
against an empty-created chat, and title generation on the first
message.
This commit is contained in:
Generated
+1
@@ -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"
|
||||
|
||||
Generated
+1
@@ -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"
|
||||
|
||||
+110
-59
@@ -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")
|
||||
|
||||
+197
-4
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}`
|
||||
|
||||
+64
-16
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-1
@@ -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"`
|
||||
|
||||
Generated
+16
-16
@@ -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
|
||||
|
||||
|
||||
Generated
+6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user