diff --git a/cli/exp_chat.go b/cli/exp_chat.go index 396504d6580f3..55461b4c7a603 100644 --- a/cli/exp_chat.go +++ b/cli/exp_chat.go @@ -3,7 +3,6 @@ package cli import ( "context" "fmt" - "os" "path" "path/filepath" "strings" @@ -11,11 +10,9 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/coder/v2/agent/agentcontextconfig" "github.com/coder/coder/v2/agent/agentsocket" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) @@ -54,7 +51,6 @@ func (r *RootCmd) chatContextCommand() *serpent.Command { r.chatContextAddCommand(&socketPath), r.chatContextRemoveCommand(&socketPath), r.chatContextRefreshCommand(&socketPath), - r.chatContextClearCommand(), }, Options: serpent.OptionSet{{ Flag: "socket-path", @@ -203,8 +199,6 @@ func (*RootCmd) chatContextShowCommand(socketPath *string) *serpent.Command { } func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command { - var chatID string - agentAuth := &AgentAuth{} cmd := &serpent.Command{ Use: "add ", Short: "Register a workspace context source", @@ -214,21 +208,13 @@ func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command { ".agents/skills//SKILL.md, and .mcp.json are picked up now and as they " + "appear. Any change to a recognized file dirties this workspace's chats until " + "you refresh.\n\nA path may be a file or a directory. Must be run from inside " + - "the workspace.\n\nPass --chat to keep the legacy one-shot behavior: read " + - "context from the path once and inject it into a single chat without " + - "registering a source.", + "the workspace.", Middleware: serpent.RequireNArgs(1), Handler: func(inv *serpent.Invocation) error { ctx := inv.Context() ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...) defer stop() - // Legacy one-shot inject into a single chat. - if chatID != "" { - return addChatContextOneShot(ctx, inv, agentAuth, inv.Args[0], chatID) - } - - // Source registration (default). path, err := resolveContextSourcePath(inv.Args[0]) if err != nil { return err @@ -246,62 +232,10 @@ func (*RootCmd) chatContextAddCommand(socketPath *string) *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "Registered context source %s\n", src.Path) return nil }, - Options: serpent.OptionSet{{ - Name: "Chat ID", - Flag: "chat", - Env: "CODER_CHAT_ID", - Description: "Inject context from into a single chat (legacy one-shot) instead of registering a source. Auto-detected from CODER_CHAT_ID, the only active chat, or the only top-level active chat.", - Value: serpent.StringOf(&chatID), - }}, } - agentAuth.AttachOptions(cmd, false) return cmd } -// addChatContextOneShot preserves the legacy `add --chat` behavior: read -// context files and skills from a directory and inject them into a single -// chat via coderd, without registering a persistent source. -func addChatContextOneShot(ctx context.Context, inv *serpent.Invocation, agentAuth *AgentAuth, dir, chatID string) error { - client, err := agentAuth.CreateClient() - if err != nil { - return xerrors.Errorf("create agent client: %w", err) - } - - resolvedDir, err := filepath.Abs(dir) - if err != nil { - return xerrors.Errorf("resolve directory: %w", err) - } - info, err := os.Stat(resolvedDir) - if err != nil { - return xerrors.Errorf("cannot read directory %q: %w", resolvedDir, err) - } - if !info.IsDir() { - return xerrors.Errorf("--chat one-shot inject requires a directory, but %q is a file", resolvedDir) - } - - parts := agentcontextconfig.ContextPartsFromDir(resolvedDir) - if len(parts) == 0 { - _, _ = fmt.Fprintln(inv.Stderr, "No context files or skills found in "+resolvedDir) - return nil - } - - resolvedChatID, err := parseChatID(chatID) - if err != nil { - return err - } - - resp, err := client.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: resolvedChatID, - Parts: parts, - }) - if err != nil { - return xerrors.Errorf("add chat context: %w", err) - } - - _, _ = fmt.Fprintf(inv.Stdout, "Added %d context part(s) to chat %s\n", resp.Count, resp.ChatID) - return nil -} - func (*RootCmd) chatContextRemoveCommand(socketPath *string) *serpent.Command { cmd := &serpent.Command{ Use: "remove ", @@ -405,66 +339,3 @@ func (r *RootCmd) chatContextRefreshCommand(socketPath *string) *serpent.Command agentAuth.AttachOptions(cmd, false) return cmd } - -func (*RootCmd) chatContextClearCommand() *serpent.Command { - var chatID string - agentAuth := &AgentAuth{} - cmd := &serpent.Command{ - Use: "clear", - Short: "Clear context from an active chat", - Long: "Soft-delete all context-file and skill messages from an active chat. " + - "The next turn will re-fetch default context from the agent.", - Handler: func(inv *serpent.Invocation) error { - ctx := inv.Context() - ctx, stop := inv.SignalNotifyContext(ctx, StopSignals...) - defer stop() - - client, err := agentAuth.CreateClient() - if err != nil { - return xerrors.Errorf("create agent client: %w", err) - } - - resolvedChatID, err := parseChatID(chatID) - if err != nil { - return err - } - - resp, err := client.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{ - ChatID: resolvedChatID, - }) - if err != nil { - return xerrors.Errorf("clear chat context: %w", err) - } - - if resp.ChatID == uuid.Nil { - _, _ = fmt.Fprintln(inv.Stdout, "No active chats to clear.") - } else { - _, _ = fmt.Fprintf(inv.Stdout, "Cleared context from chat %s\n", resp.ChatID) - } - return nil - }, - Options: serpent.OptionSet{{ - Name: "Chat ID", - Flag: "chat", - Env: "CODER_CHAT_ID", - Description: "Chat ID to clear context from. Auto-detected from CODER_CHAT_ID, the only active chat, or the only top-level active chat.", - Value: serpent.StringOf(&chatID), - }}, - } - agentAuth.AttachOptions(cmd, false) - return cmd -} - -// parseChatID returns the chat UUID from the flag value (which -// serpent already populates from --chat or CODER_CHAT_ID). Returns -// uuid.Nil if empty (the server will auto-detect). -func parseChatID(flagValue string) (uuid.UUID, error) { - if flagValue == "" { - return uuid.Nil, nil - } - parsed, err := uuid.Parse(flagValue) - if err != nil { - return uuid.Nil, xerrors.Errorf("invalid chat ID %q: %w", flagValue, err) - } - return parsed, nil -} diff --git a/cli/exp_chat_internal_test.go b/cli/exp_chat_internal_test.go index 68dbec0e7eeb2..5a557f119a841 100644 --- a/cli/exp_chat_internal_test.go +++ b/cli/exp_chat_internal_test.go @@ -4,36 +4,9 @@ import ( "path/filepath" "testing" - "github.com/google/uuid" "github.com/stretchr/testify/require" ) -func TestParseChatID(t *testing.T) { - t.Parallel() - - t.Run("EmptyIsNil", func(t *testing.T) { - t.Parallel() - got, err := parseChatID("") - require.NoError(t, err) - require.Equal(t, uuid.Nil, got) - }) - - t.Run("ValidUUID", func(t *testing.T) { - t.Parallel() - want := uuid.MustParse("11111111-1111-4111-8111-111111111111") - got, err := parseChatID(want.String()) - require.NoError(t, err) - require.Equal(t, want, got) - }) - - t.Run("InvalidErrors", func(t *testing.T) { - t.Parallel() - _, err := parseChatID("not-a-uuid") - require.Error(t, err) - require.Contains(t, err.Error(), "invalid chat ID") - }) -} - func TestResolveContextSourcePath(t *testing.T) { t.Parallel() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ddd4fdd33039..a1d025f06cd26 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16651,13 +16651,6 @@ const docTemplate = `{ "last_error": { "$ref": "#/definitions/codersdk.ChatError" }, - "last_injected_context": { - "description": "LastInjectedContext holds the most recently persisted\ninjected context parts (AGENTS.md files and skills). It\nis updated only when context changes, on first workspace\nattach or agent change.", - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.ChatMessagePart" - } - }, "last_model_config_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e089738cd31c8..9297a68cde653 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14957,13 +14957,6 @@ "last_error": { "$ref": "#/definitions/codersdk.ChatError" }, - "last_injected_context": { - "description": "LastInjectedContext holds the most recently persisted\ninjected context parts (AGENTS.md files and skills). It\nis updated only when context changes, on first workspace\nattach or agent change.", - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.ChatMessagePart" - } - }, "last_model_config_id": { "type": "string", "format": "uuid" diff --git a/coderd/coderd.go b/coderd/coderd.go index 1326895ce5d93..77e203a41a43d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1794,8 +1794,6 @@ func New(options *Options) *API { r.Post("/log-source", api.workspaceAgentPostLogSource) r.Get("/reinit", api.workspaceAgentReinit) r.Route("/experimental", func(r chi.Router) { - r.Post("/chat-context", api.workspaceAgentAddChatContext) - r.Delete("/chat-context", api.workspaceAgentClearChatContext) r.Post("/chat-context/refresh", api.workspaceAgentRefreshChatContext) }) r.Route("/tasks/{task}", func(r chi.Router) { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 24b4d60404bc3..2e4698b67c870 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1752,17 +1752,6 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database }) } } - if c.LastInjectedContext.Valid { - var parts []codersdk.ChatMessagePart - // Internal fields are stripped at write time in - // chatd.updateLastInjectedContext, so no - // StripInternal call is needed here. Unmarshal - // errors are suppressed — the column is written by - // us with a known schema. - if err := json.Unmarshal(c.LastInjectedContext.RawMessage, &parts); err == nil { - chat.LastInjectedContext = parts - } - } // Report pinned-context state when the chat is context-tracked // (has a pinned hash), dirty, or carries a snapshot error. if len(c.ContextAggregateHash) > 0 || c.ContextDirtySince.Valid || c.ContextError != "" { diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index bd82c2b5b3567..52eda2adc55c4 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -720,13 +720,6 @@ func TestChat_AllFieldsPopulated(t *testing.T) { PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true}, MCPServerIDs: []uuid.UUID{uuid.New()}, Labels: database.StringMap{"env": "prod"}, - LastInjectedContext: pqtype.NullRawMessage{ - // Use a context-file part to verify internal - // fields are not present (they are stripped at - // write time by chatd, not at read time). - RawMessage: json.RawMessage(`[{"type":"context-file","context_file_path":"/AGENTS.md"}]`), - Valid: true, - }, DynamicTools: pqtype.NullRawMessage{ RawMessage: json.RawMessage(`[{"name":"tool1","description":"test tool","inputSchema":{"type":"object"}}]`), Valid: true, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6aec60c2dc4f1..5fe1cd7e9952b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -7123,17 +7123,6 @@ func (q *querier) UpdateChatLabelsByID(ctx context.Context, arg database.UpdateC return q.db.UpdateChatLabelsByID(ctx, arg) } -func (q *querier) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) { - chat, err := q.db.GetChatByID(ctx, arg.ID) - if err != nil { - return database.Chat{}, err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return database.Chat{}, err - } - return q.db.UpdateChatLastInjectedContext(ctx, arg) -} - func (q *querier) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) { chat, err := q.db.GetChatByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index aed9a9580b09c..0cc603d4c5cec 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1899,19 +1899,6 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UpdateChatMCPServerIDs(gomock.Any(), arg).Return(chat, nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat) })) - s.Run("UpdateChatLastInjectedContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - chat := testutil.Fake(s.T(), faker, database.Chat{}) - arg := database.UpdateChatLastInjectedContextParams{ - ID: chat.ID, - LastInjectedContext: pqtype.NullRawMessage{ - RawMessage: json.RawMessage(`[{"type":"text","text":"test"}]`), - Valid: true, - }, - } - dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), arg).Return(chat, nil).AnyTimes() - check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat) - })) s.Run("UpdateChatLastTurnSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) arg := database.UpdateChatLastTurnSummaryParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index a600a6371be85..40820d9cfb899 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5114,14 +5114,6 @@ func (m queryMetricsStore) UpdateChatLabelsByID(ctx context.Context, arg databas return r0, r1 } -func (m queryMetricsStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) { - start := time.Now() - r0, r1 := m.s.UpdateChatLastInjectedContext(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateChatLastInjectedContext").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastInjectedContext").Inc() - return r0, r1 -} - func (m queryMetricsStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) { start := time.Now() r0, r1 := m.s.UpdateChatLastModelConfigByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 75ea063c0d65b..e693e675ee1dd 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -9636,21 +9636,6 @@ func (mr *MockStoreMockRecorder) UpdateChatLabelsByID(ctx, arg any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLabelsByID", reflect.TypeOf((*MockStore)(nil).UpdateChatLabelsByID), ctx, arg) } -// UpdateChatLastInjectedContext mocks base method. -func (m *MockStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateChatLastInjectedContext", ctx, arg) - ret0, _ := ret[0].(database.Chat) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// UpdateChatLastInjectedContext indicates an expected call of UpdateChatLastInjectedContext. -func (mr *MockStoreMockRecorder) UpdateChatLastInjectedContext(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastInjectedContext", reflect.TypeOf((*MockStore)(nil).UpdateChatLastInjectedContext), ctx, arg) -} - // UpdateChatLastModelConfigByID mocks base method. func (m *MockStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 4b4a0cd193859..5c2ec2ef0f0e4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1999,7 +1999,6 @@ CREATE TABLE chats ( agent_id uuid, pin_order integer DEFAULT 0 NOT NULL, last_read_message_id bigint, - last_injected_context jsonb, dynamic_tools jsonb, organization_id uuid NOT NULL, plan_mode chat_plan_mode, @@ -2114,7 +2113,6 @@ CREATE VIEW chats_expanded AS c.agent_id, c.pin_order, c.last_read_message_id, - c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, diff --git a/coderd/database/migrations/000529_chat_drop_last_injected_context.down.sql b/coderd/database/migrations/000529_chat_drop_last_injected_context.down.sql new file mode 100644 index 0000000000000..0223c3a1d2564 --- /dev/null +++ b/coderd/database/migrations/000529_chat_drop_last_injected_context.down.sql @@ -0,0 +1,55 @@ +-- Restores the last_injected_context column on chats and recreates the +-- view with that column in its original position between +-- last_read_message_id and dynamic_tools. +DROP VIEW IF EXISTS chats_expanded; + +ALTER TABLE chats ADD COLUMN last_injected_context jsonb; + +CREATE VIEW chats_expanded AS + SELECT c.id, + c.owner_id, + c.workspace_id, + c.title, + c.status, + c.worker_id, + c.started_at, + c.heartbeat_at, + c.created_at, + c.updated_at, + c.parent_chat_id, + c.root_chat_id, + c.last_model_config_id, + c.archived, + c.last_error, + c.mode, + c.mcp_server_ids, + c.labels, + c.build_id, + c.agent_id, + c.pin_order, + c.last_read_message_id, + c.last_injected_context, + c.dynamic_tools, + c.organization_id, + c.plan_mode, + c.client_type, + c.last_turn_summary, + c.snapshot_version, + c.history_version, + c.queue_version, + c.generation_attempt, + c.retry_state, + c.retry_state_version, + c.runner_id, + c.requires_action_deadline_at, + COALESCE(root.user_acl, c.user_acl) AS user_acl, + COALESCE(root.group_acl, c.group_acl) AS group_acl, + owner.username AS owner_username, + owner.name AS owner_name, + c.context_aggregate_hash, + c.context_dirty_since, + c.context_dirty_resources, + c.context_error + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); diff --git a/coderd/database/migrations/000529_chat_drop_last_injected_context.up.sql b/coderd/database/migrations/000529_chat_drop_last_injected_context.up.sql new file mode 100644 index 0000000000000..4bce65c8415f2 --- /dev/null +++ b/coderd/database/migrations/000529_chat_drop_last_injected_context.up.sql @@ -0,0 +1,55 @@ +-- Drops an unused column from chats. The view must be dropped before +-- the column it references can be removed, then recreated without it. A +-- view cannot have a column removed from the middle of its column list +-- in place. +DROP VIEW IF EXISTS chats_expanded; + +ALTER TABLE chats DROP COLUMN last_injected_context; + +CREATE VIEW chats_expanded AS + SELECT c.id, + c.owner_id, + c.workspace_id, + c.title, + c.status, + c.worker_id, + c.started_at, + c.heartbeat_at, + c.created_at, + c.updated_at, + c.parent_chat_id, + c.root_chat_id, + c.last_model_config_id, + c.archived, + c.last_error, + c.mode, + c.mcp_server_ids, + c.labels, + c.build_id, + c.agent_id, + c.pin_order, + c.last_read_message_id, + c.dynamic_tools, + c.organization_id, + c.plan_mode, + c.client_type, + c.last_turn_summary, + c.snapshot_version, + c.history_version, + c.queue_version, + c.generation_attempt, + c.retry_state, + c.retry_state_version, + c.runner_id, + c.requires_action_deadline_at, + COALESCE(root.user_acl, c.user_acl) AS user_acl, + COALESCE(root.group_acl, c.group_acl) AS group_acl, + owner.username AS owner_username, + owner.name AS owner_name, + c.context_aggregate_hash, + c.context_dirty_since, + c.context_dirty_resources, + c.context_error + FROM ((chats c + LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id)))) + JOIN visible_users owner ON ((owner.id = c.owner_id))); diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 0835527b5dd8e..e5618d5564e41 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -819,7 +819,6 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams, &i.Chat.AgentID, &i.Chat.PinOrder, &i.Chat.LastReadMessageID, - &i.Chat.LastInjectedContext, &i.Chat.DynamicTools, &i.Chat.OrganizationID, &i.Chat.PlanMode, @@ -898,7 +897,6 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, diff --git a/coderd/database/models.go b/coderd/database/models.go index 89ad03a33d3bb..dbba7981c3600 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4787,7 +4787,6 @@ type Chat struct { AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` PinOrder int32 `db:"pin_order" json:"pin_order"` LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"` DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` @@ -4977,36 +4976,35 @@ type ChatQueuedMessage struct { } type ChatTable struct { - ID uuid.UUID `db:"id" json:"id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` - Title string `db:"title" json:"title"` - Status ChatStatus `db:"status" json:"status"` - WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` - StartedAt sql.NullTime `db:"started_at" json:"started_at"` - HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` - RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` - LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` - Archived bool `db:"archived" json:"archived"` - LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` - Mode NullChatMode `db:"mode" json:"mode"` - MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` - Labels StringMap `db:"labels" json:"labels"` - BuildID uuid.NullUUID `db:"build_id" json:"build_id"` - AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` - PinOrder int32 `db:"pin_order" json:"pin_order"` - LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"` - DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` - ClientType ChatClientType `db:"client_type" json:"client_type"` - LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` - UserACL ChatACL `db:"user_acl" json:"user_acl"` - GroupACL ChatACL `db:"group_acl" json:"group_acl"` + ID uuid.UUID `db:"id" json:"id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` + Title string `db:"title" json:"title"` + Status ChatStatus `db:"status" json:"status"` + WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"` + StartedAt sql.NullTime `db:"started_at" json:"started_at"` + HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"` + RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"` + LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"` + Archived bool `db:"archived" json:"archived"` + LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"` + Mode NullChatMode `db:"mode" json:"mode"` + MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"` + Labels StringMap `db:"labels" json:"labels"` + BuildID uuid.NullUUID `db:"build_id" json:"build_id"` + AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` + PinOrder int32 `db:"pin_order" json:"pin_order"` + LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` + DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` + ClientType ChatClientType `db:"client_type" json:"client_type"` + LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"` + UserACL ChatACL `db:"user_acl" json:"user_acl"` + GroupACL ChatACL `db:"group_acl" json:"group_acl"` // Monotonic version for the full chat snapshot. Starts at 1 so stream loops and workers can use 0 to mean they have not loaded the chat yet. SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"` // Snapshot version of the latest durable history change. Starts at 0 until chat_messages triggers set it to the current snapshot_version. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index eb934e3a927d1..2b1b93b4ac4ba 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1336,11 +1336,6 @@ type sqlcQuerier interface { // caller can detect stolen or completed chats via set-difference. UpdateChatHeartbeats(ctx context.Context, arg UpdateChatHeartbeatsParams) ([]uuid.UUID, error) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLabelsByIDParams) (Chat, error) - // Updates the cached injected context parts (AGENTS.md + - // skills) on the chat row. Called only when context changes - // (first workspace attach or agent change). updated_at is - // intentionally not touched to avoid reordering the chat list. - UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error) UpdateChatLastModelConfigByID(ctx context.Context, arg UpdateChatLastModelConfigByIDParams) (Chat, error) // Updates the last read message ID for a chat. This is used to track // which messages the owner has seen, enabling unread indicators. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fdf442549348f..02e49dcc5ef7e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5482,7 +5482,7 @@ WHERE LIMIT $3::int ) -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -5508,7 +5508,6 @@ chats_expanded AS ( acquired_chats.agent_id, acquired_chats.pin_order, acquired_chats.last_read_message_id, - acquired_chats.last_injected_context, acquired_chats.dynamic_tools, acquired_chats.organization_id, acquired_chats.plan_mode, @@ -5535,7 +5534,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id) JOIN visible_users owner ON owner.id = acquired_chats.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -5579,7 +5578,6 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) ( &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -5739,7 +5737,7 @@ WITH updated_chats AS ( UPDATE chats SET archived = true, pin_order = 0, updated_at = NOW() WHERE id = $1::uuid OR root_chat_id = $1::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -5765,7 +5763,6 @@ chats_expanded AS ( updated_chats.agent_id, updated_chats.pin_order, updated_chats.last_read_message_id, - updated_chats.last_injected_context, updated_chats.dynamic_tools, updated_chats.organization_id, updated_chats.plan_mode, @@ -5792,7 +5789,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chats.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -5829,7 +5826,6 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat, &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -5901,10 +5897,10 @@ archived AS ( FROM to_archive t WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children AND c.archived = false - RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error + RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error ) SELECT - a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error, + a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error, -- Children inherit their root's activity so last_activity_at is never null. COALESCE( t.last_activity_at, @@ -5944,7 +5940,6 @@ type AutoArchiveInactiveChatsRow struct { AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` PinOrder int32 `db:"pin_order" json:"pin_order"` LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"` DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` @@ -6006,7 +6001,6 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -6302,7 +6296,7 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds } const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE agent_id = $1::uuid AND archived = false @@ -6345,7 +6339,6 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -6383,7 +6376,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U const getAutoArchiveInactiveChatCandidates = `-- name: GetAutoArchiveInactiveChatCandidates :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at FROM chats_expanded LEFT JOIN LATERAL ( @@ -6438,7 +6431,6 @@ type GetAutoArchiveInactiveChatCandidatesRow struct { AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` PinOrder int32 `db:"pin_order" json:"pin_order"` LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"` DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` @@ -6498,7 +6490,6 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -6558,7 +6549,7 @@ func (q *sqlQuerier) GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatA } const getChatByID = `-- name: GetChatByID :one -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE id = $1::uuid ` @@ -6589,7 +6580,6 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -6617,7 +6607,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error const getChatByIDForShare = `-- name: GetChatByIDForShare :one WITH shared_chat AS ( - SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats WHERE id = $1::uuid FOR SHARE @@ -6646,7 +6636,6 @@ chats_expanded AS ( shared_chat.agent_id, shared_chat.pin_order, shared_chat.last_read_message_id, - shared_chat.last_injected_context, shared_chat.dynamic_tools, shared_chat.organization_id, shared_chat.plan_mode, @@ -6673,7 +6662,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id) JOIN visible_users owner ON owner.id = shared_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -6703,7 +6692,6 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -6731,7 +6719,7 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one WITH locked_chat AS ( - SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats WHERE id = $1::uuid FOR UPDATE @@ -6760,7 +6748,6 @@ chats_expanded AS ( locked_chat.agent_id, locked_chat.pin_order, locked_chat.last_read_message_id, - locked_chat.last_injected_context, locked_chat.dynamic_tools, locked_chat.organization_id, locked_chat.plan_mode, @@ -6787,7 +6774,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id) JOIN visible_users owner ON owner.id = locked_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -6817,7 +6804,6 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -8287,7 +8273,7 @@ func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChat const getChatWorkerAcquisitionCandidates = `-- name: GetChatWorkerAcquisitionCandidates :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, chat_heartbeats.heartbeat_at AS current_heartbeat_at, NOT EXISTS ( SELECT 1 @@ -8346,7 +8332,6 @@ type GetChatWorkerAcquisitionCandidatesRow struct { AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` PinOrder int32 `db:"pin_order" json:"pin_order"` LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` - LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"` DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"` @@ -8415,7 +8400,6 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -8463,7 +8447,7 @@ WITH cursor_chat AS ( WHERE id = $7 ) SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -8697,7 +8681,6 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha &i.Chat.AgentID, &i.Chat.PinOrder, &i.Chat.LastReadMessageID, - &i.Chat.LastInjectedContext, &i.Chat.DynamicTools, &i.Chat.OrganizationID, &i.Chat.PlanMode, @@ -8736,7 +8719,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha const getChatsByChatFileID = `-- name: GetChatsByChatFileID :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE @@ -8781,7 +8764,6 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -8818,7 +8800,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID) } const getChatsByIDsForRunnerSync = `-- name: GetChatsByIDsForRunnerSync :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE id = ANY($1::uuid[]) ORDER BY id ASC @@ -8856,7 +8838,6 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -8893,7 +8874,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid. } const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE archived = false AND workspace_id = ANY($1::uuid[]) @@ -8932,7 +8913,6 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -9038,7 +9018,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many SELECT - chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, + chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, EXISTS ( SELECT 1 FROM chat_messages cm WHERE cm.chat_id = chats_expanded.id @@ -9105,7 +9085,6 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC &i.Chat.AgentID, &i.Chat.PinOrder, &i.Chat.LastReadMessageID, - &i.Chat.LastInjectedContext, &i.Chat.DynamicTools, &i.Chat.OrganizationID, &i.Chat.PlanMode, @@ -9209,7 +9188,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh const getStaleChats = `-- name: GetStaleChats :many SELECT - id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded WHERE @@ -9264,7 +9243,6 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -9491,7 +9469,7 @@ INSERT INTO chats ( $15::jsonb, $16::chat_client_type ) -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -9517,7 +9495,6 @@ chats_expanded AS ( inserted_chat.agent_id, inserted_chat.pin_order, inserted_chat.last_read_message_id, - inserted_chat.last_injected_context, inserted_chat.dynamic_tools, inserted_chat.organization_id, inserted_chat.plan_mode, @@ -9544,7 +9521,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id) JOIN visible_users owner ON owner.id = inserted_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -9610,7 +9587,6 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -10117,7 +10093,7 @@ WITH bumped_chat AS ( WHERE id = $1::uuid FOR UPDATE ) - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -10143,7 +10119,6 @@ chats_expanded AS ( bumped_chat.agent_id, bumped_chat.pin_order, bumped_chat.last_read_message_id, - bumped_chat.last_injected_context, bumped_chat.dynamic_tools, bumped_chat.organization_id, bumped_chat.plan_mode, @@ -10169,7 +10144,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id) JOIN visible_users owner ON owner.id = bumped_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10203,7 +10178,6 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -10555,7 +10529,7 @@ WITH updated_chats AS ( archived = false, updated_at = NOW() WHERE id = $1::uuid OR root_chat_id = $1::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -10581,7 +10555,6 @@ chats_expanded AS ( updated_chats.agent_id, updated_chats.pin_order, updated_chats.last_read_message_id, - updated_chats.last_injected_context, updated_chats.dynamic_tools, updated_chats.organization_id, updated_chats.plan_mode, @@ -10608,7 +10581,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chats.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC ` @@ -10649,7 +10622,6 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -10773,7 +10745,7 @@ UPDATE chats SET updated_at = NOW() WHERE id = $3::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -10799,7 +10771,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -10826,7 +10797,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10862,7 +10833,6 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -10897,7 +10867,7 @@ SET updated_at = NOW() WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -10923,7 +10893,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -10950,7 +10919,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -10985,7 +10954,6 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -11024,7 +10992,7 @@ WITH updated_chat AS ( pin_order = CASE WHEN $2::boolean THEN 0 ELSE pin_order END, updated_at = NOW() WHERE id = $7::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -11050,7 +11018,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -11076,7 +11043,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11128,7 +11095,6 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -11208,7 +11174,7 @@ SET updated_at = NOW() WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -11234,7 +11200,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -11261,7 +11226,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11296,131 +11261,6 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, - &i.DynamicTools, - &i.OrganizationID, - &i.PlanMode, - &i.ClientType, - &i.LastTurnSummary, - &i.SnapshotVersion, - &i.HistoryVersion, - &i.QueueVersion, - &i.GenerationAttempt, - &i.RetryState, - &i.RetryStateVersion, - &i.RunnerID, - &i.RequiresActionDeadlineAt, - &i.UserACL, - &i.GroupACL, - &i.OwnerUsername, - &i.OwnerName, - &i.ContextAggregateHash, - &i.ContextDirtySince, - &i.ContextDirtyResources, - &i.ContextError, - ) - return i, err -} - -const updateChatLastInjectedContext = `-- name: UpdateChatLastInjectedContext :one -WITH updated_chat AS ( -UPDATE chats SET - last_injected_context = $1::jsonb -WHERE - id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error -), -chats_expanded AS ( - SELECT - updated_chat.id, - updated_chat.owner_id, - updated_chat.workspace_id, - updated_chat.title, - updated_chat.status, - updated_chat.worker_id, - updated_chat.started_at, - updated_chat.heartbeat_at, - updated_chat.created_at, - updated_chat.updated_at, - updated_chat.parent_chat_id, - updated_chat.root_chat_id, - updated_chat.last_model_config_id, - updated_chat.archived, - updated_chat.last_error, - updated_chat.mode, - updated_chat.mcp_server_ids, - updated_chat.labels, - updated_chat.build_id, - updated_chat.agent_id, - updated_chat.pin_order, - updated_chat.last_read_message_id, - updated_chat.last_injected_context, - updated_chat.dynamic_tools, - updated_chat.organization_id, - updated_chat.plan_mode, - updated_chat.client_type, - updated_chat.last_turn_summary, - updated_chat.snapshot_version, - updated_chat.history_version, - updated_chat.queue_version, - updated_chat.generation_attempt, - updated_chat.retry_state, - updated_chat.retry_state_version, - updated_chat.runner_id, - updated_chat.requires_action_deadline_at, - COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl, - COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl, - owner.username AS owner_username, - owner.name AS owner_name, - updated_chat.context_aggregate_hash, - updated_chat.context_dirty_since, - updated_chat.context_dirty_resources, - updated_chat.context_error - FROM - updated_chat - LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) - JOIN visible_users owner ON owner.id = updated_chat.owner_id -) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error -FROM chats_expanded -` - -type UpdateChatLastInjectedContextParams struct { - LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"` - ID uuid.UUID `db:"id" json:"id"` -} - -// Updates the cached injected context parts (AGENTS.md + -// skills) on the chat row. Called only when context changes -// (first workspace attach or agent change). updated_at is -// intentionally not touched to avoid reordering the chat list. -func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error) { - row := q.db.QueryRowContext(ctx, updateChatLastInjectedContext, arg.LastInjectedContext, arg.ID) - var i Chat - err := row.Scan( - &i.ID, - &i.OwnerID, - &i.WorkspaceID, - &i.Title, - &i.Status, - &i.WorkerID, - &i.StartedAt, - &i.HeartbeatAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.ParentChatID, - &i.RootChatID, - &i.LastModelConfigID, - &i.Archived, - &i.LastError, - &i.Mode, - pq.Array(&i.MCPServerIDs), - &i.Labels, - &i.BuildID, - &i.AgentID, - &i.PinOrder, - &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -11455,7 +11295,7 @@ SET last_model_config_id = $1::uuid WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -11481,7 +11321,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -11508,7 +11347,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11543,7 +11382,6 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -11628,7 +11466,7 @@ SET updated_at = NOW() WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -11654,7 +11492,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -11681,7 +11518,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11716,7 +11553,6 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -11871,7 +11707,7 @@ SET plan_mode = $1::chat_plan_mode WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -11897,7 +11733,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -11924,7 +11759,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -11959,7 +11794,6 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -11992,7 +11826,7 @@ WITH updated_chat AS ( retry_state = $1::jsonb, updated_at = NOW() WHERE id = $2::uuid - RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error + RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -12018,7 +11852,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -12044,7 +11877,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12081,7 +11914,6 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -12120,7 +11952,7 @@ SET updated_at = NOW() WHERE id = $6::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -12146,7 +11978,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -12173,7 +12004,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12219,7 +12050,6 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -12258,7 +12088,7 @@ SET updated_at = $6::timestamptz WHERE id = $7::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -12284,7 +12114,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -12311,7 +12140,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12359,7 +12188,6 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -12396,7 +12224,7 @@ SET title = $1::text WHERE id = $2::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -12422,7 +12250,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -12449,7 +12276,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12484,7 +12311,6 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, @@ -12518,7 +12344,7 @@ UPDATE chats SET agent_id = $3::uuid, updated_at = NOW() WHERE id = $4::uuid -RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error ), chats_expanded AS ( SELECT @@ -12544,7 +12370,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -12571,7 +12396,7 @@ chats_expanded AS ( LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) JOIN visible_users owner ON owner.id = updated_chat.owner_id ) -SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error +SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error FROM chats_expanded ` @@ -12613,7 +12438,6 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC &i.AgentID, &i.PinOrder, &i.LastReadMessageID, - &i.LastInjectedContext, &i.DynamicTools, &i.OrganizationID, &i.PlanMode, diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 6c98afc337c1f..a62ce0643891d 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -29,7 +29,6 @@ chats_expanded AS ( updated_chats.agent_id, updated_chats.pin_order, updated_chats.last_read_message_id, - updated_chats.last_injected_context, updated_chats.dynamic_tools, updated_chats.organization_id, updated_chats.plan_mode, @@ -96,7 +95,6 @@ chats_expanded AS ( updated_chats.agent_id, updated_chats.pin_order, updated_chats.last_read_message_id, - updated_chats.last_injected_context, updated_chats.dynamic_tools, updated_chats.organization_id, updated_chats.plan_mode, @@ -762,7 +760,6 @@ chats_expanded AS ( inserted_chat.agent_id, inserted_chat.pin_order, inserted_chat.last_read_message_id, - inserted_chat.last_injected_context, inserted_chat.dynamic_tools, inserted_chat.organization_id, inserted_chat.plan_mode, @@ -911,7 +908,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -978,7 +974,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1043,7 +1038,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1108,7 +1102,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1173,7 +1166,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1237,7 +1229,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1301,73 +1292,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, - updated_chat.dynamic_tools, - updated_chat.organization_id, - updated_chat.plan_mode, - updated_chat.client_type, - updated_chat.last_turn_summary, - updated_chat.snapshot_version, - updated_chat.history_version, - updated_chat.queue_version, - updated_chat.generation_attempt, - updated_chat.retry_state, - updated_chat.retry_state_version, - updated_chat.runner_id, - updated_chat.requires_action_deadline_at, - COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl, - COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl, - owner.username AS owner_username, - owner.name AS owner_name, - updated_chat.context_aggregate_hash, - updated_chat.context_dirty_since, - updated_chat.context_dirty_resources, - updated_chat.context_error - FROM - updated_chat - LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id) - JOIN visible_users owner ON owner.id = updated_chat.owner_id -) -SELECT * -FROM chats_expanded; - --- name: UpdateChatLastInjectedContext :one -WITH updated_chat AS ( --- Updates the cached injected context parts (AGENTS.md + --- skills) on the chat row. Called only when context changes --- (first workspace attach or agent change). updated_at is --- intentionally not touched to avoid reordering the chat list. -UPDATE chats SET - last_injected_context = sqlc.narg('last_injected_context')::jsonb -WHERE - id = @id::uuid -RETURNING * -), -chats_expanded AS ( - SELECT - updated_chat.id, - updated_chat.owner_id, - updated_chat.workspace_id, - updated_chat.title, - updated_chat.status, - updated_chat.worker_id, - updated_chat.started_at, - updated_chat.heartbeat_at, - updated_chat.created_at, - updated_chat.updated_at, - updated_chat.parent_chat_id, - updated_chat.root_chat_id, - updated_chat.last_model_config_id, - updated_chat.archived, - updated_chat.last_error, - updated_chat.mode, - updated_chat.mcp_server_ids, - updated_chat.labels, - updated_chat.build_id, - updated_chat.agent_id, - updated_chat.pin_order, - updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1449,7 +1373,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1666,7 +1589,6 @@ chats_expanded AS ( acquired_chats.agent_id, acquired_chats.pin_order, acquired_chats.last_read_message_id, - acquired_chats.last_injected_context, acquired_chats.dynamic_tools, acquired_chats.organization_id, acquired_chats.plan_mode, @@ -1735,7 +1657,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -1804,7 +1725,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -2080,7 +2000,6 @@ chats_expanded AS ( locked_chat.agent_id, locked_chat.pin_order, locked_chat.last_read_message_id, - locked_chat.last_injected_context, locked_chat.dynamic_tools, locked_chat.organization_id, locked_chat.plan_mode, @@ -2141,7 +2060,6 @@ chats_expanded AS ( shared_chat.agent_id, shared_chat.pin_order, shared_chat.last_read_message_id, - shared_chat.last_injected_context, shared_chat.dynamic_tools, shared_chat.organization_id, shared_chat.plan_mode, @@ -2821,7 +2739,6 @@ chats_expanded AS ( bumped_chat.agent_id, bumped_chat.pin_order, bumped_chat.last_read_message_id, - bumped_chat.last_injected_context, bumped_chat.dynamic_tools, bumped_chat.organization_id, bumped_chat.plan_mode, @@ -2893,7 +2810,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, @@ -2957,7 +2873,6 @@ chats_expanded AS ( updated_chat.agent_id, updated_chat.pin_order, updated_chat.last_read_message_id, - updated_chat.last_injected_context, updated_chat.dynamic_tools, updated_chat.organization_id, updated_chat.plan_mode, diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 5ad789e52ebf3..176024a273a77 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -1982,13 +1982,6 @@ func TestWatchChats(t *testing.T) { user := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) - lastInjectedContext, err := json.Marshal([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "large-skill", - SkillDescription: strings.Repeat("x", 9000), - }}) - require.NoError(t, err) - // Insert a chat and a diff status row. chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: user.OrganizationID, @@ -1996,20 +1989,9 @@ func TestWatchChats(t *testing.T) { LastModelConfigID: modelConfig.ID, Title: "diff status watch test", }) - chat, err = db.UpdateChatLastInjectedContext( - dbauthz.AsChatd(ctx), - database.UpdateChatLastInjectedContextParams{ - ID: chat.ID, - LastInjectedContext: pqtype.NullRawMessage{ - RawMessage: lastInjectedContext, - Valid: true, - }, - }, - ) - require.NoError(t, err) refreshedAt := time.Now().UTC().Truncate(time.Second) staleAt := refreshedAt.Add(time.Hour) - _, err = db.UpsertChatDiffStatusReference( + _, err := db.UpsertChatDiffStatusReference( dbauthz.AsSystemRestricted(ctx), database.UpsertChatDiffStatusReferenceParams{ ChatID: chat.ID, @@ -2035,10 +2017,6 @@ func TestWatchChats(t *testing.T) { ) require.NoError(t, err) - storedChat, err := client.GetChat(ctx, chat.ID) - require.NoError(t, err) - require.NotEmpty(t, storedChat.LastInjectedContext) - // Open the watch WebSocket. conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil) require.NoError(t, err) @@ -2069,7 +2047,6 @@ func TestWatchChats(t *testing.T) { require.EqualValues(t, 42, ds.Additions) require.EqualValues(t, 7, ds.Deletions) require.EqualValues(t, 5, ds.ChangedFiles) - require.Empty(t, received.Chat.LastInjectedContext) }) t.Run("ArchiveAndUnarchiveEmitEventsForDescendants", func(t *testing.T) { t.Parallel() diff --git a/coderd/export_test.go b/coderd/export_test.go index 475270b994040..186cf28c8d757 100644 --- a/coderd/export_test.go +++ b/coderd/export_test.go @@ -1,8 +1,5 @@ package coderd -// InsertAgentChatTestModelConfig exposes insertAgentChatTestModelConfig for external tests. -var InsertAgentChatTestModelConfig = insertAgentChatTestModelConfig - // ChatStartWorkspace exposes chatStartWorkspace for external tests. // // chatStartWorkspace is intentionally unexported to keep symmetry with diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index f4c0aa0c99074..e424f9b536412 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -42,9 +42,6 @@ import ( "github.com/coder/coder/v2/coderd/telemetry" maputil "github.com/coder/coder/v2/coderd/util/maps" "github.com/coder/coder/v2/coderd/wspubsub" - "github.com/coder/coder/v2/coderd/x/chatd" - "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" - "github.com/coder/coder/v2/coderd/x/chatd/chatstate" "github.com/coder/coder/v2/coderd/x/gitsync" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -2387,315 +2384,11 @@ func convertWorkspaceAgentLogs(logs []database.WorkspaceAgentLog) []codersdk.Wor return sdk } -// maxChatContextParts caps the number of parts per request to -// prevent unbounded message payloads. -const maxChatContextParts = 100 - -// maxChatContextFileBytes caps each context-file part to the same -// 64KiB budget used when the agent reads instruction files from disk. -const maxChatContextFileBytes = 64 * 1024 - -// maxChatContextRequestBodyBytes caps the JSON request body size for -// agent-added context to roughly the same per-part budget used when -// reading instruction files from disk. -const maxChatContextRequestBodyBytes int64 = maxChatContextParts * maxChatContextFileBytes - -// sanitizeWorkspaceAgentContextFileContent applies prompt -// sanitization, then enforces the 64KiB per-file budget. The -// truncated flag is preserved when the caller already capped the -// file before sending it. -func sanitizeWorkspaceAgentContextFileContent( - content string, - truncated bool, -) (string, bool) { - content = chatd.SanitizePromptText(content) - if len(content) > maxChatContextFileBytes { - content = content[:maxChatContextFileBytes] - truncated = true - } - return content, truncated -} - -// readChatContextBody reads and validates the request body for chat -// context endpoints. It handles MaxBytesReader wrapping, error -// responses, and body rewind. If the body is empty or whitespace-only -// and allowEmpty is true, it returns false without writing an error. -// -//nolint:revive // Add and clear endpoints only differ by empty-body handling. -func readChatContextBody(ctx context.Context, rw http.ResponseWriter, r *http.Request, dst any, allowEmpty bool) bool { - r.Body = http.MaxBytesReader(rw, r.Body, maxChatContextRequestBodyBytes) - body, err := io.ReadAll(r.Body) - if err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{ - Message: "Request body too large.", - Detail: fmt.Sprintf("Maximum request body size is %d bytes.", maxChatContextRequestBodyBytes), - }) - return false - } - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Failed to read request body.", - Detail: err.Error(), - }) - return false - } - if allowEmpty && len(bytes.TrimSpace(body)) == 0 { - r.Body = http.NoBody - return false - } - - r.Body = io.NopCloser(bytes.NewReader(body)) - return httpapi.Read(ctx, rw, r, dst) -} - -// @x-apidocgen {"skip": true} -func (api *API) workspaceAgentAddChatContext(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - workspaceAgent := httpmw.WorkspaceAgent(r) - - var req agentsdk.AddChatContextRequest - if !readChatContextBody(ctx, rw, r, &req, false) { - return - } - - if len(req.Parts) == 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No context parts provided.", - }) - return - } - - if len(req.Parts) > maxChatContextParts { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("Too many context parts (%d). Maximum is %d.", len(req.Parts), maxChatContextParts), - }) - return - } - - // Filter to only non-empty context-file and skill parts. - filtered := chatd.FilterContextParts(req.Parts, false) - if len(filtered) == 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No context-file or skill parts provided.", - }) - return - } - req.Parts = filtered - responsePartCount := 0 - - // Use system context for chat operations since the - // workspace agent scope does not include chat resources. - // We verify agent-to-chat ownership explicitly below. - //nolint:gocritic // Agent needs system access to read/write chat resources. - sysCtx := dbauthz.AsSystemRestricted(ctx) - workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to determine workspace from agent token.", - Detail: err.Error(), - }) - return - } - - chat, err := resolveAgentChat(sysCtx, api.Database, workspaceAgent.ID, workspace.OwnerID, req.ChatID) - if err != nil { - writeAgentChatError(ctx, rw, err) - return - } - - // Stamp each persisted part with the agent identity. Context-file - // parts also get server-authoritative workspace metadata. - directory := workspaceAgent.ExpandedDirectory - if directory == "" { - directory = workspaceAgent.Directory - } - for i := range req.Parts { - req.Parts[i].ContextFileAgentID = uuid.NullUUID{ - UUID: workspaceAgent.ID, - Valid: true, - } - if req.Parts[i].Type != codersdk.ChatMessagePartTypeContextFile { - continue - } - req.Parts[i].ContextFileContent, req.Parts[i].ContextFileTruncated = sanitizeWorkspaceAgentContextFileContent( - req.Parts[i].ContextFileContent, - req.Parts[i].ContextFileTruncated, - ) - req.Parts[i].ContextFileOS = workspaceAgent.OperatingSystem - req.Parts[i].ContextFileDirectory = directory - } - req.Parts = chatd.FilterContextParts(req.Parts, false) - if len(req.Parts) == 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "No context-file or skill parts provided.", - }) - return - } - responsePartCount = len(req.Parts) - - // Skill-only messages need a sentinel context-file part so the turn - // pipeline trusts the associated skill metadata. - req.Parts = prependAgentChatContextSentinelIfNeeded( - req.Parts, - workspaceAgent.ID, - workspaceAgent.OperatingSystem, - directory, - ) - - content, err := chatprompt.MarshalParts(req.Parts) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to marshal context parts.", - Detail: err.Error(), - }) - return - } - - machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chat.ID) - err = machine.Update(sysCtx, func(tx *chatstate.Tx, store database.Store) error { - locked, err := store.GetChatByID(sysCtx, chat.ID) - if err != nil { - return xerrors.Errorf("load chat: %w", err) - } - if !isActiveAgentChat(locked) { - return errChatNotActive - } - if !locked.AgentID.Valid || locked.AgentID.UUID != workspaceAgent.ID { - return errChatDoesNotBelongToAgent - } - if locked.OwnerID != workspace.OwnerID { - return errChatDoesNotBelongToWorkspaceOwner - } - apiKeyID, err := resolveAgentChatContextAPIKeyID(sysCtx, store, locked) - if err != nil { - return err - } - sendResult, err := tx.SendMessage(chatstate.SendMessageInput{ - Message: chatstate.Message{ - Role: database.ChatMessageRoleUser, - Content: content, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: locked.LastModelConfigID, Valid: locked.LastModelConfigID != uuid.Nil}, - CreatedBy: uuid.NullUUID{UUID: locked.OwnerID, Valid: locked.OwnerID != uuid.Nil}, - ContentVersion: chatprompt.CurrentContentVersion, - APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""}, - }, - BusyBehavior: chatstate.BusyBehaviorInterrupt, - }) - if err != nil { - return err - } - if len(sendResult.InsertedMessages) == 0 { - return nil - } - if err := updateAgentChatLastInjectedContextFromMessages(sysCtx, api.Logger, store, chat.ID); err != nil { - return xerrors.Errorf("rebuild injected context cache: %w", err) - } - return nil - }) - if err != nil { - switch { - case errors.Is(err, errChatNotActive), errors.Is(err, errChatDoesNotBelongToAgent), errors.Is(err, errChatDoesNotBelongToWorkspaceOwner): - writeAgentChatError(ctx, rw, err) - case errors.Is(err, errChatAPIKeyAttributionUnavailable): - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Cannot modify context: chat has no API key attribution.", - }) - case errors.Is(err, chatstate.ErrMessageQueueFull): - var queueFull *chatstate.MessageQueueFullError - detail := "" - if errors.As(err, &queueFull) { - detail = fmt.Sprintf("Maximum %d messages can be queued.", queueFull.Max) - } - httpapi.Write(ctx, rw, http.StatusTooManyRequests, codersdk.Response{ - Message: "Message queue is full.", - Detail: detail, - }) - case errors.Is(err, chatstate.ErrInvalidState): - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Chat is in an invalid state.", - }) - case errors.Is(err, chatstate.ErrTransitionNotAllowed): - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Chat is not in a state that accepts new context.", - Detail: err.Error(), - }) - case errors.Is(err, chatstate.ErrChatNotFound): - writeAgentChatError(ctx, rw, errChatNotFound) - default: - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to persist context message.", - Detail: err.Error(), - }) - } - return - } - - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.AddChatContextResponse{ - ChatID: chat.ID, - Count: responsePartCount, - }) -} - -// @x-apidocgen {"skip": true} -func (api *API) workspaceAgentClearChatContext(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - workspaceAgent := httpmw.WorkspaceAgent(r) - - var req agentsdk.ClearChatContextRequest - populated := readChatContextBody(ctx, rw, r, &req, true) - if !populated && r.Body != http.NoBody { - return - } - - // Use system context for chat operations since the - // workspace agent scope does not include chat resources. - //nolint:gocritic // Agent needs system access to read/write chat resources. - sysCtx := dbauthz.AsSystemRestricted(ctx) - workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to determine workspace from agent token.", - Detail: err.Error(), - }) - return - } - - chat, err := resolveAgentChat(sysCtx, api.Database, workspaceAgent.ID, workspace.OwnerID, req.ChatID) - if err != nil { - // Zero active chats is not an error for clear. - if errors.Is(err, errNoActiveChats) { - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ClearChatContextResponse{}) - return - } - writeAgentChatError(ctx, rw, err) - return - } - - err = clearAgentChatContext(sysCtx, api.Database, chat.ID, workspaceAgent.ID, workspace.OwnerID) - if err != nil { - if errors.Is(err, errChatNotActive) || errors.Is(err, errChatDoesNotBelongToAgent) || errors.Is(err, errChatDoesNotBelongToWorkspaceOwner) { - writeAgentChatError(ctx, rw, err) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to clear context from chat.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ClearChatContextResponse{ - ChatID: chat.ID, - }) -} - // workspaceAgentRefreshChatContext re-pins every drifted chat bound to the // calling agent to the agent's latest context snapshot, clearing their // drift markers. It backs the in-workspace `coder exp chat context refresh` // (no chat argument), which uses the agent token rather than a user -// session, mirroring workspaceAgentClearChatContext's auth model. +// session. // // @x-apidocgen {"skip": true} func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http.Request) { @@ -2753,383 +2446,3 @@ func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http Refreshed: refreshed, }) } - -var ( - errNoActiveChats = xerrors.New("no active chats found") - errChatNotFound = xerrors.New("chat not found") - errChatNotActive = xerrors.New("chat is not active") - errChatDoesNotBelongToAgent = xerrors.New("chat does not belong to this agent") - errChatDoesNotBelongToWorkspaceOwner = xerrors.New("chat does not belong to this workspace owner") - errChatAPIKeyAttributionUnavailable = xerrors.New("chat has no API key attribution") -) - -type multipleActiveChatsError struct { - count int -} - -func (e *multipleActiveChatsError) Error() string { - return fmt.Sprintf( - "multiple active chats (%d) found for this agent, specify a chat ID", - e.count, - ) -} - -func resolveDefaultAgentChat(chats []database.Chat) (database.Chat, error) { - switch len(chats) { - case 0: - return database.Chat{}, errNoActiveChats - case 1: - return chats[0], nil - } - - var rootChat *database.Chat - for i := range chats { - chat := &chats[i] - if chat.ParentChatID.Valid { - continue - } - if rootChat != nil { - return database.Chat{}, &multipleActiveChatsError{count: len(chats)} - } - rootChat = chat - } - if rootChat != nil { - return *rootChat, nil - } - return database.Chat{}, &multipleActiveChatsError{count: len(chats)} -} - -// resolveAgentChat finds the target chat from either an explicit ID -// or auto-detection via the agent's active chats. -func resolveAgentChat( - ctx context.Context, - db database.Store, - agentID uuid.UUID, - workspaceOwnerID uuid.UUID, - explicitChatID uuid.UUID, -) (database.Chat, error) { - if explicitChatID == uuid.Nil { - chats, err := db.GetActiveChatsByAgentID(ctx, agentID) - if err != nil { - return database.Chat{}, xerrors.Errorf("list active chats: %w", err) - } - ownerChats := make([]database.Chat, 0, len(chats)) - for _, chat := range chats { - if chat.OwnerID != workspaceOwnerID { - continue - } - ownerChats = append(ownerChats, chat) - } - return resolveDefaultAgentChat(ownerChats) - } - - chat, err := db.GetChatByID(ctx, explicitChatID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return database.Chat{}, errChatNotFound - } - return database.Chat{}, xerrors.Errorf("get chat by id: %w", err) - } - if !chat.AgentID.Valid || chat.AgentID.UUID != agentID { - return database.Chat{}, errChatDoesNotBelongToAgent - } - if chat.OwnerID != workspaceOwnerID { - return database.Chat{}, errChatDoesNotBelongToWorkspaceOwner - } - if !isActiveAgentChat(chat) { - return database.Chat{}, errChatNotActive - } - return chat, nil -} - -func isActiveAgentChat(chat database.Chat) bool { - if chat.Archived { - return false - } - - switch chat.Status { - case database.ChatStatusWaiting, - database.ChatStatusPending, - database.ChatStatusRunning, - database.ChatStatusPaused, - database.ChatStatusRequiresAction: - return true - default: - return false - } -} - -func resolveAgentChatContextAPIKeyID(ctx context.Context, db database.Store, chat database.Chat) (string, error) { - messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: chat.ID, - AfterID: 0, - }) - if err != nil { - return "", xerrors.Errorf("load chat messages for API key attribution: %w", err) - } - for i := len(messages) - 1; i >= 0; i-- { - message := messages[i] - if message.Role != database.ChatMessageRoleUser { - continue - } - if !message.APIKeyID.Valid || message.APIKeyID.String == "" { - continue - } - return message.APIKeyID.String, nil - } - - loginTypes := []database.LoginType{ - database.LoginTypePassword, - database.LoginTypeOIDC, - database.LoginTypeGithub, - database.LoginTypeToken, - database.LoginTypeNone, - } - var newest database.APIKey - hasNewest := false - for _, loginType := range loginTypes { - keys, err := db.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{ - LoginType: loginType, - UserID: chat.OwnerID, - IncludeExpired: false, - }) - if err != nil { - return "", xerrors.Errorf("load owner API keys for attribution: %w", err) - } - for _, key := range keys { - if !hasNewest || key.CreatedAt.After(newest.CreatedAt) { - newest = key - hasNewest = true - } - } - } - if !hasNewest { - return "", errChatAPIKeyAttributionUnavailable - } - return newest.ID, nil -} - -func clearAgentChatContext( - ctx context.Context, - db database.Store, - chatID uuid.UUID, - agentID uuid.UUID, - workspaceOwnerID uuid.UUID, -) error { - return db.InTx(func(tx database.Store) error { - locked, err := tx.GetChatByIDForUpdate(ctx, chatID) - if err != nil { - return xerrors.Errorf("lock chat: %w", err) - } - if !isActiveAgentChat(locked) { - return errChatNotActive - } - if !locked.AgentID.Valid || locked.AgentID.UUID != agentID { - return errChatDoesNotBelongToAgent - } - if locked.OwnerID != workspaceOwnerID { - return errChatDoesNotBelongToWorkspaceOwner - } - messages, err := tx.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: chatID, - AfterID: 0, - }) - if err != nil { - return xerrors.Errorf("get chat messages: %w", err) - } - hadInjectedContext := locked.LastInjectedContext.Valid - var skillOnlyMessageIDs []int64 - for _, msg := range messages { - if !msg.Content.Valid { - continue - } - hasContextFile := messageHasPartTypes(msg.Content.RawMessage, codersdk.ChatMessagePartTypeContextFile) - hasSkill := messageHasPartTypes(msg.Content.RawMessage, codersdk.ChatMessagePartTypeSkill) - if hasContextFile || hasSkill { - hadInjectedContext = true - } - if hasSkill && !hasContextFile { - skillOnlyMessageIDs = append(skillOnlyMessageIDs, msg.ID) - } - } - if !hadInjectedContext { - return nil - } - if err := tx.SoftDeleteContextFileMessages(ctx, chatID); err != nil { - return xerrors.Errorf("soft delete context-file messages: %w", err) - } - for _, messageID := range skillOnlyMessageIDs { - if err := tx.SoftDeleteChatMessageByID(ctx, messageID); err != nil { - return xerrors.Errorf("soft delete context message %d: %w", messageID, err) - } - } - // Reset provider-side Responses chaining so the next turn replays - // the post-clear history instead of inheriting cleared context. - if err := tx.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID); err != nil { - return xerrors.Errorf("clear provider response chain: %w", err) - } - // Clear the injected-context cache inside the transaction so it is - // atomic with the soft-deletes. - param, err := chatd.BuildLastInjectedContext(nil) - if err != nil { - return xerrors.Errorf("clear injected context cache: %w", err) - } - if _, err := tx.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{ - ID: chatID, - LastInjectedContext: param, - }); err != nil { - return xerrors.Errorf("clear injected context cache: %w", err) - } - return nil - }, nil) -} - -// prependAgentChatContextSentinelIfNeeded adds an empty context-file -// part when the request only carries skills. The turn pipeline uses -// the sentinel's agent metadata to trust the skill parts. -func prependAgentChatContextSentinelIfNeeded( - parts []codersdk.ChatMessagePart, - agentID uuid.UUID, - operatingSystem string, - directory string, -) []codersdk.ChatMessagePart { - hasContextFile := false - hasSkill := false - for _, part := range parts { - switch part.Type { - case codersdk.ChatMessagePartTypeContextFile: - hasContextFile = true - case codersdk.ChatMessagePartTypeSkill: - hasSkill = true - } - if hasContextFile && hasSkill { - return parts - } - } - if !hasSkill || hasContextFile { - return parts - } - return append([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: chatd.AgentChatContextSentinelPath, - ContextFileAgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - ContextFileOS: operatingSystem, - ContextFileDirectory: directory, - }}, parts...) -} - -func sortChatMessagesByCreatedAtAndID(messages []database.ChatMessage) { - sort.SliceStable(messages, func(i, j int) bool { - if messages[i].CreatedAt.Equal(messages[j].CreatedAt) { - return messages[i].ID < messages[j].ID - } - return messages[i].CreatedAt.Before(messages[j].CreatedAt) - }) -} - -// updateAgentChatLastInjectedContextFromMessages rebuilds the -// injected-context cache from all persisted context-file and skill parts. -func updateAgentChatLastInjectedContextFromMessages( - ctx context.Context, - logger slog.Logger, - db database.Store, - chatID uuid.UUID, -) error { - messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: chatID, - AfterID: 0, - }) - if err != nil { - return xerrors.Errorf("load context messages for injected context: %w", err) - } - - sortChatMessagesByCreatedAtAndID(messages) - - parts, err := chatd.CollectContextPartsFromMessages(ctx, logger, messages, true) - if err != nil { - return xerrors.Errorf("collect injected context parts: %w", err) - } - parts = chatd.FilterContextPartsToLatestAgent(parts) - - param, err := chatd.BuildLastInjectedContext(parts) - if err != nil { - return xerrors.Errorf("update injected context: %w", err) - } - if _, err := db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{ - ID: chatID, - LastInjectedContext: param, - }); err != nil { - return xerrors.Errorf("update injected context: %w", err) - } - return nil -} - -func messageHasPartTypes(raw []byte, types ...codersdk.ChatMessagePartType) bool { - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(raw, &parts); err != nil { - return false - } - for _, part := range parts { - for _, typ := range types { - if part.Type == typ { - return true - } - } - } - return false -} - -// writeAgentChatError translates resolveAgentChat errors to HTTP -// responses. -func writeAgentChatError( - ctx context.Context, - rw http.ResponseWriter, - err error, -) { - if errors.Is(err, errNoActiveChats) { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "No active chats found for this agent.", - }) - return - } - if errors.Is(err, errChatNotFound) { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "Chat not found.", - }) - return - } - if errors.Is(err, errChatDoesNotBelongToAgent) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Chat does not belong to this agent.", - }) - return - } - if errors.Is(err, errChatDoesNotBelongToWorkspaceOwner) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Chat does not belong to this workspace owner.", - }) - return - } - if errors.Is(err, errChatNotActive) { - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: "Cannot modify context: this chat is no longer active.", - }) - return - } - - var multipleErr *multipleActiveChatsError - if errors.As(err, &multipleErr) { - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to resolve chat.", - Detail: err.Error(), - }) -} diff --git a/coderd/workspaceagents_active_chat_internal_test.go b/coderd/workspaceagents_active_chat_internal_test.go index 24e833f09ca79..1f4d3170855de 100644 --- a/coderd/workspaceagents_active_chat_internal_test.go +++ b/coderd/workspaceagents_active_chat_internal_test.go @@ -1,7 +1,7 @@ package coderd import ( - "fmt" + "database/sql" "testing" "github.com/google/uuid" @@ -16,66 +16,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestActiveAgentChatDefinitionsAgree(t *testing.T) { - t.Parallel() - - ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium)) - db, _ := dbtestutil.NewDB(t) - - org, err := db.GetDefaultOrganization(ctx) - require.NoError(t, err) - - owner := dbgen.User(t, db, database.User{}) - workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: org.ID, - OwnerID: owner.ID, - }).WithAgent().Do() - modelConfig := insertAgentChatTestModelConfig(t, db, owner.ID) - - insertedChats := make([]database.Chat, 0, len(database.AllChatStatusValues())*2) - for _, archived := range []bool{false, true} { - for _, status := range database.AllChatStatusValues() { - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: org.ID, - Status: status, - OwnerID: owner.ID, - LastModelConfigID: modelConfig.ID, - Title: fmt.Sprintf("%s-archived-%t", status, archived), - AgentID: uuid.NullUUID{UUID: workspace.Agents[0].ID, Valid: true}, - }) - - if archived { - _, err = db.ArchiveChatByID(ctx, chat.ID) - require.NoError(t, err) - - chat, err = db.GetChatByID(ctx, chat.ID) - require.NoError(t, err) - } - - insertedChats = append(insertedChats, chat) - } - } - - activeChats, err := db.GetActiveChatsByAgentID(ctx, workspace.Agents[0].ID) - require.NoError(t, err) - - activeByID := make(map[uuid.UUID]bool, len(activeChats)) - for _, chat := range activeChats { - activeByID[chat.ID] = true - } - - for _, chat := range insertedChats { - require.Equalf( - t, - isActiveAgentChat(chat), - activeByID[chat.ID], - "status=%s archived=%t", - chat.Status, - chat.Archived, - ) - } -} - func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) { t.Parallel() @@ -157,3 +97,31 @@ func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) { require.Equal(t, rootUserACL, fetchedChild.UserACL) require.Equal(t, rootGroupACL, fetchedChild.GroupACL) } + +func insertAgentChatTestModelConfig( + t testing.TB, + db database.Store, + userID uuid.UUID, +) database.ChatModelConfig { + t.Helper() + + createdBy := uuid.NullUUID{UUID: userID, Valid: true} + + provider := dbgen.AIProvider(t, db, database.AIProvider{ + Type: database.AIProviderTypeOpenai, + Name: "test-openai", + DisplayName: sql.NullString{String: "OpenAI", Valid: true}, + }) + dbgen.AIProviderKey(t, db, database.AIProviderKey{ + ProviderID: provider.ID, + APIKey: "test-api-key", + }) + + return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "openai", + AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, + CreatedBy: createdBy, + UpdatedBy: createdBy, + IsDefault: true, + }) +} diff --git a/coderd/workspaceagents_chat_context_internal_test.go b/coderd/workspaceagents_chat_context_internal_test.go deleted file mode 100644 index 8a8d126b6523f..0000000000000 --- a/coderd/workspaceagents_chat_context_internal_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package coderd - -import ( - "context" - "database/sql" - "encoding/json" - "testing" - "time" - - "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbmock" - "github.com/coder/coder/v2/codersdk" -) - -func TestUpdateAgentChatLastInjectedContextFromMessagesUsesMessageIDTieBreaker(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - chatID := uuid.New() - createdAt := time.Date(2026, time.April, 9, 13, 0, 0, 0, time.UTC) - oldAgentID := uuid.New() - newAgentID := uuid.New() - - oldContent, err := json.Marshal([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/old/AGENTS.md", - ContextFileContent: "old instructions", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }}) - require.NoError(t, err) - newContent, err := json.Marshal([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/new/AGENTS.md", - ContextFileContent: "new instructions", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }}) - require.NoError(t, err) - - db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{ - ChatID: chatID, - AfterID: 0, - }).Return([]database.ChatMessage{ - { - ID: 2, - CreatedAt: createdAt, - Content: pqtype.NullRawMessage{ - RawMessage: newContent, - Valid: true, - }, - }, - { - ID: 1, - CreatedAt: createdAt, - Content: pqtype.NullRawMessage{ - RawMessage: oldContent, - Valid: true, - }, - }, - }, nil) - - db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) { - require.Equal(t, chatID, arg.ID) - require.True(t, arg.LastInjectedContext.Valid) - var cached []codersdk.ChatMessagePart - require.NoError(t, json.Unmarshal(arg.LastInjectedContext.RawMessage, &cached)) - require.Len(t, cached, 1) - require.Equal(t, "/new/AGENTS.md", cached[0].ContextFilePath) - require.Equal(t, uuid.NullUUID{UUID: newAgentID, Valid: true}, cached[0].ContextFileAgentID) - return database.Chat{}, nil - }, - ) - - err = updateAgentChatLastInjectedContextFromMessages( - context.Background(), - slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - db, - chatID, - ) - require.NoError(t, err) -} - -func insertAgentChatTestModelConfig( - t testing.TB, - db database.Store, - userID uuid.UUID, -) database.ChatModelConfig { - t.Helper() - - createdBy := uuid.NullUUID{UUID: userID, Valid: true} - - provider := dbgen.AIProvider(t, db, database.AIProvider{ - Type: database.AIProviderTypeOpenai, - Name: "test-openai", - DisplayName: sql.NullString{String: "OpenAI", Valid: true}, - }) - dbgen.AIProviderKey(t, db, database.AIProviderKey{ - ProviderID: provider.ID, - APIKey: "test-api-key", - }) - - return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ - Provider: "openai", - AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, - CreatedBy: createdBy, - UpdatedBy: createdBy, - IsDefault: true, - }) -} diff --git a/coderd/workspaceagents_chat_context_test.go b/coderd/workspaceagents_chat_context_test.go deleted file mode 100644 index b880e9d69fe86..0000000000000 --- a/coderd/workspaceagents_chat_context_test.go +++ /dev/null @@ -1,1237 +0,0 @@ -package coderd_test - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "net/http" - "strings" - "testing" - - "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" - "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/dbfake" - "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" - dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" - coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" - "github.com/coder/coder/v2/coderd/x/chatd" - "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/codersdk/agentsdk" - "github.com/coder/coder/v2/testutil" -) - -type agentChatContextTestSetup struct { - client *codersdk.Client - db database.Store - user codersdk.CreateFirstUserResponse - workspace dbfake.WorkspaceResponse - agentClient *agentsdk.Client -} - -type agentChatContextBeforeInTxStore struct { - database.Store - beforeInTx func() -} - -func (s *agentChatContextBeforeInTxStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error { - if s.beforeInTx != nil { - beforeInTx := s.beforeInTx - s.beforeInTx = nil - beforeInTx() - } - return s.Store.InTx(fn, opts) -} - -func TestAgentChatContext(t *testing.T) { - t.Parallel() - - type addSuccessStep struct { - req agentsdk.AddChatContextRequest - wantCount int - } - - type addSuccessCase struct { - name string - steps []addSuccessStep - wantStored [][]codersdk.ChatMessagePart - storedOrdered bool - wantCached []codersdk.ChatMessagePart - cachedOrdered bool - } - - agentInstructionsPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/AGENTS.md", - ContextFileContent: "context from the agent", - } - repoHelperSkillPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper", - SkillDescription: "Repository instructions", - SkillDir: "/workspace/.agents/skills/repo-helper", - ContextFileSkillMetaFile: "SKILL.md", - } - projectInstructionsPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/AGENTS.md", - ContextFileContent: "project instructions", - } - cachedAgentInstructionsPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: agentInstructionsPart.ContextFilePath, - } - cachedRepoHelperSkillPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: repoHelperSkillPart.SkillName, - SkillDescription: repoHelperSkillPart.SkillDescription, - } - cachedProjectInstructionsPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: projectInstructionsPart.ContextFilePath, - } - - addSuccessCases := []addSuccessCase{ - { - name: "AddSuccessFiltersPartsAndUpdatesCache", - steps: []addSuccessStep{{req: agentsdk.AddChatContextRequest{Parts: []codersdk.ChatMessagePart{codersdk.ChatMessageText("ignore this text part"), agentInstructionsPart}}, wantCount: 1}}, - wantStored: [][]codersdk.ChatMessagePart{{agentInstructionsPart}}, - storedOrdered: true, - wantCached: []codersdk.ChatMessagePart{cachedAgentInstructionsPart}, - cachedOrdered: true, - }, - { - name: "AddSuccessWithSkillOnlyPartsGetsSentinel", - steps: []addSuccessStep{{req: agentsdk.AddChatContextRequest{Parts: []codersdk.ChatMessagePart{repoHelperSkillPart}}, wantCount: 1}}, - wantStored: [][]codersdk.ChatMessagePart{{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: chatd.AgentChatContextSentinelPath, - }, repoHelperSkillPart}}, - storedOrdered: true, - wantCached: []codersdk.ChatMessagePart{cachedRepoHelperSkillPart}, - cachedOrdered: true, - }, - { - name: "AddSuccessWithMixedPartsNoSentinel", - steps: []addSuccessStep{{req: agentsdk.AddChatContextRequest{Parts: []codersdk.ChatMessagePart{projectInstructionsPart, repoHelperSkillPart}}, wantCount: 2}}, - wantStored: [][]codersdk.ChatMessagePart{{projectInstructionsPart, repoHelperSkillPart}}, - storedOrdered: true, - wantCached: []codersdk.ChatMessagePart{cachedProjectInstructionsPart, cachedRepoHelperSkillPart}, - cachedOrdered: true, - }, - } - - for _, tc := range addSuccessCases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - for _, step := range tc.steps { - resp, err := setup.agentClient.AddChatContext(ctx, step.req) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - require.Equal(t, step.wantCount, resp.Count) - } - - actualStored := requireAgentChatContextStoredMessages(t, requireAgentChatContextMessages(ctx, t, setup.db, chat.ID)) - agent := setup.workspace.Agents[0] - wantStored := agentChatContextExpectedMessages(agent, tc.wantStored) - if tc.storedOrdered { - require.Equal(t, wantStored, actualStored) - } else { - require.ElementsMatch(t, wantStored, actualStored) - } - - wantCached := agentChatContextExpectedCachedParts(agent, tc.wantCached) - actualCached := requireAgentChatContextCachedParts(ctx, t, setup.db, chat.ID) - if tc.cachedOrdered { - require.Equal(t, wantCached, actualCached) - } else { - require.ElementsMatch(t, wantCached, actualCached) - } - }) - } - - t.Run("AddUsesLockedChatModelConfig", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - baseDB, pubsub := dbtestutil.NewDB(t) - interceptDB := &agentChatContextBeforeInTxStore{Store: baseDB} - client := coderdtest.New(t, &coderdtest.Options{ - Database: interceptDB, - Pubsub: pubsub, - }) - user := coderdtest.CreateFirstUser(t, client) - workspace := dbfake.WorkspaceBuild(t, baseDB, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent().Do() - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)) - - originalModel := coderd.InsertAgentChatTestModelConfig(t, baseDB, user.UserID) - updatedModel := dbgen.ChatModelConfig(t, baseDB, database.ChatModelConfig{ - Provider: originalModel.Provider, - Model: "gpt-4o-mini-updated", - DisplayName: "Updated Test Model", - CreatedBy: uuid.NullUUID{UUID: user.UserID, Valid: true}, - UpdatedBy: uuid.NullUUID{UUID: user.UserID, Valid: true}, - ContextLimit: originalModel.ContextLimit, - CompressionThreshold: originalModel.CompressionThreshold, - }) - chat := createAgentChatContextChat(t, baseDB, user.OrganizationID, user.UserID, originalModel.ID, workspace.Agents[0].ID, t.Name()) - - interceptDB.beforeInTx = func() { - _, err := baseDB.UpdateChatLastModelConfigByID( - dbauthz.AsSystemRestricted(ctx), - database.UpdateChatLastModelConfigByIDParams{ - ID: chat.ID, - LastModelConfigID: updatedModel.ID, - }, - ) - require.NoError(t, err) - } - - resp, err := agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/instructions.md", - ContextFileContent: "remember this file", - }}, - }) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - require.Equal(t, 1, resp.Count) - - messages := requireAgentChatContextMessages(ctx, t, baseDB, chat.ID) - require.Len(t, messages, 1) - require.True(t, messages[0].ModelConfigID.Valid) - require.Equal(t, updatedModel.ID, messages[0].ModelConfigID.UUID) - - persistedChat, err := baseDB.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.Equal(t, updatedModel.ID, persistedChat.LastModelConfigID) - }) - - t.Run("AddSuccessUpdatesChatStateVersionsAndPublishes", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - baseDB, pubsub := dbtestutil.NewDB(t) - client := coderdtest.New(t, &coderdtest.Options{ - Database: baseDB, - Pubsub: pubsub, - ChatWorkerDisabled: true, - }) - user := coderdtest.CreateFirstUser(t, client) - workspace := dbfake.WorkspaceBuild(t, baseDB, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent().Do() - agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)) - model := coderd.InsertAgentChatTestModelConfig(t, baseDB, user.UserID) - chat := createAgentChatContextChat(t, baseDB, user.OrganizationID, user.UserID, model.ID, workspace.Agents[0].ID, t.Name()) - - updateCh := make(chan []byte, 1) - cancelSub, err := pubsub.Subscribe(coderdpubsub.ChatStateUpdateChannel(chat.ID), func(_ context.Context, msg []byte) { - updateCh <- msg - }) - require.NoError(t, err) - defer cancelSub() - - resp, err := agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/instructions.md", - ContextFileContent: "remember this file", - }}, - }) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - require.Equal(t, 1, resp.Count) - - persisted, err := baseDB.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.Equal(t, chat.SnapshotVersion+1, persisted.SnapshotVersion) - require.Equal(t, persisted.SnapshotVersion, persisted.HistoryVersion) - - messages := requireAgentChatContextMessages(ctx, t, baseDB, chat.ID) - require.Len(t, messages, 1) - require.Equal(t, persisted.SnapshotVersion, messages[0].Revision) - - cached := requireAgentChatContextCachedParts(ctx, t, baseDB, chat.ID) - require.Len(t, cached, 1) - require.Equal(t, "/workspace/instructions.md", cached[0].ContextFilePath) - - select { - case raw := <-updateCh: - var update coderdpubsub.ChatStateUpdateMessage - require.NoError(t, json.Unmarshal(raw, &update)) - require.Equal(t, persisted.SnapshotVersion, update.SnapshotVersion) - require.Equal(t, persisted.HistoryVersion, update.HistoryVersion) - case <-ctx.Done(): - t.Fatal("timed out waiting for chat state update") - } - }) - - t.Run("AddInterruptsAndQueuesWhenChatIsRunning", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - chat = setAgentChatContextChatStatus(ctx, t, setup.db, chat.ID, database.ChatStatusRunning) - chat = acquireAgentChatContextChat(ctx, t, setup.db, chat.ID) - apiKeyID := currentAgentChatContextAPIKeyID(t, setup.client) - - resp, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/queued.md", - ContextFileContent: "queued context", - }}, - }) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - require.Equal(t, 1, resp.Count) - - require.Empty(t, requireAgentChatContextMessages(ctx, t, setup.db, chat.ID)) - - queued, err := setup.db.GetChatQueuedMessages(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.Len(t, queued, 1) - require.Equal(t, setup.user.UserID, queued[0].CreatedBy) - require.True(t, queued[0].ModelConfigID.Valid) - require.Equal(t, model.ID, queued[0].ModelConfigID.UUID) - require.True(t, queued[0].APIKeyID.Valid) - require.Equal(t, apiKeyID, queued[0].APIKeyID.String) - - parts := requireAgentChatContextParts(t, queued[0].Content) - require.Len(t, parts, 1) - require.Equal(t, "/workspace/queued.md", parts[0].ContextFilePath) - require.Equal(t, "queued context", parts[0].ContextFileContent) - require.Equal(t, uuid.NullUUID{UUID: setup.workspace.Agents[0].ID, Valid: true}, parts[0].ContextFileAgentID) - - persisted, err := setup.db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.False(t, persisted.LastInjectedContext.Valid) - require.Equal(t, database.ChatStatusInterrupting, persisted.Status) - require.Equal(t, chat.SnapshotVersion+1, persisted.SnapshotVersion) - require.Equal(t, chat.HistoryVersion, persisted.HistoryVersion) - require.Equal(t, persisted.SnapshotVersion, persisted.QueueVersion) - }) - - t.Run("AddFailsWhenQueueIsFull", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - chat = setAgentChatContextChatStatus(ctx, t, setup.db, chat.ID, database.ChatStatusRunning) - chat = acquireAgentChatContextChat(ctx, t, setup.db, chat.ID) - apiKeyID := currentAgentChatContextAPIKeyID(t, setup.client) - for i := range int(chatstate.MaxQueueSize) { - content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText(fmt.Sprintf("queued %d", i)), - }) - require.NoError(t, err) - _, err = setup.db.InsertChatQueuedMessageWithCreator( - dbauthz.AsSystemRestricted(ctx), - database.InsertChatQueuedMessageWithCreatorParams{ - ChatID: chat.ID, - Content: content.RawMessage, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - APIKeyID: sql.NullString{String: apiKeyID, Valid: true}, - CreatedBy: setup.user.UserID, - }, - ) - require.NoError(t, err) - } - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/overflow.md", - ContextFileContent: "overflow context", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusTooManyRequests) - require.Equal(t, "Message queue is full.", sdkErr.Message) - require.Contains(t, sdkErr.Detail, "Maximum") - }) - - t.Run("AddFailsWhenChatStateIsInvalid", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - _ = setAgentChatContextChatStatus(ctx, t, setup.db, chat.ID, database.ChatStatusPending) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/invalid.md", - ContextFileContent: "invalid state context", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusConflict) - require.Equal(t, "Chat is in an invalid state.", sdkErr.Message) - }) - - t.Run("ClearDeletesSkillMessages", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - skillPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper", - SkillDescription: "Repository instructions", - SkillDir: "/workspace/.agents/skills/repo-helper", - ContextFileSkillMetaFile: "SKILL.md", - } - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{skillPart}, - }) - require.NoError(t, err) - - messages, err := setup.db.GetChatMessagesByChatID( - dbauthz.AsSystemRestricted(ctx), - database.GetChatMessagesByChatIDParams{ChatID: chat.ID, AfterID: 0}, - ) - require.NoError(t, err) - require.Len(t, messages, 1) - - storedParts := requireAgentChatContextParts(t, messages[0].Content.RawMessage) - require.Len(t, storedParts, 2) - - // Strip the sentinel so clear must delete the skill message via - // the skill-part scan instead of the context-file bulk delete. - rawSkillOnly, err := json.Marshal([]codersdk.ChatMessagePart{storedParts[1]}) - require.NoError(t, err) - _, err = setup.db.UpdateChatMessageByID( - dbauthz.AsSystemRestricted(ctx), - database.UpdateChatMessageByIDParams{ - ID: messages[0].ID, - Content: pqtype.NullRawMessage{ - RawMessage: rawSkillOnly, - Valid: true, - }, - }, - ) - require.NoError(t, err) - - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - - messages, err = setup.db.GetChatMessagesByChatID( - dbauthz.AsSystemRestricted(ctx), - database.GetChatMessagesByChatIDParams{ChatID: chat.ID, AfterID: 0}, - ) - require.NoError(t, err) - require.Empty(t, messages) - - persistedChat, err := setup.db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.False(t, persistedChat.LastInjectedContext.Valid) - }) - - t.Run("ClearDeletesSkillMessagesBeforeCompressedSummary", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - skillPart := codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper", - SkillDescription: "Repository instructions", - SkillDir: "/workspace/.agents/skills/repo-helper", - ContextFileSkillMetaFile: "SKILL.md", - } - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{skillPart}, - }) - require.NoError(t, err) - - messages := requireAgentChatContextMessages(ctx, t, setup.db, chat.ID) - require.Len(t, messages, 1) - - storedParts := requireAgentChatContextParts(t, messages[0].Content.RawMessage) - require.Len(t, storedParts, 2) - - // Strip the sentinel so the skill message must be found by the - // full-history scan even after compaction hides it from the - // prompt-scoped query. - rawSkillOnly, err := json.Marshal([]codersdk.ChatMessagePart{storedParts[1]}) - require.NoError(t, err) - _, err = setup.db.UpdateChatMessageByID( - dbauthz.AsSystemRestricted(ctx), - database.UpdateChatMessageByIDParams{ - ID: messages[0].ID, - Content: pqtype.NullRawMessage{ - RawMessage: rawSkillOnly, - Valid: true, - }, - }, - ) - require.NoError(t, err) - - summaryContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("compressed summary"), - }) - require.NoError(t, err) - _ = dbgen.ChatMessage(t, setup.db, database.ChatMessage{ - ChatID: chat.ID, - Role: database.ChatMessageRoleUser, - Content: summaryContent, - Visibility: database.ChatMessageVisibilityModel, - ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - CreatedBy: uuid.NullUUID{UUID: setup.user.UserID, Valid: true}, - Compressed: true, - }) - - regularContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("keep this user message"), - }) - require.NoError(t, err) - _ = dbgen.ChatMessage(t, setup.db, database.ChatMessage{ - ChatID: chat.ID, - Role: database.ChatMessageRoleUser, - Content: regularContent, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - CreatedBy: uuid.NullUUID{UUID: setup.user.UserID, Valid: true}, - }) - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - - messages = requireAgentChatContextMessages(ctx, t, setup.db, chat.ID) - require.Len(t, messages, 1) - require.Equal(t, database.ChatMessageRoleUser, messages[0].Role) - - remainingParts := requireAgentChatContextParts(t, messages[0].Content.RawMessage) - require.Len(t, remainingParts, 1) - require.Equal(t, codersdk.ChatMessagePartTypeText, remainingParts[0].Type) - require.Equal(t, "keep this user message", remainingParts[0].Text) - - persistedChat, err := setup.db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.False(t, persistedChat.LastInjectedContext.Valid) - }) - - t.Run("ClearSuccessDeletesInjectedContext", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/instructions.md", - ContextFileContent: "remember this file", - }}, - }) - require.NoError(t, err) - - regularContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("keep this user message"), - }) - require.NoError(t, err) - _ = dbgen.ChatMessage(t, setup.db, database.ChatMessage{ - ChatID: chat.ID, - Role: database.ChatMessageRoleUser, - Content: regularContent, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - CreatedBy: uuid.NullUUID{UUID: setup.user.UserID, Valid: true}, - }) - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - - messages, err := setup.db.GetChatMessagesByChatID( - dbauthz.AsSystemRestricted(ctx), - database.GetChatMessagesByChatIDParams{ChatID: chat.ID, AfterID: 0}, - ) - require.NoError(t, err) - require.Len(t, messages, 1) - require.Equal(t, database.ChatMessageRoleUser, messages[0].Role) - - remainingParts := requireAgentChatContextParts(t, messages[0].Content.RawMessage) - require.Len(t, remainingParts, 1) - require.Equal(t, codersdk.ChatMessagePartTypeText, remainingParts[0].Type) - require.Equal(t, "keep this user message", remainingParts[0].Text) - - persistedChat, err := setup.db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.False(t, persistedChat.LastInjectedContext.Valid) - }) - - t.Run("ClearSuccessResetsProviderResponseChain", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/instructions.md", - ContextFileContent: "remember this file", - }}, - }) - require.NoError(t, err) - - assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("assistant reply"), - }) - require.NoError(t, err) - _ = dbgen.ChatMessage(t, setup.db, database.ChatMessage{ - ChatID: chat.ID, - Role: database.ChatMessageRoleAssistant, - Content: assistantContent, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - ProviderResponseID: sql.NullString{String: "resp-123", Valid: true}, - }) - - messages := requireAgentChatContextMessages(ctx, t, setup.db, chat.ID) - require.Len(t, messages, 2) - require.Equal(t, database.ChatMessageRoleAssistant, messages[1].Role) - require.True(t, messages[1].ProviderResponseID.Valid) - require.Equal(t, "resp-123", messages[1].ProviderResponseID.String) - - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - - messages = requireAgentChatContextMessages(ctx, t, setup.db, chat.ID) - require.Len(t, messages, 1) - require.Equal(t, database.ChatMessageRoleAssistant, messages[0].Role) - require.False(t, messages[0].ProviderResponseID.Valid) - - remainingParts := requireAgentChatContextParts(t, messages[0].Content.RawMessage) - require.Len(t, remainingParts, 1) - require.Equal(t, codersdk.ChatMessagePartTypeText, remainingParts[0].Type) - require.Equal(t, "assistant reply", remainingParts[0].Text) - - persistedChat, err := setup.db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID) - require.NoError(t, err) - require.False(t, persistedChat.LastInjectedContext.Valid) - }) - - t.Run("ClearWithoutContextPreservesProviderResponseChain", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("assistant reply"), - }) - require.NoError(t, err) - _ = dbgen.ChatMessage(t, setup.db, database.ChatMessage{ - ChatID: chat.ID, - Role: database.ChatMessageRoleAssistant, - Content: assistantContent, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - ProviderResponseID: sql.NullString{String: "resp-123", Valid: true}, - }) - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{ChatID: chat.ID}) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - - messages := requireAgentChatContextMessages(ctx, t, setup.db, chat.ID) - require.Len(t, messages, 1) - require.True(t, messages[0].ProviderResponseID.Valid) - require.Equal(t, "resp-123", messages[0].ProviderResponseID.String) - }) - - t.Run("AddFailsWhenAgentHasNoActiveChat", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/AGENTS.md", - ContextFileContent: "missing chat", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusNotFound) - require.Equal(t, "No active chats found for this agent.", sdkErr.Message) - }) - - t.Run("AddRejectsChatOwnedByAnotherAgent", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := coderdtest.NewWithDatabase(t, nil) - user := coderdtest.CreateFirstUser(t, client) - model := coderd.InsertAgentChatTestModelConfig(t, db, user.UserID) - - firstWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent().Do() - secondWorkspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent().Do() - - chat := createAgentChatContextChat(t, db, user.OrganizationID, user.UserID, model.ID, firstWorkspace.Agents[0].ID, t.Name()) - secondAgentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(secondWorkspace.AgentToken)) - - _, err := secondAgentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/foreign.md", - ContextFileContent: "not your chat", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusForbidden) - require.Equal(t, "Chat does not belong to this agent.", sdkErr.Message) - }) - - t.Run("AddRejectsChatOwnedByAnotherUserOnSameAgent", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - _, otherUser := coderdtest.CreateAnotherUser(t, setup.client, setup.user.OrganizationID) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, otherUser.ID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/foreign.md", - ContextFileContent: "not your chat", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusForbidden) - require.Equal(t, "Chat does not belong to this workspace owner.", sdkErr.Message) - }) - - t.Run("AddRejectsTooManyParts", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - parts := make([]codersdk.ChatMessagePart, 101) - for i := range parts { - parts[i] = codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.md", - ContextFileContent: "too many", - } - } - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{Parts: parts}) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Contains(t, sdkErr.Message, "Too many context parts") - }) - - t.Run("AddRejectsEmptyContextFileParts", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/empty.md", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No context-file or skill parts provided.", sdkErr.Message) - }) - - t.Run("AddRejectsWhitespaceOnlyContextFileParts", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/whitespace.md", - ContextFileContent: " \n\t", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusBadRequest) - require.Equal(t, "No context-file or skill parts provided.", sdkErr.Message) - }) - - t.Run("AddTruncatesOversizedContextFileParts", func(t *testing.T) { - t.Parallel() - - const maxContextFileBytes = 64 * 1024 - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - largeContent := strings.Repeat("a", maxContextFileBytes+100) - - resp, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/AGENTS.md", - ContextFileContent: largeContent, - }}, - }) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - require.Equal(t, 1, resp.Count) - - messages := requireAgentChatContextStoredMessages(t, requireAgentChatContextMessages(ctx, t, setup.db, chat.ID)) - require.Len(t, messages, 1) - require.Len(t, messages[0], 1) - require.True(t, messages[0][0].ContextFileTruncated) - require.Len(t, messages[0][0].ContextFileContent, maxContextFileBytes) - require.Equal(t, largeContent[:maxContextFileBytes], messages[0][0].ContextFileContent) - - cached := requireAgentChatContextCachedParts(ctx, t, setup.db, chat.ID) - require.Len(t, cached, 1) - require.True(t, cached[0].ContextFileTruncated) - }) - - t.Run("AddSanitizesBeforeApplyingContextFileSizeCap", func(t *testing.T) { - t.Parallel() - - const maxContextFileBytes = 64 * 1024 - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - visible := strings.Repeat("a", maxContextFileBytes-1) - content := visible + strings.Repeat("\u200b", 100) + "z" - - resp, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/AGENTS.md", - ContextFileContent: content, - }}, - }) - require.NoError(t, err) - require.Equal(t, chat.ID, resp.ChatID) - require.Equal(t, 1, resp.Count) - - messages := requireAgentChatContextStoredMessages(t, requireAgentChatContextMessages(ctx, t, setup.db, chat.ID)) - require.Len(t, messages, 1) - require.Len(t, messages[0], 1) - require.False(t, messages[0][0].ContextFileTruncated) - require.Equal(t, visible+"z", messages[0][0].ContextFileContent) - }) - - t.Run("ClearIsIdempotentWhenNoActiveChatExists", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, uuid.Nil, resp.ChatID) - }) - - t.Run("AddUsesWorkspaceOwnerChatWhenAnotherUsersChatIsActive", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - _, otherUser := coderdtest.CreateAnotherUser(t, setup.client, setup.user.OrganizationID) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - ownerChat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-owner") - foreignChat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, otherUser.ID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-foreign") - - resp, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.go", - ContextFileContent: "content", - }}, - }) - require.NoError(t, err) - require.Equal(t, ownerChat.ID, resp.ChatID) - - ownerMessages := requireAgentChatContextMessages(ctx, t, setup.db, ownerChat.ID) - require.Len(t, ownerMessages, 1) - require.Empty(t, requireAgentChatContextMessages(ctx, t, setup.db, foreignChat.ID)) - }) - - t.Run("AddUsesRootChatWhenOnlySubagentMakesActiveChatAmbiguous", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - rootChat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-root") - childChat := createAgentChatContextChildChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, rootChat.ID, t.Name()+"-child") - - resp, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.go", - ContextFileContent: "content", - }}, - }) - require.NoError(t, err) - require.Equal(t, rootChat.ID, resp.ChatID) - - rootMessages := requireAgentChatContextMessages(ctx, t, setup.db, rootChat.ID) - require.Len(t, rootMessages, 1) - require.Empty(t, requireAgentChatContextMessages(ctx, t, setup.db, childChat.ID)) - }) - - t.Run("AddFailsWithMultipleActiveChats", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-chat1") - createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-chat2") - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.go", - ContextFileContent: "content", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusConflict) - require.Contains(t, sdkErr.Message, "multiple active chats") - }) - - t.Run("ClearUsesRootChatWhenOnlySubagentMakesActiveChatAmbiguous", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - rootChat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-root") - childChat := createAgentChatContextChildChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, rootChat.ID, t.Name()+"-child") - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: rootChat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.go", - ContextFileContent: "content", - }}, - }) - require.NoError(t, err) - - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, rootChat.ID, resp.ChatID) - - require.Empty(t, requireAgentChatContextMessages(ctx, t, setup.db, rootChat.ID)) - require.Empty(t, requireAgentChatContextMessages(ctx, t, setup.db, childChat.ID)) - }) - - t.Run("ClearUsesWorkspaceOwnerChatWhenAnotherUsersChatIsActive", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - _, otherUser := coderdtest.CreateAnotherUser(t, setup.client, setup.user.OrganizationID) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - ownerChat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-owner") - _ = createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, otherUser.ID, model.ID, setup.workspace.Agents[0].ID, t.Name()+"-foreign") - - _, err := setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: ownerChat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.go", - ContextFileContent: "content", - }}, - }) - require.NoError(t, err) - - resp, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{}) - require.NoError(t, err) - require.Equal(t, ownerChat.ID, resp.ChatID) - require.Empty(t, requireAgentChatContextMessages(ctx, t, setup.db, ownerChat.ID)) - }) - - t.Run("ClearRejectsChatOwnedByAnotherUserOnSameAgent", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - _, otherUser := coderdtest.CreateAnotherUser(t, setup.client, setup.user.OrganizationID) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, otherUser.ID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - _, err := setup.agentClient.ClearChatContext(ctx, agentsdk.ClearChatContextRequest{ChatID: chat.ID}) - sdkErr := requireSDKError(t, err, http.StatusForbidden) - require.Equal(t, "Chat does not belong to this workspace owner.", sdkErr.Message) - }) - - t.Run("AddFailsWhenChatIsNotActive", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - setup := newAgentChatContextTestSetup(t) - model := coderd.InsertAgentChatTestModelConfig(t, setup.db, setup.user.UserID) - chat := createAgentChatContextChat(t, setup.db, setup.user.OrganizationID, setup.user.UserID, model.ID, setup.workspace.Agents[0].ID, t.Name()) - - _, err := setup.db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ - ID: chat.ID, - Status: database.ChatStatusCompleted, - }) - require.NoError(t, err) - - _, err = setup.agentClient.AddChatContext(ctx, agentsdk.AddChatContextRequest{ - ChatID: chat.ID, - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/file.go", - ContextFileContent: "content", - }}, - }) - sdkErr := requireSDKError(t, err, http.StatusConflict) - require.Equal(t, "Cannot modify context: this chat is no longer active.", sdkErr.Message) - }) -} - -func requireAgentChatContextMessages(ctx context.Context, t testing.TB, db database.Store, chatID uuid.UUID) []database.ChatMessage { - t.Helper() - - messages, err := db.GetChatMessagesByChatID( - dbauthz.AsSystemRestricted(ctx), - database.GetChatMessagesByChatIDParams{ChatID: chatID, AfterID: 0}, - ) - require.NoError(t, err) - return messages -} - -func requireAgentChatContextCachedParts(ctx context.Context, t testing.TB, db database.Store, chatID uuid.UUID) []codersdk.ChatMessagePart { - t.Helper() - - chat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chatID) - require.NoError(t, err) - require.True(t, chat.LastInjectedContext.Valid) - return requireAgentChatContextParts(t, chat.LastInjectedContext.RawMessage) -} - -func requireAgentChatContextStoredMessages(t testing.TB, messages []database.ChatMessage) [][]codersdk.ChatMessagePart { - t.Helper() - - stored := make([][]codersdk.ChatMessagePart, len(messages)) - for i, message := range messages { - require.Equal(t, database.ChatMessageRoleUser, message.Role) - require.True(t, message.Content.Valid) - stored[i] = requireAgentChatContextParts(t, message.Content.RawMessage) - } - return stored -} - -func agentChatContextExpectedMessages(agent database.WorkspaceAgent, messages [][]codersdk.ChatMessagePart) [][]codersdk.ChatMessagePart { - expected := make([][]codersdk.ChatMessagePart, len(messages)) - for i, parts := range messages { - expected[i] = agentChatContextExpectedStoredParts(agent, parts) - } - return expected -} - -func agentChatContextExpectedStoredParts(agent database.WorkspaceAgent, parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart { - expected := make([]codersdk.ChatMessagePart, len(parts)) - for i, part := range parts { - part.ContextFileAgentID = uuid.NullUUID{UUID: agent.ID, Valid: true} - if part.Type == codersdk.ChatMessagePartTypeContextFile { - part.ContextFileOS = agent.OperatingSystem - part.ContextFileDirectory = agentChatContextDirectory(agent) - } - expected[i] = part - } - return expected -} - -func agentChatContextExpectedCachedParts(agent database.WorkspaceAgent, parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart { - expected := make([]codersdk.ChatMessagePart, len(parts)) - for i, part := range parts { - part.ContextFileAgentID = uuid.NullUUID{UUID: agent.ID, Valid: true} - expected[i] = part - } - return expected -} - -func newAgentChatContextTestSetup(t *testing.T) agentChatContextTestSetup { - t.Helper() - - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ - ChatWorkerDisabled: true, - }) - user := coderdtest.CreateFirstUser(t, client) - workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - }).WithAgent().Do() - - return agentChatContextTestSetup{ - client: client, - db: db, - user: user, - workspace: workspace, - agentClient: agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)), - } -} - -func currentAgentChatContextAPIKeyID(t testing.TB, client *codersdk.Client) string { - t.Helper() - - apiKeyID, _, ok := strings.Cut(client.SessionToken(), "-") - require.True(t, ok) - require.NotEmpty(t, apiKeyID) - return apiKeyID -} - -func setAgentChatContextChatStatus( - ctx context.Context, - t testing.TB, - db database.Store, - chatID uuid.UUID, - status database.ChatStatus, -) database.Chat { - t.Helper() - - chat, err := db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ - ID: chatID, - Status: status, - }) - require.NoError(t, err) - return chat -} - -func acquireAgentChatContextChat(ctx context.Context, t testing.TB, db database.Store, chatID uuid.UUID) database.Chat { - t.Helper() - - machine := chatstate.NewChatMachine(db, dbpubsub.NewInMemory(), chatID) - require.NoError(t, machine.Update(dbauthz.AsSystemRestricted(ctx), func(tx *chatstate.Tx, store database.Store) error { - _, err := tx.Acquire(chatstate.AcquireInput{WorkerID: uuid.New(), RunnerID: uuid.New()}) - return err - })) - chat, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chatID) - require.NoError(t, err) - return chat -} - -func createAgentChatContextChat( - t testing.TB, - db database.Store, - orgID uuid.UUID, - ownerID uuid.UUID, - modelConfigID uuid.UUID, - agentID uuid.UUID, - title string, -) database.Chat { - t.Helper() - - return dbgen.Chat(t, db, database.Chat{ - OrganizationID: orgID, - OwnerID: ownerID, - LastModelConfigID: modelConfigID, - Title: title, - AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - }) -} - -func createAgentChatContextChildChat( - t testing.TB, - db database.Store, - orgID uuid.UUID, - ownerID uuid.UUID, - modelConfigID uuid.UUID, - agentID uuid.UUID, - parentChatID uuid.UUID, - title string, -) database.Chat { - t.Helper() - - return dbgen.Chat(t, db, database.Chat{ - OrganizationID: orgID, - OwnerID: ownerID, - LastModelConfigID: modelConfigID, - Title: title, - AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - ParentChatID: uuid.NullUUID{UUID: parentChatID, Valid: true}, - RootChatID: uuid.NullUUID{UUID: parentChatID, Valid: true}, - }) -} - -func requireAgentChatContextParts(t testing.TB, raw json.RawMessage) []codersdk.ChatMessagePart { - t.Helper() - - var parts []codersdk.ChatMessagePart - require.NoError(t, json.Unmarshal(raw, &parts)) - return parts -} - -func agentChatContextDirectory(agent database.WorkspaceAgent) string { - if agent.ExpandedDirectory != "" { - return agent.ExpandedDirectory - } - return agent.Directory -} diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 59ab72803bb54..b82a22289e34c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1,7 +1,6 @@ package chatd import ( - "bytes" "cmp" "context" "database/sql" @@ -68,31 +67,17 @@ const ( DefaultInFlightChatStaleAfter = 5 * time.Minute homeInstructionLookupTimeout = 5 * time.Second - planPathLookupTimeout = 5 * time.Second workspaceDialValidationDelay = 5 * time.Second - // Must exceed agent/x/agentmcp.connectTimeout (30s) so a - // cold-start agent's first MCP reload can settle before - // chatd gives up. - workspaceMCPDiscoveryTimeout = 35 * time.Second - // workspaceMCPPrimeMaxWait bounds the deadline used by the - // create_workspace / start_workspace post-ready cache primer - // loop. The primer checks the deadline only after each - // discoverWorkspaceMCPTools call returns, so total wall-clock - // time can exceed this by one such call (dialTimeout + - // workspaceMCPDiscoveryTimeout in the worst case). The constant - // caps when new retries can start, not when an in-flight call - // must finish. Empty results usually mean the agent's MCP - // Connect is still racing with agent startup. The agent-side - // budget is agent/x/agentmcp.connectTimeout (30s). - workspaceMCPPrimeMaxWait = 30 * time.Second - // workspaceMCPPrimeRetryInterval is the short backoff between - // re-attempts inside the primer when ListMCPTools returns an - // empty list without error. - workspaceMCPPrimeRetryInterval = 2 * time.Second - turnStatusLabelWriteTimeout = 5 * time.Second + turnStatusLabelWriteTimeout = 5 * time.Second // defaultDialTimeout matches the timeout used by ~8 other // server-side AgentConn callers. defaultDialTimeout = 30 * time.Second + // planPathLookupTimeout bounds resolving the per-chat plan path, which + // dials the workspace agent to read its home directory. It must exceed + // defaultDialTimeout so a cold dial, bounded internally by that timeout, + // can finish before this outer budget fires, with a small margin for the + // follow-up LS call. + planPathLookupTimeout = defaultDialTimeout + 5*time.Second // DefaultChatHeartbeatInterval is the default time between chat // heartbeat updates while a chat is being processed. DefaultChatHeartbeatInterval = 30 * time.Second @@ -195,11 +180,6 @@ type Server struct { configCache *chatConfigCache configCacheUnsubscribe func() - // workspaceMCPToolsCache caches workspace MCP tool definitions - // per chat to avoid re-fetching on every turn. The cache is - // keyed by chat ID and invalidated when the agent changes. - workspaceMCPToolsCache sync.Map // uuid.UUID -> *cachedWorkspaceMCPTools - usageTracker *workspacestats.UsageTracker clock quartz.Clock metrics *chatloop.Metrics @@ -444,226 +424,50 @@ func (p *Server) newAdvisorRuntime( return rt, nil } -// cachedWorkspaceMCPTools stores workspace MCP tools discovered -// from a workspace agent, keyed by the agent ID that provided them. -type cachedWorkspaceMCPTools struct { - agentID uuid.UUID - tools []workspacesdk.MCPToolInfo -} - -// loadCachedWorkspaceContext checks the MCP tools cache for the -// given chat and agent. Returns non-nil tools when the cache hits, -// which signals the caller to skip the slow MCP discovery path. -func (p *Server) loadCachedWorkspaceContext( - chatID uuid.UUID, - agent database.WorkspaceAgent, - getConn func(context.Context) (workspacesdk.AgentConn, error), -) []fantasy.AgentTool { - cached, ok := p.workspaceMCPToolsCache.Load(chatID) - if !ok { - return nil - } - entry, ok := cached.(*cachedWorkspaceMCPTools) - if !ok || entry.agentID != agent.ID { - return nil - } - - var tools []fantasy.AgentTool - invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) } - for _, t := range entry.tools { - tools = append(tools, chattool.NewWorkspaceMCPTool(t, getConn, invalidate)) - } - - return tools -} - -// discoverWorkspaceMCPTools resolves the chat's workspace agent and -// lists the workspace MCP tools advertised by that agent. Results are -// cached per chat keyed on the agent ID so subsequent calls hit the -// cache. Returns nil (and never an error) on every failure mode so the -// caller can continue without MCP tools. -// -// This helper is shared between the initial discovery path and the -// mid-turn workspace binding path triggered after create_workspace or -// start_workspace bind a workspace to a chat that started without one. -func (p *Server) discoverWorkspaceMCPTools( - ctx context.Context, - logger slog.Logger, - chatID uuid.UUID, - workspaceCtx *turnWorkspaceContext, -) []fantasy.AgentTool { - // Fast path: check cache using the in-memory cached agent - // (ensureWorkspaceAgent is free when already loaded). This - // avoids a per-turn latest-build DB query on the common - // subsequent-turn path. - if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil { - if tools := p.loadCachedWorkspaceContext( - chatID, agent, workspaceCtx.getWorkspaceConn, - ); tools != nil { - return tools - } - } // Cache miss, agent changed, or no cache: validate - // that the workspace still has a live agent before - // attempting a dial. - _, _, agentErr := workspaceCtx.workspaceAgentIDForConn(ctx) - if agentErr != nil { - if xerrors.Is(agentErr, errChatHasNoWorkspaceAgent) { - p.workspaceMCPToolsCache.Delete(chatID) - return nil - } - logger.Warn(ctx, "failed to resolve workspace agent for MCP tools", - slog.Error(agentErr)) - return nil - } - - // List workspace MCP tools via the agent conn. - conn, connErr := workspaceCtx.getWorkspaceConn(ctx) - if connErr != nil { - logger.Warn(ctx, "failed to get workspace conn for MCP tools", - slog.Error(connErr)) - return nil - } - listCtx, cancel := context.WithTimeout(ctx, workspaceMCPDiscoveryTimeout) - defer cancel() - toolsResp, listErr := conn.ListMCPTools(listCtx) - if listErr != nil { - logger.Warn(ctx, "failed to list workspace MCP tools", - slog.Error(listErr)) - return nil - } - // Cache the result for subsequent turns. Skip caching when - // the list is empty because the agent's MCP Connect may not - // have finished yet; caching an empty list would hide tools - // permanently. - if len(toolsResp.Tools) > 0 { - if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil { - p.workspaceMCPToolsCache.Store(chatID, &cachedWorkspaceMCPTools{ - agentID: agent.ID, - tools: toolsResp.Tools, - }) - } - } - - invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) } - tools := make([]fantasy.AgentTool, 0, len(toolsResp.Tools)) - for _, t := range toolsResp.Tools { - tools = append(tools, chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn, invalidate)) - } - return tools -} - -// resolveWorkspaceMCPTools selects the workspace MCP tool set for a turn. It -// prefers the chat's pinned context snapshot and falls back to the per-turn -// live discovery path for chats whose agent has not reported context yet. The -// two paths are mutually exclusive, mirroring resolveTurnWorkspaceContext for -// instructions and skills. +// resolveWorkspaceMCPTools builds the workspace MCP tool set for a turn from +// the chat's pinned context snapshot (chat_context_resources). The agent +// reports its MCP servers in the snapshot it pushes, so a chat with no pinned +// rows, or one whose workspace advertises no MCP servers, contributes no +// workspace MCP tools. A read failure is logged and yields no tools rather +// than aborting the turn. func (p *Server) resolveWorkspaceMCPTools( ctx context.Context, logger slog.Logger, chat database.Chat, workspaceCtx *turnWorkspaceContext, ) []fantasy.AgentTool { - pinned, ok, err := p.pinnedWorkspaceMCPTools(ctx, chat, workspaceCtx.getWorkspaceConn) + tools, err := p.pinnedWorkspaceMCPTools(ctx, chat, workspaceCtx.getWorkspaceConn) if err != nil { - // A pinned-read failure should not be more fatal than a live - // discovery failure (which returns nil tools), so log and fall back - // rather than aborting the turn. - logger.Warn(ctx, "failed to read pinned workspace MCP tools; falling back to live discovery", + logger.Warn(ctx, "failed to read pinned workspace MCP tools", slog.F("chat_id", chat.ID), slog.Error(err)) - } else if ok { - return pinned + return nil } - return p.discoverWorkspaceMCPTools(ctx, logger, chat.ID, workspaceCtx) + return tools } // pinnedWorkspaceMCPTools builds workspace MCP tools from the chat's pinned -// context snapshot (chat_context_resources) instead of dialing the agent for -// a live tool list. ok reports whether the caller should use these tools -// instead of the live discovery path. It is false when the chat has no pinned -// rows (an older agent that never reported context, or a chat not yet -// hydrated), so the caller falls back. When rows exist ok is true even if none -// are MCP servers, because the pin is then authoritative: a workspace with no -// MCP servers contributes no tools. -// -// Each tool still proxies its calls back through the workspace agent -// connection; the snapshot carries tool definitions, not a way to execute -// them, so execution requires a reachable agent. There is no per-chat cache to -// invalidate on the pinned path: a server removed or renamed in the workspace -// surfaces as a dirty chat on the agent's next push, and the user refreshes to -// re-pin, so a nil invalidate callback (a 404 no-op) is correct here. +// context snapshot (chat_context_resources). Each tool still proxies its calls +// back through the workspace agent connection; the snapshot carries tool +// definitions, not a way to execute them, so execution requires a reachable +// agent. There is no per-chat cache to invalidate: a server removed or renamed +// in the workspace surfaces as a dirty chat on the agent's next push, and the +// user refreshes to re-pin, so a nil invalidate callback (a 404 no-op) is +// correct here. func (p *Server) pinnedWorkspaceMCPTools( ctx context.Context, chat database.Chat, getConn func(context.Context) (workspacesdk.AgentConn, error), -) (tools []fantasy.AgentTool, ok bool, err error) { +) ([]fantasy.AgentTool, error) { resources, err := p.db.ListChatContextResourcesByChatID(ctx, chat.ID) if err != nil { - return nil, false, xerrors.Errorf("list chat context resources: %w", err) - } - if len(resources) == 0 { - return nil, false, nil + return nil, xerrors.Errorf("list chat context resources: %w", err) } infos := workspaceMCPToolInfosFromResources(resources) - tools = make([]fantasy.AgentTool, 0, len(infos)) + tools := make([]fantasy.AgentTool, 0, len(infos)) for _, info := range infos { tools = append(tools, chattool.NewWorkspaceMCPTool(info, getConn, nil)) } - return tools, true, nil -} - -// primeWorkspaceMCPCache populates workspaceMCPToolsCache after the -// create_workspace or start_workspace tool finishes waiting for the -// workspace agent to become reachable. By the time it runs the agent -// is already Ready, so a single ListMCPTools call usually succeeds. -// When the agent's MCP server is still racing with agent startup, -// ListMCPTools may return an empty list (no error) on the first call; -// the primer retries with a short backoff up to -// workspaceMCPPrimeMaxWait so the generation action that follows the -// tool call sees the workspace MCP tools in the cache and does not need -// to dial again. -// -// Returns silently on every failure mode. The chat continues without -// workspace MCP tools when the agent does not advertise any within -// the budget. The next user turn re-runs top-of-turn discovery from -// scratch. -func (p *Server) primeWorkspaceMCPCache( - ctx context.Context, - logger slog.Logger, - chatID uuid.UUID, - workspaceCtx *turnWorkspaceContext, -) { - deadline := p.clock.Now().Add(workspaceMCPPrimeMaxWait) - attempt := 0 - for { - attempt++ - tools := p.discoverWorkspaceMCPTools(ctx, logger, chatID, workspaceCtx) - if len(tools) > 0 { - logger.Debug(ctx, "primed workspace MCP cache", - slog.F("chat_id", chatID), - slog.F("tool_count", len(tools)), - slog.F("attempts", attempt), - ) - return - } - if ctx.Err() != nil { - return - } - if !p.clock.Now().Before(deadline) { - logger.Debug(ctx, - "workspace MCP cache primer gave up waiting for tools", - slog.F("chat_id", chatID), - slog.F("attempts", attempt), - ) - return - } - timer := p.clock.NewTimer(workspaceMCPPrimeRetryInterval, "chatd", "workspace-mcp-prime") - select { - case <-timer.C: - case <-ctx.Done(): - timer.Stop() - return - } - } + return tools, nil } type turnWorkspaceContext struct { @@ -3585,7 +3389,6 @@ func (p *Server) publishChatPubsubEvents(chats []database.Chat, kind codersdk.Ch func chatWatchEventSDKChat(chat database.Chat, diffStatus *codersdk.ChatDiffStatus) codersdk.Chat { sdkChat := db2sdk.Chat(chat, nil, nil) sdkChat.Files = nil - sdkChat.LastInjectedContext = nil if diffStatus != nil { sdkChat.DiffStatus = diffStatus } @@ -4079,11 +3882,6 @@ type rootChatToolsOptions struct { resolvePlanPath func(context.Context) (string, string, error) storeFile chattool.StoreFileFunc isPlanModeTurn bool - // primerCtx scopes the workspace MCP cache primer goroutines - // that onChatUpdated launches. runChat cancels it before - // workspaceCtx.close() so an in-flight primer cannot dial a - // fresh conn after the cached one was released. - primerCtx context.Context } func (p *Server) loadPlanModeInstructions( @@ -4203,56 +4001,6 @@ func (p *Server) appendRootChatTools( // Notify the frontend immediately so it can start streaming // build logs before the tool completes. p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil) - - // Note: we intentionally do not insert AGENTS.md / workspace - // context here. Local tool callbacks must not mutate chat - // history while a local-tool generation task is in flight, - // because that advances history_version before the tool - // result is committed and exits the local-tool commit as - // stale. Workspace context is persisted by the - // persist_workspace_context generation action in a later - // pass. - - // Prime the workspace MCP tools cache while the create_workspace - // or start_workspace tool is still running. The AgentID guard - // below restricts the primer to the post-ready callback, when - // the agent is reachable. ListMCPTools may still return an - // empty list on the first try when the agent's MCP Connect is - // racing with agent startup; primeWorkspaceMCPCache retries - // with a short backoff up to workspaceMCPPrimeMaxWait. Priming - // here lets the next assistant-generation action hit the cache - // instead of dialing again on a separate timeout budget. - // - // Run asynchronously: the tool itself must not block on the - // primer because the agent may not advertise any MCP tools at - // all (e.g. minimal templates), in which case the primer waits - // the full budget before giving up. The next assistant-generation - // action covers the cache miss path; the primer is purely an - // optimization that warms the cache while the LLM is thinking. - // inflight tracking ensures server shutdown still waits for any - // in-progress primer. - // - // Guard on both WorkspaceID and AgentID being valid: - // create_workspace and start_workspace each fire onChatUpdated - // twice for a new build (binding before waitForAgentReady; - // post-ready after it), and stop_workspace fires it with a nil - // agent. Only the post-ready callback has a live AgentID, so - // the pre-build and stop-side firings would otherwise spawn a - // primer goroutine that dials a missing or dying agent and - // burns the full budget for nothing. - snapshot := opts.workspaceCtx.currentChatSnapshot() - if snapshot.WorkspaceID.Valid && snapshot.AgentID.Valid { - if err := p.goInflight(func() { - p.primeWorkspaceMCPCache(opts.primerCtx, p.logger, snapshot.ID, opts.workspaceCtx) - }); err != nil { - p.logger.Error(context.WithoutCancel(ctx), "failed to schedule workspace MCP cache primer", - slog.F("chat_id", snapshot.ID), - slog.F("workspace_id", snapshot.WorkspaceID.UUID), - slog.F("agent_id", snapshot.AgentID.UUID), - slog.Error(err), - ) - } - } } tools = append(tools, @@ -4726,251 +4474,6 @@ func refreshChatWorkspaceSnapshot( return refreshedChat, nil } -// contextFileAgentID extracts the workspace agent ID from the most -// recent persisted instruction-file parts. The skill-only sentinel is -// ignored because it does not represent persisted instruction content. -// Returns uuid.Nil, false if no instruction-file parts exist. -func contextFileAgentID(messages []database.ChatMessage) (uuid.UUID, bool) { - var lastID uuid.UUID - found := false - for _, msg := range messages { - if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - continue - } - for _, p := range parts { - if p.Type != codersdk.ChatMessagePartTypeContextFile || - !p.ContextFileAgentID.Valid || - p.ContextFilePath == AgentChatContextSentinelPath { - continue - } - lastID = p.ContextFileAgentID.UUID - found = true - break - } - } - return lastID, found -} - -// fetchWorkspaceContext retrieves fresh instruction files and -// skills from the workspace agent without persisting. It handles -// agent connection, context configuration fetching, content -// sanitization, and metadata stamping. Returns the workspace -// agent, the stamped parts, discovered skills, and whether the -// workspace connection succeeded. A nil agent means the chat has -// no valid workspace or the agent lookup failed; -// workspaceConnOK is false in that case. -func (p *Server) fetchWorkspaceContext( - ctx context.Context, - chat database.Chat, - getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error), - getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error), -) (agent *database.WorkspaceAgent, agentParts []codersdk.ChatMessagePart, discoveredSkills []chattool.SkillMeta, workspaceConnOK bool) { - if !chat.WorkspaceID.Valid || getWorkspaceAgent == nil { - return nil, nil, nil, false - } - - loadedAgent, agentErr := getWorkspaceAgent(ctx) - if agentErr != nil { - return nil, nil, nil, false - } - - directory := loadedAgent.ExpandedDirectory - if directory == "" { - directory = loadedAgent.Directory - } - - // Fetch context configuration from the agent. Parts - // arrive pre-populated with context-file and skill entries - // so we don't need additional round-trips. - if getWorkspaceConn != nil { - instructionCtx, cancel := context.WithTimeout(ctx, p.instructionLookupTimeout) - defer cancel() - - conn, connErr := getWorkspaceConn(instructionCtx) - if connErr != nil { - p.logger.Debug(ctx, "failed to resolve workspace connection for instruction files", - slog.F("chat_id", chat.ID), - slog.Error(connErr), - ) - } else { - workspaceConnOK = true - - agentCfg, cfgErr := conn.ContextConfig(instructionCtx) - if cfgErr != nil { - p.logger.Debug(ctx, "failed to fetch context config from agent", - slog.F("chat_id", chat.ID), slog.Error(cfgErr)) - // Treat a transient ContextConfig failure the - // same as a failed connection so no sentinel is - // persisted. The next turn will retry. - workspaceConnOK = false - } else { - agentParts = agentCfg.Parts - } - } - } - - // Stamp server-side fields and sanitize content. The - // agent cannot know its own UUID, OS metadata, or - // directory, those are added here at the trust boundary. - agentID := uuid.NullUUID{UUID: loadedAgent.ID, Valid: true} - - for i := range agentParts { - agentParts[i].ContextFileAgentID = agentID - switch agentParts[i].Type { - case codersdk.ChatMessagePartTypeContextFile: - agentParts[i].ContextFileContent = SanitizePromptText(agentParts[i].ContextFileContent) - agentParts[i].ContextFileOS = loadedAgent.OperatingSystem - agentParts[i].ContextFileDirectory = directory - case codersdk.ChatMessagePartTypeSkill: - discoveredSkills = append(discoveredSkills, chattool.SkillMeta{ - Name: agentParts[i].SkillName, - Description: agentParts[i].SkillDescription, - Dir: agentParts[i].SkillDir, - MetaFile: agentParts[i].ContextFileSkillMetaFile, - }) - } - } - - return &loadedAgent, agentParts, discoveredSkills, workspaceConnOK -} - -func filterSkillParts(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart { - var filtered []codersdk.ChatMessagePart - for _, part := range parts { - if part.Type == codersdk.ChatMessagePartTypeSkill { - filtered = append(filtered, part) - } - } - return filtered -} - -// persistInstructionFiles fetches AGENTS.md instruction files and -// skills from the workspace agent, persisting both as message -// parts. This is called once when a workspace is first attached -// to a chat (or when the agent changes). Returns the formatted -// instruction string and skill index for injection into the -// current turn's prompt. -func (p *Server) persistInstructionFiles( - ctx context.Context, - chat database.Chat, - modelConfigID uuid.UUID, - getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error), - getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error), -) (instruction string, skills []chattool.SkillMeta, err error) { - agent, agentParts, discoveredSkills, workspaceConnOK := p.fetchWorkspaceContext( - ctx, chat, getWorkspaceAgent, getWorkspaceConn, - ) - if agent == nil { - return "", nil, nil - } - - agentID := uuid.NullUUID{UUID: agent.ID, Valid: true} - hasContent := false - hasContextFilePart := false - for _, part := range agentParts { - if part.Type == codersdk.ChatMessagePartTypeContextFile { - hasContextFilePart = true - if part.ContextFileContent != "" { - hasContent = true - } - } - } - directory := agent.ExpandedDirectory - if directory == "" { - directory = agent.Directory - } - - contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx) - if !hasContent { - if !workspaceConnOK { - return "", nil, nil - } - if !hasContextFilePart { - agentParts = append([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFileAgentID: agentID, - }}, agentParts...) - } - content, err := chatprompt.MarshalParts(agentParts) - if err != nil { - return "", nil, nil - } - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. - ChatID: chat.ID, - } - appendUserChatMessage(&msgParams, newUserChatMessage( - contextAPIKeyID, - content, - database.ChatMessageVisibilityBoth, - modelConfigID, - chatprompt.CurrentContentVersion, - )) - _, _ = p.db.InsertChatMessages(ctx, msgParams) - skillParts := filterSkillParts(agentParts) - p.updateLastInjectedContext(ctx, chat.ID, skillParts) - return "", discoveredSkills, nil - } - content, err := chatprompt.MarshalParts(agentParts) - if err != nil { - return "", nil, xerrors.Errorf("marshal context-file parts: %w", err) - } - - msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage. - ChatID: chat.ID, - } - appendUserChatMessage(&msgParams, newUserChatMessage( - contextAPIKeyID, - content, - database.ChatMessageVisibilityBoth, - modelConfigID, - chatprompt.CurrentContentVersion, - )) - if _, err := p.db.InsertChatMessages(ctx, msgParams); err != nil { - return "", nil, xerrors.Errorf("persist instruction files: %w", err) - } - stripped := make([]codersdk.ChatMessagePart, len(agentParts)) - copy(stripped, agentParts) - for i := range stripped { - stripped[i].StripInternal() - } - p.updateLastInjectedContext(ctx, chat.ID, stripped) - - return formatSystemInstructions(agent.OperatingSystem, directory, agentParts), discoveredSkills, nil -} - -// updateLastInjectedContext persists the injected context -// parts (AGENTS.md files and skills) on the chat row so they -// are directly queryable without scanning messages. This is -// best-effort, a failure here is logged but does not block -// the turn. -func (p *Server) updateLastInjectedContext(ctx context.Context, chatID uuid.UUID, parts []codersdk.ChatMessagePart) { - param := pqtype.NullRawMessage{Valid: false} - if parts != nil { - raw, err := json.Marshal(parts) - if err != nil { - p.logger.Warn(ctx, "failed to marshal injected context", - slog.F("chat_id", chatID), - slog.Error(err), - ) - return - } - param = pqtype.NullRawMessage{RawMessage: raw, Valid: true} - } - if _, err := p.db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{ - ID: chatID, - LastInjectedContext: param, - }); err != nil { - p.logger.Warn(ctx, "failed to update injected context", - slog.F("chat_id", chatID), - slog.Error(err), - ) - } -} - // resolveUserCompactionThreshold looks up the user's per-model // compaction threshold override. Returns the override value and // true if one exists and is valid, or 0 and false otherwise. diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 801cd095ec120..bafde28c610b4 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3,23 +3,19 @@ package chatd import ( "context" "database/sql" - "encoding/json" "strings" "sync" - "sync/atomic" "testing" "time" "charm.land/fantasy" "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -1343,379 +1339,6 @@ func TestRefreshChatWorkspaceSnapshot_ReturnsReloadError(t *testing.T) { require.Equal(t, chat, refreshed) } -func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) { - t.Parallel() - - ctx := context.Background() - testAPIKeyID := uuid.NewString() - ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - OperatingSystem: "linux", - Directory: "/home/coder/project", - ExpandedDirectory: "/home/coder/project", - } - - db.EXPECT().GetWorkspaceAgentByID( - gomock.Any(), - agentID, - ).Return(workspaceAgent, nil).Times(1) - db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Cond(func(x any) bool { - params, ok := x.(database.InsertChatMessagesParams) - if !ok { - return false - } - for i, role := range params.Role { - if role == database.ChatMessageRoleUser && params.APIKeyID[i] != testAPIKeyID { - return false - } - } - return true - })).Return(nil, nil).AnyTimes() - db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), - gomock.Cond(func(x any) bool { - arg, ok := x.(database.UpdateChatLastInjectedContextParams) - if !ok || arg.ID != chat.ID { - return false - } - if !arg.LastInjectedContext.Valid { - return false - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(arg.LastInjectedContext.RawMessage, &parts); err != nil { - return false - } - // Expect at least one context-file part for the - // working-directory AGENTS.md, with internal fields - // stripped (no content, OS, or directory). - for _, p := range parts { - if p.Type == codersdk.ChatMessagePartTypeContextFile && p.ContextFilePath != "" { - return p.ContextFileContent == "" && - p.ContextFileOS == "" && - p.ContextFileDirectory == "" - } - } - return false - }), - ).Return(database.Chat{}, nil).Times(1) - - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1) - conn.EXPECT().ContextConfig(gomock.Any()).Return(workspacesdk.ContextConfigResponse{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project/AGENTS.md", - ContextFileContent: "# Project instructions", - }}, - }, nil).AnyTimes() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - server := &Server{ - db: db, - logger: logger, - clock: quartz.NewReal(), - instructionLookupTimeout: 5 * time.Second, - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: 30 * time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, - } - t.Cleanup(workspaceCtx.close) - - instruction, _, err := server.persistInstructionFiles( - ctx, - chat, - uuid.New(), - workspaceCtx.getWorkspaceAgent, - workspaceCtx.getWorkspaceConn, - ) - require.NoError(t, err) - require.Contains(t, instruction, "Operating System: linux") - require.Contains(t, instruction, "Working Directory: /home/coder/project") -} - -func TestPersistInstructionFilesSkipsSentinelWhenWorkspaceUnavailable(t *testing.T) { - t.Parallel() - - ctx := context.Background() - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: uuid.New(), - Valid: true, - }, - } - server := &Server{ - db: db, - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - } - - instruction, _, err := server.persistInstructionFiles( - ctx, - chat, - uuid.New(), - func(context.Context) (database.WorkspaceAgent, error) { - return database.WorkspaceAgent{ - ID: uuid.New(), - Directory: "/home/coder/project", - }, nil - }, - func(context.Context) (workspacesdk.AgentConn, error) { - return nil, errChatHasNoWorkspaceAgent - }, - ) - require.NoError(t, err) - require.Empty(t, instruction) -} - -func TestPersistInstructionFilesSentinelWithSkills(t *testing.T) { - t.Parallel() - - ctx := context.Background() - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - OperatingSystem: "linux", - Directory: "/home/coder/project", - ExpandedDirectory: "/home/coder/project", - } - - db.EXPECT().GetWorkspaceAgentByID( - gomock.Any(), - agentID, - ).Return(workspaceAgent, nil).Times(1) - db.EXPECT().InsertChatMessages(gomock.Any(), - gomock.Cond(func(x any) bool { - arg, ok := x.(database.InsertChatMessagesParams) - if !ok || arg.ChatID != chat.ID || len(arg.Content) != 1 { - return false - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal([]byte(arg.Content[0]), &parts); err != nil { - return false - } - foundMarker := false - foundSkill := false - for _, p := range parts { - switch p.Type { - case codersdk.ChatMessagePartTypeContextFile: - if p.ContextFileAgentID == (uuid.NullUUID{UUID: agentID, Valid: true}) && p.ContextFileContent == "" { - foundMarker = true - } - case codersdk.ChatMessagePartTypeSkill: - if p.SkillName == "my-skill" && p.ContextFileAgentID == (uuid.NullUUID{UUID: agentID, Valid: true}) { - foundSkill = true - } - } - } - return foundMarker && foundSkill - }), - ).Return(nil, nil).Times(1) - db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), - gomock.Cond(func(x any) bool { - arg, ok := x.(database.UpdateChatLastInjectedContextParams) - if !ok || arg.ID != chat.ID { - return false - } - if !arg.LastInjectedContext.Valid { - return false - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(arg.LastInjectedContext.RawMessage, &parts); err != nil { - return false - } - // The sentinel path should persist only skill parts - // with ContextFileAgentID set. - for _, p := range parts { - if p.Type == codersdk.ChatMessagePartTypeSkill && - p.SkillName == "my-skill" && - p.ContextFileAgentID == (uuid.NullUUID{UUID: agentID, Valid: true}) { - return true - } - } - return false - }), - ).Return(database.Chat{}, nil).Times(1) - - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1) - conn.EXPECT().ContextConfig(gomock.Any()).Return(workspacesdk.ContextConfigResponse{ - // Agent returns pre-read content: no instruction files - // found but one skill discovered. - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "my-skill", - SkillDescription: "A test skill", - SkillDir: "/home/coder/project/.agents/skills/my-skill", - }}, - }, nil).AnyTimes() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - server := &Server{ - db: db, - logger: logger, - clock: quartz.NewReal(), - instructionLookupTimeout: 5 * time.Second, - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: 30 * time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, - } - t.Cleanup(workspaceCtx.close) - - instruction, skills, err := server.persistInstructionFiles( - ctx, - chat, - uuid.New(), - workspaceCtx.getWorkspaceAgent, - workspaceCtx.getWorkspaceConn, - ) - require.NoError(t, err) - // Sentinel path returns empty instruction string. - require.Empty(t, instruction) - // Skills are still discovered and returned. - require.Len(t, skills, 1) - require.Equal(t, "my-skill", skills[0].Name) -} - -func TestPersistInstructionFilesSentinelNoSkillsClearsColumn(t *testing.T) { - t.Parallel() - - ctx := context.Background() - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - OperatingSystem: "linux", - Directory: "/home/coder/project", - ExpandedDirectory: "/home/coder/project", - } - - db.EXPECT().GetWorkspaceAgentByID( - gomock.Any(), - agentID, - ).Return(workspaceAgent, nil).Times(1) - db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() - db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), - gomock.Cond(func(x any) bool { - arg, ok := x.(database.UpdateChatLastInjectedContextParams) - if !ok || arg.ID != chat.ID { - return false - } - // No skills discovered, so the column should be - // cleared to NULL. - return !arg.LastInjectedContext.Valid - }), - ).Return(database.Chat{}, nil).Times(1) - - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1) - conn.EXPECT().ContextConfig(gomock.Any()).Return(workspacesdk.ContextConfigResponse{ - // Agent returns pre-read content: no files, no skills. - Parts: []codersdk.ChatMessagePart{}, - }, nil).AnyTimes() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) - server := &Server{ - db: db, - logger: logger, - clock: quartz.NewReal(), - instructionLookupTimeout: 5 * time.Second, - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: 30 * time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return database.Chat{}, nil }, - } - t.Cleanup(workspaceCtx.close) - - instruction, skills, err := server.persistInstructionFiles( - ctx, - chat, - uuid.New(), - workspaceCtx.getWorkspaceAgent, - workspaceCtx.getWorkspaceConn, - ) - require.NoError(t, err) - // Sentinel path: empty instruction, no skills. - require.Empty(t, instruction) - require.Empty(t, skills) -} - func TestTurnWorkspaceContext_BindingFirstPath(t *testing.T) { t.Parallel() @@ -2284,155 +1907,6 @@ func requireFieldValue(t *testing.T, entry slog.SinkEntry, name string, expected t.Fatalf("field %q not found in log entry", name) } -func TestSkillsFromParts(t *testing.T) { - t.Parallel() - - t.Run("Empty", func(t *testing.T) { - t.Parallel() - got := skillsFromParts(nil) - require.Empty(t, got) - }) - - t.Run("NoSkillParts", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - {Type: codersdk.ChatMessagePartTypeText, Text: "hello"}, - }), - } - got := skillsFromParts(msgs) - require.Empty(t, got) - }) - - t.Run("SingleSkill", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "deep-review", - SkillDescription: "Multi-reviewer code review", - SkillDir: "/home/coder/.agents/skills/deep-review", - }, - }), - } - got := skillsFromParts(msgs) - require.Len(t, got, 1) - require.Equal(t, "deep-review", got[0].Name) - require.Equal(t, "Multi-reviewer code review", got[0].Description) - require.Equal(t, "/home/coder/.agents/skills/deep-review", got[0].Dir) - }) - - t.Run("MultipleSkillsAcrossMessages", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "pull-requests", - SkillDir: "/home/coder/.agents/skills/pull-requests", - }, - }), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "deep-review", - SkillDir: "/home/coder/.agents/skills/deep-review", - }, - }), - } - got := skillsFromParts(msgs) - require.Len(t, got, 2) - require.Equal(t, "pull-requests", got[0].Name) - require.Equal(t, "deep-review", got[1].Name) - }) - - t.Run("MixedPartTypes", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/.coder/AGENTS.md", - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "refine-plan", - SkillDir: "/home/coder/.agents/skills/refine-plan", - }, - }), - // A text-only message should be skipped entirely. - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - {Type: codersdk.ChatMessagePartTypeText, Text: "user turn"}, - }), - } - got := skillsFromParts(msgs) - require.Len(t, got, 1) - require.Equal(t, "refine-plan", got[0].Name) - require.Equal(t, "/home/coder/.agents/skills/refine-plan", got[0].Dir) - }) - - t.Run("OptionalDescriptionOmitted", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "refine-plan", - SkillDir: "/home/coder/.agents/skills/refine-plan", - }, - }), - } - got := skillsFromParts(msgs) - require.Len(t, got, 1) - require.Equal(t, "refine-plan", got[0].Name) - require.Empty(t, got[0].Description) - }) - - t.Run("InvalidJSON", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - { - Content: pqtype.NullRawMessage{ - RawMessage: []byte(`not valid json with "skill" in it`), - Valid: true, - }, - }, - } - got := skillsFromParts(msgs) - require.Empty(t, got) - }) - - t.Run("RoundTrip", func(t *testing.T) { - // Simulate persist -> reconstruct cycle: marshal skill - // parts, then verify skillsFromParts recovers the metadata. - t.Parallel() - want := []chattool.SkillMeta{ - {Name: "deep-review", Description: "Multi-reviewer review", Dir: "/skills/deep-review"}, - {Name: "pull-requests", Description: "", Dir: "/skills/pull-requests"}, - } - agentID := uuid.New() - var parts []codersdk.ChatMessagePart - for _, s := range want { - parts = append(parts, codersdk.ChatMessagePart{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: s.Name, - SkillDescription: s.Description, - SkillDir: s.Dir, - ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - }) - } - msgs := []database.ChatMessage{chattest.ChatMessageWithParts(parts)} - got := skillsFromParts(msgs) - require.Len(t, got, len(want)) - for i, w := range want { - require.Equal(t, w.Name, got[i].Name) - require.Equal(t, w.Description, got[i].Description) - require.Equal(t, w.Dir, got[i].Dir) - } - }) -} - func TestPersonalSkillsInSystemPrompt(t *testing.T) { t.Parallel() @@ -2744,290 +2218,23 @@ func TestLoadPersonalSkillBody(t *testing.T) { }) } -func systemPromptText(t *testing.T, prompt []fantasy.Message) string { - t.Helper() - - var b strings.Builder - for _, msg := range prompt { - if msg.Role != fantasy.MessageRoleSystem { - continue - } - for _, part := range msg.Content { - textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](part) - if ok { - _, _ = b.WriteString(textPart.Text) - _, _ = b.WriteString("\n") - } - } - } - return b.String() -} - -func TestContextFileAgentID(t *testing.T) { - t.Parallel() - - t.Run("EmptyMessages", func(t *testing.T) { - t.Parallel() - id, ok := contextFileAgentID(nil) - require.Equal(t, uuid.Nil, id) - require.False(t, ok) - }) - - t.Run("NoContextFileParts", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - {Type: codersdk.ChatMessagePartTypeText, Text: "hello"}, - }), - } - id, ok := contextFileAgentID(msgs) - require.Equal(t, uuid.Nil, id) - require.False(t, ok) - }) - - t.Run("SingleContextFile", func(t *testing.T) { - t.Parallel() - agentID := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/some/path", - ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - }, - }), - } - id, ok := contextFileAgentID(msgs) - require.Equal(t, agentID, id) - require.True(t, ok) - }) - - t.Run("MultipleContextFiles", func(t *testing.T) { - t.Parallel() - agentID1 := uuid.New() - agentID2 := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/first/path", - ContextFileAgentID: uuid.NullUUID{UUID: agentID1, Valid: true}, - }, - }), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/second/path", - ContextFileAgentID: uuid.NullUUID{UUID: agentID2, Valid: true}, - }, - }), - } - id, ok := contextFileAgentID(msgs) - require.Equal(t, agentID2, id) - require.True(t, ok) - }) - - t.Run("IgnoresSkillOnlySentinel", func(t *testing.T) { - t.Parallel() - instructionAgentID := uuid.New() - sentinelAgentID := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/workspace/AGENTS.md", - ContextFileAgentID: uuid.NullUUID{UUID: instructionAgentID, Valid: true}, - }}), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: AgentChatContextSentinelPath, - ContextFileAgentID: uuid.NullUUID{ - UUID: sentinelAgentID, - Valid: true, - }, - }}), - } - id, ok := contextFileAgentID(msgs) - require.Equal(t, instructionAgentID, id) - require.True(t, ok) - }) - - t.Run("SentinelWithoutAgentID", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFileAgentID: uuid.NullUUID{Valid: false}, - }, - }), - } - id, ok := contextFileAgentID(msgs) - require.Equal(t, uuid.Nil, id) - require.False(t, ok) - }) -} - -func TestInstructionFromContextFilesUsesLatestContextAgent(t *testing.T) { - t.Parallel() - - oldAgentID := uuid.New() - newAgentID := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/old/AGENTS.md", - ContextFileContent: "old instructions", - ContextFileOS: "darwin", - ContextFileDirectory: "/old", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }}), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/new/AGENTS.md", - ContextFileContent: "new instructions", - ContextFileOS: "linux", - ContextFileDirectory: "/new", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }}), - } - - got := instructionFromContextFiles(msgs) - require.Contains(t, got, "new instructions") - require.Contains(t, got, "Operating System: linux") - require.Contains(t, got, "Working Directory: /new") - require.NotContains(t, got, "old instructions") - require.NotContains(t, got, "Operating System: darwin") -} - -func TestInstructionFromContextFilesKeepsLegacyUnstampedParts(t *testing.T) { - t.Parallel() - - oldAgentID := uuid.New() - newAgentID := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/legacy/AGENTS.md", - ContextFileContent: "legacy instructions", - }}), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/old/AGENTS.md", - ContextFileContent: "old instructions", - ContextFileOS: "darwin", - ContextFileDirectory: "/old", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }}), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/new/AGENTS.md", - ContextFileContent: "new instructions", - ContextFileOS: "linux", - ContextFileDirectory: "/new", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }}), - } - - got := instructionFromContextFiles(msgs) - require.Contains(t, got, "legacy instructions") - require.Contains(t, got, "new instructions") - require.Contains(t, got, "Operating System: linux") - require.Contains(t, got, "Working Directory: /new") - require.NotContains(t, got, "old instructions") - require.NotContains(t, got, "Operating System: darwin") -} - -func TestSkillsFromPartsKeepsLegacyUnstampedParts(t *testing.T) { - t.Parallel() - - oldAgentID := uuid.New() - newAgentID := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-legacy", - SkillDir: "/skills/repo-helper-legacy", - }}), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/old/AGENTS.md", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-old", - SkillDir: "/skills/repo-helper-old", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - }), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: AgentChatContextSentinelPath, - ContextFileAgentID: uuid.NullUUID{ - UUID: newAgentID, - Valid: true, - }, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-new", - SkillDir: "/skills/repo-helper-new", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }, - }), - } - - got := skillsFromParts(msgs) - require.Equal(t, []chattool.SkillMeta{ - {Name: "repo-helper-legacy", Dir: "/skills/repo-helper-legacy"}, - {Name: "repo-helper-new", Dir: "/skills/repo-helper-new"}, - }, got) -} - -func TestSkillsFromPartsUsesLatestContextAgent(t *testing.T) { - t.Parallel() - - oldAgentID := uuid.New() - newAgentID := uuid.New() - msgs := []database.ChatMessage{ - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/old/AGENTS.md", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-old", - SkillDir: "/skills/repo-helper-old", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - }), - chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: AgentChatContextSentinelPath, - ContextFileAgentID: uuid.NullUUID{ - UUID: newAgentID, - Valid: true, - }, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-new", - SkillDir: "/skills/repo-helper-new", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }, - }), - } +func systemPromptText(t *testing.T, prompt []fantasy.Message) string { + t.Helper() - got := skillsFromParts(msgs) - require.Equal(t, []chattool.SkillMeta{{ - Name: "repo-helper-new", - Dir: "/skills/repo-helper-new", - }}, got) + var b strings.Builder + for _, msg := range prompt { + if msg.Role != fantasy.MessageRoleSystem { + continue + } + for _, part := range msg.Content { + textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](part) + if ok { + _, _ = b.WriteString(textPart.Text) + _, _ = b.WriteString("\n") + } + } + } + return b.String() } func TestGetWorkspaceConn_StaleAgentRecovery(t *testing.T) { @@ -4145,438 +3352,6 @@ func TestGetWorkspaceConn_DialErrorNotMisclassifiedAsTimeout(t *testing.T) { require.ErrorContains(t, err, "authentication failed") } -func TestPrimeWorkspaceMCPCache_SuccessOnFirstAttempt(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - now := time.Now() - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - FirstConnectedAt: sql.NullTime{ - Time: now.Add(-time.Minute), - Valid: true, - }, - LastConnectedAt: sql.NullTime{ - Time: now, - Valid: true, - }, - } - - db.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID). - Return(workspaceAgent, nil).AnyTimes() - db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). - Return([]database.WorkspaceAgent{workspaceAgent}, nil).AnyTimes() - - toolName := "workspace-mcp__echo" - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - conn.EXPECT().ListMCPTools(gomock.Any()).Return(workspacesdk.ListMCPToolsResponse{ - Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-mcp", - Name: toolName, - Schema: map[string]any{}, - }}, - }, nil).Times(1) - - server := &Server{ - db: db, - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - clock: quartz.NewMock(t), - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return chat, nil }, - } - t.Cleanup(workspaceCtx.close) - - server.primeWorkspaceMCPCache(ctx, server.logger, chat.ID, &workspaceCtx) - - cached, ok := server.workspaceMCPToolsCache.Load(chat.ID) - require.True(t, ok, "primer must populate the cache on success") - entry, ok := cached.(*cachedWorkspaceMCPTools) - require.True(t, ok) - require.Equal(t, agentID, entry.agentID) - require.Len(t, entry.tools, 1) - require.Equal(t, toolName, entry.tools[0].Name) -} - -// TestPrimeWorkspaceMCPCache_RetriesUntilToolsAppear simulates the -// race between agent reachability and the agent's MCP Connect: the -// first ListMCPTools call returns an empty list (no error), the -// second returns the workspace tools. The primer must retry after -// workspaceMCPPrimeRetryInterval and write the cache on the second -// attempt. -func TestPrimeWorkspaceMCPCache_RetriesUntilToolsAppear(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - now := time.Now() - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - FirstConnectedAt: sql.NullTime{ - Time: now.Add(-time.Minute), - Valid: true, - }, - LastConnectedAt: sql.NullTime{ - Time: now, - Valid: true, - }, - } - - db.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID). - Return(workspaceAgent, nil).AnyTimes() - db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). - Return([]database.WorkspaceAgent{workspaceAgent}, nil).AnyTimes() - - toolName := "workspace-mcp__echo" - var listCalls atomic.Int32 - emptyOnce := make(chan struct{}, 1) - emptyOnce <- struct{}{} - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - conn.EXPECT().ListMCPTools(gomock.Any()).DoAndReturn( - func(context.Context) (workspacesdk.ListMCPToolsResponse, error) { - listCalls.Add(1) - select { - case <-emptyOnce: - return workspacesdk.ListMCPToolsResponse{}, nil - default: - return workspacesdk.ListMCPToolsResponse{ - Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-mcp", - Name: toolName, - Schema: map[string]any{}, - }}, - }, nil - } - }, - ).AnyTimes() - - mockClock := quartz.NewMock(t) - timerTrap := mockClock.Trap().NewTimer("chatd", "workspace-mcp-prime") - t.Cleanup(timerTrap.Close) - - server := &Server{ - db: db, - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - clock: mockClock, - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return chat, nil }, - } - t.Cleanup(workspaceCtx.close) - - done := make(chan struct{}) - go func() { - defer close(done) - server.primeWorkspaceMCPCache(ctx, server.logger, chat.ID, &workspaceCtx) - }() - - // First attempt returns empty. The primer arms a timer; release - // it and advance the clock so the second attempt fires. - call := timerTrap.MustWait(ctx) - call.MustRelease(ctx) - mockClock.Advance(workspaceMCPPrimeRetryInterval).MustWait(ctx) - - select { - case <-done: - case <-ctx.Done(): - t.Fatal("primer did not finish after second attempt") - } - - require.GreaterOrEqual(t, listCalls.Load(), int32(2), - "primer must retry after empty result") - cached, ok := server.workspaceMCPToolsCache.Load(chat.ID) - require.True(t, ok, "primer must populate the cache on retry success") - entry, ok := cached.(*cachedWorkspaceMCPTools) - require.True(t, ok) - require.Equal(t, agentID, entry.agentID) - require.Len(t, entry.tools, 1) - require.Equal(t, toolName, entry.tools[0].Name) -} - -// TestPrimeWorkspaceMCPCache_GivesUpAfterDeadline verifies the -// bounded-wait guarantee: when ListMCPTools always returns an empty -// list (e.g. the agent's MCP server never advertises tools), the -// primer stops trying at workspaceMCPPrimeMaxWait and does not cache -// the empty result. PrepareTools is then free to retry on the next -// chat step. -func TestPrimeWorkspaceMCPCache_GivesUpAfterDeadline(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - now := time.Now() - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - FirstConnectedAt: sql.NullTime{ - Time: now.Add(-time.Minute), - Valid: true, - }, - LastConnectedAt: sql.NullTime{ - Time: now, - Valid: true, - }, - } - - db.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID). - Return(workspaceAgent, nil).AnyTimes() - db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). - Return([]database.WorkspaceAgent{workspaceAgent}, nil).AnyTimes() - - var listCalls atomic.Int32 - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - conn.EXPECT().ListMCPTools(gomock.Any()).DoAndReturn( - func(context.Context) (workspacesdk.ListMCPToolsResponse, error) { - listCalls.Add(1) - return workspacesdk.ListMCPToolsResponse{}, nil - }, - ).AnyTimes() - - mockClock := quartz.NewMock(t) - timerTrap := mockClock.Trap().NewTimer("chatd", "workspace-mcp-prime") - t.Cleanup(timerTrap.Close) - - server := &Server{ - db: db, - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - clock: mockClock, - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return chat, nil }, - } - t.Cleanup(workspaceCtx.close) - - done := make(chan struct{}) - go func() { - defer close(done) - server.primeWorkspaceMCPCache(ctx, server.logger, chat.ID, &workspaceCtx) - }() - - // Drive the retry loop forward until the primer gives up. Each - // iteration: release the trapped NewTimer call, then advance the - // clock past the retry interval. The primer exits when - // p.clock.Now() is no longer before deadline. The loop bounds - // itself on maxIterations and uses a done-aware wait context so - // the test fails cleanly instead of hanging when the primer - // shuts down between iterations. - maxIterations := int(workspaceMCPPrimeMaxWait/workspaceMCPPrimeRetryInterval) + 2 -Loop: - for i := 0; i < maxIterations; i++ { - waitCtx, cancel := context.WithCancel(ctx) - go func() { - select { - case <-done: - cancel() - case <-waitCtx.Done(): - } - }() - call, err := timerTrap.Wait(waitCtx) - cancel() - if err != nil { - break Loop - } - call.MustRelease(ctx) - mockClock.Advance(workspaceMCPPrimeRetryInterval).MustWait(ctx) - } - - // expectedAttempts is the floor on how many times the primer - // should call discoverWorkspaceMCPTools before the deadline - // expires. The primer makes one attempt before sleeping, then - // one per workspaceMCPPrimeRetryInterval until the deadline. - // We assert a high-water mark (rather than exact equality) so - // the test is robust to off-by-one boundaries while still - // catching deadline miscomputations: a primer that exits after a - // handful of attempts would suggest the deadline was set with a - // shorter window than workspaceMCPPrimeMaxWait. - expectedAttempts := int32(workspaceMCPPrimeMaxWait/workspaceMCPPrimeRetryInterval) / 2 - require.GreaterOrEqual(t, listCalls.Load(), expectedAttempts, - "primer must retry enough times to consume the full budget") - _, ok := server.workspaceMCPToolsCache.Load(chat.ID) - require.False(t, ok, - "primer must not cache an empty result; PrepareTools needs to retry on the next step") -} - -// TestPrimeWorkspaceMCPCache_ExitsOnContextCancel verifies the -// primer's context.Done() branch: the retry loop must exit promptly -// when the chat ctx is canceled (runChat cancels its primerCtx -// before workspaceCtx.close runs to prevent a primer from re-dialing -// the freed conn). -func TestPrimeWorkspaceMCPCache_ExitsOnContextCancel(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitShort) - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - - workspaceID := uuid.New() - agentID := uuid.New() - chat := database.Chat{ - ID: uuid.New(), - WorkspaceID: uuid.NullUUID{ - UUID: workspaceID, - Valid: true, - }, - AgentID: uuid.NullUUID{ - UUID: agentID, - Valid: true, - }, - } - now := time.Now() - workspaceAgent := database.WorkspaceAgent{ - ID: agentID, - FirstConnectedAt: sql.NullTime{ - Time: now.Add(-time.Minute), - Valid: true, - }, - LastConnectedAt: sql.NullTime{ - Time: now, - Valid: true, - }, - } - - db.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID). - Return(workspaceAgent, nil).AnyTimes() - db.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceID(gomock.Any(), workspaceID). - Return([]database.WorkspaceAgent{workspaceAgent}, nil).AnyTimes() - - conn := agentconnmock.NewMockAgentConn(ctrl) - conn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - conn.EXPECT().ListMCPTools(gomock.Any()). - Return(workspacesdk.ListMCPToolsResponse{}, nil).AnyTimes() - - mockClock := quartz.NewMock(t) - timerTrap := mockClock.Trap().NewTimer("chatd", "workspace-mcp-prime") - t.Cleanup(timerTrap.Close) - - server := &Server{ - db: db, - logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), - clock: mockClock, - agentInactiveDisconnectTimeout: 30 * time.Second, - dialTimeout: time.Second, - agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return conn, func() {}, nil - }, - } - - chatStateMu := &sync.Mutex{} - currentChat := chat - workspaceCtx := turnWorkspaceContext{ - server: server, - chatStateMu: chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: func(context.Context, uuid.UUID) (database.Chat, error) { return chat, nil }, - } - t.Cleanup(workspaceCtx.close) - - primerCtx, primerCancel := context.WithCancel(ctx) - t.Cleanup(primerCancel) - - done := make(chan struct{}) - go func() { - defer close(done) - server.primeWorkspaceMCPCache(primerCtx, server.logger, chat.ID, &workspaceCtx) - }() - - // Let the primer arm at least one retry timer so we know it is - // blocked in the select. Canceling before this would race with - // the loop entering the retry path. - call := timerTrap.MustWait(ctx) - call.MustRelease(ctx) - - primerCancel() - - select { - case <-done: - case <-ctx.Done(): - t.Fatal("primer did not exit after context cancellation") - } - - _, ok := server.workspaceMCPToolsCache.Load(chat.ID) - require.False(t, ok, "primer must not cache anything when canceled") -} - // TestGetWorkspaceConnBumpsWorkspaceUsage verifies that acquiring a // workspace agent connection bumps the workspace's last_used_at via // the usage tracker and extends the build's autostop deadline. diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index fabbada49f885..101a869031ffe 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -45,7 +45,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/dbtime" dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/workspacestats" @@ -1385,23 +1384,21 @@ func TestPlanModeRootChatAllowsApprovedExternalMCPTools(t *testing.T) { }) ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + // Workspace MCP tools now come from the agent's pinned snapshot, not live + // discovery. Seed the workspace MCP server so chats bound to the agent + // hydrate the "workspace-plan-mcp__echo" tool. + seedAgentMCPToolContext(ctx, t, db, agentMCPToolContext{ + AgentID: dbAgent.ID, + ServerName: "workspace-plan-mcp", + ToolName: "echo", + ToolDescription: "Workspace echo tool", + }) ctrl := gomock.NewController(t) mockConn := agentconnmock.NewMockAgentConn(ctrl) mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() mockConn.EXPECT().ContextConfig(gomock.Any()). Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes() workspaceToolName := "workspace-plan-mcp__echo" - mockConn.EXPECT().ListMCPTools(gomock.Any()). - Return(workspacesdk.ListMCPToolsResponse{Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-plan-mcp", - Name: workspaceToolName, - Description: "Workspace echo tool", - Schema: map[string]any{ - "input": map[string]any{"type": "string"}, - }, - Required: []string{"input"}, - }}}, nil). - Times(1) mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()). Return(workspacesdk.LSResponse{AbsolutePathString: "/home/coder"}, nil).AnyTimes() mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). @@ -6303,11 +6300,12 @@ func TestActiveServer_ToolErrorRecordsMetric(t *testing.T) { t.Parallel() tests := []struct { - name string - toolName string - toolArgs string - chatMode database.NullChatMode - setupAgent func(*agentconnmock.MockAgentConn) + name string + toolName string + toolArgs string + chatMode database.NullChatMode + setupAgent func(*agentconnmock.MockAgentConn) + seedContext func(ctx context.Context, t *testing.T, db database.Store, agentID uuid.UUID) }{ { name: "builtin tool IsError", @@ -6321,7 +6319,7 @@ func TestActiveServer_ToolErrorRecordsMetric(t *testing.T) { }, { name: "non builtin MCP style tool IsError", - toolName: "dynamic_error_tool", + toolName: "dynamic__error_tool", toolArgs: `{"input":"hello"}`, setupAgent: func(mockConn *agentconnmock.MockAgentConn) { mockConn.EXPECT().CallMCPTool(gomock.Any(), gomock.Any()). @@ -6334,6 +6332,14 @@ func TestActiveServer_ToolErrorRecordsMetric(t *testing.T) { }, nil). Times(1) }, + seedContext: func(ctx context.Context, t *testing.T, db database.Store, agentID uuid.UUID) { + seedAgentMCPToolContext(ctx, t, db, agentMCPToolContext{ + AgentID: agentID, + ServerName: "dynamic", + ToolName: "error_tool", + ToolDescription: "dynamic error tool", + }) + }, }, { name: "tool Run returns error", @@ -6373,16 +6379,7 @@ func TestActiveServer_ToolErrorRecordsMetric(t *testing.T) { ctrl := gomock.NewController(t) mockConn := agentconnmock.NewMockAgentConn(ctrl) - setupToolExecutionAgentConn(t, mockConn, workspacesdk.MCPToolInfo{ - Name: "dynamic_error_tool", - Description: "dynamic error tool", - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "input": map[string]any{"type": "string"}, - }, - }, - }) + setupToolExecutionAgentConn(t, mockConn) tt.setupAgent(mockConn) server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { @@ -6405,6 +6402,9 @@ func TestActiveServer_ToolErrorRecordsMetric(t *testing.T) { codersdk.ChatMessageText("run an erroring tool"), }, } + if tt.seedContext != nil { + tt.seedContext(ctx, t, db, dbAgent.ID) + } chat, err := server.CreateChat(ctx, chatOpts) require.NoError(t, err) waitForChatStatus(ctx, t, db, chat.ID, database.ChatStatusWaiting) @@ -6492,13 +6492,6 @@ func setupToolExecutionAgentConn( Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes() } -func mustParseChatParts(t *testing.T, msg database.ChatMessage) []codersdk.ChatMessagePart { - t.Helper() - parts, err := chatprompt.ParseContent(msg) - require.NoError(t, err) - return parts -} - func dynamicToolJSON(t *testing.T, name string) []byte { t.Helper() encoded, err := json.Marshal([]mcpgo.Tool{{ @@ -9909,6 +9902,11 @@ func TestProcessChat_AIGatewayRoutingPreservesAPIKeyAfterWorkspaceContext(t *tes require.NoError(t, err) const contextText = "# Project instructions\nAlways keep routing metadata." + // Workspace context is sourced from the agent's pinned snapshot. Seed it so + // the chat hydrates it on the lazy first-turn bind and the workspace-context + // path runs before AI gateway routing resolves the key. + seedAgentInstructionContext(ctx, t, db, dbAgent.ID, + "/home/coder/project/AGENTS.md", contextText) _ = newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(factory) cfg.AIGatewayRoutingEnabled = true @@ -9927,24 +9925,12 @@ func TestProcessChat_AIGatewayRoutingPreservesAPIKeyAfterWorkspaceContext(t *tes require.Equal(t, database.ChatStatusWaiting, chatResult.Status) require.False(t, chatResult.LastError.Valid) - messages := persistedChatMessages(ctx, t, db, chat.ID) - var contextMessages []database.ChatMessage - for _, msg := range messages { - if msg.Role != database.ChatMessageRoleUser || - msg.Visibility != database.ChatMessageVisibilityBoth { - continue - } - for _, part := range mustParseChatParts(t, msg) { - if part.Type == codersdk.ChatMessagePartTypeContextFile && - part.ContextFileAgentID.Valid && - part.ContextFileAgentID.UUID == dbAgent.ID { - contextMessages = append(contextMessages, msg) - } - } - } - require.Len(t, contextMessages, 1) - require.True(t, contextMessages[0].APIKeyID.Valid) - require.Equal(t, apiKey.ID, contextMessages[0].APIKeyID.String) + // Workspace context is pinned to the chat, not injected as a user message. + // Confirm the agent's pushed instruction hydrated onto the chat so the + // workspace-context path ran before AI gateway routing resolved the key. + pinned, err := db.ListChatContextResourcesByChatID(ctx, chat.ID) + require.NoError(t, err) + require.NotEmpty(t, pinned, "workspace context should be pinned to the chat") requests := factory.requestsSnapshot() require.NotEmpty(t, requests) @@ -11394,7 +11380,7 @@ func TestAgentContextFilesAndSkillsLoadedIntoChat(t *testing.T) { ctx := testutil.Context(t, testutil.WaitSuperLong) deploymentValues := directChatRoutingDeploymentValues(t) - client := coderdtest.New(t, &coderdtest.Options{ + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ DeploymentValues: deploymentValues, IncludeProvisionerDaemon: true, ChatdInstructionLookupTimeout: testutil.WaitLong, @@ -11417,6 +11403,26 @@ func TestAgentContextFilesAndSkillsLoadedIntoChat(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, agenttest.WithContextConfigFromEnv()) coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + // Pinned context is the sole source of workspace context. The chat binds + // its agent lazily on the first turn and re-pins from that agent's pushed + // snapshot, so the snapshot must exist before the turn runs. Wait for the + // agent to push its instruction file and skill before creating the chat; + // otherwise the re-pin copies nothing and the prompt omits them. + builtWorkspace, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + var agentID uuid.UUID + for _, res := range builtWorkspace.LatestBuild.Resources { + for _, agent := range res.Agents { + agentID = agent.ID + } + } + require.NotEqual(t, uuid.Nil, agentID, "workspace should expose an agent") + require.Eventually(t, func() bool { + pushed, lerr := db.ListWorkspaceAgentContextResources( + dbauthz.AsSystemRestricted(ctx), agentID) + return lerr == nil && len(pushed) >= 2 + }, testutil.WaitSuperLong, testutil.IntervalFast) + // Capture LLM requests so we can inspect the system prompt. var streamedCallsMu sync.Mutex streamedCalls := make([][]chattest.OpenAIMessage, 0, 2) @@ -12628,349 +12634,6 @@ func nullRawMessage(raw []byte) pqtype.NullRawMessage { return pqtype.NullRawMessage{RawMessage: raw, Valid: true} } -// Regression for the cold-start race: chatd must wait long enough -// for ListMCPTools to return after the agent's MCP reload settles. -func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { - t.Parallel() - - t.Run("persists workspace context before provider request", func(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - requestsMu.Unlock() - - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) - - const contextText = "# Project instructions\nAlways write tests." - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - require.Equal(t, dbAgent.ID, agentID) - - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - setupWorkspaceContextAgentConn(t, mockConn, dbAgent, contextText, nil) - return mockConn, func() {}, nil - } - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-context-before-provider", - ModelConfigID: model.ID, - WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("What are the workspace rules?"), - }, - }) - require.NoError(t, err) - - chatResult := waitForTerminalChat(ctx, t, db, chat.ID) - if chatResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(chatResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, chatResult.Status) - - parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, allContextFilePartsForAgent(parts, dbAgent.ID), 1) - contextPart := allContextFilePartsForAgent(parts, dbAgent.ID)[0] - require.Equal(t, "/home/coder/project/AGENTS.md", contextPart.ContextFilePath) - require.Equal(t, contextText, contextPart.ContextFileContent) - require.Equal(t, "linux", contextPart.ContextFileOS) - require.Equal(t, "/home/coder/project", contextPart.ContextFileDirectory) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.Len(t, recorded, 1, "expected exactly one streamed model call") - require.True(t, requestHasSystemSubstring(recorded[0], "")) - require.True(t, requestHasSystemSubstring(recorded[0], contextText)) - require.True(t, requestHasSystemSubstring(recorded[0], "AGENTS.md")) - }) - - t.Run("persists workspace context once for the same agent", func(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - requestsMu.Unlock() - - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) - - const contextText = "# Project instructions\nKeep it simple." - var contextConfigCalls atomic.Int32 - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - require.Equal(t, dbAgent.ID, agentID) - - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - setupWorkspaceContextAgentConn(t, mockConn, dbAgent, contextText, &contextConfigCalls) - return mockConn, func() {}, nil - } - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-context-once", - ModelConfigID: model.ID, - WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("First turn."), - }, - }) - require.NoError(t, err) - firstResult := waitForTerminalChat(ctx, t, db, chat.ID) - if firstResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(firstResult.LastError)) - } - - _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ - ChatID: chat.ID, - CreatedBy: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Content: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Second turn."), - }, - }) - require.NoError(t, err) - - secondResult := waitForTerminalChat(ctx, t, db, chat.ID) - if secondResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(secondResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, secondResult.Status) - - parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, allContextFilePartsForAgent(parts, dbAgent.ID), 1) - require.Equal(t, int32(1), contextConfigCalls.Load()) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.GreaterOrEqual(t, len(recorded), 2) - require.True(t, requestHasSystemSubstring(recorded[0], contextText)) - require.True(t, requestHasSystemSubstring(recorded[len(recorded)-1], contextText)) - }) - - t.Run("commits marker when selected agent is unreachable", func(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - requestsMu.Unlock() - - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) - require.NoError(t, db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, ws.ID)) - - var agentDialCalls atomic.Int32 - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - agentDialCalls.Add(1) - return nil, nil, xerrors.New("unexpected workspace agent dial") - } - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-context-agent-unreachable", - ModelConfigID: model.ID, - WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, - AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Continue without workspace context."), - }, - }) - require.NoError(t, err) - - chatResult := waitForTerminalChat(ctx, t, db, chat.ID) - require.Equal(t, database.ChatStatusWaiting, chatResult.Status) - - parts := persistedChatParts(ctx, t, db, chat.ID) - markers := contextFileMarkersForAgent(parts, dbAgent.ID) - require.Len(t, markers, 1) - require.Empty(t, markers[0].ContextFileContent) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.Len(t, recorded, 1, "expected model call after marker commit") - require.False(t, requestHasSystemSubstring(recorded[0], "Source: ")) - require.Zero(t, agentDialCalls.Load()) - }) - - t.Run("repersists workspace context after agent changes", func(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - requestsMu.Unlock() - - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - ws, firstAgent := seedWorkspaceWithAgent(t, db, user.ID) - - oldContext := "# Old instructions\nUse the old agent." - newContext := "# New instructions\nUse the new agent." - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - switch agentID { - case firstAgent.ID: - setupWorkspaceContextAgentConn(t, mockConn, firstAgent, oldContext, nil) - default: - setupWorkspaceContextAgentConn(t, mockConn, database.WorkspaceAgent{ - ID: agentID, - OperatingSystem: "linux", - Directory: "/home/coder/project-new", - ExpandedDirectory: "/home/coder/project-new", - }, newContext, nil) - } - return mockConn, func() {}, nil - } - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-context-agent-change", - ModelConfigID: model.ID, - WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("First turn."), - }, - }) - require.NoError(t, err) - firstResult := waitForTerminalChat(ctx, t, db, chat.ID) - if firstResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(firstResult.LastError)) - } - - secondTV := dbgen.TemplateVersion(t, db, database.TemplateVersion{ - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - secondBuild, secondAgent := seedNewWorkspaceAgentBuild(t, db, user.ID, org.ID, ws.ID, secondTV.ID) - _, err = db.UpdateChatBuildAgentBinding(ctx, database.UpdateChatBuildAgentBindingParams{ - ID: chat.ID, - BuildID: uuid.NullUUID{UUID: secondBuild.ID, Valid: true}, - AgentID: uuid.NullUUID{UUID: secondAgent.ID, Valid: true}, - }) - require.NoError(t, err) - - _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ - ChatID: chat.ID, - CreatedBy: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Content: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Second turn."), - }, - }) - require.NoError(t, err) - - secondResult := waitForTerminalChat(ctx, t, db, chat.ID) - if secondResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(secondResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, secondResult.Status) - - parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, allContextFilePartsForAgent(parts, firstAgent.ID), 1) - require.Len(t, allContextFilePartsForAgent(parts, secondAgent.ID), 1) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.GreaterOrEqual(t, len(recorded), 2) - latest := recorded[len(recorded)-1] - require.True(t, requestHasSystemSubstring(latest, newContext)) - require.False(t, requestHasSystemSubstring(latest, oldContext)) - }) -} - func setupWorkspaceContextAgentConn( t *testing.T, mockConn *agentconnmock.MockAgentConn, @@ -13014,690 +12677,3 @@ func setupWorkspaceContextAgentConn( mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes() } - -func persistedChatParts( - ctx context.Context, - t *testing.T, - db database.Store, - chatID uuid.UUID, -) []codersdk.ChatMessagePart { - t.Helper() - messages := persistedChatMessages(ctx, t, db, chatID) - var parts []codersdk.ChatMessagePart - for _, msg := range messages { - parts = append(parts, mustParseChatParts(t, msg)...) - } - return parts -} - -func persistedChatMessages( - ctx context.Context, - t *testing.T, - db database.Store, - chatID uuid.UUID, -) []database.ChatMessage { - t.Helper() - messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: chatID, - AfterID: 0, - }) - require.NoError(t, err) - return messages -} - -func allContextFilePartsForAgent( - parts []codersdk.ChatMessagePart, - agentID uuid.UUID, -) []codersdk.ChatMessagePart { - var matched []codersdk.ChatMessagePart - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeContextFile || - !part.ContextFileAgentID.Valid || - part.ContextFileAgentID.UUID != agentID || - part.ContextFileContent == "" { - continue - } - matched = append(matched, part) - } - return matched -} - -func contextFileMarkersForAgent( - parts []codersdk.ChatMessagePart, - agentID uuid.UUID, -) []codersdk.ChatMessagePart { - var matched []codersdk.ChatMessagePart - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeContextFile || - !part.ContextFileAgentID.Valid || - part.ContextFileAgentID.UUID != agentID { - continue - } - matched = append(matched, part) - } - return matched -} - -func requireChatToolPart( - t *testing.T, - messages []database.ChatMessage, - partType codersdk.ChatMessagePartType, - toolName string, -) codersdk.ChatMessagePart { - t.Helper() - for _, msg := range messages { - for _, part := range mustParseChatParts(t, msg) { - if part.Type == partType && part.ToolName == toolName { - return part - } - } - } - require.FailNowf(t, "missing chat tool part", "type=%q tool=%q", partType, toolName) - return codersdk.ChatMessagePart{} -} - -func openAIRequestContainsToolResult(req recordedOpenAIRequest, toolResultText string) bool { - for _, msg := range req.Messages { - if msg.Role == "tool" && strings.Contains(msg.Content, toolResultText) { - return true - } - } - return false -} - -func nextWorkspaceBuildNumber(t *testing.T, db database.Store, workspaceID uuid.UUID) int32 { - t.Helper() - builds, err := db.GetWorkspaceBuildsByWorkspaceID(context.Background(), database.GetWorkspaceBuildsByWorkspaceIDParams{ - WorkspaceID: workspaceID, - OffsetOpt: 0, - LimitOpt: 100, - }) - require.NoError(t, err) - var maxBuild int32 - for _, build := range builds { - if build.BuildNumber > maxBuild { - maxBuild = build.BuildNumber - } - } - return maxBuild + 1 -} - -func seedNewWorkspaceAgentBuild( - t *testing.T, - db database.Store, - userID uuid.UUID, - orgID uuid.UUID, - workspaceID uuid.UUID, - templateVersionID uuid.UUID, -) (database.WorkspaceBuild, database.WorkspaceAgent) { - t.Helper() - pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ - InitiatorID: userID, - OrganizationID: orgID, - StartedAt: sql.NullTime{Time: dbtime.Now().Add(-time.Minute), Valid: true}, - CompletedAt: sql.NullTime{Time: dbtime.Now(), Valid: true}, - }) - build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ - WorkspaceID: workspaceID, - TemplateVersionID: templateVersionID, - JobID: pj.ID, - BuildNumber: nextWorkspaceBuildNumber(t, db, workspaceID), - InitiatorID: userID, - Transition: database.WorkspaceTransitionStart, - }) - res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ - Transition: database.WorkspaceTransitionStart, - JobID: pj.ID, - }) - now := dbtime.Now() - agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ - ResourceID: res.ID, - LifecycleState: database.WorkspaceAgentLifecycleStateReady, - StartedAt: sql.NullTime{Time: now, Valid: true}, - ReadyAt: sql.NullTime{Time: now, Valid: true}, - FirstConnectedAt: sql.NullTime{Time: now, Valid: true}, - LastConnectedAt: sql.NullTime{Time: now, Valid: true}, - Directory: "/home/coder/project-new", - OperatingSystem: "linux", - }) - require.NoError(t, db.UpdateWorkspaceAgentStartupByID(context.Background(), database.UpdateWorkspaceAgentStartupByIDParams{ - ID: agent.ID, - Version: "v1.0.0", - ExpandedDirectory: "/home/coder/project-new", - })) - loadedAgent, err := db.GetWorkspaceAgentByID(context.Background(), agent.ID) - require.NoError(t, err) - return build, loadedAgent -} - -func seedWorkspaceForCreateTool( - t *testing.T, - db database.Store, - user database.User, - org database.Organization, -) (database.Template, database.WorkspaceTable, database.WorkspaceBuild, database.WorkspaceAgent) { - t.Helper() - tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ - OrganizationID: org.ID, - CreatedBy: user.ID, - }) - tpl := dbgen.Template(t, db, database.Template{ - CreatedBy: user.ID, - OrganizationID: org.ID, - ActiveVersionID: tv.ID, - }) - ws := dbgen.Workspace(t, db, database.WorkspaceTable{ - TemplateID: tpl.ID, - OwnerID: user.ID, - OrganizationID: org.ID, - }) - build, agent := seedNewWorkspaceAgentBuild(t, db, user.ID, org.ID, ws.ID, tv.ID) - return tpl, ws, build, agent -} - -func TestRunChat_WorkspaceMCPDiscoveryWaitsForSlowAgent(t *testing.T) { - t.Parallel() - - const slowAgentMCPListDelay = 7 * time.Second - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - requestsMu.Unlock() - - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) - - workspaceToolName := "workspace-slow-mcp__echo" - workspaceToolsResp := workspacesdk.ListMCPToolsResponse{ - Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-slow-mcp", - Name: workspaceToolName, - Description: "Slow workspace echo tool", - Schema: map[string]any{ - "input": map[string]any{"type": "string"}, - }, - Required: []string{"input"}, - }}, - } - - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - mockConn.EXPECT().ContextConfig(gomock.Any()). - Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes() - // Honor ctx so the goroutine exits if chatd cancels. - mockConn.EXPECT().ListMCPTools(gomock.Any()). - DoAndReturn(func(ctx context.Context) (workspacesdk.ListMCPToolsResponse, error) { - select { - case <-time.After(slowAgentMCPListDelay): - return workspaceToolsResp, nil - case <-ctx.Done(): - return workspacesdk.ListMCPToolsResponse{}, ctx.Err() - } - }).AnyTimes() - mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()). - Return(workspacesdk.LSResponse{}, nil).AnyTimes() - mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes() - - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - require.Equal(t, dbAgent.ID, agentID) - return mockConn, func() {}, nil - } - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-mcp-slow-agent", - ModelConfigID: model.ID, - WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("List the workspace MCP tools."), - }, - }) - require.NoError(t, err) - - chatResult := waitForTerminalChat(ctx, t, db, chat.ID) - if chatResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(chatResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, chatResult.Status) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.Len(t, recorded, 1, "expected exactly one streamed model call") - require.Contains(t, recorded[0].Tools, workspaceToolName, - "workspace MCP tool should reach the LLM once chatd's discovery "+ - "timeout exceeds the agent's MCP reload time") -} - -// TestActiveServer_WorkspaceMCPToolDiscoveredMidTurnExecutes guards that -// a workspace MCP tool discovered after mid-turn workspace binding is -// active and executable in later generation actions for the same turn. -func TestActiveServer_WorkspaceMCPToolDiscoveredMidTurnExecutes(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - - workspaceToolName := "workspace-exec-mcp__echo" - workspaceCreateToolArgsJSON := "" - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - callIdx := len(requests) - requestsMu.Unlock() - - switch callIdx { - case 1: - return chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk("create_workspace", workspaceCreateToolArgsJSON)) - case 2: - return chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk(workspaceToolName, `{"input":"hello"}`)) - default: - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - } - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - - // Seed a workspace and agent for create_workspace to bind to. - tpl, ws, build, dbAgent := seedWorkspaceForCreateTool(t, db, user, org) - workspaceCreateToolArgsJSON = fmt.Sprintf(`{"template_id":%q}`, tpl.ID.String()) - - workspaceToolsResp := workspacesdk.ListMCPToolsResponse{ - Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-exec-mcp", - Name: workspaceToolName, - Description: "workspace echo tool", - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "input": map[string]any{"type": "string"}, - }, - }, - Required: []string{"input"}, - }}, - } - - var callMCPToolCount atomic.Int32 - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - mockConn.EXPECT().ContextConfig(gomock.Any()). - Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes() - mockConn.EXPECT().ListMCPTools(gomock.Any()). - Return(workspaceToolsResp, nil).AnyTimes() - mockConn.EXPECT().CallMCPTool(gomock.Any(), gomock.Cond(func(req workspacesdk.CallMCPToolRequest) bool { - return req.ToolName == workspaceToolName && req.Arguments["input"] == "hello" - })).DoAndReturn(func(_ context.Context, _ workspacesdk.CallMCPToolRequest) (workspacesdk.CallMCPToolResponse, error) { - callMCPToolCount.Add(1) - return workspacesdk.CallMCPToolResponse{ - Content: []workspacesdk.MCPToolContent{{ - Type: "text", - Text: "echo: hello", - }}, - }, nil - }).Times(1) - mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()). - Return(workspacesdk.LSResponse{}, nil).AnyTimes() - mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes() - mockConn.EXPECT().ReadFileLines(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(workspacesdk.ReadFileLinesResponse{Success: true}, nil).AnyTimes() - mockConn.EXPECT().AwaitReachable(gomock.Any()).Return(true).AnyTimes() - - createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { - return codersdk.Workspace{ - ID: ws.ID, - Name: req.Name, - OwnerName: user.Username, - OrganizationID: org.ID, - TemplateID: tpl.ID, - LatestBuild: codersdk.WorkspaceBuild{ - ID: build.ID, - Status: codersdk.WorkspaceStatusRunning, - }, - }, nil - } - - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - require.Equal(t, dbAgent.ID, agentID) - return mockConn, func() {}, nil - } - cfg.CreateWorkspace = createFn - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-mcp-midturn-executes", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Create a workspace and call the workspace MCP tool."), - }, - }) - require.NoError(t, err) - - chatResult := waitForTerminalChat(ctx, t, db, chat.ID) - if chatResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(chatResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, chatResult.Status) - require.Equal(t, int32(1), callMCPToolCount.Load()) - - messages := persistedChatMessages(ctx, t, db, chat.ID) - toolCall := requireChatToolPart(t, messages, codersdk.ChatMessagePartTypeToolCall, workspaceToolName) - require.NotEmpty(t, toolCall.ToolCallID) - toolResult := requireChatToolPart(t, messages, codersdk.ChatMessagePartTypeToolResult, workspaceToolName) - require.Contains(t, string(toolResult.Result), "echo: hello") - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.GreaterOrEqual(t, len(recorded), 3) - require.Contains(t, recorded[1].Tools, workspaceToolName) - require.True(t, openAIRequestContainsToolResult(recorded[len(recorded)-1], "echo: hello")) -} - -func TestActiveServer_WorkspaceMCPDiscoveryAfterMidTurnCreateWorkspace(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - - workspaceToolName := "workspace-midturn-mcp__echo" - workspaceCreateToolArgsJSON := "" - - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - callIdx := len(requests) - requestsMu.Unlock() - - if callIdx == 1 { - return chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk("create_workspace", workspaceCreateToolArgsJSON)) - } - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - - // Seed a workspace and agent for create_workspace to bind to. - tpl, ws, build, dbAgent := seedWorkspaceForCreateTool(t, db, user, org) - workspaceCreateToolArgsJSON = fmt.Sprintf(`{"template_id":%q}`, tpl.ID.String()) - - workspaceToolsResp := workspacesdk.ListMCPToolsResponse{ - Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-midturn-mcp", - Name: workspaceToolName, - Description: "workspace echo tool", - Schema: map[string]any{ - "input": map[string]any{"type": "string"}, - }, - Required: []string{"input"}, - }}, - } - - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - mockConn.EXPECT().ContextConfig(gomock.Any()). - Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes() - mockConn.EXPECT().ListMCPTools(gomock.Any()). - Return(workspaceToolsResp, nil).AnyTimes() - mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()). - Return(workspacesdk.LSResponse{}, nil).AnyTimes() - mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes() - mockConn.EXPECT().AwaitReachable(gomock.Any()).Return(true).AnyTimes() - - createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { - return codersdk.Workspace{ - ID: ws.ID, - Name: req.Name, - OwnerName: user.Username, - OrganizationID: org.ID, - TemplateID: tpl.ID, - LatestBuild: codersdk.WorkspaceBuild{ - ID: build.ID, - Status: codersdk.WorkspaceStatusRunning, - }, - }, nil - } - - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - require.Equal(t, dbAgent.ID, agentID) - return mockConn, func() {}, nil - } - cfg.CreateWorkspace = createFn - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-mcp-midturn", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Create a workspace and call the workspace MCP tool."), - }, - }) - require.NoError(t, err) - - chatResult := waitForTerminalChat(ctx, t, db, chat.ID) - if chatResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(chatResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, chatResult.Status) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.GreaterOrEqual(t, len(recorded), 2, - "expected at least two streamed model calls (create_workspace + follow-up)") - require.NotContains(t, recorded[0].Tools, workspaceToolName, - "first call should not advertise workspace MCP tools because the chat has no workspace yet") - require.Contains(t, recorded[1].Tools, workspaceToolName, - "second call (after create_workspace) must advertise the workspace MCP tool: "+ - "this is the fix for mid-turn workspace MCP discovery") -} - -// TestActiveServer_WorkspaceMCPDiscoveryRetriesAfterEmptyResult guards -// the regression where an empty workspace MCP discovery result -// permanently blocked retries within the turn. The active worker should -// retry discovery in later generation actions until tools appear. -func TestActiveServer_WorkspaceMCPDiscoveryRetriesAfterEmptyResult(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - - var ( - requestsMu sync.Mutex - requests []recordedOpenAIRequest - ) - - workspaceToolName := "workspace-empty-retry-mcp__echo" - workspaceCreateToolArgsJSON := "" - - openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { - if !req.Stream { - return chattest.OpenAINonStreamingResponse("title") - } - - requestsMu.Lock() - requests = append(requests, recordOpenAIRequest(req)) - callIdx := len(requests) - requestsMu.Unlock() - - // Step 1: trigger create_workspace. - if callIdx == 1 { - return chattest.OpenAIStreamingResponse(chattest.OpenAIToolCallChunk("create_workspace", workspaceCreateToolArgsJSON)) - } - // Step 2..N-1 calls a cheap workspace tool so the active worker - // runs several generation actions before the final assistant text. - if callIdx < 6 { - return chattest.OpenAIStreamingResponse( - chattest.OpenAIToolCallChunk("ls", `{"path":"/tmp"}`), - ) - } - // Final step: finish the chat. - return chattest.OpenAIStreamingResponse( - chattest.OpenAITextChunks("done")..., - ) - }) - - user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) - - // Seed a workspace and agent for create_workspace to bind to. - tpl, ws, build, dbAgent := seedWorkspaceForCreateTool(t, db, user, org) - workspaceCreateToolArgsJSON = fmt.Sprintf(`{"template_id":%q}`, tpl.ID.String()) - - workspaceToolsResp := workspacesdk.ListMCPToolsResponse{ - Tools: []workspacesdk.MCPToolInfo{{ - ServerName: "workspace-empty-retry-mcp", - Name: workspaceToolName, - Description: "workspace echo tool", - Schema: map[string]any{ - "input": map[string]any{"type": "string"}, - }, - Required: []string{"input"}, - }}, - } - - // First two ListMCPTools calls return empty (no error). One may - // come from the cache primer and one from the first generation - // action after create_workspace. Later calls return the workspace - // tool, proving discovery retries after empty results. - var listCalls atomic.Int32 - ctrl := gomock.NewController(t) - mockConn := agentconnmock.NewMockAgentConn(ctrl) - mockConn.EXPECT().SetExtraHeaders(gomock.Any()).AnyTimes() - mockConn.EXPECT().ContextConfig(gomock.Any()). - Return(workspacesdk.ContextConfigResponse{}, xerrors.New("not supported")).AnyTimes() - mockConn.EXPECT().ListMCPTools(gomock.Any()).DoAndReturn( - func(context.Context) (workspacesdk.ListMCPToolsResponse, error) { - n := listCalls.Add(1) - if n <= 2 { - return workspacesdk.ListMCPToolsResponse{}, nil - } - return workspaceToolsResp, nil - }, - ).AnyTimes() - mockConn.EXPECT().LS(gomock.Any(), gomock.Any(), gomock.Any()). - Return(workspacesdk.LSResponse{}, nil).AnyTimes() - mockConn.EXPECT().ReadFile(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(io.NopCloser(strings.NewReader("")), "", nil).AnyTimes() - mockConn.EXPECT().AwaitReachable(gomock.Any()).Return(true).AnyTimes() - - createFn := func(_ context.Context, _ uuid.UUID, req codersdk.CreateWorkspaceRequest) (codersdk.Workspace, error) { - return codersdk.Workspace{ - ID: ws.ID, - Name: req.Name, - OwnerName: user.Username, - OrganizationID: org.ID, - TemplateID: tpl.ID, - LatestBuild: codersdk.WorkspaceBuild{ - ID: build.ID, - Status: codersdk.WorkspaceStatusRunning, - }, - }, nil - } - - server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { - cfg.AgentConn = func(_ context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - require.Equal(t, dbAgent.ID, agentID) - return mockConn, func() {}, nil - } - cfg.CreateWorkspace = createFn - }) - - chat, err := server.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "workspace-mcp-empty-retry", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{ - codersdk.ChatMessageText("Create a workspace and call the workspace MCP tool."), - }, - }) - require.NoError(t, err) - - chatResult := waitForTerminalChat(ctx, t, db, chat.ID) - if chatResult.Status == database.ChatStatusError { - require.FailNowf(t, "chat failed", "last_error=%q", - chatLastErrorMessage(chatResult.LastError)) - } - require.Equal(t, database.ChatStatusWaiting, chatResult.Status) - - requestsMu.Lock() - recorded := append([]recordedOpenAIRequest(nil), requests...) - requestsMu.Unlock() - require.GreaterOrEqual(t, len(recorded), 3, - "expected at least three streamed model calls; chat must run past the empty discovery") - - // The first call has no workspace yet. By a later post-binding - // call, workspace MCP discovery must have retried after the empty - // results and advertised the workspace tool. - sawWorkspaceTool := false - for i := 2; i < len(recorded); i++ { - if slices.Contains(recorded[i].Tools, workspaceToolName) { - sawWorkspaceTool = true - break - } - } - require.True(t, sawWorkspaceTool, - "workspace MCP discovery must retry on subsequent steps; "+ - "without the fix the first empty result would permanently "+ - "block retries within the turn") -} diff --git a/coderd/x/chatd/chatstate/transitions.go b/coderd/x/chatd/chatstate/transitions.go index 964610f84d3a1..a052a51df23f9 100644 --- a/coderd/x/chatd/chatstate/transitions.go +++ b/coderd/x/chatd/chatstate/transitions.go @@ -19,23 +19,22 @@ import ( // CreateChatInput configures [CreateChat]. type CreateChatInput struct { - OrganizationID uuid.UUID - OwnerID uuid.UUID - WorkspaceID uuid.NullUUID - BuildID uuid.NullUUID - AgentID uuid.NullUUID - ParentChatID uuid.NullUUID - RootChatID uuid.NullUUID - LastModelConfigID uuid.UUID - Title string - Mode database.NullChatMode - PlanMode database.NullChatPlanMode - MCPServerIDs []uuid.UUID - Labels pqtype.NullRawMessage - DynamicTools pqtype.NullRawMessage - ClientType database.ChatClientType - InitialMessages []Message - LastInjectedContext pqtype.NullRawMessage + OrganizationID uuid.UUID + OwnerID uuid.UUID + WorkspaceID uuid.NullUUID + BuildID uuid.NullUUID + AgentID uuid.NullUUID + ParentChatID uuid.NullUUID + RootChatID uuid.NullUUID + LastModelConfigID uuid.UUID + Title string + Mode database.NullChatMode + PlanMode database.NullChatPlanMode + MCPServerIDs []uuid.UUID + Labels pqtype.NullRawMessage + DynamicTools pqtype.NullRawMessage + ClientType database.ChatClientType + InitialMessages []Message } // CreateChatResult is the value returned by [CreateChat]. It carries @@ -106,14 +105,6 @@ func CreateChat( if err != nil { return xerrors.Errorf("insert initial messages: %w", err) } - if input.LastInjectedContext.Valid { - if _, err := store.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{ - ID: chat.ID, - LastInjectedContext: input.LastInjectedContext, - }); err != nil { - return xerrors.Errorf("set last injected context: %w", err) - } - } refreshed, err := store.GetChatByID(ctx, chat.ID) if err != nil { return xerrors.Errorf("reload chat after initial messages: %w", err) diff --git a/coderd/x/chatd/chattool/skill.go b/coderd/x/chatd/chattool/skill.go index 966c960cac707..4267dff5ad69b 100644 --- a/coderd/x/chatd/chattool/skill.go +++ b/coderd/x/chatd/chattool/skill.go @@ -135,57 +135,6 @@ func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) s return b.String() } -// LoadSkillBody reads the full skill meta file for a discovered -// skill and lists the supporting files in its directory. -func LoadSkillBody( - ctx context.Context, - conn workspacesdk.AgentConn, - skill SkillMeta, - metaFile string, -) (SkillContent, error) { - metaPath := path.Join(skill.Dir, metaFile) - - reader, _, err := conn.ReadFile( - ctx, metaPath, 0, maxSkillMetaBytes+1, - ) - if err != nil { - return SkillContent{}, xerrors.Errorf( - "read skill body: %w", err, - ) - } - raw, err := io.ReadAll(io.LimitReader(reader, maxSkillMetaBytes+1)) - reader.Close() - if err != nil { - return SkillContent{}, xerrors.Errorf( - "read skill body bytes: %w", err, - ) - } - - if int64(len(raw)) > maxSkillMetaBytes { - raw = raw[:maxSkillMetaBytes] - } - - _, _, body, err := workspacesdk.ParseSkillFrontmatter(string(raw)) - if err != nil { - return SkillContent{}, xerrors.Errorf( - "parse skill frontmatter: %w", err, - ) - } - - // List supporting files so the model knows what it can - // request via read_skill_file. - files, err := listSkillFiles(ctx, conn, skill.Dir, metaFile) - if err != nil { - return SkillContent{}, err - } - - return SkillContent{ - SkillMeta: skill, - Body: body, - Files: files, - }, nil -} - // listSkillFiles lists the supporting files in a skill directory, // excluding the skill meta file itself. Directory entries are // suffixed with "/" so the model can tell them apart from files. @@ -524,30 +473,11 @@ func readWorkspaceSkillBody( return SkillContent{}, skillNotFoundResponse(requestedName), true } - // Pinned path: the SKILL.md body travels in the workspace context - // snapshot, so it is served without dialing the workspace. - if len(skill.Meta) > 0 { - content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill) - if err != nil { - return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true - } - return content, fantasy.ToolResponse{}, false - } - - // Legacy path: read the SKILL.md body live over the workspace - // connection for agents that have not pushed context. - if options.GetWorkspaceConn == nil { - return SkillContent{}, fantasy.NewTextErrorResponse( - "workspace connection resolver is not configured", - ), true - } - - conn, err := options.GetWorkspaceConn(ctx) - if err != nil { - return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true - } - - content, err := LoadSkillBody(ctx, conn, skill, cmp.Or(skill.MetaFile, DefaultSkillMetaFile)) + // The SKILL.md body travels in the workspace context snapshot, so it + // is served from the pin without dialing the workspace. The supporting + // file list is still a best-effort live lookup; see + // loadPinnedWorkspaceSkillContent. + content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill) if err != nil { return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true } diff --git a/coderd/x/chatd/chattool/skill_test.go b/coderd/x/chatd/chattool/skill_test.go index 650b26e5325a7..e9c3205ffdbcd 100644 --- a/coderd/x/chatd/chattool/skill_test.go +++ b/coderd/x/chatd/chattool/skill_test.go @@ -123,51 +123,6 @@ func TestFormatResolvedSkillIndex(t *testing.T) { }) } -func TestLoadSkillBody(t *testing.T) { - t.Parallel() - - t.Run("ReturnsBodyAndFiles", func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - conn := agentconnmock.NewMockAgentConn(ctrl) - - skill := chattool.SkillMeta{ - Name: "my-skill", - Description: "desc", - Dir: "/work/.agents/skills/my-skill", - } - - // Read the full SKILL.md. - conn.EXPECT().ReadFile( - gomock.Any(), - "/work/.agents/skills/my-skill/SKILL.md", - int64(0), - int64(64*1024+1), - ).Return( - io.NopCloser(strings.NewReader(validSkillMD("my-skill", "desc"))), - "text/markdown", - nil, - ) - - // List supporting files. - conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return( - workspacesdk.LSResponse{ - Contents: []workspacesdk.LSFile{ - {Name: "SKILL.md"}, - {Name: "helper.md"}, - {Name: "roles", IsDir: true}, - }, - }, nil, - ) - - content, err := chattool.LoadSkillBody(context.Background(), conn, skill, "SKILL.md") - require.NoError(t, err) - assert.Contains(t, content.Body, "Do the thing.") - assert.Equal(t, []string{"helper.md", "roles/"}, content.Files) - }) -} - func TestLoadSkillFile(t *testing.T) { t.Parallel() @@ -309,50 +264,6 @@ func TestLoadSkillFile(t *testing.T) { func TestReadSkillTool(t *testing.T) { t.Parallel() - t.Run("ValidSkill", func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - conn := agentconnmock.NewMockAgentConn(ctrl) - - skills := []chattool.SkillMeta{{ - Name: "my-skill", - Description: "test", - Dir: "/work/.agents/skills/my-skill", - }} - - conn.EXPECT().ReadFile( - gomock.Any(), gomock.Any(), int64(0), gomock.Any(), - ).Return( - io.NopCloser(strings.NewReader(validSkillMD("my-skill", "test"))), - "text/markdown", - nil, - ) - conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return( - workspacesdk.LSResponse{ - Contents: []workspacesdk.LSFile{ - {Name: "SKILL.md"}, - }, - }, nil, - ) - - tool := chattool.ReadSkill(chattool.ReadSkillOptions{ - GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) { - return conn, nil - }, - GetSkills: func() []chattool.SkillMeta { return skills }, - }) - - resp, err := tool.Run(context.Background(), fantasy.ToolCall{ - ID: "call-1", - Name: "read_skill", - Input: `{"name":"my-skill"}`, - }) - require.NoError(t, err) - assert.False(t, resp.IsError) - assert.Contains(t, resp.Content, "Do the thing.") - }) - t.Run("PinnedBodyFromMeta", func(t *testing.T) { t.Parallel() @@ -514,15 +425,9 @@ func TestReadSkillTool(t *testing.T) { Name: "my-skill", Description: "test", Dir: "/work/.agents/skills/my-skill", + Meta: []byte(validSkillMD("my-skill", "test")), }} - conn.EXPECT().ReadFile( - gomock.Any(), gomock.Any(), int64(0), gomock.Any(), - ).Return( - io.NopCloser(strings.NewReader(validSkillMD("my-skill", "test"))), - "text/markdown", - nil, - ) conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return( workspacesdk.LSResponse{}, nil, ) @@ -566,15 +471,9 @@ func TestReadSkillTool(t *testing.T) { Name: "deploy", Description: "workspace deploy", Dir: "/work/.agents/skills/deploy", + Meta: []byte(validSkillMD("deploy", "workspace deploy")), }} - conn.EXPECT().ReadFile( - gomock.Any(), gomock.Any(), int64(0), gomock.Any(), - ).Return( - io.NopCloser(strings.NewReader(validSkillMD("deploy", "workspace deploy"))), - "text/markdown", - nil, - ) conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return( workspacesdk.LSResponse{}, nil, ) diff --git a/coderd/x/chatd/context_helpers.go b/coderd/x/chatd/context_helpers.go deleted file mode 100644 index be684c7c6d528..0000000000000 --- a/coderd/x/chatd/context_helpers.go +++ /dev/null @@ -1,82 +0,0 @@ -package chatd - -import ( - "bytes" - "encoding/json" - - "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/codersdk" -) - -// agentChatContextSentinelPath marks the synthetic empty context-file -// part used to record an attempted workspace-context fetch when no -// AGENTS.md content is available. It mirrors the constant of the same -// value in the chatd package so the worker can recognize sentinel -// parts without importing chatd (which would be a cycle). -const agentChatContextSentinelPath = ".coder/agent-chat-context-sentinel" - -// contextFileAgentIDFromMessages returns the most recent workspace -// agent ID stamped on a persisted context-file part, ignoring the -// skill-only sentinel. Returns uuid.Nil, false when no stamped -// non-sentinel context-file parts exist. -// -// This mirrors chatd.contextFileAgentID. It is duplicated here as a -// small pure helper so chatworker can decide whether workspace -// context is current without importing chatd. -func contextFileAgentIDFromMessages(messages []database.ChatMessage) (uuid.UUID, bool) { - var lastID uuid.UUID - found := false - for _, msg := range messages { - if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - continue - } - for _, p := range parts { - if p.Type != codersdk.ChatMessagePartTypeContextFile || - !p.ContextFileAgentID.Valid || - p.ContextFilePath == agentChatContextSentinelPath { - continue - } - lastID = p.ContextFileAgentID.UUID - found = true - break - } - } - return lastID, found -} - -// hasPersistedContextFileForAgent reports whether messages include -// any persisted context-file marker for the given agent, including -// the skill-only sentinel. This is true once the -// persist_workspace_context action has committed at least one -// context-file row for the agent (with or without content), so a -// subsequent decision pass will not loop on the same agent. -func hasPersistedContextFileForAgent(messages []database.ChatMessage, agentID uuid.UUID) bool { - if agentID == uuid.Nil { - return false - } - for _, msg := range messages { - if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - continue - } - for _, p := range parts { - if p.Type != codersdk.ChatMessagePartTypeContextFile || - !p.ContextFileAgentID.Valid { - continue - } - if p.ContextFileAgentID.UUID == agentID { - return true - } - } - } - return false -} diff --git a/coderd/x/chatd/context_hydration.go b/coderd/x/chatd/context_hydration.go index 5e409a8f65961..147dbf36c7599 100644 --- a/coderd/x/chatd/context_hydration.go +++ b/coderd/x/chatd/context_hydration.go @@ -85,6 +85,33 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store }, nil } +// hydrateAgentChatsFromSnapshot stamps every chat bound to agentID that still +// carries a NULL pinned hash with the agent's latest pushed snapshot and copies +// that snapshot's resources. It runs in one repeatable-read transaction so a +// concurrent push cannot commit between the hash read and the resource copy and +// leave a chat stamped with one snapshot's hash but another snapshot's +// resources. It is the shared core of first-time pinning: idempotent because +// HydrateAgentChatsContext only touches NULL-hash chats (a concurrent push that +// already hydrated the chat is not clobbered), and snapshot-gated so it does +// nothing when the agent has not pushed yet, never stamping empty state that +// would keep a later push from hydrating. +func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid.UUID) error { + return database.ReadModifyUpdate(p.db, func(tx database.Store) error { + aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, agentID) + if err != nil { + return err + } + if !ok { + return nil + } + return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + AgentID: agentID, + AggregateHash: aggregateHash, + ContextError: snapshotError, + }) + }) +} + // hydrateChatContextOnCreate pins a newly created chat to its agent's latest // context snapshot when one already exists. Best-effort: a chat whose agent // has not pushed yet is hydrated later by that agent's next push. Failures @@ -98,32 +125,37 @@ func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.C } //nolint:gocritic // Chatd stamps chats it does not own as the daemon subject. ctx = dbauthz.AsChatd(ctx) - - // Read the snapshot hash and copy the agent's resources in one - // repeatable-read transaction so a concurrent push cannot commit between - // the two and leave the chat stamped with one snapshot's hash but another - // snapshot's resources. The NULL-hash guard inside the statement still - // keeps a concurrent push that already hydrated the chat from being - // clobbered. - if err := database.ReadModifyUpdate(p.db, func(tx database.Store) error { - aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, chat.AgentID.UUID) - if err != nil { - return err - } - if !ok { - return nil - } - return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ - AgentID: chat.AgentID.UUID, - AggregateHash: aggregateHash, - ContextError: snapshotError, - }) - }); err != nil { + if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); err != nil { p.logger.Warn(ctx, "hydrate chat context on create", slog.F("chat_id", chat.ID), slog.Error(err)) } } +// ensureChatContextPinnedOnFirstTurn pins a chat to its freshly bound agent's +// latest pushed snapshot when the chat is still unpinned. API-created chats +// carry no agent at create, so hydrateChatContextOnCreate is a no-op for them; +// they bind their agent lazily on the first turn. Without this, such a chat +// reads empty pinned context on its first turn whenever the agent pushed before +// the chat existed, because that push could not hydrate a chat that did not yet +// exist. It reuses the create-path hydration, which is idempotent and +// snapshot-gated, so it never clobbers an already-pinned chat and never stamps +// empty state. The NULL-hash gate also leaves dirtied chats alone: their stale +// pinned hash is non-NULL until the refresh endpoint re-pins. Best-effort: +// failures are logged and swallowed so they never fail the turn. +func (p *Server) ensureChatContextPinnedOnFirstTurn(ctx context.Context, chat database.Chat) { + if !chat.AgentID.Valid || chat.ContextAggregateHash != nil { + return + } + //nolint:gocritic // Chatd stamps chats it does not own as the daemon subject. + ctx = dbauthz.AsChatd(ctx) + if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); err != nil { + p.logger.Warn(ctx, "ensure chat context pinned on first turn", + slog.F("chat_id", chat.ID), + slog.F("agent_id", chat.AgentID.UUID), + slog.Error(err)) + } +} + // repinChatContext re-pins a single chat to its agent's latest context // snapshot: it sets the pinned hash and error and rewrites the chat's pinned // resources (clear-then-copy) so the two always agree. A chat with no bound diff --git a/coderd/x/chatd/context_hydration_internal_test.go b/coderd/x/chatd/context_hydration_internal_test.go index 1a6a1a82deeca..7e71c053e31ca 100644 --- a/coderd/x/chatd/context_hydration_internal_test.go +++ b/coderd/x/chatd/context_hydration_internal_test.go @@ -83,3 +83,71 @@ func TestHydrateChatContextOnCreate(t *testing.T) { }) }) } + +// TestEnsureChatContextPinnedOnFirstTurn covers the lazy-bind pinning path. An +// API-created chat carries no agent at create, binds its agent on the first +// turn, and must pin the agent's already-pushed snapshot then. This is the +// mechanism that lets a workspace created mid-turn have its context pinned on +// the next turn: the agent pushes its snapshot before the chat is bound to it, +// so HydrateAgentChatsContext on that push cannot reach the chat, and the +// rebind-only binding does not pin a first-time agent. +func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) { + t.Parallel() + + t.Run("PinsWhenUnpinnedAndSnapshotExists", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db, logger: slogtest.Make(t, nil)} + + agentID := uuid.New() + chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}} + snapshot := database.WorkspaceAgentContextSnapshot{ + WorkspaceAgentID: agentID, + AggregateHash: []byte{0x0a, 0x0b}, + } + + db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn( + func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) }) + db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID). + Return(snapshot, nil) + // The guarded agent-scoped stamp, not an unconditional SetChatContextSnapshot, + // so a concurrent push that already hydrated the chat wins. + db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{ + AgentID: agentID, + AggregateHash: snapshot.AggregateHash, + ContextError: snapshot.SnapshotError, + }).Return(nil) + + server.ensureChatContextPinnedOnFirstTurn(ctx, chat) + }) + + t.Run("SkipsWhenAlreadyPinned", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + // A non-NULL pinned hash means the chat is already pinned (or dirty + // awaiting refresh); the hook must touch the database zero times so it + // never clobbers existing bodies or a dirty chat's stale hash. + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db, logger: slogtest.Make(t, nil)} + + server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{ + ID: uuid.New(), + AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true}, + ContextAggregateHash: []byte{0x01}, + }) + }) + + t.Run("SkipsWhenAgentless", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + // No agent bound yet: the hook must touch the database zero times. + db := dbmock.NewMockStore(ctrl) + server := &Server{db: db, logger: slogtest.Make(t, nil)} + + server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{ID: uuid.New()}) + }) +} diff --git a/coderd/x/chatd/context_integration_test.go b/coderd/x/chatd/context_integration_test.go index 145244decd9dd..26aac7ac405b8 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -1,16 +1,20 @@ package chatd_test import ( + "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" agentproto "github.com/coder/coder/v2/agent/proto" "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/database/dbtime" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner/echo" @@ -396,3 +400,107 @@ func TestChatContextRefreshFromAgentToken(t *testing.T) { require.NoError(t, err) require.Equal(t, 0, refresh.Refreshed, "nothing left to refresh") } + +// agentMCPToolContext specifies an mcp_server tool to seed into an agent's +// pushed context snapshot. +type agentMCPToolContext struct { + AgentID uuid.UUID + ServerName string + ToolName string + ToolDescription string +} + +// seedAgentMCPToolContext upserts an mcp_server context snapshot and resource +// for the agent, mirroring what PushContextState writes, so a chat bound to the +// agent hydrates a pinned, execution-ready MCP tool. The model-facing tool name +// is "__". It seeds the raw store directly so unit tests +// can exercise pinned MCP execution without a live agent connection. +func seedAgentMCPToolContext( + ctx context.Context, + t *testing.T, + db database.Store, + tool agentMCPToolContext, +) { + t.Helper() + + schema, err := structpb.NewStruct(map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{"type": "string"}, + }, + }) + require.NoError(t, err) + + body, err := protojson.Marshal(&agentproto.MCPServerBody{ + ServerName: tool.ServerName, + Tools: []*agentproto.MCPTool{{ + Name: tool.ToolName, + Description: tool.ToolDescription, + InputSchema: schema, + }}, + }) + require.NoError(t, err) + + now := dbtime.Now() + hash := []byte(tool.ServerName + ":" + tool.ToolName) + _, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{ + WorkspaceAgentID: tool.AgentID, + Version: 1, + AggregateHash: hash, + ReceivedAt: now, + }) + require.NoError(t, err) + + _, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{ + WorkspaceAgentID: tool.AgentID, + Source: tool.ServerName, + BodyKind: database.WorkspaceAgentContextBodyKindMcpServer, + Body: body, + ContentHash: hash, + SizeBytes: int64(len(body)), + Status: database.WorkspaceAgentContextResourceStatusOk, + Now: now, + }) + require.NoError(t, err) +} + +// seedAgentInstructionContext upserts an instruction_file context snapshot and +// resource for the agent, mirroring what PushContextState writes, so a chat +// bound to the agent hydrates a pinned instruction block. It seeds the raw +// store directly so unit tests can exercise pinned workspace context without a +// live agent connection. +func seedAgentInstructionContext( + ctx context.Context, + t *testing.T, + db database.Store, + agentID uuid.UUID, + source string, + content string, +) { + t.Helper() + + body, err := protojson.Marshal(&agentproto.InstructionFileBody{Content: []byte(content)}) + require.NoError(t, err) + + now := dbtime.Now() + hash := []byte("instruction:" + source) + _, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{ + WorkspaceAgentID: agentID, + Version: 1, + AggregateHash: hash, + ReceivedAt: now, + }) + require.NoError(t, err) + + _, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{ + WorkspaceAgentID: agentID, + Source: source, + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: body, + ContentHash: hash, + SizeBytes: int64(len(body)), + Status: database.WorkspaceAgentContextResourceStatusOk, + Now: now, + }) + require.NoError(t, err) +} diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 7890cfb7cd330..558a5b791a3af 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -17,6 +17,13 @@ import ( "github.com/coder/coder/v2/codersdk/workspacesdk" ) +// AgentChatContextSentinelPath is the canonical path of the synthetic empty +// context-file part that legacy chats used to mark skill-only workspace-agent +// context. New turns no longer emit it; it is retained as the canonical value +// so historical-message handling and the chatopenai chain-mode tests stay in +// sync. +const AgentChatContextSentinelPath = ".coder/agent-chat-context-sentinel" + // contextBodyUnmarshalOptions reads the protojson resource bodies written by // the agent context push (coderd/agentapi/context.go). DiscardUnknown keeps // the reader forward compatible as new body fields are added to the proto. @@ -171,30 +178,24 @@ func decodeSkillIdentity(body json.RawMessage) (name, description string, decode // pinnedWorkspaceContext builds the system-prompt instruction block and // workspace skills from the chat's pinned context resources -// (chat_context_resources), populated at hydrate and refresh time. -// -// ok reports whether the caller should use these values instead of the -// per-turn, history-derived path. It is false when the chat has no pinned -// rows (an older agent that never reported context, or a chat not yet -// hydrated), so the caller falls back to the legacy path. When rows exist ok -// is true even if they all filter to empty content, because the pin is then -// the source of truth. A read error is returned rather than swallowed, -// matching the other prompt-input reads in prepareGeneration. +// (chat_context_resources), populated at hydrate and refresh time. A chat +// with no pinned rows yields no context. A read error is returned rather than +// swallowed, matching the other prompt-input reads in prepareGeneration. // // agent only decorates the instruction header with its OS and directory; an -// unresolved (zero-value) agent does not force a fallback, so the pin keeps +// unresolved (zero-value) agent does not blank the context, so the pin keeps // working when the workspace is unreachable. func (server *Server) pinnedWorkspaceContext( ctx context.Context, chat database.Chat, agent database.WorkspaceAgent, -) (instruction string, skills []chattool.SkillMeta, ok bool, err error) { +) (instruction string, skills []chattool.SkillMeta, err error) { resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID) if err != nil { - return "", nil, false, xerrors.Errorf("list chat context resources: %w", err) + return "", nil, xerrors.Errorf("list chat context resources: %w", err) } if len(resources) == 0 { - return "", nil, false, nil + return "", nil, nil } directory := agent.ExpandedDirectory @@ -218,42 +219,23 @@ func (server *Server) pinnedWorkspaceContext( slog.F("skill_count", len(skills)), slog.F("has_instruction", instruction != ""), ) - return instruction, skills, true, nil + return instruction, skills, nil } // resolveTurnWorkspaceContext selects the instruction block and workspace -// skills for a turn. It prefers the chat's pinned context copy when the -// workspace agent has reported context, and falls back to the per-turn, -// history-derived context-file and skill parts for older agents that have -// not. The two paths are mutually exclusive. agent is the chat's resolved -// workspace agent, used only to decorate the pinned instruction header. A -// non-workspace chat yields no context. +// skills for a turn from the chat's pinned context snapshot +// (chat_context_resources). agent is the chat's resolved workspace agent, +// used only to decorate the pinned instruction header. A non-workspace chat +// yields no context. func (server *Server) resolveTurnWorkspaceContext( ctx context.Context, chat database.Chat, agent database.WorkspaceAgent, - promptRows []database.ChatMessage, ) (instruction string, skills []chattool.SkillMeta, err error) { if !chat.WorkspaceID.Valid { return "", nil, nil } - - pinnedInstruction, pinnedSkills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent) - if err != nil { - return "", nil, err - } - if ok { - return pinnedInstruction, pinnedSkills, nil - } - - // History fallback: re-derive the instruction and skills from the - // context-file and skill parts the per-turn pull persisted. Skills are - // included only when context files are present; the pinned path resolves - // them independently. - if _, found := contextFileAgentID(promptRows); found { - return instructionFromContextFiles(promptRows), skillsFromParts(promptRows), nil - } - return "", nil, nil + return server.pinnedWorkspaceContext(ctx, chat, agent) } // contextResourcesToPrompt converts a chat's pinned context resources into diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index b5b784d4355d6..278d8ea1e1727 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" @@ -270,12 +269,11 @@ func TestPinnedWorkspaceContext(t *testing.T) { Return(nil, xerrors.New("boom")) server := newPinServer(t, db) - _, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) + _, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.Error(t, err) - require.False(t, ok) }) - t.Run("NoRowsFallsBack", func(t *testing.T) { + t.Run("NoRowsYieldsNothing", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -285,9 +283,8 @@ func TestPinnedWorkspaceContext(t *testing.T) { Return([]database.ChatContextResource{}, nil) server := newPinServer(t, db) - instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) + instruction, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.NoError(t, err) - require.False(t, ok) require.Empty(t, instruction) require.Empty(t, skills) }) @@ -306,9 +303,8 @@ func TestPinnedWorkspaceContext(t *testing.T) { server := newPinServer(t, db) agent := database.WorkspaceAgent{OperatingSystem: "linux", ExpandedDirectory: "/home/coder"} - instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent) + instruction, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent) require.NoError(t, err) - require.True(t, ok) require.Contains(t, instruction, "Operating System: linux") require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") require.Contains(t, instruction, "be helpful") @@ -330,9 +326,8 @@ func TestPinnedWorkspaceContext(t *testing.T) { // Zero-value agent: the pin still resolves, just without the // OS/directory header. - instruction, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) + instruction, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{}) require.NoError(t, err) - require.True(t, ok) require.Contains(t, instruction, "Source: /home/coder/AGENTS.md") require.NotContains(t, instruction, "Operating System:") }) @@ -415,9 +410,8 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) server := &Server{db: db, logger: logger} - instruction, skills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent) + instruction, skills, err := server.pinnedWorkspaceContext(ctx, chat, agent) require.NoError(t, err) - require.True(t, ok) require.Contains(t, instruction, "Operating System: linux") require.Contains(t, instruction, "Working Directory: /home/coder/ws") require.Contains(t, instruction, "Source: /home/coder/ws/AGENTS.md") @@ -428,8 +422,7 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { require.Equal(t, "/home/coder/ws/.coder/skills/deploy", skills[0].Dir) // A chat created after hydration keeps a NULL pinned hash and no pinned - // rows, so the pin resolves to ok=false and the caller falls back to the - // per-turn history path. + // rows, so the pin yields no instruction or skills. unpinnedChat := dbgen.Chat(t, db, database.Chat{ OwnerID: user.ID, OrganizationID: org.ID, @@ -438,37 +431,15 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, Status: database.ChatStatusWaiting, }) - _, _, ok, err = server.pinnedWorkspaceContext(ctx, unpinnedChat, agent) + emptyInstruction, emptySkills, err := server.pinnedWorkspaceContext(ctx, unpinnedChat, agent) require.NoError(t, err) - require.False(t, ok) -} - -func historyContextMessage(t *testing.T, agentID uuid.UUID) database.ChatMessage { - t.Helper() - parts := []codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - ContextFilePath: "/home/coder/AGENTS.md", - ContextFileContent: "history content", - ContextFileOS: "linux", - ContextFileDirectory: "/home/coder", - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, - SkillName: "history-skill", - SkillDescription: "from history", - }, - } - raw, err := json.Marshal(parts) - require.NoError(t, err) - return database.ChatMessage{Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}} + require.Empty(t, emptyInstruction) + require.Empty(t, emptySkills) } // TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration -// wires up: the pinned copy when the chat has pinned rows, otherwise the -// per-turn history-derived parts, and nothing for a non-workspace chat. +// wires up: the pinned copy when the chat has pinned rows, and nothing for a +// non-workspace chat or a chat without pinned rows. func TestResolveTurnWorkspaceContext(t *testing.T) { t.Parallel() @@ -483,7 +454,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { db := dbmock.NewMockStore(ctrl) server := newPinServer(t, db) - instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}, nil) + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}) require.NoError(t, err) require.Empty(t, instruction) require.Empty(t, skills) @@ -495,7 +466,6 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) chat := workspaceChat() - agentID := uuid.New() db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). Return([]database.ChatContextResource{ instructionResource(t, "/home/coder/AGENTS.md", "pinned content", database.WorkspaceAgentContextResourceStatusOk), @@ -503,48 +473,25 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { }, nil) server := newPinServer(t, db) - // History rows are present too; the pinned path must take precedence. - promptRows := []database.ChatMessage{historyContextMessage(t, agentID)} - instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"}, promptRows) + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"}) require.NoError(t, err) require.Contains(t, instruction, "pinned content") - require.NotContains(t, instruction, "history content") require.Len(t, skills, 1) require.Equal(t, "deploy", skills[0].Name) }) - t.Run("HistoryFallbackWhenNoPin", func(t *testing.T) { - t.Parallel() - - ctrl := gomock.NewController(t) - db := dbmock.NewMockStore(ctrl) - chat := workspaceChat() - // No pinned rows: the resolver falls back to the per-turn history path. - db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). - Return([]database.ChatContextResource{}, nil) - server := newPinServer(t, db) - - agentID := uuid.New() - promptRows := []database.ChatMessage{historyContextMessage(t, agentID)} - instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, promptRows) - require.NoError(t, err) - require.Contains(t, instruction, "history content") - require.Len(t, skills, 1) - require.Equal(t, "history-skill", skills[0].Name) - }) - - t.Run("NoContextWhenHistoryEmpty", func(t *testing.T) { + t.Run("NoPinYieldsNothing", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) chat := workspaceChat() - // No pinned rows and no history parts: the turn carries no context. + // No pinned rows: the turn carries no context. db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID). Return([]database.ChatContextResource{}, nil) server := newPinServer(t, db) - instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) + instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}) require.NoError(t, err) require.Empty(t, instruction) require.Empty(t, skills) @@ -560,7 +507,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) { Return(nil, xerrors.New("boom")) server := newPinServer(t, db) - _, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil) + _, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}) require.Error(t, err) }) } @@ -807,7 +754,7 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) { return nil, xerrors.New("not dialed in this test") } - t.Run("NoRowsFallsBack", func(t *testing.T) { + t.Run("NoRowsYieldsNoTools", func(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) @@ -817,9 +764,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) { Return([]database.ChatContextResource{}, nil) server := newPinServer(t, db) - tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) + tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) require.NoError(t, err) - require.False(t, ok) require.Empty(t, tools) }) @@ -833,9 +779,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) { Return(nil, xerrors.New("boom")) server := newPinServer(t, db) - _, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) + _, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) require.Error(t, err) - require.False(t, ok) }) t.Run("BuildsToolsFromMCPServers", func(t *testing.T) { @@ -857,9 +802,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) { }, nil) server := newPinServer(t, db) - tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) + tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) require.NoError(t, err) - require.True(t, ok) require.Len(t, tools, 2) require.Equal(t, "github__create_issue", tools[0].Info().Name) require.Equal(t, "github__search", tools[1].Info().Name) @@ -872,17 +816,16 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) { db := dbmock.NewMockStore(ctrl) chatID := uuid.New() // The chat is pinned (an instruction file is present) but the agent - // reported no MCP servers: ok is true with zero tools so the caller does - // not fall back to a live pull that could resurrect stale tools. + // reported no MCP servers: the pin is authoritative, yielding zero + // tools without a live pull that could resurrect stale tools. db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID). Return([]database.ChatContextResource{ instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk), }, nil) server := newPinServer(t, db) - tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) + tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn) require.NoError(t, err) - require.True(t, ok) require.Empty(t, tools) }) } diff --git a/coderd/x/chatd/contextparts.go b/coderd/x/chatd/contextparts.go deleted file mode 100644 index b013620b8cbfa..0000000000000 --- a/coderd/x/chatd/contextparts.go +++ /dev/null @@ -1,153 +0,0 @@ -package chatd - -import ( - "context" - "encoding/json" - - "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" - "golang.org/x/xerrors" - - "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/codersdk" -) - -// AgentChatContextSentinelPath marks the synthetic empty context-file -// part used to preserve skill-only workspace-agent additions across -// turns without treating them as persisted instruction files. -const AgentChatContextSentinelPath = ".coder/agent-chat-context-sentinel" - -// FilterContextParts keeps only context-file and skill parts from parts. -// When keepEmptyContextFiles is false, context-file parts with empty -// content are dropped. When keepEmptyContextFiles is true, empty -// context-file parts are preserved. -// revive:disable-next-line:flag-parameter // Required by shared helper callers. -func FilterContextParts( - parts []codersdk.ChatMessagePart, - keepEmptyContextFiles bool, -) []codersdk.ChatMessagePart { - var filtered []codersdk.ChatMessagePart - for _, part := range parts { - switch part.Type { - case codersdk.ChatMessagePartTypeContextFile: - if !keepEmptyContextFiles && part.ContextFileContent == "" { - continue - } - case codersdk.ChatMessagePartTypeSkill: - default: - continue - } - filtered = append(filtered, part) - } - return filtered -} - -// CollectContextPartsFromMessages unmarshals chat message content and -// collects the context-file and skill parts it contains. When -// keepEmptyContextFiles is false, empty context-file parts are skipped. -// When it is true, empty context-file parts are included in the result. -func CollectContextPartsFromMessages( - ctx context.Context, - logger slog.Logger, - messages []database.ChatMessage, - keepEmptyContextFiles bool, -) ([]codersdk.ChatMessagePart, error) { - var collected []codersdk.ChatMessagePart - for _, msg := range messages { - if !msg.Content.Valid { - continue - } - - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - logger.Warn(ctx, "skipping malformed chat context message", - slog.F("chat_message_id", msg.ID), - slog.Error(err), - ) - continue - } - - collected = append( - collected, - FilterContextParts(parts, keepEmptyContextFiles)..., - ) - } - - return collected, nil -} - -func latestContextAgentIDFromParts(parts []codersdk.ChatMessagePart) (uuid.UUID, bool) { - var lastID uuid.UUID - found := false - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeContextFile || - !part.ContextFileAgentID.Valid { - continue - } - lastID = part.ContextFileAgentID.UUID - found = true - } - return lastID, found -} - -// FilterContextPartsToLatestAgent keeps parts stamped with the latest -// workspace-agent ID seen in the slice, plus legacy unstamped parts. -// When no stamped context-file parts exist, it returns the original -// slice unchanged. -func FilterContextPartsToLatestAgent(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart { - latestAgentID, ok := latestContextAgentIDFromParts(parts) - if !ok { - return parts - } - - filtered := make([]codersdk.ChatMessagePart, 0, len(parts)) - for _, part := range parts { - switch part.Type { - case codersdk.ChatMessagePartTypeContextFile, - codersdk.ChatMessagePartTypeSkill: - if part.ContextFileAgentID.Valid && - part.ContextFileAgentID.UUID != latestAgentID { - continue - } - default: - continue - } - filtered = append(filtered, part) - } - return filtered -} - -// BuildLastInjectedContext filters parts down to non-empty context-file -// and skill parts, strips their internal fields, and marshals the -// result for LastInjectedContext. A nil or fully filtered input returns -// an invalid NullRawMessage. -func BuildLastInjectedContext( - parts []codersdk.ChatMessagePart, -) (pqtype.NullRawMessage, error) { - if parts == nil { - return pqtype.NullRawMessage{Valid: false}, nil - } - - filtered := FilterContextParts(parts, false) - if len(filtered) == 0 { - return pqtype.NullRawMessage{Valid: false}, nil - } - - stripped := make([]codersdk.ChatMessagePart, 0, len(filtered)) - for _, part := range filtered { - cp := part - cp.StripInternal() - stripped = append(stripped, cp) - } - - raw, err := json.Marshal(stripped) - if err != nil { - return pqtype.NullRawMessage{}, xerrors.Errorf( - "marshal injected context: %w", - err, - ) - } - - return pqtype.NullRawMessage{RawMessage: raw, Valid: true}, nil -} diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index de8a42d2d6ec9..ef3a7e7a70ab3 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -69,12 +69,6 @@ type generationPrepared struct { Cleanup func() Debug *generationDebug - - // WorkspaceContextEligible reports whether the current turn is allowed - // by policy to inject workspace context. The decision helper combines - // this fact with committed chat metadata and history to decide whether - // the persist_workspace_context action should run. - WorkspaceContextEligible bool } // generationCompaction contains compaction inputs prepared for generation. @@ -94,16 +88,6 @@ type generationDebug struct { ModelConfig database.ChatModelConfig } -type workspaceContextBuildInput struct { - Chat database.Chat - Messages []database.ChatMessage - ActiveAPIKeyID string -} - -type workspaceContextBuildResult struct { - Messages []chatstate.Message -} - // generationOutcome describes a completed generation outcome. type generationOutcome struct { Chat database.Chat @@ -117,12 +101,11 @@ type generationOutcome struct { type generationActionKind string const ( - generationActionExecuteLocalTools generationActionKind = "execute_local_tools" - generationActionEnterRequiresAction generationActionKind = "enter_requires_action" - generationActionFinishTurn generationActionKind = "finish_turn" - generationActionCompact generationActionKind = "compact" - generationActionGenerateAssistant generationActionKind = "generate_assistant" - generationActionPersistWorkspaceContext generationActionKind = "persist_workspace_context" + generationActionExecuteLocalTools generationActionKind = "execute_local_tools" + generationActionEnterRequiresAction generationActionKind = "enter_requires_action" + generationActionFinishTurn generationActionKind = "finish_turn" + generationActionCompact generationActionKind = "compact" + generationActionGenerateAssistant generationActionKind = "generate_assistant" ) type generationFinishReason string @@ -193,34 +176,6 @@ type generationDecisionInput struct { compactionNeeded bool compactionThresholdPercent int32 compactionContextLimit int64 - workspaceContextEligible bool -} - -// shouldPersistWorkspaceContext reports whether the committed chat -// state and history indicate that the persistWorkspaceContext -// generation action should run before the next assistant call. The -// decision uses two facts: -// - chat metadata says a workspace and selected agent are attached; -// - committed history either has no context-file marker for the -// currently selected workspace agent, or the latest non-sentinel -// marker points to a different agent. -// -// The decision is intentionally pure so generation can choose the -// action without dialing the workspace. Once the action commits a -// context-file marker for the agent (with or without content), this -// helper returns false on the next pass and the loop is broken. -func shouldPersistWorkspaceContext(chat database.Chat, messages []database.ChatMessage) bool { - if !chat.WorkspaceID.Valid || !chat.AgentID.Valid { - return false - } - if hasPersistedContextFileForAgent(messages, chat.AgentID.UUID) { - return false - } - persistedAgentID, found := contextFileAgentIDFromMessages(messages) - if !found { - return true - } - return persistedAgentID != chat.AgentID.UUID } func decideGenerationAction(input generationDecisionInput) (generationDecision, error) { @@ -262,9 +217,6 @@ func decideGenerationAction(input generationDecisionInput) (generationDecision, if input.maxSteps > 0 && currentTurnStepCount(input.messages) >= input.maxSteps { return generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonMaxSteps}, nil } - if input.workspaceContextEligible && shouldPersistWorkspaceContext(input.chat, input.messages) { - return generationDecision{kind: generationActionPersistWorkspaceContext}, nil - } compactionRequirement := compactionRequirementNotNeeded if input.compactionEnabled && input.compactionNeeded { compactionRequirement = compactionRequirementNeeded @@ -382,7 +334,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required, compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction), compactionContextLimit: prepared.ContextLimitFallback, - workspaceContextEligible: prepared.WorkspaceContextEligible, }) }) if err != nil { @@ -415,8 +366,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS actionErr = s.executeLocalTools(ctx, machine, input, prepared, decision) case generationActionCompact: actionErr = s.generateCompaction(ctx, machine, input, prepared) - case generationActionPersistWorkspaceContext: - actionErr = s.persistWorkspaceContext(ctx, machine, input, prepared.Chat) default: return s.finishGenerationError(ctx, machine, input, 0, xerrors.Errorf("unknown generation action %q", decision.kind), generationAttemptNotRequired) } @@ -805,77 +754,6 @@ func compactionModel(opts chatloop.GenerateCompactionOptions) string { return opts.Model.Model() } -// persistWorkspaceContext is the generation action that commits durable -// workspace context messages (e.g. AGENTS.md, workspace skills) into -// chat history. It records a generation attempt, calls the injected -// workspace context builder without holding the DB lock, then commits -// the returned messages fenced to the attempt. If context cannot be -// fetched, it commits a marker for the selected agent so the generation -// loop can continue. -func (s *taskStarter) persistWorkspaceContext( - ctx context.Context, - machine *chatstate.ChatMachine, - input chatWorkerTaskStartInput, - locked database.Chat, -) error { - messages, err := s.opts.Store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: input.ChatID, - AfterID: 0, - }) - if err != nil { - return taskRetryableError{err: xerrors.Errorf("load chat messages for workspace context: %w", err)} - } - attempt, _, _, closeEpisode, err := s.beginGenerationAttempt(ctx, machine, input) - if err != nil { - return xerrors.Errorf("beginGenerationAttempt: %w", err) - } - defer closeEpisode() - modelOpts := modelBuildOptionsFromMessages(messages) - result, err := s.server.buildWorkspaceContext(ctx, workspaceContextBuildInput{ - Chat: locked, - Messages: messages, - ActiveAPIKeyID: modelOpts.ActiveAPIKeyID, - }) - if err != nil { - s.opts.Logger.Warn(ctx, "failed to build workspace context, committing marker", - slog.F("chat_id", input.ChatID), - slog.F("worker_id", input.WorkerID), - slogError(err), - ) - marker, err := workspaceContextMarkerMessage(locked, modelOpts.ActiveAPIKeyID) - if err != nil { - return xerrors.Errorf("build workspace context marker: %w", err) - } - result.Messages = []chatstate.Message{marker} - } - return s.commitGenerationStep(ctx, machine, input, attempt, generationActionPersistWorkspaceContext, stepMessagesForCommit{ - Messages: result.Messages, - VisibleIndexes: visibleMessageIndexes(result.Messages), - }) -} - -// workspaceContextMarkerMessage builds an empty context-file sentinel -// for the chat's selected agent. Committing this marker lets the -// generation loop proceed when the agent is unreachable. -func workspaceContextMarkerMessage(chat database.Chat, activeAPIKeyID string) (chatstate.Message, error) { - content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFileAgentID: chat.AgentID, - }}) - if err != nil { - return chatstate.Message{}, xerrors.Errorf("marshal workspace context marker: %w", err) - } - modelConfigID := chat.LastModelConfigID - return chatstate.Message{ - Role: database.ChatMessageRoleUser, - Content: content, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, - ContentVersion: chatprompt.CurrentContentVersion, - APIKeyID: sql.NullString{String: activeAPIKeyID, Valid: activeAPIKeyID != ""}, - }, nil -} - func (s *taskStarter) beginGenerationAttempt( ctx context.Context, machine *chatstate.ChatMachine, diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 901d562e74279..cbbf6d117710c 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -213,6 +213,7 @@ func (server *Server) prepareGeneration( workspaceSkills []chattool.SkillMeta personalSkills []skillspkg.Skill resolvedUserPrompt string + planPathBlock string ) if chat.WorkspaceID.Valid { @@ -225,8 +226,16 @@ func (server *Server) prepareGeneration( // history; only metadata is mutated here. agent, _ := workspaceCtx.getWorkspaceAgent(ctx) + // API-created chats bind their agent lazily here, after + // hydrateChatContextOnCreate ran with no agent. Pin the chat to the + // bound agent's pushed snapshot now if it is still unpinned, so the + // first turn reads workspace context instead of waiting for the + // agent's next push. Idempotent and snapshot-gated; runs before the + // pinned context is read below. + server.ensureChatContextPinnedOnFirstTurn(ctx, workspaceCtx.currentChatSnapshot()) + var resolveErr error - instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent, promptRows) + instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent) if resolveErr != nil { cleanup() return generationPrepared{}, resolveErr @@ -271,6 +280,17 @@ func (server *Server) prepareGeneration( return nil }) } + // Resolve the per-chat plan path block in the parallel phase. It dials + // the workspace agent to read the home directory, so running it here lets + // the cold dial overlap with the rest of turn preparation instead of + // blocking system prompt assembly on a sequential dial. Best-effort: + // resolvePlanPathBlock logs and returns an empty block on failure. + if chat.WorkspaceID.Valid && !chat.ParentChatID.Valid { + g2.Go(func() error { + planPathBlock = resolvePlanPathBlock(ctx) + return nil + }) + } if err := g2.Wait(); err != nil { cleanup() return generationPrepared{}, err @@ -322,7 +342,7 @@ func (server *Server) prepareGeneration( if advisorRuntime != nil { prompt = chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock) } - prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx)) + prompt = renderPlanPathPrompt(prompt, planPathBlock) setAdvisorPromptSnapshot(prompt) storeChatAttachment := server.newStoreChatAttachmentFunc(&workspaceCtx) @@ -359,7 +379,6 @@ func (server *Server) prepareGeneration( resolvePlanPath: resolvePlanPathForTools, storeFile: storeChatAttachment, isPlanModeTurn: isPlanModeTurn, - primerCtx: ctx, }) } @@ -563,13 +582,10 @@ func (server *Server) prepareGeneration( compactionOptions.StepUsage = latestPromptUsage(promptRows) compactionNeeded := shouldCompactPromptUsage(compactionOptions.StepUsage, modelConfig.ContextLimit, effectiveThreshold) - workspaceContextEligible := chat.WorkspaceID.Valid && isRootChat && !isPlanModeTurn && !isExploreSubagent - // workspaceCtx.currentChatSnapshot may carry a freshly persisted // AgentID/BuildID binding from the getWorkspaceAgent call above. - // Return that snapshot so the chatworker decision helper sees - // the up-to-date metadata when deciding whether to run - // persist_workspace_context. + // Return that snapshot so downstream consumers see the up-to-date + // metadata. refreshedChat := workspaceCtx.currentChatSnapshot() if refreshedChat.ID == uuid.Nil { refreshedChat = chat @@ -601,9 +617,8 @@ func (server *Server) prepareGeneration( Required: compactionNeeded, Options: compactionOptions, }, - Cleanup: cleanup, - Debug: debug, - WorkspaceContextEligible: workspaceContextEligible, + Cleanup: cleanup, + Debug: debug, }, nil } diff --git a/coderd/x/chatd/instruction.go b/coderd/x/chatd/instruction.go index 05476ed6f022c..6911deb51e4fc 100644 --- a/coderd/x/chatd/instruction.go +++ b/coderd/x/chatd/instruction.go @@ -1,14 +1,8 @@ package chatd import ( - "bytes" - "encoding/json" "strings" - "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" ) @@ -58,109 +52,3 @@ func formatSystemInstructions( _, _ = b.WriteString("") return b.String() } - -// latestContextAgentID returns the most recent workspace-agent ID seen -// on any persisted context-file part, including the skill-only sentinel. -// Returns uuid.Nil, false when no stamped context-file parts exist. -func latestContextAgentID(messages []database.ChatMessage) (uuid.UUID, bool) { - var lastID uuid.UUID - found := false - for _, msg := range messages { - if !msg.Content.Valid || - !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - continue - } - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeContextFile || - !part.ContextFileAgentID.Valid { - continue - } - lastID = part.ContextFileAgentID.UUID - found = true - break - } - } - return lastID, found -} - -// instructionFromContextFiles reconstructs the formatted instruction -// string from persisted context-file parts. This is used on non-first -// turns so the instruction can be re-injected after compaction -// without re-dialing the workspace agent. -func instructionFromContextFiles( - messages []database.ChatMessage, -) string { - filterAgentID, filterByAgent := latestContextAgentID(messages) - var contextParts []codersdk.ChatMessagePart - var os, dir string - for _, msg := range messages { - if !msg.Content.Valid || - !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - continue - } - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeContextFile { - continue - } - if filterByAgent && part.ContextFileAgentID.Valid && - part.ContextFileAgentID.UUID != filterAgentID { - continue - } - if part.ContextFileOS != "" { - os = part.ContextFileOS - } - if part.ContextFileDirectory != "" { - dir = part.ContextFileDirectory - } - if part.ContextFileContent != "" { - contextParts = append(contextParts, part) - } - } - } - return formatSystemInstructions(os, dir, contextParts) -} - -// skillsFromParts reconstructs skill metadata from persisted -// skill parts. This is analogous to instructionFromContextFiles -// so the skill index can be re-injected after compaction without -// re-dialing the workspace agent. -func skillsFromParts( - messages []database.ChatMessage, -) []chattool.SkillMeta { - filterAgentID, filterByAgent := latestContextAgentID(messages) - var skills []chattool.SkillMeta - for _, msg := range messages { - if !msg.Content.Valid || - !bytes.Contains(msg.Content.RawMessage, []byte(`"skill"`)) { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - continue - } - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeSkill { - continue - } - if filterByAgent && part.ContextFileAgentID.Valid && - part.ContextFileAgentID.UUID != filterAgentID { - continue - } - skills = append(skills, chattool.SkillMeta{ - Name: part.SkillName, - Description: part.SkillDescription, - Dir: part.SkillDir, - MetaFile: part.ContextFileSkillMetaFile, - }) - } - } - return skills -} diff --git a/coderd/x/chatd/instruction_internal_test.go b/coderd/x/chatd/instruction_internal_test.go index 794efe0c407dc..13717f03476b7 100644 --- a/coderd/x/chatd/instruction_internal_test.go +++ b/coderd/x/chatd/instruction_internal_test.go @@ -1,15 +1,12 @@ package chatd import ( - "encoding/json" "strings" "testing" "charm.land/fantasy" - "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" @@ -290,55 +287,3 @@ func TestFormatSystemInstructions(t *testing.T) { require.Contains(t, got, "Source: /real/AGENTS.md") }) } - -func TestInstructionFromContextFiles(t *testing.T) { - t.Parallel() - - makeMsg := func(parts []codersdk.ChatMessagePart) database.ChatMessage { - raw, _ := json.Marshal(parts) - return database.ChatMessage{ - Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}, - } - } - - t.Run("EmptyMessages", func(t *testing.T) { - t.Parallel() - got := instructionFromContextFiles(nil) - require.Empty(t, got) - }) - - t.Run("NoContextFileParts", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - makeMsg([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "test", - SkillDescription: "test skill", - }, - }), - } - got := instructionFromContextFiles(msgs) - require.Empty(t, got) - }) - - t.Run("ReconstructsFromContextFileParts", func(t *testing.T) { - t.Parallel() - msgs := []database.ChatMessage{ - makeMsg([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFileOS: "linux", - ContextFileDirectory: "/home/coder/project", - ContextFileContent: "project rules", - ContextFilePath: "/home/coder/project/AGENTS.md", - }, - }), - } - got := instructionFromContextFiles(msgs) - require.Contains(t, got, "Operating System: linux") - require.Contains(t, got, "Working Directory: /home/coder/project") - require.Contains(t, got, "Source: /home/coder/project/AGENTS.md") - require.Contains(t, got, "project rules") - }) -} diff --git a/coderd/x/chatd/subagent.go b/coderd/x/chatd/subagent.go index a735f0b3f4c56..6a7b6bc916bfe 100644 --- a/coderd/x/chatd/subagent.go +++ b/coderd/x/chatd/subagent.go @@ -1029,27 +1029,10 @@ func (p *Server) createChildSubagentChatWithOptions( } initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, modelConfigID)) - copiedContextParts, err := copyParentContextMessages(ctx, p.logger, p.db, parent) - if err != nil { - return database.Chat{}, xerrors.Errorf("copy parent context messages: %w", err) - } - var lastInjectedContext pqtype.NullRawMessage - if len(copiedContextParts) > 0 { - filteredContent, err := chatprompt.MarshalParts(copiedContextParts) - if err != nil { - return database.Chat{}, xerrors.Errorf("marshal copied context parts: %w", err) - } - initialMessages = append(initialMessages, userMessageWithAPIKeyID( - filteredContent, - modelConfigID, - parent.OwnerID, - childAPIKeyID, - )) - lastInjectedContext, err = BuildLastInjectedContext(FilterContextPartsToLatestAgent(copiedContextParts)) - if err != nil { - return database.Chat{}, xerrors.Errorf("build inherited injected context: %w", err) - } - } + // The child shares the parent's workspace and agent, so it inherits + // workspace context the same way a top-level chat does: pinned from the + // agent's latest snapshot (see hydrateChatContextOnCreate below). The + // parent's context is not copied into child history. initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, modelConfigID, parent.OwnerID, childAPIKeyID)) publisher := p.pubsub @@ -1073,10 +1056,9 @@ func (p *Server) createChildSubagentChatWithOptions( RawMessage: labelsJSON, Valid: true, }, - DynamicTools: pqtype.NullRawMessage{}, - ClientType: parent.ClientType, - InitialMessages: initialMessages, - LastInjectedContext: lastInjectedContext, + DynamicTools: pqtype.NullRawMessage{}, + ClientType: parent.ClientType, + InitialMessages: initialMessages, }) if err != nil { return database.Chat{}, xerrors.Errorf("create child chat: %w", err) @@ -1084,58 +1066,16 @@ func (p *Server) createChildSubagentChatWithOptions( child := result.Chat + // Pin the child to its agent's latest context snapshot, mirroring the + // top-level create path. The child shares the parent's workspace agent, + // so this reproduces the parent's workspace context without copying it + // through chat history. + p.hydrateChatContextOnCreate(ctx, child) + p.publishChatPubsubEvent(child, codersdk.ChatWatchEventKindCreated, nil) return child, nil } -// copyParentContextMessages reads persisted context-file and skill -// messages from the parent chat. This ensures sub-agents inherit the -// same instruction and skill context as their parent without -// independently re-fetching from the agent. -func copyParentContextMessages( - ctx context.Context, - logger slog.Logger, - store database.Store, - parent database.Chat, -) ([]codersdk.ChatMessagePart, error) { - parentMessages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: parent.ID, - AfterID: 0, - }) - if err != nil { - return nil, xerrors.Errorf("get parent messages: %w", err) - } - - var copiedParts []codersdk.ChatMessagePart - for _, msg := range parentMessages { - if !msg.Content.Valid { - continue - } - var parts []codersdk.ChatMessagePart - if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { - logger.Warn(ctx, "failed to unmarshal parent context message", - slog.F("parent_chat_id", parent.ID), - slog.F("message_id", msg.ID), - slog.Error(err), - ) - continue - } - - messageContextParts := FilterContextParts(parts, true) - if len(messageContextParts) == 0 { - continue - } - copiedParts = append(copiedParts, messageContextParts...) - } - if len(copiedParts) == 0 { - return nil, nil - } - - copiedParts = FilterContextPartsToLatestAgent(copiedParts) - - return copiedParts, nil -} - func (p *Server) sendSubagentMessage( ctx context.Context, parentChatID uuid.UUID, diff --git a/coderd/x/chatd/subagent_context_internal_test.go b/coderd/x/chatd/subagent_context_internal_test.go index d56bdbbcb0338..aff5fd96a9f12 100644 --- a/coderd/x/chatd/subagent_context_internal_test.go +++ b/coderd/x/chatd/subagent_context_internal_test.go @@ -2,498 +2,117 @@ package chatd import ( "context" + "database/sql" "encoding/json" "testing" "charm.land/fantasy" "github.com/google/uuid" - "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" - "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/codersdk" ) -func TestCollectContextPartsFromMessagesSkipsSentinelContextFiles(t *testing.T) { - t.Parallel() - - content, err := json.Marshal([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project/.agents/skills/my-skill/SKILL.md", - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "my-skill", - SkillDescription: "A test skill", - }, - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project/AGENTS.md", - ContextFileContent: "# Project instructions", - }, - codersdk.ChatMessageText("ignored"), - }) - require.NoError(t, err) - - parts, err := CollectContextPartsFromMessages(context.Background(), slog.Make(), []database.ChatMessage{ //nolint:exhaustruct // Only content fields matter for this unit test. - { - ID: 1, - Content: pqtype.NullRawMessage{ - RawMessage: content, - Valid: true, - }, - }, - }, false) - require.NoError(t, err) - require.Len(t, parts, 2) - require.Equal(t, codersdk.ChatMessagePartTypeSkill, parts[0].Type) - require.Equal(t, "my-skill", parts[0].SkillName) - require.Equal(t, codersdk.ChatMessagePartTypeContextFile, parts[1].Type) - require.Equal(t, "/home/coder/project/AGENTS.md", parts[1].ContextFilePath) - require.Equal(t, "# Project instructions", parts[1].ContextFileContent) -} - -func TestCollectContextPartsFromMessagesKeepsEmptyContextFilesWhenRequested(t *testing.T) { - t.Parallel() - - content, err := json.Marshal([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: AgentChatContextSentinelPath, - ContextFileAgentID: uuid.NullUUID{ - UUID: uuid.New(), - Valid: true, - }, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "my-skill", - }, - }) - require.NoError(t, err) - - parts, err := CollectContextPartsFromMessages(context.Background(), slog.Make(), []database.ChatMessage{ //nolint:exhaustruct // Only content fields matter for this unit test. - { - ID: 1, - Content: pqtype.NullRawMessage{ - RawMessage: content, - Valid: true, - }, - }, - }, true) - require.NoError(t, err) - require.Len(t, parts, 2) - require.Equal(t, AgentChatContextSentinelPath, parts[0].ContextFilePath) - require.Equal(t, "my-skill", parts[1].SkillName) -} - -func TestFilterContextPartsToLatestAgent(t *testing.T) { - t.Parallel() - - oldAgentID := uuid.New() - newAgentID := uuid.New() - parts := []codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/legacy/AGENTS.md", - ContextFileContent: "legacy instructions", - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-legacy", - }, - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/old/AGENTS.md", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-old", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: AgentChatContextSentinelPath, - ContextFileAgentID: uuid.NullUUID{ - UUID: newAgentID, - Valid: true, - }, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "repo-helper-new", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }, - } - - got := FilterContextPartsToLatestAgent(parts) - require.Len(t, got, 4) - require.Equal(t, "/legacy/AGENTS.md", got[0].ContextFilePath) - require.Equal(t, "repo-helper-legacy", got[1].SkillName) - require.Equal(t, AgentChatContextSentinelPath, got[2].ContextFilePath) - require.Equal(t, "repo-helper-new", got[3].SkillName) -} - -func createParentChatWithInheritedContext( +// createWorkspaceBoundParentChat creates a parent chat bound to a workspace +// agent that has already pushed a context snapshot, so CreateChat pins the chat +// to that snapshot at create time. It returns the pinned parent and the source +// of its single pinned resource, letting a spawned child be asserted to inherit +// the same pin. +func createWorkspaceBoundParentChat( ctx context.Context, t *testing.T, db database.Store, server *Server, -) database.Chat { +) (database.Chat, string) { t.Helper() user, org, model := seedInternalChatDeps(t, db) - parent, err := server.CreateChat(ctx, CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "parent-with-context", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, }) - require.NoError(t, err) - - inheritedParts := []codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project/AGENTS.md", - ContextFileContent: "# Project instructions", - ContextFileOS: "linux", - ContextFileDirectory: "/home/coder/project", - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "my-skill", - SkillDescription: "A test skill", - SkillDir: "/home/coder/project/.agents/skills/my-skill", - ContextFileSkillMetaFile: "SKILL.md", - }, - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project/.agents/skills/my-skill/SKILL.md", - }, - } - content, err := json.Marshal(inheritedParts) - require.NoError(t, err) - - _ = dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: parent.ID, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleUser, - Content: pqtype.NullRawMessage{RawMessage: content, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: tv.ID, + CreatedBy: user.ID, }) - - parentChat, err := db.GetChatByID(ctx, parent.ID) - require.NoError(t, err) - return parentChat -} - -func assertChildInheritedContext( - ctx context.Context, - t *testing.T, - db database.Store, - childID uuid.UUID, - prompt string, -) { - t.Helper() - - childChat, err := db.GetChatByID(ctx, childID) - require.NoError(t, err) - require.True(t, childChat.LastInjectedContext.Valid) - - var cached []codersdk.ChatMessagePart - require.NoError(t, json.Unmarshal(childChat.LastInjectedContext.RawMessage, &cached)) - require.Len(t, cached, 2) - - var sawContextFile bool - var sawSkill bool - for _, part := range cached { - switch part.Type { - case codersdk.ChatMessagePartTypeContextFile: - sawContextFile = true - require.Equal(t, "/home/coder/project/AGENTS.md", part.ContextFilePath) - require.Empty(t, part.ContextFileContent) - require.Empty(t, part.ContextFileOS) - require.Empty(t, part.ContextFileDirectory) - case codersdk.ChatMessagePartTypeSkill: - sawSkill = true - require.Equal(t, "my-skill", part.SkillName) - require.Equal(t, "A test skill", part.SkillDescription) - require.Empty(t, part.SkillDir) - require.Empty(t, part.ContextFileSkillMetaFile) - default: - t.Fatalf("unexpected cached part type %q", part.Type) - } - } - require.True(t, sawContextFile) - require.True(t, sawSkill) - - childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: childID, - AfterID: 0, + ws := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: tmpl.ID, }) - require.NoError(t, err) - - var ( - contextMessageIndexes []int - userPromptIndex = -1 - sawDBAgentsContextFile bool - sawDBSkillCompanionContext bool - sawDBSkill bool - ) - for i, msg := range childMessages { - if !msg.Content.Valid { - continue - } - - var parts []codersdk.ChatMessagePart - require.NoError(t, json.Unmarshal(msg.Content.RawMessage, &parts)) - - if len(parts) == 1 && parts[0].Type == codersdk.ChatMessagePartTypeText && parts[0].Text == prompt { - require.Equal(t, database.ChatMessageRoleUser, msg.Role) - userPromptIndex = i - continue - } - - hasInheritedContext := false - for _, part := range parts { - switch part.Type { - case codersdk.ChatMessagePartTypeContextFile: - hasInheritedContext = true - switch part.ContextFilePath { - case "/home/coder/project/AGENTS.md": - sawDBAgentsContextFile = true - require.Equal(t, "# Project instructions", part.ContextFileContent) - require.Equal(t, "linux", part.ContextFileOS) - require.Equal(t, "/home/coder/project", part.ContextFileDirectory) - case "/home/coder/project/.agents/skills/my-skill/SKILL.md": - sawDBSkillCompanionContext = true - require.Empty(t, part.ContextFileContent) - require.Empty(t, part.ContextFileOS) - require.Empty(t, part.ContextFileDirectory) - default: - t.Fatalf("unexpected child inherited context file path %q", part.ContextFilePath) - } - case codersdk.ChatMessagePartTypeSkill: - hasInheritedContext = true - sawDBSkill = true - require.Equal(t, "my-skill", part.SkillName) - require.Equal(t, "A test skill", part.SkillDescription) - require.Equal(t, "/home/coder/project/.agents/skills/my-skill", part.SkillDir) - require.Equal(t, "SKILL.md", part.ContextFileSkillMetaFile) - default: - t.Fatalf("unexpected child inherited part type %q", part.Type) - } - } - if hasInheritedContext { - require.Equal(t, database.ChatMessageRoleUser, msg.Role) - contextMessageIndexes = append(contextMessageIndexes, i) - } - } - - require.NotEmpty(t, contextMessageIndexes) - require.NotEqual(t, -1, userPromptIndex) - for _, idx := range contextMessageIndexes { - require.Less(t, idx, userPromptIndex) - } - require.True(t, sawDBAgentsContextFile) - require.True(t, sawDBSkillCompanionContext) - require.True(t, sawDBSkill) -} - -func createParentChatWithRotatedInheritedContext( - ctx context.Context, - t *testing.T, - db database.Store, - server *Server, -) database.Chat { - t.Helper() + pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + OrganizationID: org.ID, + CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: ws.ID, + TemplateVersionID: tv.ID, + JobID: pj.ID, + Transition: database.WorkspaceTransitionStart, + }) + res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + Transition: database.WorkspaceTransitionStart, + JobID: pj.ID, + }) + agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID}) - user, org, model := seedInternalChatDeps(t, db) + const source = "/home/coder/project/AGENTS.md" + seedAgentContext(ctx, t, db, agent.ID, source, []byte("instruction:parent"), + database.WorkspaceAgentContextBodyKindInstructionFile, + json.RawMessage(`{"instruction_file":{"content":"parent"}}`)) parent, err := server.CreateChat(ctx, CreateOptions{ OrganizationID: org.ID, OwnerID: user.ID, APIKeyID: testAPIKeyID(t, db, user.ID), - Title: "parent-with-rotated-context", + Title: "parent-with-context", ModelConfigID: model.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + BuildID: uuid.NullUUID{UUID: build.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, }) require.NoError(t, err) - oldAgentID := uuid.New() - newAgentID := uuid.New() - oldContent, err := json.Marshal([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project-old/AGENTS.md", - ContextFileContent: "# Old instructions", - ContextFileOS: "darwin", - ContextFileDirectory: "/home/coder/project-old", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "old-skill", - SkillDescription: "Old skill", - SkillDir: "/home/coder/project-old/.agents/skills/old-skill", - ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true}, - }, - }) - require.NoError(t, err) - newContent, err := json.Marshal([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "/home/coder/project-new/AGENTS.md", - ContextFileContent: "# New instructions", - ContextFileOS: "linux", - ContextFileDirectory: "/home/coder/project-new", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "new-skill", - SkillDescription: "New skill", - SkillDir: "/home/coder/project-new/.agents/skills/new-skill", - ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true}, - }, - }) - require.NoError(t, err) - - _ = dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: parent.ID, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleUser, - Content: pqtype.NullRawMessage{RawMessage: oldContent, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - }) - _ = dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: parent.ID, - CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleUser, - Content: pqtype.NullRawMessage{RawMessage: newContent, Valid: true}, - ContentVersion: chatprompt.CurrentContentVersion, - }) - parentChat, err := db.GetChatByID(ctx, parent.ID) require.NoError(t, err) - return parentChat -} - -func TestCreateChildSubagentChatCopiesOnlyLatestAgentContext(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) - - ctx := chatdTestContext(t) - parentChat := createParentChatWithRotatedInheritedContext(ctx, t, db, server) - - ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID)) - child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "") - require.NoError(t, err) - - childChat, err := db.GetChatByID(ctx, child.ID) - require.NoError(t, err) - require.True(t, childChat.LastInjectedContext.Valid) - - var cached []codersdk.ChatMessagePart - require.NoError(t, json.Unmarshal(childChat.LastInjectedContext.RawMessage, &cached)) - require.Len(t, cached, 2) - require.Equal(t, "/home/coder/project-new/AGENTS.md", cached[0].ContextFilePath) - require.Equal(t, "new-skill", cached[1].SkillName) - - childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: child.ID, - AfterID: 0, - }) - require.NoError(t, err) - - var inherited [][]codersdk.ChatMessagePart - for _, msg := range childMessages { - if !msg.Content.Valid { - continue - } - var parts []codersdk.ChatMessagePart - require.NoError(t, json.Unmarshal(msg.Content.RawMessage, &parts)) - if len(parts) == 0 || parts[0].Type == codersdk.ChatMessagePartTypeText { - continue - } - inherited = append(inherited, parts) - } - require.Len(t, inherited, 1) - require.Len(t, inherited[0], 2) - require.Equal(t, "/home/coder/project-new/AGENTS.md", inherited[0][0].ContextFilePath) - require.Equal(t, "# New instructions", inherited[0][0].ContextFileContent) - require.Equal(t, "new-skill", inherited[0][1].SkillName) + return parentChat, source } -func TestCreateChildSubagentChatUpdatesInheritedLastInjectedContext(t *testing.T) { +// TestSpawnComputerUseAgentInheritsPinnedContext verifies that a spawned +// subagent inherits its parent's pinned workspace context. The child shares the +// parent's workspace agent, so create-time hydration pins it to the same +// snapshot instead of copying any context through chat history. It also asserts +// the child is created in computer_use mode. +func TestSpawnComputerUseAgentInheritsPinnedContext(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) + require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) ctx := chatdTestContext(t) - parentChat := createParentChatWithInheritedContext(ctx, t, db, server) + parentChat, wantSource := createWorkspaceBoundParentChat(ctx, t, db, server) - // Set a delegated API key so that copied user-role context messages - // are stamped with api_key_id, preserving AI Gateway routing. - apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: parentChat.OwnerID}) - ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID) - - child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "") + parentRes, err := db.ListChatContextResourcesByChatID(ctx, parentChat.ID) require.NoError(t, err) + require.Len(t, parentRes, 1, "parent is pinned to its agent's snapshot at create") + require.Equal(t, wantSource, parentRes[0].Source) + require.NotNil(t, parentChat.ContextAggregateHash) - assertChildInheritedContext(ctx, t, db, child.ID, "inspect bindings") - - // Verify that all user-role messages in the child chat carry - // api_key_id so activeTurnAPIKeyIDFromMessages resolves correctly. - childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: child.ID, - AfterID: 0, - }) - require.NoError(t, err) - var userMsgCount int - for _, msg := range childMessages { - if msg.Role != database.ChatMessageRoleUser { - continue - } - userMsgCount++ - require.True(t, msg.APIKeyID.Valid, "child user message (id=%d) should have api_key_id set", msg.ID) - require.Equal(t, apiKey.ID, msg.APIKeyID.String, "child user message (id=%d) api_key_id mismatch", msg.ID) - } - require.Greater(t, userMsgCount, 0, "expected at least one user-role message in child chat") -} - -func TestSpawnComputerUseAgentInheritsContext(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - require.NoError(t, db.UpsertChatDesktopEnabled(chatdTestContext(t), true)) - server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) - - ctx := chatdTestContext(t) - parentChat := createParentChatWithInheritedContext(ctx, t, db, server) insertEnabledAnthropicProvider(t, db, parentChat.OwnerID) - // The direct DB insert above bypasses the pubsub event that - // production uses to invalidate the provider cache. Explicitly - // invalidate here so the background processing goroutine does - // not serve a stale provider list (OpenAI only) that was cached - // before the Anthropic provider was inserted. + // The direct DB insert above bypasses the pubsub event that production + // uses to invalidate the provider cache. Explicitly invalidate here so the + // background processing goroutine does not serve a stale provider list + // (OpenAI only) that was cached before the Anthropic provider was inserted. server.configCache.InvalidateProviders() ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, parentChat.OwnerID)) @@ -522,5 +141,15 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) { require.True(t, childChat.Mode.Valid) require.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode) - assertChildInheritedContext(ctx, t, db, childID, "inspect bindings") + // The child shares the parent's workspace agent, so create-time hydration + // pins it to the same snapshot: it inherits the parent's pinned resources + // verbatim, with nothing copied through chat history. + childRes, err := db.ListChatContextResourcesByChatID(ctx, childID) + require.NoError(t, err) + require.Len(t, childRes, len(parentRes), "child inherits the parent's pinned resource set") + require.Equal(t, parentRes[0].Source, childRes[0].Source) + require.Equal(t, parentRes[0].ContentHash, childRes[0].ContentHash) + require.JSONEq(t, string(parentRes[0].Body), string(childRes[0].Body)) + require.Equal(t, parentChat.ContextAggregateHash, childChat.ContextAggregateHash, + "child pins the same aggregate hash as the parent") } diff --git a/coderd/x/chatd/workspace_context_builder.go b/coderd/x/chatd/workspace_context_builder.go deleted file mode 100644 index d27da4d78ebc9..0000000000000 --- a/coderd/x/chatd/workspace_context_builder.go +++ /dev/null @@ -1,148 +0,0 @@ -package chatd - -import ( - "context" - "database/sql" - "sync" - - "github.com/google/uuid" - "golang.org/x/xerrors" - - "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" - "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/codersdk/workspacesdk" -) - -// errWorkspaceContextUnavailable is returned by buildWorkspaceContext -// when there is nothing safe to persist for the current committed -// metadata, e.g. the chat has no bound workspace agent or the agent is -// no longer resolvable. -var errWorkspaceContextUnavailable = xerrors.New("workspace context unavailable") - -// buildWorkspaceContext fetches workspace context for the chat's -// bound workspace agent and returns durable chatstate.Message values -// for the generation action to commit. It returns -// errWorkspaceContextUnavailable when there is nothing safe to -// persist for the current committed metadata. -func (server *Server) buildWorkspaceContext( - ctx context.Context, - input workspaceContextBuildInput, -) (workspaceContextBuildResult, error) { - chat := input.Chat - if !chat.WorkspaceID.Valid || !chat.AgentID.Valid { - return workspaceContextBuildResult{}, errWorkspaceContextUnavailable - } - logger := server.logger.With( - slog.F("chat_id", chat.ID), - slog.F("owner_id", chat.OwnerID), - ) - - // Build a per-call workspace context with the latest committed - // chat snapshot so getWorkspaceAgent and getWorkspaceConn dial - // the agent we actually want to fetch context from. - currentChat := chat - var chatStateMu sync.Mutex - wsCtx := turnWorkspaceContext{ - server: server, - chatStateMu: &chatStateMu, - currentChat: ¤tChat, - loadChatSnapshot: server.db.GetChatByID, - } - defer wsCtx.close() - - parts, expectedAgentID := server.fetchContextForBuild(ctx, chat, &wsCtx, logger) - // If the workspace or agent is gone, report unavailable. - if expectedAgentID == uuid.Nil { - return workspaceContextBuildResult{}, errWorkspaceContextUnavailable - } - - hasContent := false - hasContextFilePart := false - for _, part := range parts { - if part.Type == codersdk.ChatMessagePartTypeContextFile { - hasContextFilePart = true - if part.ContextFileContent != "" { - hasContent = true - } - } - } - - agentID := uuid.NullUUID{UUID: expectedAgentID, Valid: true} - - // If we have no content but the agent is known, commit a blank - // context-file marker (sentinel) so subsequent turns skip the - // workspace-agent dial and the decision helper observes the - // attempt in committed history. This applies whether the - // workspace connection succeeded but returned no AGENTS.md, or - // the agent's context config fetch failed: in both cases we - // have a known agent and committing a sentinel breaks the - // otherwise-infinite decision loop. - if !hasContent { - if !hasContextFilePart { - parts = append([]codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFileAgentID: agentID, - }}, parts...) - } - } - - content, err := chatprompt.MarshalParts(parts) - if err != nil { - return workspaceContextBuildResult{}, xerrors.Errorf("marshal workspace context parts: %w", err) - } - - modelConfigID := chat.LastModelConfigID - msg := chatstate.Message{ - Role: database.ChatMessageRoleUser, - Content: content, - Visibility: database.ChatMessageVisibilityBoth, - ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, - ContentVersion: chatprompt.CurrentContentVersion, - APIKeyID: sql.NullString{String: input.ActiveAPIKeyID, Valid: input.ActiveAPIKeyID != ""}, - } - - // Update the cache column so subsequent turns can read the last - // injected context without scanning messages. This is a - // best-effort write that does not mutate chat history; the - // generation action separately commits the durable message - // below. - stripped := make([]codersdk.ChatMessagePart, len(parts)) - copy(stripped, parts) - for i := range stripped { - stripped[i].StripInternal() - } - server.updateLastInjectedContext(ctx, chat.ID, stripped) - - return workspaceContextBuildResult{Messages: []chatstate.Message{msg}}, nil -} - -// fetchContextForBuild fetches workspace context parts from the -// agent, returning the parts to persist. expectedAgentID is the agent -// ID the fetch was bound to, or uuid.Nil if the agent could not be -// resolved. -func (server *Server) fetchContextForBuild( - ctx context.Context, - chat database.Chat, - wsCtx *turnWorkspaceContext, - logger slog.Logger, -) (parts []codersdk.ChatMessagePart, expectedAgentID uuid.UUID) { - agent, agentParts, _, _ := server.fetchWorkspaceContext( - ctx, chat, wsCtx.getWorkspaceAgent, - func(instructionCtx context.Context) (workspacesdk.AgentConn, error) { - if _, _, err := wsCtx.workspaceAgentIDForConn(instructionCtx); err != nil { - return nil, err - } - return wsCtx.getWorkspaceConn(instructionCtx) - }, - ) - if agent == nil { - // fetchWorkspaceContext returns nil for the agent when the - // chat has no valid workspace or the agent lookup fails. - logger.Debug(ctx, "workspace context build: workspace agent not resolvable") - return nil, uuid.Nil - } - return agentParts, agent.ID -} diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 27ca1135651f8..76ca939453741 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -990,37 +990,6 @@ func (s *SSEAgentReinitReceiver) Receive(ctx context.Context) (*Reinitialization } } -// AddChatContextRequest is the request body for adding chat context. -type AddChatContextRequest struct { - // ChatID optionally identifies the chat to add context to. - // If empty, auto-detection is used (CODER_CHAT_ID env, the - // only active chat, or the only top-level active chat for this - // agent). - ChatID uuid.UUID `json:"chat_id,omitempty"` - // Parts are the context-file and skill parts to add. - Parts []codersdk.ChatMessagePart `json:"parts"` -} - -// AddChatContextResponse is the response for adding chat context. -type AddChatContextResponse struct { - ChatID uuid.UUID `json:"chat_id"` - Count int `json:"count"` -} - -// ClearChatContextRequest is the request body for clearing chat context. -type ClearChatContextRequest struct { - // ChatID optionally identifies the chat to clear context from. - // If empty, auto-detection is used (CODER_CHAT_ID env, the - // only active chat, or the only top-level active chat for this - // agent). - ChatID uuid.UUID `json:"chat_id,omitempty"` -} - -// ClearChatContextResponse is the response for clearing chat context. -type ClearChatContextResponse struct { - ChatID uuid.UUID `json:"chat_id"` -} - // RefreshChatContextResponse is the response for refreshing chat context. type RefreshChatContextResponse struct { // Refreshed is the number of drifted chats that were re-pinned to the @@ -1028,38 +997,6 @@ type RefreshChatContextResponse struct { Refreshed int `json:"refreshed"` } -// AddChatContext adds context-file and skill parts to an active chat. -func (c *Client) AddChatContext(ctx context.Context, req AddChatContextRequest) (AddChatContextResponse, error) { - res, err := c.SDK.Request(ctx, http.MethodPost, "/api/v2/workspaceagents/me/experimental/chat-context", req) - if err != nil { - return AddChatContextResponse{}, xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return AddChatContextResponse{}, codersdk.ReadBodyAsError(res) - } - - var resp AddChatContextResponse - return resp, json.NewDecoder(res.Body).Decode(&resp) -} - -// ClearChatContext soft-deletes context-file and skill messages from an active chat. -func (c *Client) ClearChatContext(ctx context.Context, req ClearChatContextRequest) (ClearChatContextResponse, error) { - res, err := c.SDK.Request(ctx, http.MethodDelete, "/api/v2/workspaceagents/me/experimental/chat-context", req) - if err != nil { - return ClearChatContextResponse{}, xerrors.Errorf("execute request: %w", err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return ClearChatContextResponse{}, codersdk.ReadBodyAsError(res) - } - - var resp ClearChatContextResponse - return resp, json.NewDecoder(res.Body).Decode(&resp) -} - // RefreshChatContext re-pins every drifted chat bound to this agent to the // agent's latest context snapshot, clearing their drift markers. It backs // the in-workspace `coder exp chat context refresh` (no chat argument), diff --git a/codersdk/chats.go b/codersdk/chats.go index dcccc0909181a..a8e01a481dea2 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -137,11 +137,6 @@ type Chat struct { // the owner's read cursor, which updates on stream // connect and disconnect. HasUnread bool `json:"has_unread"` - // LastInjectedContext holds the most recently persisted - // injected context parts (AGENTS.md files and skills). It - // is updated only when context changes, on first workspace - // attach or agent change. - LastInjectedContext []ChatMessagePart `json:"last_injected_context,omitempty"` // Context reports the chat's pinned workspace-context state and // whether it has drifted from the agent's latest pushed snapshot. // Nil when the chat has no pinned context yet. diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 62b0302dd1586..c30b7d9413166 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -13,41 +13,41 @@ We track the following resources: -| Resource | | | -|-----------------------------------------------------------------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| -| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| -| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| -| AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| -| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| AuditableGroupAIBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| -| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| -| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_injected_contextfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| -| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| -| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| -| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| -| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| -| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| -| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| -| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
default_org_member_rolestrue
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| -| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| -| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| -| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| -| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| -| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| -| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| -| User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| -| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| -| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| -| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| +| Resource | | | +|-----------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AIGatewayKey
create, delete | |
FieldTracked
created_atfalse
hashed_secrettrue
idtrue
last_used_atfalse
nametrue
secret_prefixtrue
| +| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| +| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| +| AISeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| +| APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete | |
FieldTracked
avatar_urltrue
chat_spend_limit_microstrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| AuditableGroupAIBudget
write, delete | |
FieldTracked
created_atfalse
group_idfalse
group_namefalse
spend_limittrue
spend_limit_microsfalse
updated_atfalse
| +| AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| +| AuditableUserAIBudgetOverride
write, delete | |
FieldTracked
created_atfalse
group_idtrue
group_nametrue
spend_limittrue
spend_limit_microsfalse
updated_atfalse
user_idfalse
usernamefalse
| +| Chat
create, write | |
FieldTracked
agent_idfalse
archivedtrue
build_idfalse
client_typefalse
context_aggregate_hashfalse
context_dirty_resourcesfalse
context_dirty_sincefalse
context_errorfalse
created_atfalse
dynamic_toolsfalse
generation_attemptfalse
group_acltrue
heartbeat_atfalse
history_versionfalse
idtrue
labelstrue
last_errorfalse
last_model_config_idfalse
last_read_message_idfalse
last_turn_summaryfalse
mcp_server_idstrue
modetrue
organization_idfalse
owner_idtrue
owner_namefalse
owner_usernamefalse
parent_chat_idfalse
pin_ordertrue
plan_modefalse
queue_versionfalse
requires_action_deadline_atfalse
retry_statefalse
retry_state_versionfalse
root_chat_idfalse
runner_idfalse
snapshot_versionfalse
started_atfalse
statusfalse
titletrue
updated_atfalse
user_acltrue
worker_idfalse
workspace_idtrue
| +| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| +| GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
private_key_key_idfalse
public_keytrue
updated_atfalse
user_idtrue
| +| GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| +| HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| +| License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| +| NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| +| OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| +| OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| +| Organization
| |
FieldTracked
created_atfalse
default_org_member_rolestrue
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
shareable_workspace_ownerstrue
updated_attrue
| +| OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| +| PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| +| RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| +| TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| +| Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| +| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| +| User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| +| WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| +| WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 2259f82e2a3e9..2f215c50ce10c 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -106,70 +106,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -205,131 +141,85 @@ Experimental: this endpoint is subject to change. Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------------------|------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[array item]` | array | false | | | -| `» agent_id` | string(uuid) | false | | | -| `» archived` | boolean | false | | | -| `» build_id` | string(uuid) | false | | | -| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | -| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | -| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | -| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | -| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | -| `»» resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | -| `»»» error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | -| `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | -| `»»» size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | -| `»»» skill_description` | string | false | | | -| `»»» skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | -| `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | -| `»»» status` | [codersdk.ChatContextResourceStatus](schemas.md#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, tools) are empty. | -| `»»» tools` | array | false | | Tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | -| `»»»» description` | string | false | | Description is the tool's human-readable summary; may be empty. | -| `»»»» name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | -| `» created_at` | string(date-time) | false | | | -| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | -| `»» additions` | integer | false | | | -| `»» approved` | boolean | false | | | -| `»» author_avatar_url` | string | false | | | -| `»» author_login` | string | false | | | -| `»» base_branch` | string | false | | | -| `»» changed_files` | integer | false | | | -| `»» changes_requested` | boolean | false | | | -| `»» chat_id` | string(uuid) | false | | | -| `»» commits` | integer | false | | | -| `»» deletions` | integer | false | | | -| `»» head_branch` | string | false | | | -| `»» pr_number` | integer | false | | | -| `»» pull_request_draft` | boolean | false | | | -| `»» pull_request_state` | string | false | | | -| `»» pull_request_title` | string | false | | | -| `»» refreshed_at` | string(date-time) | false | | | -| `»» reviewer_count` | integer | false | | | -| `»» stale_at` | string(date-time) | false | | | -| `»» url` | string | false | | | -| `» files` | array | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» id` | string(uuid) | false | | | -| `»» mime_type` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» owner_id` | string(uuid) | false | | | -| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `» id` | string(uuid) | false | | | -| `» labels` | object | false | | | -| `»» [any property]` | string | false | | | -| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | -| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | -| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | -| `»» message` | string | false | | Message is the normalized, user-facing error message. | -| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | -| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | -| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | -| `»» args` | array | false | | | -| `»» args_delta` | string | false | | | -| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | -| `»» content` | string | false | | The code content from the diff that was commented on. | -| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | -| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | -| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | -| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | -| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | -| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | -| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | -| `»» data` | array | false | | | -| `»» end_line` | integer | false | | | -| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» file_name` | string | false | | | -| `»» is_error` | boolean | false | | | -| `»» is_media` | boolean | false | | | -| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» media_type` | string | false | | | -| `»» name` | string | false | | | -| `»» parsed_commands` | array | false | | Parsed commands holds parsed programs from an execute tool call's shell command, one entry per simple command in source order. Each entry is [program] or [program, arg] where arg is the first non-flag positional argument. Program names are normalized to their base name (e.g. /usr/bin/go becomes go). Only populated when ToolName is "execute" and the command parses successfully; nil otherwise. | -| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | -| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | -| `»» result` | array | false | | | -| `»» result_delta` | string | false | | | -| `»» result_reset` | boolean | false | | | -| `»» signature` | string | false | | | -| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | -| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | -| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | -| `»» source_id` | string | false | | | -| `»» start_line` | integer | false | | | -| `»» text` | string | false | | | -| `»» title` | string | false | | | -| `»» tool_call_id` | string | false | | | -| `»» tool_name` | string | false | | | -| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | -| `»» url` | string | false | | | -| `» last_model_config_id` | string(uuid) | false | | | -| `» last_turn_summary` | string | false | | | -| `» mcp_server_ids` | array | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» owner_id` | string(uuid) | false | | | -| `» owner_name` | string | false | | | -| `» owner_username` | string | false | | | -| `» parent_chat_id` | string(uuid) | false | | | -| `» pin_order` | integer | false | | | -| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | -| `» root_chat_id` | string(uuid) | false | | | -| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | -| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» title` | string | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» warnings` | array | false | | | -| `» workspace_id` | string(uuid) | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------------|------------------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» agent_id` | string(uuid) | false | | | +| `» archived` | boolean | false | | | +| `» build_id` | string(uuid) | false | | | +| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | +| `» context` | [codersdk.ChatContext](schemas.md#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | +| `»» dirty` | boolean | false | | Dirty is true when the agent's latest snapshot hash differs from the chat's pinned hash. | +| `»» dirty_since` | string(date-time) | false | | Dirty since is when drift was first detected; nil when not dirty. | +| `»» error` | string | false | | Error is the snapshot-level error copied from the pinned snapshot (empty when healthy). | +| `»» resources` | array | false | | Resources is the chat's pinned context (instruction files and skills) the prompt is built from, metadata only (no bodies). It is populated only on the single-chat GET response; list and watch payloads leave it nil to stay lightweight. | +| `»»» error` | string | false | | Error explains a non-ok Status; empty when healthy. May also carry a non-fatal warning when Status is ok. | +| `»»» kind` | [codersdk.ChatContextResourceKind](schemas.md#codersdkchatcontextresourcekind) | false | | | +| `»»» size_bytes` | integer | false | | Size bytes is the original payload size in bytes. | +| `»»» skill_description` | string | false | | | +| `»»» skill_name` | string | false | | Skill name and SkillDescription are populated only for skill kinds. | +| `»»» source` | string | false | | Source is the resource locator: the canonical file path for an instruction file, the skill directory for a skill, the file path for an MCP config, or the server name for an MCP server. | +| `»»» status` | [codersdk.ChatContextResourceStatus](schemas.md#codersdkchatcontextresourcestatus) | false | | Status is the resource's health. Non-ok resources (invalid, unreadable, oversize, excluded) are still reported so the UI can surface why a resource was dropped from the prompt instead of silently omitting it; their body-specific fields (skill name, tools) are empty. | +| `»»» tools` | array | false | | Tools lists the tools exposed by an MCP server. Populated only for the mcp_server kind; nil otherwise. | +| `»»»» description` | string | false | | Description is the tool's human-readable summary; may be empty. | +| `»»»» name` | string | false | | Name is the tool name with the "__" prefix the agent adds stripped, so it reads as the server exposes it. | +| `» created_at` | string(date-time) | false | | | +| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | +| `»» additions` | integer | false | | | +| `»» approved` | boolean | false | | | +| `»» author_avatar_url` | string | false | | | +| `»» author_login` | string | false | | | +| `»» base_branch` | string | false | | | +| `»» changed_files` | integer | false | | | +| `»» changes_requested` | boolean | false | | | +| `»» chat_id` | string(uuid) | false | | | +| `»» commits` | integer | false | | | +| `»» deletions` | integer | false | | | +| `»» head_branch` | string | false | | | +| `»» pr_number` | integer | false | | | +| `»» pull_request_draft` | boolean | false | | | +| `»» pull_request_state` | string | false | | | +| `»» pull_request_title` | string | false | | | +| `»» refreshed_at` | string(date-time) | false | | | +| `»» reviewer_count` | integer | false | | | +| `»» stale_at` | string(date-time) | false | | | +| `»» url` | string | false | | | +| `» files` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» mime_type` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» owner_id` | string(uuid) | false | | | +| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `» id` | string(uuid) | false | | | +| `» labels` | object | false | | | +| `»» [any property]` | string | false | | | +| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | +| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | +| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | +| `»» message` | string | false | | Message is the normalized, user-facing error message. | +| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | +| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | +| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | +| `» last_model_config_id` | string(uuid) | false | | | +| `» last_turn_summary` | string | false | | | +| `» mcp_server_ids` | array | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» owner_id` | string(uuid) | false | | | +| `» owner_name` | string | false | | | +| `» owner_username` | string | false | | | +| `» parent_chat_id` | string(uuid) | false | | | +| `» pin_order` | integer | false | | | +| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | +| `» root_chat_id` | string(uuid) | false | | | +| `» shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | +| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» warnings` | array | false | | | +| `» workspace_id` | string(uuid) | false | | | #### Enumerated Values @@ -338,7 +228,6 @@ Status Code **200** | `client_type` | `api`, `ui` | | `kind` | `auth`, `config`, `generic`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | | `status` | `completed`, `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `paused`, `pending`, `requires_action`, `running`, `unreadable`, `waiting` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | | `plan_mode` | `plan` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -489,70 +378,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -645,70 +470,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -952,70 +713,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -1162,70 +859,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -1318,70 +951,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -1565,70 +1134,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -1721,70 +1226,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -1966,70 +1407,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -2118,74 +1495,10 @@ Experimental: this endpoint is subject to change. "detail": "string", "kind": "generic", "message": "string", - "provider": "string", - "retryable": true, - "status_code": 0 - }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], + "provider": "string", + "retryable": true, + "status_code": 0 + }, "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -2934,70 +2247,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -3090,70 +2339,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -3660,70 +2845,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -3816,70 +2937,6 @@ Experimental: this endpoint is subject to change. "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9b3e42de28259..12c7d03bab6ed 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2080,70 +2080,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -2236,70 +2172,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ @@ -2326,40 +2198,39 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------|-----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent_id` | string | false | | | -| `archived` | boolean | false | | | -| `build_id` | string | false | | | -| `children` | array of [codersdk.Chat](#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | -| `context` | [codersdk.ChatContext](#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | -| `created_at` | string | false | | | -| `diff_status` | [codersdk.ChatDiffStatus](#codersdkchatdiffstatus) | false | | | -| `files` | array of [codersdk.ChatFileMetadata](#codersdkchatfilemetadata) | false | | | -| `has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `id` | string | false | | | -| `labels` | object | false | | | -| » `[any property]` | string | false | | | -| `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | | -| `last_injected_context` | array of [codersdk.ChatMessagePart](#codersdkchatmessagepart) | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | -| `last_model_config_id` | string | false | | | -| `last_turn_summary` | string | false | | | -| `mcp_server_ids` | array of string | false | | | -| `organization_id` | string | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | | -| `owner_username` | string | false | | | -| `parent_chat_id` | string | false | | | -| `pin_order` | integer | false | | | -| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | -| `root_chat_id` | string | false | | | -| `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | -| `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | -| `title` | string | false | | | -| `updated_at` | string | false | | | -| `warnings` | array of string | false | | | -| `workspace_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|-----------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent_id` | string | false | | | +| `archived` | boolean | false | | | +| `build_id` | string | false | | | +| `children` | array of [codersdk.Chat](#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `client_type` | [codersdk.ChatClientType](#codersdkchatclienttype) | false | | | +| `context` | [codersdk.ChatContext](#codersdkchatcontext) | false | | Context reports the chat's pinned workspace-context state and whether it has drifted from the agent's latest pushed snapshot. Nil when the chat has no pinned context yet. | +| `created_at` | string | false | | | +| `diff_status` | [codersdk.ChatDiffStatus](#codersdkchatdiffstatus) | false | | | +| `files` | array of [codersdk.ChatFileMetadata](#codersdkchatfilemetadata) | false | | | +| `has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `id` | string | false | | | +| `labels` | object | false | | | +| » `[any property]` | string | false | | | +| `last_error` | [codersdk.ChatError](#codersdkchaterror) | false | | | +| `last_model_config_id` | string | false | | | +| `last_turn_summary` | string | false | | | +| `mcp_server_ids` | array of string | false | | | +| `organization_id` | string | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | | +| `owner_username` | string | false | | | +| `parent_chat_id` | string | false | | | +| `pin_order` | integer | false | | | +| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | | +| `root_chat_id` | string | false | | | +| `shared` | boolean | false | | Shared is true when this chat's root chat has explicit user or group ACL entries. | +| `status` | [codersdk.ChatStatus](#codersdkchatstatus) | false | | | +| `title` | string | false | | | +| `updated_at` | string | false | | | +| `warnings` | array of string | false | | | +| `workspace_id` | string | false | | | ## codersdk.ChatACL @@ -4061,70 +3932,6 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "retryable": true, "status_code": 0 }, - "last_injected_context": [ - { - "args": [ - 0 - ], - "args_delta": "string", - "completed_at": "2019-08-24T14:15:22Z", - "content": "string", - "context_file_agent_id": { - "uuid": "string", - "valid": true - }, - "context_file_content": "string", - "context_file_directory": "string", - "context_file_os": "string", - "context_file_path": "string", - "context_file_skill_meta_file": "string", - "context_file_truncated": true, - "created_at": "2019-08-24T14:15:22Z", - "data": [ - 0 - ], - "end_line": 0, - "file_id": { - "uuid": "string", - "valid": true - }, - "file_name": "string", - "is_error": true, - "is_media": true, - "mcp_server_config_id": { - "uuid": "string", - "valid": true - }, - "media_type": "string", - "name": "string", - "parsed_commands": [ - [ - "string" - ] - ], - "provider_executed": true, - "provider_metadata": [ - 0 - ], - "result": [ - 0 - ], - "result_delta": "string", - "result_reset": true, - "signature": "string", - "skill_description": "string", - "skill_dir": "string", - "skill_name": "string", - "source_id": "string", - "start_line": 0, - "text": "string", - "title": "string", - "tool_call_id": "string", - "tool_name": "string", - "type": "text", - "url": "string" - } - ], "last_model_config_id": "30ebb95f-c255-4759-9429-89aa4ec1554c", "last_turn_summary": "string", "mcp_server_ids": [ diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index f88c50b86ee6c..e412a2c2eb84b 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -466,7 +466,6 @@ var auditableResourcesTypes = map[any]map[string]Action{ "group_acl": ActionTrack, "pin_order": ActionTrack, "last_read_message_id": ActionIgnore, // User-scoped read cursor. - "last_injected_context": ActionIgnore, // Internal lifecycle. "context_aggregate_hash": ActionIgnore, // Agent-pushed context snapshot state. "context_dirty_since": ActionIgnore, // Agent-pushed context snapshot state. "context_dirty_resources": ActionIgnore, // Agent-pushed context snapshot state. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9bbc885540862..03f9b3e27e4dc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1569,13 +1569,6 @@ export interface Chat { * connect and disconnect. */ readonly has_unread: boolean; - /** - * LastInjectedContext holds the most recently persisted - * injected context parts (AGENTS.md files and skills). It - * is updated only when context changes, on first workspace - * attach or agent change. - */ - readonly last_injected_context?: readonly ChatMessagePart[]; /** * Context reports the chat's pinned workspace-context state and * whether it has drifted from the agent's latest pushed snapshot. diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 80aa853169096..e741730d7c602 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1673,7 +1673,6 @@ const AgentChatPage: FC = () => { selectedMCPServerIds={effectiveMCPServerIds} onMCPSelectionChange={handleMCPSelectionChange} onMCPAuthComplete={handleMCPAuthComplete} - lastInjectedContext={chatQuery.data?.last_injected_context} chatContext={chatQuery.data?.context} /> ); diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index 46b7879bf28f5..3013ff0d24e75 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -215,7 +215,6 @@ interface AgentChatPageViewProps { // Desktop chat ID (optional). desktopChatId?: string; - lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; chatContext?: TypesGen.ChatContext; } @@ -373,7 +372,6 @@ export const AgentChatPageView: FC = ({ onMCPSelectionChange, onMCPAuthComplete, desktopChatId, - lastInjectedContext, chatContext, }) => { const queryClient = useQueryClient(); @@ -965,7 +963,6 @@ export const AgentChatPageView: FC = ({ selectedMCPServerIds={selectedMCPServerIds} onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} - lastInjectedContext={lastInjectedContext} chatContext={chatContext} workspace={workspace} workspaceAgent={workspaceAgent} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx index aa87313a898dc..42f288fdfdb64 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.stories.tsx @@ -3,7 +3,10 @@ import { MonitorDotIcon } from "lucide-react"; import { useEffect, useRef } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; -import { MockMCPServerConfig } from "#/testHelpers/chatEntities"; +import { + MockChatContextClean, + MockMCPServerConfig, +} from "#/testHelpers/chatEntities"; import { MockWorkspace, MockWorkspaceAgent } from "#/testHelpers/entities"; import { createMockFile } from "#/testHelpers/files"; import { withProxyProvider } from "#/testHelpers/storybook"; @@ -1188,32 +1191,12 @@ export const WithContextUsage: Story = { }, }; -/** Tooltip includes loaded AGENTS.md files and discovered skills. */ +/** Tooltip lists the chat's pinned context resources. */ export const WithContextFiles: Story = { args: { contextUsage: { ...baseContextUsage, - lastInjectedContext: [ - { - type: "context-file" as const, - context_file_path: "/home/coder/project/AGENTS.md", - }, - { - type: "context-file" as const, - context_file_path: "/home/coder/project/.claude/docs/WORKFLOWS.md", - context_file_truncated: true, - }, - { - type: "skill" as const, - skill_name: "pull-requests", - skill_description: "Guide for creating and updating pull requests", - }, - { - type: "skill" as const, - skill_name: "deep-review", - skill_description: "Multi-reviewer code review", - }, - ] as TypesGen.ChatMessagePart[], + context: MockChatContextClean, }, }, }; @@ -1228,12 +1211,6 @@ export const ContextNearLimit: Story = { outputTokens: 20_000, cacheReadTokens: 4_000, compressionThreshold: 90, - lastInjectedContext: [ - { - type: "context-file" as const, - context_file_path: "/home/coder/project/AGENTS.md", - }, - ] as TypesGen.ChatMessagePart[], }, }, }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index e2fd365253f67..1c13aa1644569 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -208,7 +208,6 @@ interface ChatPageInputProps { selectedMCPServerIds?: readonly string[]; onMCPSelectionChange?: (ids: string[]) => void; onMCPAuthComplete?: (serverId: string) => void; - lastInjectedContext?: readonly TypesGen.ChatMessagePart[]; // Pinned workspace-context state for the chat, surfaced by the // context indicator (dirty marker and pinned resources). chatContext?: TypesGen.ChatContext; @@ -265,7 +264,6 @@ export const ChatPageInput: FC = ({ selectedMCPServerIds, onMCPSelectionChange, onMCPAuthComplete, - lastInjectedContext, chatContext, workspaceOptions, chatOrganizationId, @@ -305,11 +303,10 @@ export const ChatPageInput: FC = ({ const rawUsage = getLatestContextUsage(messages); const latestContextUsage = - rawUsage || lastInjectedContext || chatContext + rawUsage || chatContext ? { ...(rawUsage ?? {}), compressionThreshold, - lastInjectedContext, context: chatContext, } : rawUsage; diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx index 8abb1b9a6c6d8..0b8b4e36f62ff 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.stories.tsx @@ -3,7 +3,6 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { MockChatContextClean, MockChatContextDirty, - MockLastInjectedContextEmptyFile, } from "#/testHelpers/chatEntities"; import { ContextUsageIndicator } from "./ContextUsageIndicator"; @@ -90,35 +89,6 @@ export const Dirty: Story = { }, }; -// Regression: a dirty pin whose pinned resources have not loaded falls back to -// the agent's injected context, which can carry an empty context-file marker. -// The popover must skip it rather than render a nameless "Context files" row, -// while still surfacing the drift affordances. -export const DirtyEmptyInjectedContext: Story = { - args: { - usage: { - usedTokens: 12_000, - contextLimitTokens: 200_000, - lastInjectedContext: MockLastInjectedContextEmptyFile, - context: { - dirty: true, - }, - }, - }, - play: async ({ canvasElement }) => { - const button = within(canvasElement).getByRole("button"); - await userEvent.hover(button); - const body = within(document.body); - // The drift affordances still render. - await waitFor(() => - expect(body.getByText("Context changed")).toBeVisible(), - ); - expect(body.getByRole("button", { name: "Refresh context" })).toBeVisible(); - // The empty injected marker must not produce a nameless file list. - expect(body.queryByText("Context files")).toBeNull(); - }, -}; - // Snapshot-level error: the ring shows a distinct error treatment and the // popover surfaces the error message. export const SnapshotError: Story = { diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index c03d06aca9728..179b03f68d280 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -11,7 +11,6 @@ import type { ChatContextResourceKind, ChatContextResourceStatus, ChatContextTool, - ChatMessagePart, } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { @@ -41,18 +40,14 @@ export interface AgentContextUsage { readonly reasoningTokens?: number; // Percentage (0-100) at which the context will be compacted. readonly compressionThreshold?: number; - // Last injected context parts (AGENTS.md files and skills). Used as a - // fallback to list the context when the chat's pinned resources have not - // loaded yet. - readonly lastInjectedContext?: readonly ChatMessagePart[]; // Pinned workspace-context state: the resources the chat is built from and // whether they have drifted from the agent's latest snapshot. readonly context?: ChatContext; } -// Normalized popover entries, sourced from either the chat's pinned context -// resources or, as a fallback, the last injected context parts. -type ContextFileItem = { readonly path: string; readonly truncated?: boolean }; +// Normalized popover entries, sourced from the chat's pinned context +// resources. +type ContextFileItem = { readonly path: string }; type ContextSkillItem = { readonly name: string; readonly description?: string; @@ -175,80 +170,48 @@ export const ContextUsageIndicator: FC<{ const hasContextError = contextError !== ""; const pinnedResources = context?.resources; - // Drive the listed context from the chat's pinned resources, falling back - // to the last injected context parts while the pin has not loaded. - const usePinned = (pinnedResources?.length ?? 0) > 0; - const fileItems: readonly ContextFileItem[] = ( - usePinned - ? (pinnedResources ?? []) - .filter( - (resource) => - resource.kind === "instruction_file" && resource.status === "ok", - ) - .map((resource) => ({ path: resource.source })) - : (usage?.lastInjectedContext ?? []) - .filter((part) => part.type === "context-file") - .map((part) => ({ - path: part.context_file_path, - truncated: part.context_file_truncated, - })) - ) - // Drop entries with no usable path. The injected-context fallback can - // carry an empty context-file marker, which would otherwise render as a + // Drive the listed context from the chat's pinned resources. + const fileItems: readonly ContextFileItem[] = (pinnedResources ?? []) + .filter( + (resource) => + resource.kind === "instruction_file" && resource.status === "ok", + ) + .map((resource) => ({ path: resource.source })) + // Drop entries with no usable path so an empty marker never renders as a // nameless "Context files" row. .filter((file) => file.path.trim().length > 0); - const skillItems: readonly ContextSkillItem[] = ( - usePinned - ? (pinnedResources ?? []) - .filter( - (resource) => resource.kind === "skill" && resource.status === "ok", - ) - .map((resource) => ({ - name: resource.skill_name || getPathBasename(resource.source), - description: resource.skill_description, - })) - : (usage?.lastInjectedContext ?? []) - .filter((part) => part.type === "skill") - .map((part) => ({ - name: part.skill_name, - description: part.skill_description, - })) - ) + const skillItems: readonly ContextSkillItem[] = (pinnedResources ?? []) + .filter((resource) => resource.kind === "skill" && resource.status === "ok") + .map((resource) => ({ + name: resource.skill_name || getPathBasename(resource.source), + description: resource.skill_description, + })) // Drop entries with no usable name so an empty skill marker never renders // as a blank row. .filter((skill) => skill.name.trim().length > 0); - // MCP configs/servers are only ever surfaced from the chat's pinned - // resources; there is no injected-context fallback for them. An MCP server's - // source is its server name, while an MCP config's source is its file path. - const mcpItems: readonly ContextMcpItem[] = ( - usePinned - ? (pinnedResources ?? []) - .filter( - (resource) => - (resource.kind === "mcp_config" || - resource.kind === "mcp_server") && - resource.status === "ok", - ) - .map((resource) => ({ - name: - resource.kind === "mcp_server" - ? resource.source - : getPathBasename(resource.source), - source: resource.source, - tools: resource.tools ?? [], - })) - : [] - ) + // An MCP server's source is its server name, while an MCP config's source is + // its file path. + const mcpItems: readonly ContextMcpItem[] = (pinnedResources ?? []) + .filter( + (resource) => + (resource.kind === "mcp_config" || resource.kind === "mcp_server") && + resource.status === "ok", + ) + .map((resource) => ({ + name: + resource.kind === "mcp_server" + ? resource.source + : getPathBasename(resource.source), + source: resource.source, + tools: resource.tools ?? [], + })) // Drop entries with no usable name so an empty MCP marker never renders as // a blank row. .filter((mcp) => mcp.name.trim().length > 0); // Pinned resources the agent could not use (invalid skill, unreadable or // oversize file) are surfaced as issues with their error so the failure is - // visible rather than a silent omission. Pinned-only; the injected-context - // fallback has no status. - const issueItems: readonly ContextIssueItem[] = ( - usePinned ? (pinnedResources ?? []) : [] - ) + // visible rather than a silent omission. + const issueItems: readonly ContextIssueItem[] = (pinnedResources ?? []) .filter((resource) => resource.status !== "ok") .map((resource) => ({ name: @@ -303,11 +266,6 @@ export const ContextUsageIndicator: FC<{ {getPathBasename(file.path)} - {file.truncated && ( - - (truncated) - - )} ))} diff --git a/site/src/testHelpers/chatEntities.ts b/site/src/testHelpers/chatEntities.ts index 54a4fa58b4759..2c634ecbb1aaa 100644 --- a/site/src/testHelpers/chatEntities.ts +++ b/site/src/testHelpers/chatEntities.ts @@ -3,7 +3,6 @@ import type { ChatContext, ChatContextResource, ChatMessage, - ChatMessagePart, ChatQueuedMessage, MCPServerConfig, } from "#/api/typesGenerated"; @@ -90,13 +89,6 @@ export const MockChatContextDirty: ChatContext = { resources: MockChatContextResources, }; -// Injected-context fallback whose only context-file marker has no path. The -// agent emits this empty placeholder for skill-only additions; the context -// indicator must skip it rather than render a nameless "Context files" row. -export const MockLastInjectedContextEmptyFile: readonly ChatMessagePart[] = [ - { type: "context-file", context_file_path: "" }, -]; - export const MockMCPServerConfig: MCPServerConfig = { id: "mcp-1", display_name: "MCP Server",