From d9dd3c02c8064d0fd48b1578f1045a142d1042c8 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 22 Jun 2026 20:53:46 +0000 Subject: [PATCH 1/6] refactor: remove legacy live-read and injected-history chat context paths Make the agent-pushed pinned snapshot (chat_context_resources) the sole source of workspace context for chats. Removes: - Live-read paths that dial the workspace for skills, MCP, and instructions at turn time (MCP discovery, skill live-body reads, and the instruction/skill history fallback). - The context-injected-as-message-history mechanism, including the persist_workspace_context generation action and its loop guard. - The legacy POST/DELETE /chat-context HTTP endpoints, the agentsdk AddChatContext/ClearChatContext methods, and the CLI one-shot writer. - The chats.last_injected_context column and all of its plumbing (migration 000529, queries, db2sdk, dbauthz, audit table, frontend). Subagent context inheritance now hydrates pinned chat_context_resources on child create instead of copying parent context messages. --- cli/exp_chat.go | 131 +- cli/exp_chat_internal_test.go | 27 - coderd/apidoc/docs.go | 7 - coderd/apidoc/swagger.json | 7 - coderd/coderd.go | 2 - coderd/database/db2sdk/db2sdk.go | 11 - coderd/database/db2sdk/db2sdk_test.go | 7 - coderd/database/dbauthz/dbauthz.go | 11 - coderd/database/dbauthz/dbauthz_test.go | 13 - coderd/database/dbmetrics/querymetrics.go | 8 - coderd/database/dbmock/dbmock.go | 15 - coderd/database/dump.sql | 2 - ...9_chat_drop_last_injected_context.down.sql | 55 + ...529_chat_drop_last_injected_context.up.sql | 55 + coderd/database/modelqueries.go | 2 - coderd/database/models.go | 60 +- coderd/database/querier.go | 5 - coderd/database/queries.sql.go | 276 +--- coderd/database/queries/chats.sql | 85 -- coderd/exp_chats_test.go | 25 +- coderd/export_test.go | 3 - coderd/workspaceagents.go | 689 +-------- ...rkspaceagents_active_chat_internal_test.go | 90 +- ...kspaceagents_chat_context_internal_test.go | 117 -- coderd/workspaceagents_chat_context_test.go | 1237 ---------------- coderd/x/chatd/chatd.go | 546 +------ coderd/x/chatd/chatd_internal_test.go | 1257 +---------------- coderd/x/chatd/chatopenai/responses_test.go | 3 +- coderd/x/chatd/chatstate/transitions.go | 41 +- coderd/x/chatd/chattool/skill.go | 80 +- coderd/x/chatd/chattool/skill_test.go | 45 - coderd/x/chatd/context_helpers.go | 82 -- coderd/x/chatd/context_prompt.go | 51 +- .../x/chatd/context_prompt_internal_test.go | 107 +- coderd/x/chatd/contextparts.go | 153 -- coderd/x/chatd/generation.go | 132 +- coderd/x/chatd/generation_preparer.go | 15 +- coderd/x/chatd/instruction.go | 112 -- coderd/x/chatd/instruction_internal_test.go | 55 - coderd/x/chatd/subagent.go | 86 +- .../x/chatd/subagent_context_internal_test.go | 407 +----- coderd/x/chatd/workspace_context_builder.go | 148 -- codersdk/agentsdk/agentsdk.go | 63 - codersdk/chats.go | 5 - docs/admin/security/audit-logs.md | 70 +- docs/reference/api/chats.md | 1109 ++------------- docs/reference/api/schemas.md | 259 +--- enterprise/audit/table.go | 1 - site/src/api/typesGenerated.ts | 7 - site/src/pages/AgentsPage/AgentChatPage.tsx | 1 - .../pages/AgentsPage/AgentChatPageView.tsx | 3 - .../components/AgentChatInput.stories.tsx | 35 +- .../AgentsPage/components/ChatPageContent.tsx | 5 +- .../ContextUsageIndicator.stories.tsx | 30 - .../components/ContextUsageIndicator.tsx | 112 +- site/src/testHelpers/chatEntities.ts | 8 - 56 files changed, 536 insertions(+), 7432 deletions(-) create mode 100644 coderd/database/migrations/000529_chat_drop_last_injected_context.down.sql create mode 100644 coderd/database/migrations/000529_chat_drop_last_injected_context.up.sql delete mode 100644 coderd/workspaceagents_chat_context_internal_test.go delete mode 100644 coderd/workspaceagents_chat_context_test.go delete mode 100644 coderd/x/chatd/context_helpers.go delete mode 100644 coderd/x/chatd/contextparts.go delete mode 100644 coderd/x/chatd/workspace_context_builder.go 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..26044980ad469 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" @@ -70,26 +69,7 @@ const ( 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 @@ -195,11 +175,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 +419,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 +3384,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 +3877,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 +3996,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 +4469,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/chatopenai/responses_test.go b/coderd/x/chatd/chatopenai/responses_test.go index 59c5cdb44f6eb..c4edec6ab045f 100644 --- a/coderd/x/chatd/chatopenai/responses_test.go +++ b/coderd/x/chatd/chatopenai/responses_test.go @@ -844,8 +844,7 @@ func chainModeUserMessage(text string) database.ChatMessage { func chainModeSkillOnlyUserMessage() database.ChatMessage { msg := chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ { - Type: codersdk.ChatMessagePartTypeContextFile, - // Keep this in sync with chatd.AgentChatContextSentinelPath. + Type: codersdk.ChatMessagePartTypeContextFile, ContextFilePath: ".coder/agent-chat-context-sentinel", ContextFileAgentID: uuid.NullUUID{ UUID: uuid.New(), 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..0283e10b97303 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() 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_prompt.go b/coderd/x/chatd/context_prompt.go index 7890cfb7cd330..7dd8fe909116e 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -171,30 +171,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 +212,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..7ffb280d09435 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -226,7 +226,7 @@ func (server *Server) prepareGeneration( agent, _ := workspaceCtx.getWorkspaceAgent(ctx) 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 @@ -359,7 +359,6 @@ func (server *Server) prepareGeneration( resolvePlanPath: resolvePlanPathForTools, storeFile: storeChatAttachment, isPlanModeTurn: isPlanModeTurn, - primerCtx: ctx, }) } @@ -563,13 +562,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 +597,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..8dd468ae3b2b0 100644 --- a/coderd/x/chatd/subagent_context_internal_test.go +++ b/coderd/x/chatd/subagent_context_internal_test.go @@ -10,7 +10,6 @@ import ( "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" @@ -20,128 +19,6 @@ import ( "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( ctx context.Context, t *testing.T, @@ -199,287 +76,7 @@ func createParentChatWithInheritedContext( 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, - }) - 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() - - 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-rotated-context", - ModelConfigID: model.ID, - 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) -} - -func TestCreateChildSubagentChatUpdatesInheritedLastInjectedContext(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) - - ctx := chatdTestContext(t) - parentChat := createParentChatWithInheritedContext(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", "") - require.NoError(t, err) - - 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) { +func TestSpawnComputerUseAgentCreatesComputerUseChild(t *testing.T) { t.Parallel() db, ps := dbtestutil.NewDB(t) @@ -521,6 +118,4 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) { require.NoError(t, err) require.True(t, childChat.Mode.Valid) require.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode) - - assertChildInheritedContext(ctx, t, db, childID, "inspect bindings") } 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", From 71a925ea77be38afe10668bbca8e446a2eec1631 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 22 Jun 2026 22:41:55 +0000 Subject: [PATCH 2/6] fix(coderd/x/chatd): hydrate first-turn context and align plan-path timeout Follow-up to the legacy live-read/injected-history removal so test-go-pg passes and API-created chats keep first-turn workspace context. Production: - Pin a chat to its bound agent's pushed snapshot on the first turn when it is still unpinned (ensureChatContextPinnedOnFirstTurn). API-created chats carry no agent at create (hydrateChatContextOnCreate is a no-op) and bind lazily on the first turn, so without this the first turn read empty pinned context whenever the agent pushed before the chat existed. The hook reuses the create-path hydration: idempotent (only NULL-hash chats), snapshot-gated (no empty stamping), and leaves dirtied chats for the refresh endpoint. persistBuildAgentBinding stays passive (rebind-only), honoring the #26438 contract that the create/push path owns first-time pinning. - Widen planPathLookupTimeout to defaultDialTimeout + 5s. Removing live-read removed connection warming, so the plan-path resolver now does the first cold dial; the old 5s budget was smaller than the 30s dial timeout and was cut off. Resolve the plan-path block in the parallel turn-prep phase so the cold dial overlaps with other work instead of blocking sequentially. Tests: - Delete tests of removed live MCP discovery (slow-agent wait, mid-turn discovery, retry-after-empty) and their now-orphaned helpers. - Rewrite the AI-gateway and plan-mode tests to seed pinned context instead of mocking live ContextConfig/ListMCPTools, keeping their original intent (API-key preservation; plan/ask MCP tool visibility). Coder Agents generated on behalf of @kylecarbs. --- coderd/x/chatd/chatd.go | 7 +- coderd/x/chatd/chatd_test.go | 1135 +------------------- coderd/x/chatd/chattool/skill_test.go | 60 +- coderd/x/chatd/context_hydration.go | 74 +- coderd/x/chatd/context_integration_test.go | 102 ++ coderd/x/chatd/generation_preparer.go | 22 +- 6 files changed, 235 insertions(+), 1165 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 26044980ad469..b82a22289e34c 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -67,12 +67,17 @@ const ( DefaultInFlightChatStaleAfter = 5 * time.Minute homeInstructionLookupTimeout = 5 * time.Second - planPathLookupTimeout = 5 * time.Second workspaceDialValidationDelay = 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 diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index fabbada49f885..945de87a6da1e 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,17 @@ 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, dbAgent.ID, + "workspace-plan-mcp", "echo", "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 +6296,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 +6315,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 +6328,9 @@ 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, agentID, "dynamic", "error_tool", "dynamic error tool") + }, }, { name: "tool Run returns error", @@ -6373,16 +6370,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 +6393,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 +6483,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 +9893,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 +9916,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 +11371,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 +11394,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 +12625,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 +12668,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/chattool/skill_test.go b/coderd/x/chatd/chattool/skill_test.go index 0283e10b97303..e9c3205ffdbcd 100644 --- a/coderd/x/chatd/chattool/skill_test.go +++ b/coderd/x/chatd/chattool/skill_test.go @@ -264,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() @@ -469,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, ) @@ -521,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_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_integration_test.go b/coderd/x/chatd/context_integration_test.go index 145244decd9dd..8b294e4daa7a5 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,101 @@ func TestChatContextRefreshFromAgentToken(t *testing.T) { require.NoError(t, err) require.Equal(t, 0, refresh.Refreshed, "nothing left to refresh") } + +// 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, + agentID uuid.UUID, + serverName string, + toolName string, + toolDescription string, +) { + 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: serverName, + Tools: []*agentproto.MCPTool{{ + Name: toolName, + Description: toolDescription, + InputSchema: schema, + }}, + }) + require.NoError(t, err) + + now := dbtime.Now() + hash := []byte(serverName + ":" + toolName) + _, 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: 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/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 7ffb280d09435..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,6 +226,14 @@ 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) if resolveErr != nil { @@ -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) From ef418841eafdf51a8b06dc4239dcfb3576170cf1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 22 Jun 2026 23:33:32 +0000 Subject: [PATCH 3/6] test(coderd/x/chatd): cover first-turn context pinning Add TestEnsureChatContextPinnedOnFirstTurn covering the lazy-bind pinning path that hydrates a chat's pinned context from its agent's snapshot on the first turn after binding. This is the mechanism that makes a workspace created mid-turn pin its context on the next turn: the agent's push cannot hydrate a chat that is not yet bound to it, and binding stays rebind-only. Asserts it pins when bound and unpinned with a snapshot present, skips when already pinned, and skips when agentless. Coder Agents generated on behalf of @kylecarbs. --- .../chatd/context_hydration_internal_test.go | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) 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()}) + }) +} From 5470890e91cedf4c01f205b388ef5989d01f53fa Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 00:32:07 +0000 Subject: [PATCH 4/6] test(coderd/x/chatd): assert subagents inherit pinned context Replace the misleading createParentChatWithInheritedContext helper, which seeded legacy message-part context that the pinned-only system now ignores, with createWorkspaceBoundParentChat. It binds the parent to a workspace agent that has pushed a snapshot, so CreateChat pins the parent at create. Rewrite the subagent spawn test to assert the spawned child inherits the parent's pinned chat_context_resources verbatim (same source, content hash, body, and aggregate hash). The child shares the parent's agent and hydrates the same snapshot on create, so context is reproduced without copying it through chat history. It still asserts the child is created in computer_use mode. Coder Agents generated on behalf of @kylecarbs. --- .../x/chatd/subagent_context_internal_test.go | 122 +++++++++++------- 1 file changed, 78 insertions(+), 44 deletions(-) diff --git a/coderd/x/chatd/subagent_context_internal_test.go b/coderd/x/chatd/subagent_context_internal_test.go index 8dd468ae3b2b0..aff5fd96a9f12 100644 --- a/coderd/x/chatd/subagent_context_internal_test.go +++ b/coderd/x/chatd/subagent_context_internal_test.go @@ -2,81 +2,97 @@ 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" "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 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) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + tmpl := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: tv.ID, + CreatedBy: user.ID, + }) + ws := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: tmpl.ID, + }) + 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}) + + 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-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) - 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, - }) - parentChat, err := db.GetChatByID(ctx, parent.ID) require.NoError(t, err) - return parentChat + return parentChat, source } -func TestSpawnComputerUseAgentCreatesComputerUseChild(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) @@ -84,13 +100,19 @@ func TestSpawnComputerUseAgentCreatesComputerUseChild(t *testing.T) { server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{}) ctx := chatdTestContext(t) - parentChat := createParentChatWithInheritedContext(ctx, t, db, server) + parentChat, wantSource := createWorkspaceBoundParentChat(ctx, t, db, server) + + 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) + 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)) @@ -118,4 +140,16 @@ func TestSpawnComputerUseAgentCreatesComputerUseChild(t *testing.T) { require.NoError(t, err) require.True(t, childChat.Mode.Valid) require.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode) + + // 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") } From 5b48d23532c0a25389e88b01f72acabd4ab5dc33 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 01:07:44 +0000 Subject: [PATCH 5/6] refactor(coderd/x/chatd): restore chat-context sentinel path constant Restore AgentChatContextSentinelPath, removed along with contextparts.go, as the canonical value of the legacy skill-only context-file sentinel. The chatopenai chain-mode test hardcodes this path and documents the sync against the constant via a comment, since that test package does not import chatd. Restore that comment too. Coder Agents generated on behalf of @kylecarbs. --- coderd/x/chatd/chatopenai/responses_test.go | 3 ++- coderd/x/chatd/context_prompt.go | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/coderd/x/chatd/chatopenai/responses_test.go b/coderd/x/chatd/chatopenai/responses_test.go index c4edec6ab045f..59c5cdb44f6eb 100644 --- a/coderd/x/chatd/chatopenai/responses_test.go +++ b/coderd/x/chatd/chatopenai/responses_test.go @@ -844,7 +844,8 @@ func chainModeUserMessage(text string) database.ChatMessage { func chainModeSkillOnlyUserMessage() database.ChatMessage { msg := chattest.ChatMessageWithParts([]codersdk.ChatMessagePart{ { - Type: codersdk.ChatMessagePartTypeContextFile, + Type: codersdk.ChatMessagePartTypeContextFile, + // Keep this in sync with chatd.AgentChatContextSentinelPath. ContextFilePath: ".coder/agent-chat-context-sentinel", ContextFileAgentID: uuid.NullUUID{ UUID: uuid.New(), diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 7dd8fe909116e..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. From f5566d7d2e820112654362aaa1fa70d8d6c64148 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 01:08:17 +0000 Subject: [PATCH 6/6] test(coderd/x/chatd): pass seedAgentMCPToolContext params as a struct Replace the positional agentID/serverName/toolName/toolDescription arguments with an agentMCPToolContext struct so call sites are self-documenting. Coder Agents generated on behalf of @kylecarbs. --- coderd/x/chatd/chatd_test.go | 15 ++++++++--- coderd/x/chatd/context_integration_test.go | 30 +++++++++++++--------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 945de87a6da1e..101a869031ffe 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -1387,8 +1387,12 @@ func TestPlanModeRootChatAllowsApprovedExternalMCPTools(t *testing.T) { // 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, dbAgent.ID, - "workspace-plan-mcp", "echo", "Workspace 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() @@ -6329,7 +6333,12 @@ func TestActiveServer_ToolErrorRecordsMetric(t *testing.T) { Times(1) }, seedContext: func(ctx context.Context, t *testing.T, db database.Store, agentID uuid.UUID) { - seedAgentMCPToolContext(ctx, t, db, agentID, "dynamic", "error_tool", "dynamic error tool") + seedAgentMCPToolContext(ctx, t, db, agentMCPToolContext{ + AgentID: agentID, + ServerName: "dynamic", + ToolName: "error_tool", + ToolDescription: "dynamic error tool", + }) }, }, { diff --git a/coderd/x/chatd/context_integration_test.go b/coderd/x/chatd/context_integration_test.go index 8b294e4daa7a5..26aac7ac405b8 100644 --- a/coderd/x/chatd/context_integration_test.go +++ b/coderd/x/chatd/context_integration_test.go @@ -401,19 +401,25 @@ func TestChatContextRefreshFromAgentToken(t *testing.T) { 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 +// 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, - agentID uuid.UUID, - serverName string, - toolName string, - toolDescription string, + tool agentMCPToolContext, ) { t.Helper() @@ -426,19 +432,19 @@ func seedAgentMCPToolContext( require.NoError(t, err) body, err := protojson.Marshal(&agentproto.MCPServerBody{ - ServerName: serverName, + ServerName: tool.ServerName, Tools: []*agentproto.MCPTool{{ - Name: toolName, - Description: toolDescription, + Name: tool.ToolName, + Description: tool.ToolDescription, InputSchema: schema, }}, }) require.NoError(t, err) now := dbtime.Now() - hash := []byte(serverName + ":" + toolName) + hash := []byte(tool.ServerName + ":" + tool.ToolName) _, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{ - WorkspaceAgentID: agentID, + WorkspaceAgentID: tool.AgentID, Version: 1, AggregateHash: hash, ReceivedAt: now, @@ -446,8 +452,8 @@ func seedAgentMCPToolContext( require.NoError(t, err) _, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{ - WorkspaceAgentID: agentID, - Source: serverName, + WorkspaceAgentID: tool.AgentID, + Source: tool.ServerName, BodyKind: database.WorkspaceAgentContextBodyKindMcpServer, Body: body, ContentHash: hash,