mirror of
https://github.com/coder/coder.git
synced 2026-11-03 05:55:24 +08:00
## Summary Scope chat model configurations and runtime model resolution to organizations. Each model configuration now belongs to exactly one organization, each organization can have its own default model, and Chatd only uses models that belong to the chat's organization. Depends on #27968. ## Problem Chat model configurations are currently deployment-wide even though chats belong to organizations. A default model, explicitly selected model, or configured model override can therefore reference the same model configuration from every organization. This prevents model catalogs and defaults from being isolated by organization and allows a chat to retain or select a model that its organization does not own. Existing model configurations also have no organization ownership or organization-scoped ACL data, and the database enforces a single live default across the entire deployment. ## Fix Add required organization ownership and ACL storage to chat model configurations. The migration assigns every existing configuration to the default organization, seeds organization-wide read access, and replaces the deployment-wide default constraint with a per-organization constraint. Scope Chatd lookup, admission, fallback, and queue-promotion behavior to the chat's organization: - New chats use the usable default model from their organization. - Explicit model selections from another organization are rejected. - Deployment or personal overrides that point to a foreign model are not used for the chat. - Historical cross-organization model references remain unchanged in the database, but runtime generation falls back to a usable local default. - Queue promotion resolves unavailable, disabled, missing, or foreign stored models to the usable local default and fails when no usable local default exists. The existing model configuration management and availability routes remain compatibility routes for the default organization in this layer. A later layer in the stack adds organization-scoped management routes and updates clients to use them. ## Breaking change This changes chat model behavior from deployment-wide to organization-scoped. Existing configurations are migrated only to the default organization; they are not copied into every organization. As a result: - A non-default organization no longer inherits the deployment's existing default model. - Creating a chat in an organization without a usable local default fails with `400: No default chat model config is configured.` - Explicitly selecting a configuration owned by another organization fails with `400: Model config does not belong to the specified organization.` - Existing chats whose stored model belongs to another organization can generate only if a usable local default is available; otherwise generation fails. Deployments using Agents in non-default organizations must configure a usable default chat model separately for each organization after the organization-scoped management layers in this stack are available. _This pull request description was generated by Coder Agents._
781 lines
30 KiB
Go
781 lines
30 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// withChatWorkerDisabled turns off the chat daemon's background worker
|
|
// so every test in this file observes synchronous chatstate endpoint
|
|
// behavior deterministically. Without it the worker races the tests:
|
|
// it can finish a turn (running -> waiting), promote queued messages,
|
|
// or commit steps concurrently with the driveChatTo* fixtures.
|
|
func withChatWorkerDisabled(o *coderdtest.Options) {
|
|
o.ChatWorkerDisabled = true
|
|
}
|
|
|
|
// driveChatToWaiting transitions the chat from `running` (its initial
|
|
// state per the RFC) to `waiting` by running chatstate.FinishTurn.
|
|
// Tests use this when they need to exercise endpoint behavior that
|
|
// only succeeds from idle execution states (W, E0).
|
|
func driveChatToWaiting(ctx context.Context, t *testing.T, api *coderd.API, chatID uuid.UUID) {
|
|
t.Helper()
|
|
chatdCtx := dbauthz.AsChatd(ctx) //nolint:gocritic // Test fixture mirrors chatd background transitions.
|
|
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chatID)
|
|
require.NoError(t, machine.Update(chatdCtx, func(tx *chatstate.Tx, store database.Store) error {
|
|
_, err := tx.FinishTurn(chatstate.FinishTurnInput{})
|
|
return err
|
|
}))
|
|
}
|
|
|
|
// driveChatToRequiresAction commits an assistant message with a single
|
|
// dynamic tool_call part and then transitions the chat to
|
|
// `requires_action`. The tool_call_id returned lets the caller
|
|
// assemble a valid SubmitToolResultsRequest.
|
|
func driveChatToRequiresAction(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
api *coderd.API,
|
|
chat codersdk.Chat,
|
|
toolName string,
|
|
) (toolCallID string) {
|
|
t.Helper()
|
|
chatdCtx := dbauthz.AsChatd(ctx) //nolint:gocritic // Test fixture mirrors chatd background transitions.
|
|
|
|
toolCallID = "call-" + uuid.NewString()
|
|
assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
|
|
codersdk.ChatMessageText("dispatching dynamic tool"),
|
|
{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
Args: json.RawMessage(`{}`),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chat.ID)
|
|
require.NoError(t, machine.Update(chatdCtx, func(tx *chatstate.Tx, store database.Store) error {
|
|
_, err := tx.CommitStep(chatstate.CommitStepInput{
|
|
Messages: []chatstate.Message{{
|
|
Role: database.ChatMessageRoleAssistant,
|
|
Content: assistantContent,
|
|
Visibility: database.ChatMessageVisibilityBoth,
|
|
ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true},
|
|
ContentVersion: chatprompt.CurrentContentVersion,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.EnterRequiresAction(chatstate.EnterRequiresActionInput{})
|
|
return err
|
|
}))
|
|
return toolCallID
|
|
}
|
|
|
|
// TestPostChatsStartsRunning verifies the RFC-mandated `running`
|
|
// initial status surfaced by the create-chat endpoint.
|
|
func TestPostChatsStartsRunning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, api := newChatClientWithAPI(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{
|
|
Type: codersdk.ChatInputPartTypeText,
|
|
Text: "hello",
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusRunning, chat.Status,
|
|
"new chats must start in `running` per chatd RFC")
|
|
|
|
// Re-reading also reports `running` because the chat row is
|
|
// authoritative and no worker has advanced it.
|
|
gotChat, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusRunning, gotChat.Status)
|
|
require.NotNil(t, api.Pubsub)
|
|
}
|
|
|
|
// TestArchiveChatStateTransitions covers the two RFC-mandated archive
|
|
// behaviors at the endpoint contract level: archiving from an idle
|
|
// chat (W) succeeds, and archiving from an active chat (R0) returns
|
|
// a state conflict and leaves the chat unarchived.
|
|
func TestArchiveChatStateTransitions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("IdleSucceeds", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, api := newChatClientWithAPI(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "archive me"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
driveChatToWaiting(ctx, t, api, chat.ID)
|
|
|
|
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
|
require.NoError(t, err)
|
|
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.True(t, got.Archived)
|
|
})
|
|
|
|
t.Run("ActiveChatReturnsConflict", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "no archive"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = client.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
|
requireSDKError(t, err, http.StatusConflict)
|
|
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.False(t, got.Archived, "active chat must remain unarchived after a conflict")
|
|
})
|
|
}
|
|
|
|
// TestPostChatMessagesBusyInterrupt verifies that a busy-interrupt
|
|
// send returns a queued response and leaves the chat in `interrupting`
|
|
// from the endpoint's perspective.
|
|
func TestPostChatMessagesBusyInterrupt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusRunning, chat.Status)
|
|
|
|
// CreateChat leaves the chat in `running`; an interrupt-style
|
|
// follow-up should land it in `interrupting`.
|
|
resp, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "stop"}},
|
|
BusyBehavior: codersdk.ChatBusyBehaviorInterrupt,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, resp.Queued, "busy interrupt must return queued=true")
|
|
require.NotNil(t, resp.QueuedMessage)
|
|
|
|
got, err := client.GetChat(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusInterrupting, got.Status,
|
|
"busy interrupt send must land the chat in `interrupting`")
|
|
}
|
|
|
|
// TestDeleteChatQueuedMessageMissingReturns404 covers the new
|
|
// chatstate-driven 404 path for missing queued IDs. The chat must
|
|
// have at least one queued message so the request is in a state where
|
|
// DeleteQueuedMessage is allowed; the looked-up ID then mismatches
|
|
// and the endpoint returns 404 instead of a state-conflict 409.
|
|
func TestDeleteChatQueuedMessageMissingReturns404(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Seed one queued message via the public endpoint (the chat
|
|
// starts in R0, so a queue send lands in R1).
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "queued"}},
|
|
BusyBehavior: codersdk.ChatBusyBehaviorQueue,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
res, err := client.Request(
|
|
ctx,
|
|
http.MethodDelete,
|
|
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999", chat.ID),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
|
}
|
|
|
|
// TestDeleteChatQueuedMessageEmptyQueueReturnsConflict covers the
|
|
// state-conflict 409 path when the chat has no queued messages.
|
|
func TestDeleteChatQueuedMessageEmptyQueueReturnsConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
res, err := client.Request(
|
|
ctx,
|
|
http.MethodDelete,
|
|
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999", chat.ID),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusConflict, res.StatusCode)
|
|
}
|
|
|
|
// TestPromoteChatQueuedMessageMissingReturns404 mirrors the delete
|
|
// test for the promote endpoint: with a non-empty queue, an unknown
|
|
// queued-message ID returns 404 rather than a 409.
|
|
func TestPromoteChatQueuedMessageMissingReturns404(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Seed one queued message so the promote transition is allowed.
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "queued"}},
|
|
BusyBehavior: codersdk.ChatBusyBehaviorQueue,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
res, err := client.Request(
|
|
ctx,
|
|
http.MethodPost,
|
|
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999/promote", chat.ID),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
|
}
|
|
|
|
// TestPromoteChatQueuedMessageEmptyQueueReturnsConflict verifies
|
|
// the state-conflict 409 path when the chat has no queued messages.
|
|
func TestPromoteChatQueuedMessageEmptyQueueReturnsConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
res, err := client.Request(
|
|
ctx,
|
|
http.MethodPost,
|
|
fmt.Sprintf("/api/experimental/chats/%s/queue/99999999/promote", chat.ID),
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusConflict, res.StatusCode)
|
|
}
|
|
|
|
// TestInterruptChatIdleReturnsConflict verifies that interrupting an
|
|
// idle chat is now rejected. The fixture composes chatstate
|
|
// transitions to reach the W state without depending on the
|
|
// background worker.
|
|
func TestInterruptChatIdleReturnsConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, api := newChatClientWithAPI(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "interrupt me"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
driveChatToWaiting(ctx, t, api, chat.ID)
|
|
|
|
_, err = client.InterruptChat(ctx, chat.ID)
|
|
requireSDKError(t, err, http.StatusConflict)
|
|
}
|
|
|
|
// TestSubmitToolResultsWrongStateReturnsConflict covers the wrong
|
|
// chat-status response when the chat is not in requires_action.
|
|
func TestSubmitToolResultsWrongStateReturnsConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusRunning, chat.Status)
|
|
|
|
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
|
|
Results: []codersdk.ToolResult{{
|
|
ToolCallID: "unknown-call",
|
|
Output: json.RawMessage(`{}`),
|
|
}},
|
|
})
|
|
requireSDKError(t, err, http.StatusConflict)
|
|
}
|
|
|
|
// TestSubmitToolResultsRequiresActionSucceeds drives a chat into
|
|
// requires_action with a single dynamic tool call and verifies a
|
|
// matching SubmitToolResults call returns 204 with the tool result
|
|
// persisted.
|
|
func TestSubmitToolResultsRequiresActionSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, api := newChatClientWithAPI(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
dynamicTools := []codersdk.DynamicTool{{
|
|
Name: "echo",
|
|
Description: "test echo tool",
|
|
InputSchema: json.RawMessage(`{"type":"object"}`),
|
|
}}
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
UnsafeDynamicTools: dynamicTools,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
toolCallID := driveChatToRequiresAction(ctx, t, api, chat, "echo")
|
|
|
|
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
|
|
Results: []codersdk.ToolResult{{
|
|
ToolCallID: toolCallID,
|
|
Output: json.RawMessage(`{"ok":true}`),
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// The tool result must be persisted as a visible tool message.
|
|
got, err := client.GetChatMessages(ctx, chat.ID, nil)
|
|
require.NoError(t, err)
|
|
foundToolResult := false
|
|
for _, msg := range got.Messages {
|
|
if msg.Role != codersdk.ChatMessageRoleTool {
|
|
continue
|
|
}
|
|
for _, part := range msg.Content {
|
|
if part.Type == codersdk.ChatMessagePartTypeToolResult && part.ToolCallID == toolCallID {
|
|
foundToolResult = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
require.True(t, foundToolResult, "tool result message must be visible in chat history")
|
|
}
|
|
|
|
// TestPatchChatArchiveChildRejected verifies that PATCH /api/experimental/chats/{child}
|
|
// with archived=true returns the root-only error regardless of the
|
|
// child's current archived value, and does not change archive state on
|
|
// any family member.
|
|
func TestPatchChatArchiveChildRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db, api := newChatClientWithAPIAndDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
modelConfig := createChatModelConfig(t, client)
|
|
|
|
root, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "root"}},
|
|
})
|
|
require.NoError(t, err)
|
|
driveChatToWaiting(ctx, t, api, root.ID)
|
|
|
|
// Sibling child A and B; both unarchived.
|
|
childA := dbgen.Chat(t, db, database.Chat{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: firstUser.UserID,
|
|
LastModelConfigID: modelConfig.ID,
|
|
Title: "child-a",
|
|
Status: database.ChatStatusWaiting,
|
|
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
})
|
|
childB := dbgen.Chat(t, db, database.Chat{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: firstUser.UserID,
|
|
LastModelConfigID: modelConfig.ID,
|
|
Title: "child-b",
|
|
Status: database.ChatStatusWaiting,
|
|
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
})
|
|
|
|
err = client.UpdateChat(ctx, childA.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
|
requireSDKError(t, err, http.StatusBadRequest)
|
|
|
|
for _, id := range []uuid.UUID{root.ID, childA.ID, childB.ID} {
|
|
got, gerr := loadChatRow(ctx, db, id)
|
|
require.NoError(t, gerr)
|
|
require.False(t, got.Archived, "no family member may flip archive state after a rejected child archive")
|
|
}
|
|
}
|
|
|
|
// TestPatchChatUnarchiveChildRejected verifies that PATCH /api/experimental/chats/{child}
|
|
// with archived=false on an archived family is rejected with the
|
|
// root-only error and leaves every family member archived. The child
|
|
// already matches the requested value? No, the family is archived;
|
|
// we are asking to unarchive a child individually.
|
|
func TestPatchChatUnarchiveChildRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db, api := newChatClientWithAPIAndDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
modelConfig := createChatModelConfig(t, client)
|
|
|
|
root, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "root"}},
|
|
})
|
|
require.NoError(t, err)
|
|
driveChatToWaiting(ctx, t, api, root.ID)
|
|
|
|
childA := dbgen.Chat(t, db, database.Chat{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: firstUser.UserID,
|
|
LastModelConfigID: modelConfig.ID,
|
|
Title: "child-a",
|
|
Status: database.ChatStatusWaiting,
|
|
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
})
|
|
childB := dbgen.Chat(t, db, database.Chat{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: firstUser.UserID,
|
|
LastModelConfigID: modelConfig.ID,
|
|
Title: "child-b",
|
|
Status: database.ChatStatusWaiting,
|
|
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
})
|
|
|
|
// Archive the whole family via the root.
|
|
err = client.UpdateChat(ctx, root.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
|
require.NoError(t, err)
|
|
for _, id := range []uuid.UUID{root.ID, childA.ID, childB.ID} {
|
|
got, gerr := loadChatRow(ctx, db, id)
|
|
require.NoError(t, gerr)
|
|
require.True(t, got.Archived, "precondition: family archived after root archive")
|
|
}
|
|
|
|
// Unarchiving a child must be rejected.
|
|
err = client.UpdateChat(ctx, childA.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(false)})
|
|
requireSDKError(t, err, http.StatusBadRequest)
|
|
|
|
for _, id := range []uuid.UUID{root.ID, childA.ID, childB.ID} {
|
|
got, gerr := loadChatRow(ctx, db, id)
|
|
require.NoError(t, gerr)
|
|
require.True(t, got.Archived, "no family member may flip archive state after a rejected child unarchive")
|
|
}
|
|
}
|
|
|
|
// TestPatchChatArchiveRootRollsBackWhenChildCannotArchive verifies the
|
|
// family-archive atomicity guarantee surfaced through the endpoint:
|
|
// when a child is in a state that rejects SetArchived (running here),
|
|
// the whole cascade rolls back and no family member changes archive
|
|
// state.
|
|
func TestPatchChatArchiveRootRollsBackWhenChildCannotArchive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db, api := newChatClientWithAPIAndDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
modelConfig := createChatModelConfig(t, client)
|
|
|
|
root, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "root"}},
|
|
})
|
|
require.NoError(t, err)
|
|
driveChatToWaiting(ctx, t, api, root.ID)
|
|
|
|
// Child is running (R0) which is NOT archive-eligible.
|
|
child := dbgen.Chat(t, db, database.Chat{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
OwnerID: firstUser.UserID,
|
|
LastModelConfigID: modelConfig.ID,
|
|
Title: "child",
|
|
Status: database.ChatStatusRunning,
|
|
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
RootChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
|
|
})
|
|
|
|
err = client.UpdateChat(ctx, root.ID, codersdk.UpdateChatRequest{Archived: ptr.Ref(true)})
|
|
requireSDKError(t, err, http.StatusConflict)
|
|
|
|
for _, id := range []uuid.UUID{root.ID, child.ID} {
|
|
got, gerr := loadChatRow(ctx, db, id)
|
|
require.NoError(t, gerr)
|
|
require.False(t, got.Archived, "rolled-back family archive must not leave any member archived")
|
|
}
|
|
}
|
|
|
|
// TestPostChatMessagesInvalidStateReturnsSharedResponse drives a chat
|
|
// into the chatstate-invalid state (waiting with a queued backlog)
|
|
// and asserts the shared invalid-state response. This is the
|
|
// representative endpoint required by the review.
|
|
func TestPostChatMessagesInvalidStateReturnsSharedResponse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, _, api := newChatClientWithAPIAndDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Drive the chat to an invalid combination: status=waiting (W),
|
|
// archived=false, and a queued message. ClassifyExecutionState
|
|
// returns StateInvalid for (waiting, queue=true).
|
|
driveChatToInvalidWaitingWithQueue(ctx, t, api, chat.ID)
|
|
|
|
_, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "send"}},
|
|
})
|
|
sdkErr := requireSDKError(t, err, http.StatusConflict)
|
|
require.Equal(t, "Chat is in an invalid state.", sdkErr.Message,
|
|
"invalid-state endpoint response uses the shared message")
|
|
}
|
|
|
|
// TestPostChatToolResultsInvalidStateReturnsSharedResponse drives a
|
|
// chat into the chatstate-invalid state and asserts that the tool
|
|
// results endpoint returns the shared invalid-state response instead
|
|
// of the old "Chat is not waiting for tool results." status-conflict
|
|
// message. This locks the fix that removes the endpoint fast-path
|
|
// and routes invalid chats through the chatstate-backed transaction.
|
|
func TestPostChatToolResultsInvalidStateReturnsSharedResponse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, _, api := newChatClientWithAPIAndDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Drive the chat to an invalid combination so the tool-results
|
|
// endpoint must surface the shared invalid-state response rather
|
|
// than the requires_action status conflict.
|
|
driveChatToInvalidWaitingWithQueue(ctx, t, api, chat.ID)
|
|
|
|
err = client.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{
|
|
Results: []codersdk.ToolResult{{
|
|
ToolCallID: "call-irrelevant",
|
|
Output: json.RawMessage(`{}`),
|
|
}},
|
|
})
|
|
sdkErr := requireSDKError(t, err, http.StatusConflict)
|
|
require.Equal(t, "Chat is in an invalid state.", sdkErr.Message,
|
|
"tool-results invalid-state response uses the shared message")
|
|
}
|
|
|
|
// TestReconcileInvalidChatStateSucceeds drives a chat into the
|
|
// chatstate-invalid combination (waiting with a queued backlog) and
|
|
// verifies the reconcile endpoint moves it into a valid error state
|
|
// while preserving the queued message.
|
|
func TestReconcileInvalidChatStateSucceeds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client, db, api := newChatClientWithAPIAndDatabase(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Drive the chat to an invalid combination: status=waiting (W),
|
|
// archived=false, with a queued message. ClassifyExecutionState
|
|
// returns StateInvalid for (waiting, queue=true).
|
|
driveChatToInvalidWaitingWithQueue(ctx, t, api, chat.ID)
|
|
|
|
reconciled, err := client.ReconcileInvalidChatState(ctx, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, chat.ID, reconciled.ID)
|
|
require.Equal(t, codersdk.ChatStatusError, reconciled.Status)
|
|
|
|
// The persisted row must reflect a valid error state with the
|
|
// queued message preserved (E1) and a populated last_error.
|
|
persisted, err := loadChatRow(ctx, db, chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, database.ChatStatusError, persisted.Status)
|
|
require.False(t, persisted.Archived)
|
|
require.True(t, persisted.LastError.Valid)
|
|
|
|
queueCount, err := db.CountChatQueuedMessages(dbauthz.AsChatd(ctx), chat.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(1), queueCount, "queued message is preserved by reconcile")
|
|
}
|
|
|
|
// TestReconcileInvalidChatStateNotInvalidReturnsConflict verifies that
|
|
// reconciling a chat that is in a valid execution state is rejected
|
|
// with a 409 conflict.
|
|
func TestReconcileInvalidChatStateNotInvalidReturnsConflict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
|
_ = createChatModelConfig(t, client)
|
|
|
|
// A freshly created chat starts in the valid running state (R0).
|
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
|
OrganizationID: firstUser.OrganizationID,
|
|
Content: []codersdk.ChatInputPart{{Type: codersdk.ChatInputPartTypeText, Text: "hello"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, codersdk.ChatStatusRunning, chat.Status)
|
|
|
|
_, err = client.ReconcileInvalidChatState(ctx, chat.ID)
|
|
sdkErr := requireSDKError(t, err, http.StatusConflict)
|
|
require.Equal(t, "Chat is not in an invalid state.", sdkErr.Message)
|
|
}
|
|
|
|
// TestReconcileInvalidChatStateNotFound verifies the reconcile
|
|
// endpoint returns 404 for a chat that does not exist.
|
|
func TestReconcileInvalidChatStateNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := newChatClient(t, withChatWorkerDisabled)
|
|
_ = coderdtest.CreateFirstUser(t, client.Client)
|
|
|
|
_, err := client.ReconcileInvalidChatState(ctx, uuid.New())
|
|
requireSDKError(t, err, http.StatusNotFound)
|
|
}
|
|
|
|
// loadChatRow reads a chat row directly through dbauthz.AsChatd so
|
|
// endpoint tests verify side effects with the daemon's narrower
|
|
// permission set.
|
|
func loadChatRow(ctx context.Context, db database.Store, id uuid.UUID) (database.Chat, error) {
|
|
chatdCtx := dbauthz.AsChatd(ctx) //nolint:gocritic // Test fixture reads rows with chatd permissions.
|
|
return db.GetChatByID(chatdCtx, id)
|
|
}
|
|
|
|
// driveChatToInvalidWaitingWithQueue forces a chat into the
|
|
// chatstate-invalid combination (status=waiting, archived=false,
|
|
// queue non-empty) by writing directly through the database. This is
|
|
// an intentional invalid fixture: chatstate transitions reject
|
|
// driving toward this combination, so AsChatd is not used here.
|
|
func driveChatToInvalidWaitingWithQueue(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
api *coderd.API,
|
|
chatID uuid.UUID,
|
|
) {
|
|
t.Helper()
|
|
sysCtx := dbauthz.AsSystemRestricted(ctx) //nolint:gocritic // Test fixture writes invalid combination by design.
|
|
|
|
// Seed the queue with one row attributed to the chat owner. The
|
|
// content is a minimal valid JSON payload; only the row's
|
|
// presence matters for ClassifyExecutionState. The owner_id is
|
|
// filled from the chat row by the SQL.
|
|
rawContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
|
|
codersdk.ChatMessageText("queued"),
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = api.Database.InsertChatQueuedMessage(sysCtx, database.InsertChatQueuedMessageParams{
|
|
ChatID: chatID,
|
|
Content: rawContent.RawMessage,
|
|
ModelConfigID: uuid.NullUUID{},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Flip the chat's status to waiting via a raw execution-state
|
|
// update. This bypasses the transition matrix to produce the
|
|
// (waiting, queued) invalid pairing.
|
|
_, err = api.Database.UpdateChatExecutionState(sysCtx, database.UpdateChatExecutionStateParams{
|
|
ID: chatID,
|
|
Status: database.ChatStatusWaiting,
|
|
Archived: false,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|