From fb2dd559c5f6f8176459e858bede05f782040135 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:49:42 +0000 Subject: [PATCH 01/13] feat: add workspace skills to agent chat slash menu Expose workspace agent skills through a new /api/experimental/workspaces/{workspace}/skills endpoint and list them alongside personal skills in the chat composer slash menu. Workspace skills for existing chat turns come from the chat's pinned context resources. UpdateChatWorkspaceBinding now returns without writing when the requested workspace/build/agent binding is unchanged, preserving updated_at and chat list ordering. --- coderd/apidoc/docs.go | 54 +++- coderd/apidoc/swagger.json | 50 +++- coderd/coderd.go | 7 + coderd/database/querier_test.go | 86 ++++++ coderd/database/queries.sql.go | 131 +++++---- coderd/database/queries/chats.sql | 127 ++++---- coderd/export_test.go | 11 + coderd/workspaceskills.go | 146 +++++++++ coderd/workspaceskills_test.go | 276 ++++++++++++++++++ coderd/x/chatd/chattool/skill.go | 19 ++ coderd/x/chatd/chattool/skill_test.go | 27 ++ codersdk/chats.go | 4 +- codersdk/workspaceskills.go | 35 +++ docs/reference/api/schemas.md | 18 +- site/src/api/api.ts | 11 + site/src/api/queries/chats.test.ts | 9 +- site/src/api/queries/chats.ts | 4 + site/src/api/queries/workspaceSkills.ts | 11 + site/src/api/typesGenerated.ts | 18 +- .../AgentsPage/components/AgentChatInput.tsx | 8 + .../ChatMessageInput.stories.tsx | 72 ++++- .../ChatMessageInput.test.tsx | 22 +- .../ChatMessageInput/ChatMessageInput.tsx | 115 ++++++-- .../PersonalSkillsTriggerMenu.stories.tsx | 113 ------- .../PersonalSkillsTriggerMenu.tsx | 188 ------------ .../SkillsTriggerMenu.stories.tsx | 143 +++++++++ .../ChatMessageInput/SkillsTriggerMenu.tsx | 236 +++++++++++++++ .../ChatMessageInput/SkillsTriggerPlugin.tsx | 18 +- .../components/ChatPageContent.test.ts | 66 +++++ .../AgentsPage/components/ChatPageContent.tsx | 16 + .../AgentsPage/utils/personalSkills.test.ts | 39 ++- .../pages/AgentsPage/utils/personalSkills.ts | 42 ++- 32 files changed, 1627 insertions(+), 495 deletions(-) create mode 100644 coderd/workspaceskills.go create mode 100644 coderd/workspaceskills_test.go create mode 100644 codersdk/workspaceskills.go create mode 100644 site/src/api/queries/workspaceSkills.ts delete mode 100644 site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx delete mode 100644 site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatPageContent.test.ts diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 54add36baea..7db7aa0da46 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1272,6 +1272,47 @@ const docTemplate = `{ } } }, + "/api/experimental/workspaces/{workspace}/skills": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Workspaces" + ], + "summary": "List workspace skills", + "operationId": "list-workspace-skills", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceSkillMetadata" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/v2/": { "get": { "produces": [ @@ -17321,7 +17362,7 @@ const docTemplate = `{ "type": "string" }, "context_file_agent_id": { - "description": "ContextFileAgentID is the workspace agent that provided\nthis context file. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", + "description": "ContextFileAgentID is the workspace agent that provided\nthis context part. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", "format": "uuid", "allOf": [ { @@ -27639,6 +27680,17 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceSkillMetadata": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5687248ce4f..d0ee62a7144 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1127,6 +1127,43 @@ } } }, + "/api/experimental/workspaces/{workspace}/skills": { + "get": { + "produces": ["application/json"], + "tags": ["Workspaces"], + "summary": "List workspace skills", + "operationId": "list-workspace-skills", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceSkillMetadata" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/v2/": { "get": { "produces": ["application/json"], @@ -15589,7 +15626,7 @@ "type": "string" }, "context_file_agent_id": { - "description": "ContextFileAgentID is the workspace agent that provided\nthis context file. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", + "description": "ContextFileAgentID is the workspace agent that provided\nthis context part. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", "format": "uuid", "allOf": [ { @@ -25467,6 +25504,17 @@ } } }, + "codersdk.WorkspaceSkillMetadata": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index aabd12188c0..1690bd3f724 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1305,6 +1305,13 @@ func New(options *Options) *API { r.Delete("/", api.deleteUserAIProviderKey) }) }) + r.Route("/workspaces/{workspace}/skills", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractWorkspaceParam(options.Database), + ) + r.Get("/", api.getWorkspaceSkills) + }) r.Route("/chats", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 8b526f8389e..8d33dc78fac 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -13505,6 +13505,92 @@ func TestUpdateChatLastTurnSummary(t *testing.T) { require.NotEqual(t, chat.HistoryVersion, fetched.HistoryVersion) } +func TestUpdateChatWorkspaceBindingNoOp(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + + ctx := testutil.Context(t, testutil.WaitMedium) + owner := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: owner.ID, OrganizationID: org.ID}) + + dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "openai", + DisplayName: "OpenAI", + APIKey: "test-key", + Enabled: true, + CentralApiKeyEnabled: true, + }) + + modelCfg, err := insertChatModelConfigForTest(ctx, t, db, "openai", database.InsertChatModelConfigParams{ + Model: "test-model", + DisplayName: "Test Model", + CreatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: owner.ID, Valid: true}, + Enabled: true, + IsDefault: true, + ContextLimit: 128000, + CompressionThreshold: 80, + Options: json.RawMessage(`{}`), + }) + require.NoError(t, err) + + chat, err := db.InsertChat(ctx, database.InsertChatParams{ + OrganizationID: org.ID, + Status: database.ChatStatusWaiting, + ClientType: database.ChatClientTypeUi, + OwnerID: owner.ID, + LastModelConfigID: modelCfg.ID, + Title: "binding-chat", + }) + require.NoError(t, err) + + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: owner.ID, + }) + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: owner.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + }) + workspaceID := workspace.ID + + bound, err := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: chat.ID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, workspaceID, bound.WorkspaceID.UUID) + require.False(t, bound.UpdatedAt.Before(chat.UpdatedAt)) + + // Rebinding to the same workspace/build/agent is a no-op and must + // preserve updated_at so chat list ordering and watch events stay + // stable. + rebound, err := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: chat.ID, + WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true}, + }) + require.NoError(t, err) + require.Equal(t, workspaceID, rebound.WorkspaceID.UUID) + require.Equal(t, bound.UpdatedAt, rebound.UpdatedAt) + + // Clearing the binding is a real change and must advance updated_at. + cleared, err := db.UpdateChatWorkspaceBinding(ctx, database.UpdateChatWorkspaceBindingParams{ + ID: chat.ID, + }) + require.NoError(t, err) + require.False(t, cleared.WorkspaceID.Valid) + require.True(t, cleared.UpdatedAt.After(bound.UpdatedAt)) +} + func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a65852cf8de..2d66e47a346 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12296,83 +12296,104 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl } const updateChatWorkspaceBinding = `-- name: UpdateChatWorkspaceBinding :one -WITH updated_chat AS ( -UPDATE chats SET - workspace_id = $1::uuid, - build_id = $2::uuid, - 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, 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, last_reasoning_effort +WITH current_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, 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, last_reasoning_effort + FROM chats + WHERE id = $1::uuid +), +binding_changed AS ( + SELECT + workspace_id IS DISTINCT FROM $2::uuid + OR build_id IS DISTINCT FROM $3::uuid + OR agent_id IS DISTINCT FROM $4::uuid AS changed + FROM current_chat +), +changed_chat AS ( + UPDATE chats SET + workspace_id = $2::uuid, + build_id = $3::uuid, + agent_id = $4::uuid, + updated_at = NOW() + WHERE id = $1::uuid + AND (SELECT changed FROM binding_changed) + 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, last_reasoning_effort +), +result_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, 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, last_reasoning_effort + FROM changed_chat + UNION ALL + 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, last_reasoning_effort + FROM current_chat + WHERE NOT (SELECT changed FROM binding_changed) ), 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.last_reasoning_effort, - 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.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, + result_chat.id, + result_chat.owner_id, + result_chat.workspace_id, + result_chat.title, + result_chat.status, + result_chat.worker_id, + result_chat.started_at, + result_chat.heartbeat_at, + result_chat.created_at, + result_chat.updated_at, + result_chat.parent_chat_id, + result_chat.root_chat_id, + result_chat.last_model_config_id, + result_chat.last_reasoning_effort, + result_chat.archived, + result_chat.last_error, + result_chat.mode, + result_chat.mcp_server_ids, + result_chat.labels, + result_chat.build_id, + result_chat.agent_id, + result_chat.pin_order, + result_chat.last_read_message_id, + result_chat.dynamic_tools, + result_chat.organization_id, + result_chat.plan_mode, + result_chat.client_type, + result_chat.last_turn_summary, + result_chat.snapshot_version, + result_chat.history_version, + result_chat.queue_version, + result_chat.generation_attempt, + result_chat.retry_state, + result_chat.retry_state_version, + result_chat.runner_id, + result_chat.requires_action_deadline_at, + COALESCE(root.user_acl, result_chat.user_acl) AS user_acl, + COALESCE(root.group_acl, result_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 + result_chat.context_aggregate_hash, + result_chat.context_dirty_since, + result_chat.context_dirty_resources, + result_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 + result_chat + LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id) + JOIN visible_users owner ON owner.id = result_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, last_reasoning_effort, 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 ` type UpdateChatWorkspaceBindingParams struct { + ID uuid.UUID `db:"id" json:"id"` WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"` BuildID uuid.NullUUID `db:"build_id" json:"build_id"` AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"` - ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) { row := q.db.QueryRowContext(ctx, updateChatWorkspaceBinding, + arg.ID, arg.WorkspaceID, arg.BuildID, arg.AgentID, - arg.ID, ) var i Chat err := row.Scan( diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 51bd02e34bd..1bfc7d04606 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1264,65 +1264,86 @@ SELECT * FROM chats_expanded; -- name: UpdateChatWorkspaceBinding :one -WITH updated_chat AS ( -UPDATE chats SET - workspace_id = sqlc.narg('workspace_id')::uuid, - build_id = sqlc.narg('build_id')::uuid, - agent_id = sqlc.narg('agent_id')::uuid, - updated_at = NOW() -WHERE id = @id::uuid -RETURNING * +WITH current_chat AS ( + SELECT * + FROM chats + WHERE id = @id::uuid +), +binding_changed AS ( + SELECT + workspace_id IS DISTINCT FROM sqlc.narg('workspace_id')::uuid + OR build_id IS DISTINCT FROM sqlc.narg('build_id')::uuid + OR agent_id IS DISTINCT FROM sqlc.narg('agent_id')::uuid AS changed + FROM current_chat +), +changed_chat AS ( + UPDATE chats SET + workspace_id = sqlc.narg('workspace_id')::uuid, + build_id = sqlc.narg('build_id')::uuid, + agent_id = sqlc.narg('agent_id')::uuid, + updated_at = NOW() + WHERE id = @id::uuid + AND (SELECT changed FROM binding_changed) + RETURNING * +), +result_chat AS ( + SELECT * + FROM changed_chat + UNION ALL + SELECT * + FROM current_chat + WHERE NOT (SELECT changed FROM binding_changed) ), 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.last_reasoning_effort, - 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.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, + result_chat.id, + result_chat.owner_id, + result_chat.workspace_id, + result_chat.title, + result_chat.status, + result_chat.worker_id, + result_chat.started_at, + result_chat.heartbeat_at, + result_chat.created_at, + result_chat.updated_at, + result_chat.parent_chat_id, + result_chat.root_chat_id, + result_chat.last_model_config_id, + result_chat.last_reasoning_effort, + result_chat.archived, + result_chat.last_error, + result_chat.mode, + result_chat.mcp_server_ids, + result_chat.labels, + result_chat.build_id, + result_chat.agent_id, + result_chat.pin_order, + result_chat.last_read_message_id, + result_chat.dynamic_tools, + result_chat.organization_id, + result_chat.plan_mode, + result_chat.client_type, + result_chat.last_turn_summary, + result_chat.snapshot_version, + result_chat.history_version, + result_chat.queue_version, + result_chat.generation_attempt, + result_chat.retry_state, + result_chat.retry_state_version, + result_chat.runner_id, + result_chat.requires_action_deadline_at, + COALESCE(root.user_acl, result_chat.user_acl) AS user_acl, + COALESCE(root.group_acl, result_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 + result_chat.context_aggregate_hash, + result_chat.context_dirty_since, + result_chat.context_dirty_resources, + result_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 + result_chat + LEFT JOIN chats root ON root.id = COALESCE(result_chat.root_chat_id, result_chat.parent_chat_id) + JOIN visible_users owner ON owner.id = result_chat.owner_id ) SELECT * FROM chats_expanded; diff --git a/coderd/export_test.go b/coderd/export_test.go index 186cf28c8d7..aa1137ce0cc 100644 --- a/coderd/export_test.go +++ b/coderd/export_test.go @@ -1,5 +1,16 @@ package coderd +import "github.com/coder/coder/v2/coderd/workspaceapps" + +// SetAgentProviderForTest replaces the workspace agent provider for external tests. +func SetAgentProviderForTest(api *API, provider workspaceapps.AgentProvider) func() { + previous := api.agentProvider + api.agentProvider = provider + return func() { + api.agentProvider = previous + } +} + // ChatStartWorkspace exposes chatStartWorkspace for external tests. // // chatStartWorkspace is intentionally unexported to keep symmetry with diff --git a/coderd/workspaceskills.go b/coderd/workspaceskills.go new file mode 100644 index 00000000000..bf961c9f356 --- /dev/null +++ b/coderd/workspaceskills.go @@ -0,0 +1,146 @@ +package coderd + +import ( + "context" + "net/http" + "time" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/x/chatd/agentselect" + "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/codersdk" +) + +const ( + workspaceSkillsAgentConnTimeout = 30 * time.Second + workspaceSkillsContextConfigTimeout = 5 * time.Second +) + +// @Summary List workspace skills +// @ID list-workspace-skills +// @Security CoderSessionToken +// @Produce json +// @Tags Workspaces +// @Param workspace path string true "Workspace ID" format(uuid) +// @Success 200 {array} codersdk.WorkspaceSkillMetadata +// @Router /api/experimental/workspaces/{workspace}/skills [get] +// @x-apidocgen {"skip": true} +func (api *API) getWorkspaceSkills(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route. + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + logger := api.Logger.With(slog.F("workspace_id", workspace.ID)) + + if !api.Authorize(r, policy.ActionSSH, workspace) { + httpapi.Forbidden(rw) + return + } + if workspace.Deleted { + writeWorkspaceSkills(ctx, rw, nil) + return + } + + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if build.Transition != database.WorkspaceTransitionStart { + writeWorkspaceSkills(ctx, rw, nil) + return + } + job, err := api.Database.GetProvisionerJobByID(ctx, build.JobID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if job.JobStatus != database.ProvisionerJobStatusSucceeded { + writeWorkspaceSkills(ctx, rw, nil) + return + } + + agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if len(agents) == 0 { + writeWorkspaceSkills(ctx, rw, nil) + return + } + + agent, err := agentselect.FindChatAgent(agents) + if err != nil { + logger.Debug(ctx, "failed to select workspace skills agent", slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ + Message: "Failed to select workspace skills agent.", + Detail: err.Error(), + }) + return + } + + apiAgent, err := db2sdk.WorkspaceAgent( + api.DERPMap(), + *api.TailnetCoordinator.Load(), + agent, + nil, + nil, + nil, + api.AgentInactiveDisconnectTimeout, + api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), + ) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + writeWorkspaceSkills(ctx, rw, nil) + return + } + + dialCtx, cancel := context.WithTimeout(ctx, workspaceSkillsAgentConnTimeout) + conn, release, err := api.agentProvider.AgentConn(dialCtx, agent.ID) + cancel() + if err != nil { + logger.Debug(ctx, "failed to dial workspace skills agent", slog.F("agent_id", agent.ID), slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ + Message: "Failed to connect to workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + configCtx, cancel := context.WithTimeout(ctx, workspaceSkillsContextConfigTimeout) + cfg, err := conn.ContextConfig(configCtx) + cancel() + if err != nil { + logger.Debug(ctx, "failed to fetch workspace skills context config", slog.F("agent_id", agent.ID), slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ + Message: "Failed to fetch workspace skills from agent.", + Detail: err.Error(), + }) + return + } + + metas := chattool.SkillMetasFromContextParts(cfg.Parts) + skills := make([]codersdk.WorkspaceSkillMetadata, 0, len(metas)) + for _, meta := range metas { + skills = append(skills, codersdk.WorkspaceSkillMetadata{ + Name: meta.Name, + Description: meta.Description, + }) + } + writeWorkspaceSkills(ctx, rw, skills) +} + +func writeWorkspaceSkills(ctx context.Context, rw http.ResponseWriter, skills []codersdk.WorkspaceSkillMetadata) { + if skills == nil { + skills = []codersdk.WorkspaceSkillMetadata{} + } + httpapi.Write(ctx, rw, http.StatusOK, skills) +} diff --git a/coderd/workspaceskills_test.go b/coderd/workspaceskills_test.go new file mode 100644 index 00000000000..f35848df238 --- /dev/null +++ b/coderd/workspaceskills_test.go @@ -0,0 +1,276 @@ +package coderd_test + +import ( + "context" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/agent/agenttest" + "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/rbac" + "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/workspacesdk" + "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" +) + +func TestGetWorkspaceSkills(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t), + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + expClient := codersdk.NewExperimentalClient(client) + + agentToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken), + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + _ = agenttest.New(t, client.URL, agentToken) + coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + + readOnlyClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(user.OrganizationID)) + _, err := codersdk.NewExperimentalClient(readOnlyClient).WorkspaceSkills(ctx, workspace.ID) + requireWorkspaceSkillsSDKError(t, err, http.StatusForbidden, "", "") + + expectedSkills := []codersdk.WorkspaceSkillMetadata{{ + Name: "review-code", + Description: "Review code", + }} + for _, tt := range []struct { + name string + provider func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider + wantSkills []codersdk.WorkspaceSkillMetadata + wantStatus int + wantMessage string + wantDetail string + wantRelease bool + wantConfigDeadline bool + }{ + { + name: "dial failure", + provider: func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider { + return workspaceSkillsAgentProvider{ + agentConn: func(ctx context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + deadlines.recordDial(ctx) + return nil, nil, xerrors.New("dial failure") + }, + } + }, + wantStatus: http.StatusBadGateway, + wantMessage: "Failed to connect to workspace agent.", + wantDetail: "dial failure", + }, + { + name: "context config failure", + provider: func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider { + conn := agentconnmock.NewMockAgentConn(gomock.NewController(t)) + conn.EXPECT().ContextConfig(gomock.Any()).DoAndReturn(func(ctx context.Context) (workspacesdk.ContextConfigResponse, error) { + deadlines.recordConfig(ctx) + return workspacesdk.ContextConfigResponse{}, xerrors.New("context config failure") + }) + return workspaceSkillsAgentProvider{ + agentConn: func(ctx context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + deadlines.recordDial(ctx) + return conn, func() { *releaseCalled = true }, nil + }, + } + }, + wantStatus: http.StatusBadGateway, + wantMessage: "Failed to fetch workspace skills from agent.", + wantDetail: "context config failure", + wantRelease: true, + wantConfigDeadline: true, + }, + { + name: "success", + provider: func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider { + conn := agentconnmock.NewMockAgentConn(gomock.NewController(t)) + conn.EXPECT().ContextConfig(gomock.Any()).DoAndReturn(func(ctx context.Context) (workspacesdk.ContextConfigResponse, error) { + deadlines.recordConfig(ctx) + return workspacesdk.ContextConfigResponse{ + Parts: []codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "review-code", + SkillDescription: "Review code", + }}, + }, nil + }) + return workspaceSkillsAgentProvider{ + agentConn: func(ctx context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { + deadlines.recordDial(ctx) + return conn, func() { *releaseCalled = true }, nil + }, + } + }, + wantSkills: expectedSkills, + wantRelease: true, + wantConfigDeadline: true, + }, + } { + releaseCalled := false + deadlines := &workspaceSkillsDeadlineRecorder{} + restore := coderd.SetAgentProviderForTest(api, tt.provider(t, &releaseCalled, deadlines)) + skills, err := expClient.WorkspaceSkills(ctx, workspace.ID) + restore() + + if tt.wantStatus != 0 { + requireWorkspaceSkillsSDKError(t, err, tt.wantStatus, tt.wantMessage, tt.wantDetail) + } else { + require.NoError(t, err, tt.name) + require.Equal(t, tt.wantSkills, skills, tt.name) + } + require.Equal(t, tt.wantRelease, releaseCalled, tt.name) + deadlines.requireDial(t, tt.name, 30*time.Second) + if tt.wantConfigDeadline { + deadlines.requireConfig(t, tt.name, 5*time.Second) + } else { + deadlines.requireNoConfig(t, tt.name) + } + } + + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + requireWorkspaceSkillsEmptyWithoutDial(ctx, t, expClient, api, workspace.ID) + + badVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ApplyFailed, + ProvisionGraph: echo.GraphComplete, + }, func(req *codersdk.CreateTemplateVersionRequest) { + req.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, badVersion.ID) + coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, badVersion.ID) + failedBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart, func(req *codersdk.CreateWorkspaceBuildRequest) { + req.TemplateVersionID = badVersion.ID + }) + failedBuild = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, failedBuild.ID) + require.Equal(t, codersdk.ProvisionerJobFailed, failedBuild.Job.Status) + requireWorkspaceSkillsEmptyWithoutDial(ctx, t, expClient, api, workspace.ID) +} + +type workspaceSkillsAgentProvider struct { + workspaceapps.AgentProvider + agentConn func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) +} + +func (p workspaceSkillsAgentProvider) AgentConn(ctx context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { + return p.agentConn(ctx, agentID) +} + +type workspaceSkillsDeadlineRecorder struct { + mu sync.Mutex + dial workspaceSkillsDeadlineObservation + config workspaceSkillsDeadlineObservation +} + +type workspaceSkillsDeadlineObservation struct { + seen bool + ok bool + observed time.Time + deadline time.Time +} + +func (r *workspaceSkillsDeadlineRecorder) recordDial(ctx context.Context) { + r.record(ctx, &r.dial) +} + +func (r *workspaceSkillsDeadlineRecorder) recordConfig(ctx context.Context) { + r.record(ctx, &r.config) +} + +func (r *workspaceSkillsDeadlineRecorder) record(ctx context.Context, observation *workspaceSkillsDeadlineObservation) { + deadline, ok := ctx.Deadline() + r.mu.Lock() + defer r.mu.Unlock() + *observation = workspaceSkillsDeadlineObservation{ + seen: true, + ok: ok, + observed: time.Now(), + deadline: deadline, + } +} + +func (r *workspaceSkillsDeadlineRecorder) requireDial(t testing.TB, name string, want time.Duration) { + t.Helper() + r.requireDeadline(t, name, "dial", &r.dial, want) +} + +func (r *workspaceSkillsDeadlineRecorder) requireConfig(t testing.TB, name string, want time.Duration) { + t.Helper() + r.requireDeadline(t, name, "context config", &r.config, want) +} + +func (r *workspaceSkillsDeadlineRecorder) requireNoConfig(t testing.TB, name string) { + t.Helper() + r.mu.Lock() + observation := r.config + r.mu.Unlock() + require.False(t, observation.seen, "%s: context config deadline recorded", name) +} + +func (r *workspaceSkillsDeadlineRecorder) requireDeadline(t testing.TB, name string, label string, observed *workspaceSkillsDeadlineObservation, want time.Duration) { + t.Helper() + r.mu.Lock() + observation := *observed + r.mu.Unlock() + require.True(t, observation.seen, "%s: %s deadline was not recorded", name, label) + require.True(t, observation.ok, "%s: %s context has no deadline", name, label) + remaining := observation.deadline.Sub(observation.observed) + require.Greater(t, remaining, want-2*time.Second, "%s: %s deadline too short", name, label) + require.LessOrEqual(t, remaining, want, "%s: %s deadline too long", name, label) +} + +func requireWorkspaceSkillsEmptyWithoutDial(ctx context.Context, t testing.TB, expClient *codersdk.ExperimentalClient, api *coderd.API, workspaceID uuid.UUID) { + t.Helper() + var called atomic.Bool + restore := coderd.SetAgentProviderForTest(api, workspaceSkillsAgentProvider{ + agentConn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { + called.Store(true) + return nil, nil, xerrors.New("workspace skills should not dial agent") + }, + }) + skills, err := expClient.WorkspaceSkills(ctx, workspaceID) + restore() + require.NoError(t, err) + require.Empty(t, skills) + require.False(t, called.Load()) +} + +func requireWorkspaceSkillsSDKError(t testing.TB, err error, statusCode int, message string, detail string) { + t.Helper() + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, statusCode, sdkErr.StatusCode()) + if message != "" { + require.Equal(t, message, sdkErr.Message) + } + if detail != "" { + require.Equal(t, detail, sdkErr.Detail) + } +} diff --git a/coderd/x/chatd/chattool/skill.go b/coderd/x/chatd/chattool/skill.go index f93786af464..6f114c89461 100644 --- a/coderd/x/chatd/chattool/skill.go +++ b/coderd/x/chatd/chattool/skill.go @@ -12,6 +12,7 @@ import ( "golang.org/x/xerrors" skillspkg "github.com/coder/coder/v2/coderd/x/skills" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -48,6 +49,24 @@ type SkillMeta struct { Meta []byte } +// SkillMetasFromContextParts converts skill context parts into workspace skill +// metadata used by chat tools. Non-skill parts are ignored. +func SkillMetasFromContextParts(parts []codersdk.ChatMessagePart) []SkillMeta { + metas := make([]SkillMeta, 0, len(parts)) + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeSkill { + continue + } + metas = append(metas, SkillMeta{ + Name: part.SkillName, + Description: part.SkillDescription, + Dir: part.SkillDir, + MetaFile: part.ContextFileSkillMetaFile, + }) + } + return metas +} + // SkillContent is the full body of a skill, loaded on demand // when the model calls read_skill. type SkillContent struct { diff --git a/coderd/x/chatd/chattool/skill_test.go b/coderd/x/chatd/chattool/skill_test.go index 717518cd3d4..2ac18f6b9a8 100644 --- a/coderd/x/chatd/chattool/skill_test.go +++ b/coderd/x/chatd/chattool/skill_test.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" skillspkg "github.com/coder/coder/v2/coderd/x/skills" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" ) @@ -47,6 +48,32 @@ func responseDir(t *testing.T, resp fantasy.ToolResponse) string { return payload.Dir } +func TestSkillMetasFromContextParts(t *testing.T) { + t.Parallel() + + got := chattool.SkillMetasFromContextParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "AGENTS.md", + ContextFileContent: "rules", + }, + { + Type: codersdk.ChatMessagePartTypeSkill, + SkillName: "review-code", + SkillDescription: "Review code", + SkillDir: "/workspace/.agents/skills/review-code", + ContextFileSkillMetaFile: "SKILL.md", + }, + }) + + require.Equal(t, []chattool.SkillMeta{{ + Name: "review-code", + Description: "Review code", + Dir: "/workspace/.agents/skills/review-code", + MetaFile: "SKILL.md", + }}, got) +} + func TestFormatResolvedSkillIndex(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index aea0e0b4d7e..928cd9f0f86 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -393,10 +393,10 @@ type ChatMessagePart struct { // instruction file limit and was truncated. ContextFileTruncated bool `json:"context_file_truncated,omitempty" variants:"context-file?"` // ContextFileAgentID is the workspace agent that provided - // this context file. Used to detect when the agent changes + // this context part. Used to detect when the agent changes // (e.g. workspace rebuilt) so instruction files can be // re-persisted with fresh content. - ContextFileAgentID uuid.NullUUID `json:"context_file_agent_id,omitempty" format:"uuid" variants:"context-file?"` + ContextFileAgentID uuid.NullUUID `json:"context_file_agent_id,omitempty" format:"uuid" variants:"context-file?,skill?"` // ContextFileOS 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 diff --git a/codersdk/workspaceskills.go b/codersdk/workspaceskills.go new file mode 100644 index 00000000000..98ca99e3f6a --- /dev/null +++ b/codersdk/workspaceskills.go @@ -0,0 +1,35 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/google/uuid" +) + +// WorkspaceSkillMetadata represents a workspace skill without its raw Markdown content. +type WorkspaceSkillMetadata struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func workspaceSkillsPath(workspaceID uuid.UUID) string { + return fmt.Sprintf("/api/experimental/workspaces/%s/skills", url.PathEscape(workspaceID.String())) +} + +// WorkspaceSkills lists workspace skill metadata for the specified workspace. +func (c *ExperimentalClient) WorkspaceSkills(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceSkillMetadata, error) { + res, err := c.Request(ctx, http.MethodGet, workspaceSkillsPath(workspaceID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var skills []WorkspaceSkillMetadata + return skills, json.NewDecoder(res.Body).Decode(&skills) +} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fcbe267ec20..1250d594897 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2880,7 +2880,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `args_delta` | string | false | | | | `completed_at` | string | 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](#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. | +| `context_file_agent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context part. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | | `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. | @@ -16502,6 +16502,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| |------------------------------|----------------------------------------| | `shareable_workspace_owners` | `everyone`, `none`, `service_accounts` | +## codersdk.WorkspaceSkillMetadata + +```json +{ + "description": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `description` | string | false | | | +| `name` | string | false | | | + ## codersdk.WorkspaceStatus ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index e89a8f1994a..8e6dc7618b9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -439,6 +439,8 @@ const userSkillPath = (user: string, name: string) => `${userSkillsPath(user)}/${encodeURIComponent(name)}`; const userAIProviderKeysPath = (user = "me") => `/api/experimental/users/${encodeURIComponent(user)}/ai-provider-keys`; +const workspaceSkillsPath = (workspaceId: string) => + `/api/experimental/workspaces/${encodeURIComponent(workspaceId)}/skills`; const mcpServerConfigsPath = "/api/experimental/mcp/servers"; type ChatCostDateParams = { @@ -3858,6 +3860,15 @@ class ExperimentalApiMethods { return response.data; }; + getWorkspaceSkills = async ( + workspaceId: string, + ): Promise => { + const response = await this.axios.get( + workspaceSkillsPath(workspaceId), + ); + return response.data; + }; + getUserSkillByName = async ( user: string, name: string, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 7789d4877be..19e3f92fc23 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -966,7 +966,7 @@ describe("mutation invalidation scope", () => { } }); - it("createChatMessage invalidates only debug runs, not chat detail or messages", async () => { + it("createChatMessage invalidates debug runs and chat detail, not messages", async () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); @@ -980,10 +980,9 @@ describe("mutation invalidation scope", () => { ).toBe(true); const chatState = queryClient.getQueryState(chatKey(chatId)); - expect( - chatState?.isInvalidated, - "chatKey should NOT be invalidated", - ).not.toBe(true); + expect(chatState?.isInvalidated, "chatKey should be invalidated").toBe( + true, + ); const messagesState = queryClient.getQueryState(chatMessagesKey(chatId)); expect( diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 281f04234b1..51f63b9fe1b 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1337,6 +1337,10 @@ export const createChatMessage = ( API.experimental.createChatMessage(chatId, req), onSuccess: () => { void invalidateChatDebugRuns(queryClient, chatId); + void queryClient.invalidateQueries({ + queryKey: chatKey(chatId), + exact: true, + }); void queryClient.invalidateQueries({ queryKey: chatPromptsKey(chatId), exact: true, diff --git a/site/src/api/queries/workspaceSkills.ts b/site/src/api/queries/workspaceSkills.ts new file mode 100644 index 00000000000..69fdde501c6 --- /dev/null +++ b/site/src/api/queries/workspaceSkills.ts @@ -0,0 +1,11 @@ +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; + +const workspaceSkillsKey = (workspaceId: string) => + ["workspace", workspaceId, "skills"] as const; + +export const workspaceSkills = (workspaceId: string) => ({ + queryKey: workspaceSkillsKey(workspaceId), + queryFn: (): Promise => + API.experimental.getWorkspaceSkills(workspaceId), +}); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index da54ce2ce04..8ccdc7bfedd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1798,7 +1798,7 @@ export interface ChatContextFilePart { readonly context_file_truncated?: boolean; /** * ContextFileAgentID is the workspace agent that provided - * this context file. Used to detect when the agent changes + * this context part. Used to detect when the agent changes * (e.g. workspace rebuilt) so instruction files can be * re-persisted with fresh content. */ @@ -3025,6 +3025,13 @@ export interface ChatSkillPart { * from the workspace's .agents/skills/ directory. */ readonly skill_name: string; + /** + * ContextFileAgentID is the workspace agent that provided + * this context part. Used to detect when the agent changes + * (e.g. workspace rebuilt) so instruction files can be + * re-persisted with fresh content. + */ + readonly context_file_agent_id?: string; /** * SkillDescription is the short description from the skill's * SKILL.md frontmatter. @@ -11032,6 +11039,15 @@ export interface WorkspaceSharingSettings { readonly shareable_workspace_owners: ShareableWorkspaceOwners; } +// From codersdk/workspaceskills.go +/** + * WorkspaceSkillMetadata represents a workspace skill without its raw Markdown content. + */ +export interface WorkspaceSkillMetadata { + readonly name: string; + readonly description: string; +} + // From codersdk/workspacebuilds.go export type WorkspaceStatus = | "canceled" diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 335434dacfb..d4b7884c257 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -190,6 +190,7 @@ interface AgentChatInputProps { selectedMCPServerIds?: readonly string[]; onMCPSelectionChange?: (ids: string[]) => void; onMCPAuthComplete?: (serverId: string) => void; + workspaceSkillsOverride?: readonly TypesGen.WorkspaceSkillMetadata[]; workspace?: TypesGen.Workspace; workspaceAgent?: TypesGen.WorkspaceAgent; chatId?: string; @@ -398,6 +399,7 @@ export const AgentChatInput: FC = ({ selectedMCPServerIds, onMCPSelectionChange, onMCPAuthComplete, + workspaceSkillsOverride, workspace, workspaceAgent, chatId, @@ -582,6 +584,10 @@ export const AgentChatInput: FC = ({ }); }; + const attachedWorkspaceId = attachedWorkspace?.id ?? workspace?.id; + const skillsWorkspaceId = + attachedWorkspaceId ?? selectedWorkspaceId ?? undefined; + const selectedWorkspace = workspaceOptions?.find( (ws) => ws.id === selectedWorkspaceId, ); @@ -1210,6 +1216,8 @@ export const AgentChatInput: FC = ({ onEnter={handleSubmit} sendShortcut={sendShortcut} disabled={isDisabled || isLoading} + workspaceId={skillsWorkspaceId} + workspaceSkillsOverride={workspaceSkillsOverride} autoFocus /> {/* Warn about invisible Unicode in the message text. diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 70816ee8d14..a55f58bdc14 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -10,6 +10,18 @@ import { MockSkills, } from "./storyHelpers"; +// Override props keep skill menu stories deterministic without network calls. +const mockWorkspaceSkills: TypesGen.WorkspaceSkillMetadata[] = [ + { + name: "test-runner", + description: "Run the workspace test command.", + }, + { + name: "workspace-docs", + description: "Use repository documentation conventions.", + }, +]; + const meta: Meta = { title: "components/ChatMessageInput/ChatMessageInput", component: ChatMessageInput, @@ -156,6 +168,50 @@ export const ClickSelectsSkill: Story = { }, }; +export const OpensWithPersonalAndWorkspaceSkills: Story = { + args: { + workspaceId: "workspace-1", + workspaceSkillsOverride: mockWorkspaceSkills, + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + expect(await findVisibleText("Personal skills")).toBeDefined(); + expect(await findVisibleText("Workspace skills")).toBeDefined(); + expect(await findVisibleText("/reviewer")).toBeDefined(); + expect(await findVisibleText("/workspace/test-runner")).toBeDefined(); + }, +}; + +export const ArrowDownSelectsWorkspaceSkill: Story = { + args: { + workspaceId: "workspace-1", + workspaceSkillsOverride: mockWorkspaceSkills, + }, + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/"); + await findVisibleText("/workspace/test-runner"); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{Enter}"); + await waitFor(() => { + expect(editor.textContent).toBe("/workspace/test-runner"); + }); + }, +}; + +export const UniqueWorkspaceQualifiedPrefixStaysSearchable: Story = { + args: { + workspaceId: "workspace-1", + workspaceSkillsOverride: mockWorkspaceSkills, + }, + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/workspace/t"); + expect(await findVisibleText("/workspace/test-runner")).toBeDefined(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(editor.textContent).toBe("/workspace/test-runner"); + }); + }, +}; + export const EmptyDescriptionInsertsNameOnly: Story = { play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/pla"); @@ -174,18 +230,6 @@ export const SlashInsideUrlDoesNotOpen: Story = { }, }; -export const BackspaceClosesWithoutEmptyStateFlash: Story = { - play: async ({ canvasElement }) => { - const editor = await typeInEditor(canvasElement, "/"); - await findVisibleText("/reviewer"); - await userEvent.keyboard("{Backspace}"); - - expect(editor.textContent).toBe(""); - expectNoVisibleTextImmediately("No personal skills found."); - await expectNoVisibleText("/reviewer"); - }, -}; - export const EscapeClosesWithoutReplacing: Story = { play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/"); @@ -230,8 +274,8 @@ export const OutsideClickDismissesTriggerOnRefocus: Story = { }, }; -// Stories below verify that on mobile viewports, the personal skills -// popup sits directly above the chat input rather than being clipped +// Stories below verify that on mobile viewports, the skills popup +// sits directly above the chat input rather than being clipped // above the visible viewport. const MOBILE_MEDIA_QUERY = "(max-width: 767px)"; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx index 0a7605bb801..ba7e9957e61 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx @@ -6,14 +6,15 @@ import { useRef, useState, } from "react"; -import { QueryClientProvider } from "react-query"; -import { describe, expect, it } from "vitest"; +import { type QueryClient, QueryClientProvider } from "react-query"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTestQueryClient } from "#/testHelpers/renderHelpers"; import { ChatMessageInput, type ChatMessageInputRef } from "./ChatMessageInput"; -const renderWithQueryClient = (children: ReactNode) => { - const queryClient = createTestQueryClient(); - +const renderWithQueryClient = ( + children: ReactNode, + queryClient: QueryClient = createTestQueryClient(), +) => { return render( {children}, ); @@ -65,7 +66,18 @@ const QueuedReplacementHarness: FC<{ ); }; +beforeAll(() => { + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 1, 16), + }); +}); + describe("ChatMessageInput", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("returns the initial draft before the editor visually hydrates", async () => { renderWithQueryClient( , diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 545bdff455c..0c0791071a4 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -34,6 +34,7 @@ import { } from "react"; import { useQuery } from "react-query"; import { userSkills } from "#/api/queries/userSkills"; +import { workspaceSkills } from "#/api/queries/workspaceSkills"; import type * as TypesGen from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; import { isMobileViewport } from "#/utils/mobile"; @@ -43,16 +44,14 @@ import { } from "../../utils/agentChatSendShortcut"; import { isChatAttachmentFile } from "../../utils/chatAttachments"; import { - filterPersonalSkills, + filterSkillsByQuery, isPersonalSkillTriggerToken, - personalSkillTriggerText, } from "../../utils/personalSkills"; import { $createFileReferenceNode, FileReferenceNode, } from "./FileReferenceNode"; import { IOSBackspacePlugin } from "./iosBackspace"; -import { PersonalSkillsTriggerMenu } from "./PersonalSkillsTriggerMenu"; import { createPasteFile, getPasteDataTransfer, @@ -60,6 +59,11 @@ import { isLargePaste, type PasteCommandEvent, } from "./pasteHelpers"; +import { + createSkillMenuItem, + type SkillMenuItem, + SkillsTriggerMenu, +} from "./SkillsTriggerMenu"; import { type ActiveSkillsTrigger, SkillsTriggerPlugin, @@ -501,7 +505,17 @@ interface ChatMessageInputProps allowTextAttachmentPaste?: boolean; disabled?: boolean; autoFocus?: boolean; + workspaceId?: string; + /** + * Story and test seam for deterministic personal skill menu data. + */ personalSkillsOverride?: readonly TypesGen.UserSkillMetadata[]; + /** + * Authoritative workspace skill menu data. Existing chats pass the + * chat's pinned context skills so the menu matches read_skill + * resolution. + */ + workspaceSkillsOverride?: readonly TypesGen.WorkspaceSkillMetadata[]; "aria-label"?: string; } @@ -567,7 +581,9 @@ const ChatMessageInput = ({ allowTextAttachmentPaste, disabled, autoFocus, + workspaceId, personalSkillsOverride, + workspaceSkillsOverride, "aria-label": ariaLabel, ref, ...props @@ -597,30 +613,61 @@ const ChatMessageInput = ({ const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0); const hasSkillsTrigger = Boolean(skillsTrigger); const hasPersonalSkillsOverride = personalSkillsOverride !== undefined; + const hasWorkspaceSkillsOverride = workspaceSkillsOverride !== undefined; + const personalSkillsQueryEnabled = + hasSkillsTrigger && !hasPersonalSkillsOverride; + const workspaceSkillsQueryEnabled = + hasSkillsTrigger && Boolean(workspaceId) && !hasWorkspaceSkillsOverride; const skillsQuery = useQuery({ ...userSkills(), - enabled: hasSkillsTrigger && !hasPersonalSkillsOverride, + enabled: personalSkillsQueryEnabled, // Avoid refetching on each trigger toggle from caret movement. staleTime: 60_000, }); + const workspaceSkillsQuery = useQuery({ + ...workspaceSkills(workspaceId ?? ""), + enabled: workspaceSkillsQueryEnabled, + staleTime: 60_000, + }); const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? []; + const loadedWorkspaceSkills = + workspaceSkillsOverride ?? workspaceSkillsQuery.data ?? []; // A stale empty cache with a refetch in flight must not dismiss the menu. - const isResolvedEmptySkillsList = hasPersonalSkillsOverride + const isResolvedEmptyPersonalSkills = hasPersonalSkillsOverride ? personalSkills.length === 0 : skillsQuery.isSuccess && !skillsQuery.isFetching && personalSkills.length === 0; - // When the loaded skills list is empty, "/" is plain text. When only - // the filtered result is empty, keep the menu open for the no-match - // message. - const skillsMenuOpen = hasSkillsTrigger && !isResolvedEmptySkillsList; - const filteredPersonalSkills = skillsTrigger - ? filterPersonalSkills(personalSkills, skillsTrigger.query) - : []; + const isResolvedEmptyWorkspaceSkills = workspaceSkillsQueryEnabled + ? workspaceSkillsQuery.isSuccess && + !workspaceSkillsQuery.isFetching && + loadedWorkspaceSkills.length === 0 + : loadedWorkspaceSkills.length === 0; + // When both loaded skills lists are empty, "/" is plain text. When + // only the filtered result is empty, keep the menu open for the + // no-match message. + const skillsMenuOpen = + hasSkillsTrigger && + !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills); + const skillsSearchQuery = skillsTrigger?.query ?? ""; + const personalSkillItems: readonly SkillMenuItem[] = filterSkillsByQuery( + personalSkills.map((skill) => createSkillMenuItem("personal", skill)), + skillsSearchQuery, + ); + const workspaceSkillItems: readonly SkillMenuItem[] = filterSkillsByQuery( + loadedWorkspaceSkills.map((skill) => + createSkillMenuItem("workspace", skill), + ), + skillsSearchQuery, + ); + const allFilteredSkills: readonly SkillMenuItem[] = [ + ...personalSkillItems, + ...workspaceSkillItems, + ]; const selectedSkillIndex = - filteredPersonalSkills.length === 0 + allFilteredSkills.length === 0 ? -1 - : Math.min(skillsMenuSelectedIndex, filteredPersonalSkills.length - 1); + : Math.min(skillsMenuSelectedIndex, allFilteredSkills.length - 1); const handleSkillsTriggerChange = (trigger: ActiveSkillsTrigger | null) => { if ( @@ -640,7 +687,7 @@ const ChatMessageInput = ({ setSkillsTrigger(trigger); }; - const replaceActiveSkillsTrigger = (skill: TypesGen.UserSkillMetadata) => { + const replaceActiveSkillsTrigger = (skill: SkillMenuItem) => { const editor = editorRef.current; const trigger = skillsTrigger; if (!editor || !trigger) { @@ -680,7 +727,7 @@ const ChatMessageInput = ({ selection.anchor.set(trigger.nodeKey, trigger.slashOffset, "text"); selection.focus.set(trigger.nodeKey, caretOffset, "text"); - selection.insertText(personalSkillTriggerText(skill)); + selection.insertText(skill.triggerText); }); setSkillsTrigger(null); setSkillsMenuSelectedIndex(0); @@ -890,7 +937,7 @@ const ChatMessageInput = ({ {autoFocus && } - handleSkillsTriggerChange(null)} /> diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx deleted file mode 100644 index c1c5f1214a0..00000000000 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent } from "storybook/test"; -import { filterPersonalSkills } from "../../utils/personalSkills"; -import { PersonalSkillsTriggerMenu } from "./PersonalSkillsTriggerMenu"; -import { - expectNoVisibleText, - findVisibleText, - MockSkills, -} from "./storyHelpers"; - -const meta: Meta = { - title: "components/ChatMessageInput/PersonalSkillsTriggerMenu", - component: PersonalSkillsTriggerMenu, - args: { - open: true, - anchorRect: { top: 120, left: 80, height: 20 }, - query: "", - skills: MockSkills, - onSelectedIndexChange: fn(), - selectedIndex: 0, - onSelect: fn(), - onClose: fn(), - }, - decorators: [ - (Story) => ( -
-

- The menu is anchored to a mock caret position. -

- -
- ), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Open: Story = { - play: async () => { - expect(await findVisibleText("/reviewer")).toBeDefined(); - expect( - await findVisibleText("Review changed files and suggest fixes."), - ).toBeDefined(); - }, -}; - -export const Loading: Story = { - args: { - isLoading: true, - skills: [], - }, - play: async () => { - expect(await findVisibleText("Loading personal skills...")).toBeDefined(); - }, -}; - -export const ErrorState: Story = { - args: { - isError: true, - skills: [], - }, - play: async () => { - expect( - await findVisibleText( - "Could not load personal skills. Close and type / again to retry.", - ), - ).toBeDefined(); - }, -}; - -export const Empty: Story = { - args: { - skills: [], - }, - play: async () => { - expect(await findVisibleText("No personal skills found.")).toBeDefined(); - }, -}; - -export const FilteredEmpty: Story = { - args: { - query: "xyz", - skills: [], - }, - play: async () => { - expect( - await findVisibleText("No personal skills match that query."), - ).toBeDefined(); - }, -}; - -export const Filtered: Story = { - args: { - query: "rev", - skills: filterPersonalSkills(MockSkills, "rev"), - }, - play: async () => { - expect(await findVisibleText("/reviewer")).toBeDefined(); - await expectNoVisibleText("/docs"); - }, -}; - -export const SelectsByClick: Story = { - args: { - onSelect: fn(), - }, - play: async ({ args }) => { - await userEvent.click(await findVisibleText("/reviewer")); - expect(args.onSelect).toHaveBeenCalledTimes(1); - expect(args.onSelect).toHaveBeenCalledWith(MockSkills[0]); - }, -}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx deleted file mode 100644 index ae0b509bbd5..00000000000 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useLayoutEffect, useState } from "react"; -import type * as TypesGen from "#/api/typesGenerated"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandItem, - CommandList, -} from "#/components/Command/Command"; -import { - Popover, - PopoverAnchor, - PopoverContent, -} from "#/components/Popover/Popover"; -import { personalSkillTriggerText } from "../../utils/personalSkills"; - -// Prevent zero-height anchors when the browser returns a degenerate caret rect. -const MIN_ANCHOR_HEIGHT_PX = 16; - -export type CaretAnchorRect = { - top: number; - left: number; - height: number; -}; - -type PersonalSkillsTriggerMenuProps = { - open: boolean; - anchorRect: CaretAnchorRect | null; - query: string; - skills: readonly TypesGen.UserSkillMetadata[]; - isLoading?: boolean; - isError?: boolean; - selectedIndex: number; - onSelectedIndexChange: (index: number) => void; - onSelect: (skill: TypesGen.UserSkillMetadata) => void; - onClose: () => void; -}; - -type PersonalSkillsMenuState = { - anchorRect: CaretAnchorRect; - query: string; - skills: readonly TypesGen.UserSkillMetadata[]; - isLoading?: boolean; - isError?: boolean; - selectedIndex: number; -}; - -export const PersonalSkillsTriggerMenu = ({ - open, - anchorRect, - query, - skills, - isLoading, - isError, - selectedIndex, - onSelectedIndexChange, - onSelect, - onClose, -}: PersonalSkillsTriggerMenuProps) => { - const [lastOpenMenuState, setLastOpenMenuState] = - useState(null); - const isAnchoredOpen = open && anchorRect !== null; - const activeMenuState: PersonalSkillsMenuState | null = isAnchoredOpen - ? { - anchorRect, - query, - skills, - isLoading, - isError, - selectedIndex, - } - : null; - const menuState = activeMenuState ?? lastOpenMenuState; - const menuAnchorRect = menuState?.anchorRect ?? null; - const menuSkills = menuState?.skills ?? []; - const menuSelectedIndex = menuState?.selectedIndex ?? -1; - - useLayoutEffect(() => { - if (!isAnchoredOpen) { - return; - } - setLastOpenMenuState({ - anchorRect, - query, - skills, - isLoading, - isError, - selectedIndex, - }); - }, [ - anchorRect, - isAnchoredOpen, - isError, - isLoading, - query, - selectedIndex, - skills, - ]); - - const handleHighlightedValueChange = (value: string) => { - const nextIndex = menuSkills.findIndex((skill) => skill.name === value); - if (nextIndex >= 0) { - onSelectedIndexChange(nextIndex); - } - }; - - return ( - { - if (!nextOpen) { - onClose(); - } - }} - > - {menuAnchorRect && ( - - - )} - event.preventDefault()} - onOpenAutoFocus={(event) => event.preventDefault()} - onCloseAutoFocus={(event) => event.preventDefault()} - > - - - {menuState?.isLoading ? ( - - Loading personal skills... - - ) : menuState?.isError ? ( - - Could not load personal skills. Close and type / again to retry. - - ) : menuSkills.length === 0 ? ( - - {menuState?.query - ? "No personal skills match that query." - : "No personal skills found."} - - ) : ( - - {menuSkills.map((skill) => ( - onSelect(skill)} - > -
-
- {personalSkillTriggerText(skill)} -
- {skill.description.trim() && ( -
- {skill.description} -
- )} -
-
- ))} -
- )} -
-
-
-
- ); -}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx new file mode 100644 index 00000000000..871bdc81c53 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent } from "storybook/test"; +import type * as TypesGen from "#/api/typesGenerated"; +import { filterSkillsByQuery } from "../../utils/personalSkills"; +import { createSkillMenuItem, SkillsTriggerMenu } from "./SkillsTriggerMenu"; +import { + expectNoVisibleText, + findVisibleText, + MockSkills, +} from "./storyHelpers"; + +const mockWorkspaceSkills: TypesGen.WorkspaceSkillMetadata[] = [ + { + name: "test-runner", + description: "Run the workspace test command.", + }, + { + name: "workspace-docs", + description: "Use repository documentation conventions.", + }, +]; + +const mockPersonalSkillItems = MockSkills.map((skill) => + createSkillMenuItem("personal", skill), +); +const mockWorkspaceSkillItems = mockWorkspaceSkills.map((skill) => + createSkillMenuItem("workspace", skill), +); + +const meta: Meta = { + title: "components/ChatMessageInput/SkillsTriggerMenu", + component: SkillsTriggerMenu, + args: { + open: true, + anchorRect: { top: 120, left: 80, height: 20 }, + query: "", + personalSkills: mockPersonalSkillItems, + workspaceSkills: [], + workspaceSkillsEnabled: false, + onSelectedIndexChange: fn(), + selectedIndex: 0, + onSelect: fn(), + onClose: fn(), + }, + decorators: [ + (Story) => ( +
+

+ The menu is anchored to a mock caret position. +

+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const PersonalOnly: Story = { + play: async () => { + expect(await findVisibleText("Personal skills")).toBeDefined(); + expect(await findVisibleText("/reviewer")).toBeDefined(); + expect( + await findVisibleText("Review changed files and suggest fixes."), + ).toBeDefined(); + await expectNoVisibleText("Workspace skills"); + }, +}; + +export const BothGroups: Story = { + args: { + workspaceSkills: mockWorkspaceSkillItems, + workspaceSkillsEnabled: true, + }, + play: async () => { + expect(await findVisibleText("Personal skills")).toBeDefined(); + expect(await findVisibleText("Workspace skills")).toBeDefined(); + expect(await findVisibleText("/reviewer")).toBeDefined(); + expect(await findVisibleText("/workspace/test-runner")).toBeDefined(); + }, +}; + +export const Loading: Story = { + args: { + isPersonalLoading: true, + personalSkills: [], + }, + play: async () => { + expect(await findVisibleText("Loading personal skills...")).toBeDefined(); + }, +}; + +export const WorkspaceError: Story = { + args: { + personalSkills: [], + workspaceSkills: [], + workspaceSkillsEnabled: true, + isWorkspaceError: true, + }, + play: async () => { + expect( + await findVisibleText( + "Could not load workspace skills. Close and type / again to retry.", + ), + ).toBeDefined(); + }, +}; + +export const Empty: Story = { + args: { + personalSkills: [], + workspaceSkills: [], + }, + play: async () => { + expect(await findVisibleText("No personal skills found.")).toBeDefined(); + }, +}; + +export const Filtered: Story = { + args: { + query: "rev", + personalSkills: filterSkillsByQuery(mockPersonalSkillItems, "rev"), + workspaceSkills: filterSkillsByQuery(mockWorkspaceSkillItems, "rev"), + workspaceSkillsEnabled: true, + }, + play: async () => { + expect(await findVisibleText("/reviewer")).toBeDefined(); + await expectNoVisibleText("/docs"); + await expectNoVisibleText("/workspace/test-runner"); + }, +}; + +export const SelectsByClick: Story = { + args: { + onSelect: fn(), + }, + play: async ({ args }) => { + await userEvent.click(await findVisibleText("/reviewer")); + expect(args.onSelect).toHaveBeenCalledTimes(1); + expect(args.onSelect).toHaveBeenCalledWith(mockPersonalSkillItems[0]); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx new file mode 100644 index 00000000000..cbff86cad1f --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -0,0 +1,236 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "#/components/Command/Command"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "#/components/Popover/Popover"; +import { cn } from "#/utils/cn"; + +// Prevent zero-height anchors when the browser returns a degenerate caret rect. +const MIN_ANCHOR_HEIGHT_PX = 16; + +export type CaretAnchorRect = { + top: number; + left: number; + height: number; +}; + +type SkillSource = "personal" | "workspace"; + +type SkillMetadata = { + name: string; + description: string; +}; + +export type SkillMenuItem = SkillMetadata & { + source: SkillSource; + triggerText: string; +}; + +export const createSkillMenuItem = ( + source: SkillSource, + skill: SkillMetadata, +): SkillMenuItem => ({ + name: skill.name, + description: skill.description, + source, + triggerText: + source === "workspace" ? `/workspace/${skill.name}` : `/${skill.name}`, +}); + +type SkillsTriggerMenuProps = { + open: boolean; + anchorRect: CaretAnchorRect | null; + query: string; + personalSkills: readonly SkillMenuItem[]; + workspaceSkills: readonly SkillMenuItem[]; + workspaceSkillsEnabled?: boolean; + isPersonalLoading?: boolean; + isPersonalError?: boolean; + isWorkspaceLoading?: boolean; + isWorkspaceError?: boolean; + selectedIndex: number; + onSelectedIndexChange: (index: number) => void; + onSelect: (skill: SkillMenuItem) => void; + onClose: () => void; +}; + +const getEmptyMessage = (query: string, workspaceSkillsEnabled: boolean) => { + if (query) { + return workspaceSkillsEnabled + ? "No skills match that query." + : "No personal skills match that query."; + } + return workspaceSkillsEnabled + ? "No personal or workspace skills found." + : "No personal skills found."; +}; + +const SkillCommandItem = ({ + skill, + value, + selected, + onSelect, +}: { + skill: SkillMenuItem; + value: string; + selected: boolean; + onSelect: (skill: SkillMenuItem) => void; +}) => { + const handleSelect = () => onSelect(skill); + + return ( + +
+
+ {skill.triggerText} +
+ {skill.description.trim() && ( +
+ {skill.description} +
+ )} +
+
+ ); +}; + +export const SkillsTriggerMenu = ({ + open, + anchorRect, + query, + personalSkills, + workspaceSkills, + workspaceSkillsEnabled, + isPersonalLoading, + isPersonalError, + isWorkspaceLoading, + isWorkspaceError, + selectedIndex, + onSelectedIndexChange, + onSelect, + onClose, +}: SkillsTriggerMenuProps) => { + const allSkills = [...personalSkills, ...workspaceSkills]; + const statusItems = [ + isPersonalLoading && personalSkills.length === 0 + ? "Loading personal skills..." + : undefined, + isPersonalError && personalSkills.length === 0 + ? "Could not load personal skills. Close and type / again to retry." + : undefined, + isWorkspaceLoading && workspaceSkills.length === 0 + ? "Loading workspace skills..." + : undefined, + isWorkspaceError && workspaceSkills.length === 0 + ? "Could not load workspace skills. Close and type / again to retry." + : undefined, + ].filter((item) => item !== undefined); + const shouldRender = open && anchorRect; + const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0; + const selectedValue = selectedIndex >= 0 ? String(selectedIndex) : ""; + + const handleHighlightedValueChange = (value: string) => { + const nextIndex = Number(value); + if ( + Number.isInteger(nextIndex) && + nextIndex >= 0 && + nextIndex < allSkills.length + ) { + onSelectedIndexChange(nextIndex); + } + }; + + const renderSkill = (skill: SkillMenuItem, index: number) => ( + + ); + + return ( + { + if (!nextOpen) { + onClose(); + } + }} + > + {shouldRender && ( + + + )} + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + > + + + {personalSkills.length > 0 && ( + + {personalSkills.map((skill, index) => + renderSkill(skill, index), + )} + + )} + {workspaceSkills.length > 0 && ( + + {workspaceSkills.map((skill, index) => + renderSkill(skill, personalSkills.length + index), + )} + + )} + {statusItems.map((message) => ( + + {message} + + ))} + {shouldShowEmpty && ( + + {getEmptyMessage(query, Boolean(workspaceSkillsEnabled))} + + )} + + + + + ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx index cc230d9535a..9d314789426 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx @@ -12,9 +12,8 @@ import { type NodeKey, } from "lexical"; import { useEffect, useEffectEvent, useLayoutEffect, useRef } from "react"; -import type * as TypesGen from "#/api/typesGenerated"; import { parsePersonalSkillTrigger } from "../../utils/personalSkills"; -import type { CaretAnchorRect } from "./PersonalSkillsTriggerMenu"; +import type { CaretAnchorRect, SkillMenuItem } from "./SkillsTriggerMenu"; export type ActiveSkillsTrigger = { nodeKey: NodeKey; @@ -30,11 +29,11 @@ type DismissedSkillsTrigger = Pick< type SkillsTriggerPluginProps = { open: boolean; - skills: readonly TypesGen.UserSkillMetadata[]; + skills: readonly SkillMenuItem[]; selectedIndex: number; onSelectedIndexChange: (index: number) => void; onTriggerChange: (trigger: ActiveSkillsTrigger | null) => void; - onSkillSelect: (skill: TypesGen.UserSkillMetadata) => void; + onSkillSelect: (skill: SkillMenuItem) => void; }; const currentCaretRect = (): CaretAnchorRect | null => { @@ -181,8 +180,11 @@ export const SkillsTriggerPlugin = ({ if (count === 0) { return true; } - const currentIndex = Math.max(0, selectedIndex); - onSelectedIndexChange((currentIndex + delta + count) % count); + if (selectedIndex < 0) { + onSelectedIndexChange(delta > 0 ? 0 : count - 1); + return true; + } + onSelectedIndexChange((selectedIndex + delta + count) % count); return true; }, ); @@ -192,7 +194,7 @@ export const SkillsTriggerPlugin = ({ return false; } event?.preventDefault(); - const skill = skills[selectedIndex]; + const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; if (skill) { onSkillSelect(skill); } @@ -203,7 +205,7 @@ export const SkillsTriggerPlugin = ({ if (!open) { return false; } - const skill = skills[selectedIndex]; + const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; if (!skill) { return false; } diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.test.ts b/site/src/pages/AgentsPage/components/ChatPageContent.test.ts new file mode 100644 index 00000000000..5959272e0bc --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatPageContent.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type * as TypesGen from "#/api/typesGenerated"; +import { workspaceSkillsFromChatContext } from "./ChatPageContent"; + +const skillResource = ( + name: string, + overrides: Partial = {}, +): TypesGen.ChatContextResource => ({ + source: `/workspace/.agents/skills/${name}`, + kind: "skill", + size_bytes: 128, + skill_name: name, + skill_description: `${name} description`, + status: "ok", + ...overrides, +}); + +const instructionResource = (): TypesGen.ChatContextResource => ({ + source: "/workspace/AGENTS.md", + kind: "instruction_file", + size_bytes: 64, + status: "ok", +}); + +describe("workspaceSkillsFromChatContext", () => { + it("returns undefined without pinned resources", () => { + expect(workspaceSkillsFromChatContext(undefined)).toBeUndefined(); + expect(workspaceSkillsFromChatContext({ dirty: false })).toBeUndefined(); + }); + + it("maps healthy skill resources to workspace skills", () => { + const context: TypesGen.ChatContext = { + dirty: false, + resources: [ + instructionResource(), + skillResource("reviewer"), + skillResource("docs"), + ], + }; + expect(workspaceSkillsFromChatContext(context)).toEqual([ + { name: "reviewer", description: "reviewer description" }, + { name: "docs", description: "docs description" }, + ]); + }); + + it("omits non-ok skill resources", () => { + const context: TypesGen.ChatContext = { + dirty: true, + resources: [ + skillResource("reviewer"), + skillResource("broken", { status: "unreadable", skill_name: "" }), + ], + }; + expect(workspaceSkillsFromChatContext(context)).toEqual([ + { name: "reviewer", description: "reviewer description" }, + ]); + }); + + it("returns an empty authoritative list when pinned context has no skills", () => { + const context: TypesGen.ChatContext = { + dirty: false, + resources: [instructionResource()], + }; + expect(workspaceSkillsFromChatContext(context)).toEqual([]); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 02d5590e1ba..9f163474dd5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -46,6 +46,20 @@ const isChatMessage = ( message: TypesGen.ChatMessage | undefined, ): message is TypesGen.ChatMessage => Boolean(message); +export const workspaceSkillsFromChatContext = ( + context: TypesGen.ChatContext | undefined, +): TypesGen.WorkspaceSkillMetadata[] | undefined => + context?.resources + ? context.resources + .filter( + (resource) => resource.kind === "skill" && resource.status === "ok", + ) + .map((resource) => ({ + name: resource.skill_name ?? "", + description: resource.skill_description ?? "", + })) + : undefined; + interface ChatPageTimelineProps { store: ChatStoreHandle; persistedError: ChatDetailError | undefined; @@ -329,6 +343,7 @@ export const ChatPageInput: FC = ({ onError: () => toast.error("Failed to refresh context."), }) : undefined; + const workspaceSkillsOverride = workspaceSkillsFromChatContext(chatContext); const composeAttachments = useChatDraftAttachments(organizationId, chatId, { provider: getProviderForModelOption(modelOptions, selectedModel), }); @@ -521,6 +536,7 @@ export const ChatPageInput: FC = ({ selectedMCPServerIds={selectedMCPServerIds} onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} + workspaceSkillsOverride={workspaceSkillsOverride} workspace={workspace} workspaceAgent={workspaceAgent} chatId={chatId} diff --git a/site/src/pages/AgentsPage/utils/personalSkills.test.ts b/site/src/pages/AgentsPage/utils/personalSkills.test.ts index 881362f0db8..83f6c9bfb7c 100644 --- a/site/src/pages/AgentsPage/utils/personalSkills.test.ts +++ b/site/src/pages/AgentsPage/utils/personalSkills.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; import { buildPersonalSkillMarkdown, - filterPersonalSkills, + filterSkillsByQuery, getPersonalSkillContentSizeBytes, isPersonalSkillTriggerToken, isValidPersonalSkillDescription, @@ -28,7 +28,7 @@ const skill = ( updated_at: now, }); -describe("filterPersonalSkills", () => { +describe("filterSkillsByQuery", () => { const skills = [ skill("deploy", "Ship reviewed production changes", 0), skill("reviewer", "Review changed files", 1), @@ -37,7 +37,7 @@ describe("filterPersonalSkills", () => { ]; it("sorts unfiltered skills by name", () => { - expect(filterPersonalSkills(skills, "").map(({ name }) => name)).toEqual([ + expect(filterSkillsByQuery(skills, "").map(({ name }) => name)).toEqual([ "api-review", "deploy", "docs", @@ -46,9 +46,11 @@ describe("filterPersonalSkills", () => { }); it("ranks prefix, name substring, then description matches", () => { - expect(filterPersonalSkills(skills, "rev").map(({ name }) => name)).toEqual( - ["reviewer", "api-review", "deploy"], - ); + expect(filterSkillsByQuery(skills, "rev").map(({ name }) => name)).toEqual([ + "reviewer", + "api-review", + "deploy", + ]); }); it("matches names and descriptions case-insensitively", () => { @@ -58,12 +60,33 @@ describe("filterPersonalSkills", () => { ]; expect( - filterPersonalSkills(mixedCaseSkills, "DEP").map(({ name }) => name), + filterSkillsByQuery(mixedCaseSkills, "DEP").map(({ name }) => name), ).toEqual(["deploy-bot"]); expect( - filterPersonalSkills(mixedCaseSkills, "changes").map(({ name }) => name), + filterSkillsByQuery(mixedCaseSkills, "changes").map(({ name }) => name), ).toEqual(["deploy-bot"]); }); + + it("matches trigger text", () => { + const skillsWithTriggerText = [ + { + name: "reviewer", + description: "Review changed files", + triggerText: "/reviewer", + }, + { + name: "test-runner", + description: "Run tests", + triggerText: "/workspace/test-runner", + }, + ]; + + expect( + filterSkillsByQuery(skillsWithTriggerText, "workspace/t").map( + ({ triggerText }) => triggerText, + ), + ).toEqual(["/workspace/test-runner"]); + }); }); describe("personal skill slash triggers", () => { diff --git a/site/src/pages/AgentsPage/utils/personalSkills.ts b/site/src/pages/AgentsPage/utils/personalSkills.ts index 905fa4efe9c..a5b1d9da646 100644 --- a/site/src/pages/AgentsPage/utils/personalSkills.ts +++ b/site/src/pages/AgentsPage/utils/personalSkills.ts @@ -1,5 +1,4 @@ import frontMatter from "front-matter"; -import type * as TypesGen from "#/api/typesGenerated"; export const PERSONAL_SKILL_MAX_SIZE_BYTES = 64 * 1024; const PERSONAL_SKILL_MAX_NAME_BYTES = 256; @@ -15,15 +14,20 @@ export type PersonalSkillFormValues = { body: string; }; -type RankedPersonalSkill = { - skill: TypesGen.UserSkillMetadata; +type SkillSearchMetadata = { + name: string; + description: string; + triggerText?: string; +}; + +type RankedSkill = { + skill: T; rank: number; index: number; }; -export const personalSkillTriggerText = ( - skill: TypesGen.UserSkillMetadata, -): string => `/${skill.name}`; +export const personalSkillTriggerText = (skill: { name: string }): string => + `/${skill.name}`; type PersonalSkillTriggerMatch = { slashOffset: number; @@ -48,26 +52,36 @@ export const isPersonalSkillTriggerToken = (token: string): boolean => /^\/\S*$/.test(token); /** - * Filters personal skills by name and description. Matches are ranked by - * name prefix, name substring, then description substring. + * Filters skills by name, trigger text, and description. Matches are ranked + * by name or trigger text prefix, name or trigger text substring, then + * description substring. */ -export const filterPersonalSkills = ( - skills: readonly TypesGen.UserSkillMetadata[], +export const filterSkillsByQuery = ( + skills: readonly T[], query: string, -): TypesGen.UserSkillMetadata[] => { +): T[] => { const normalizedQuery = query.toLocaleLowerCase("en-US"); if (!normalizedQuery) { return skills.toSorted((a, b) => a.name.localeCompare(b.name, "en-US")); } - const rankedSkills: RankedPersonalSkill[] = []; + const rankedSkills: RankedSkill[] = []; for (const [index, skill] of skills.entries()) { const name = skill.name.toLocaleLowerCase("en-US"); + const triggerText = skill.triggerText + ?.replace(/^\//, "") + .toLocaleLowerCase("en-US"); const description = skill.description.toLocaleLowerCase("en-US"); let rank: number | undefined; - if (name.startsWith(normalizedQuery)) { + if ( + name.startsWith(normalizedQuery) || + triggerText?.startsWith(normalizedQuery) + ) { rank = 0; - } else if (name.includes(normalizedQuery)) { + } else if ( + name.includes(normalizedQuery) || + triggerText?.includes(normalizedQuery) + ) { rank = 1; } else if (description.includes(normalizedQuery)) { rank = 2; From d9e81cc54938a3d52a6ad105893c662534ddfa47 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:52:19 +0000 Subject: [PATCH 02/13] fix(site/src/pages/AgentsPage/components/ChatMessageInput): qualify colliding personal skill triggers --- .../ChatMessageInput.stories.tsx | 18 ++++++++++++++++++ .../ChatMessageInput/ChatMessageInput.tsx | 11 ++++++++++- .../ChatMessageInput/SkillsTriggerMenu.tsx | 6 ++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index a55f58bdc14..80f5d5eda6c 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -197,6 +197,24 @@ export const ArrowDownSelectsWorkspaceSkill: Story = { }, }; +export const CollidingPersonalSkillInsertsQualifiedTrigger: Story = { + args: { + workspaceId: "workspace-1", + workspaceSkillsOverride: [ + { name: "reviewer", description: "Workspace review process." }, + ], + }, + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/rev"); + expect(await findVisibleText("/personal/reviewer")).toBeDefined(); + expect(await findVisibleText("/workspace/reviewer")).toBeDefined(); + await userEvent.click(await findVisibleText("/personal/reviewer")); + await waitFor(() => { + expect(editor.textContent).toBe("/personal/reviewer"); + }); + }, +}; + export const UniqueWorkspaceQualifiedPrefixStaysSearchable: Story = { args: { workspaceId: "workspace-1", diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 0c0791071a4..b976ebdfa3d 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -650,8 +650,17 @@ const ChatMessageInput = ({ hasSkillsTrigger && !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills); const skillsSearchQuery = skillsTrigger?.query ?? ""; + const workspaceSkillNames = new Set( + loadedWorkspaceSkills.map((skill) => skill.name), + ); const personalSkillItems: readonly SkillMenuItem[] = filterSkillsByQuery( - personalSkills.map((skill) => createSkillMenuItem("personal", skill)), + personalSkills.map((skill) => + createSkillMenuItem( + "personal", + skill, + workspaceSkillNames.has(skill.name), + ), + ), skillsSearchQuery, ); const workspaceSkillItems: readonly SkillMenuItem[] = filterSkillsByQuery( diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index cbff86cad1f..87948d580ae 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -36,12 +36,14 @@ export type SkillMenuItem = SkillMetadata & { export const createSkillMenuItem = ( source: SkillSource, skill: SkillMetadata, + // Bare personal names are ambiguous to read_skill when a workspace + // skill shares the name, so colliding triggers must stay qualified. + qualifyTrigger = source === "workspace", ): SkillMenuItem => ({ name: skill.name, description: skill.description, source, - triggerText: - source === "workspace" ? `/workspace/${skill.name}` : `/${skill.name}`, + triggerText: qualifyTrigger ? `/${source}/${skill.name}` : `/${skill.name}`, }); type SkillsTriggerMenuProps = { From 7a3c62e591565ee8a9c956d7de923ae5e391f5b2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:04:39 +0000 Subject: [PATCH 03/13] fix(site/src/pages/AgentsPage/components/ChatMessageInput): harden workspace skill collision handling --- .../ChatMessageInput/ChatMessageInput.stories.tsx | 12 ++++++++++++ .../components/ChatMessageInput/ChatMessageInput.tsx | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 80f5d5eda6c..0d94676a68d 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -215,6 +215,18 @@ export const CollidingPersonalSkillInsertsQualifiedTrigger: Story = { }, }; +export const PersonalTriggersQualifiedWhileWorkspaceSkillsUnknown: Story = { + args: { + // No workspaceSkillsOverride: the workspace skills query stays + // unresolved in the story environment, so collisions are unknown. + workspaceId: "workspace-unknown", + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/rev"); + expect(await findVisibleText("/personal/reviewer")).toBeDefined(); + }, +}; + export const UniqueWorkspaceQualifiedPrefixStaysSearchable: Story = { args: { workspaceId: "workspace-1", diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index b976ebdfa3d..5685eb7ea57 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -627,7 +627,10 @@ const ChatMessageInput = ({ const workspaceSkillsQuery = useQuery({ ...workspaceSkills(workspaceId ?? ""), enabled: workspaceSkillsQueryEnabled, - staleTime: 60_000, + // An empty list is transient (workspace stopped or starting, agent + // not connected yet), so only cache non-empty results. + staleTime: (query) => + query.state.data && query.state.data.length > 0 ? 60_000 : 0, }); const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? []; const loadedWorkspaceSkills = @@ -650,6 +653,10 @@ const ChatMessageInput = ({ hasSkillsTrigger && !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills); const skillsSearchQuery = skillsTrigger?.query ?? ""; + // Until workspace skills resolve, collisions are unknown, so keep + // personal triggers qualified; a qualified alias always resolves. + const workspaceSkillsKnown = + !workspaceSkillsQueryEnabled || workspaceSkillsQuery.data !== undefined; const workspaceSkillNames = new Set( loadedWorkspaceSkills.map((skill) => skill.name), ); @@ -658,7 +665,7 @@ const ChatMessageInput = ({ createSkillMenuItem( "personal", skill, - workspaceSkillNames.has(skill.name), + !workspaceSkillsKnown || workspaceSkillNames.has(skill.name), ), ), skillsSearchQuery, From 38bf5323a68a8633a722504d2695439f9b210a31 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:24:26 +0000 Subject: [PATCH 04/13] refactor(coderd): source workspace skills from pushed context snapshots The slash menu now reads the same agent-pushed context inventory that chats pin and read_skill resolves from, instead of dialing the agent for the legacy context-config path. The endpoint is read-gated end to end, matching the dbauthz posture of the snapshot tables and the chat context exposure. Qualified personal triggers stay searchable while the displayed trigger is bare. --- coderd/export_test.go | 11 - coderd/workspaceskills.go | 74 ++--- coderd/workspaceskills_test.go | 279 +++++------------- coderd/x/chatd/context_prompt.go | 8 + .../ChatMessageInput.stories.tsx | 17 ++ .../ChatMessageInput/ChatMessageInput.tsx | 4 +- .../ChatMessageInput/SkillsTriggerMenu.tsx | 5 + .../AgentsPage/utils/personalSkills.test.ts | 15 + .../pages/AgentsPage/utils/personalSkills.ts | 12 +- 9 files changed, 152 insertions(+), 273 deletions(-) diff --git a/coderd/export_test.go b/coderd/export_test.go index aa1137ce0cc..186cf28c8d7 100644 --- a/coderd/export_test.go +++ b/coderd/export_test.go @@ -1,16 +1,5 @@ package coderd -import "github.com/coder/coder/v2/coderd/workspaceapps" - -// SetAgentProviderForTest replaces the workspace agent provider for external tests. -func SetAgentProviderForTest(api *API, provider workspaceapps.AgentProvider) func() { - previous := api.agentProvider - api.agentProvider = provider - return func() { - api.agentProvider = previous - } -} - // ChatStartWorkspace exposes chatStartWorkspace for external tests. // // chatStartWorkspace is intentionally unexported to keep symmetry with diff --git a/coderd/workspaceskills.go b/coderd/workspaceskills.go index bf961c9f356..a3466a7df86 100644 --- a/coderd/workspaceskills.go +++ b/coderd/workspaceskills.go @@ -3,24 +3,16 @@ package coderd import ( "context" "net/http" - "time" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/agentselect" - "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" ) -const ( - workspaceSkillsAgentConnTimeout = 30 * time.Second - workspaceSkillsContextConfigTimeout = 5 * time.Second -) - // @Summary List workspace skills // @ID list-workspace-skills // @Security CoderSessionToken @@ -35,10 +27,6 @@ func (api *API) getWorkspaceSkills(rw http.ResponseWriter, r *http.Request) { // workspace := httpmw.WorkspaceParam(r) logger := api.Logger.With(slog.F("workspace_id", workspace.ID)) - if !api.Authorize(r, policy.ActionSSH, workspace) { - httpapi.Forbidden(rw) - return - } if workspace.Deleted { writeWorkspaceSkills(ctx, rw, nil) return @@ -83,56 +71,28 @@ func (api *API) getWorkspaceSkills(rw http.ResponseWriter, r *http.Request) { // return } - apiAgent, err := db2sdk.WorkspaceAgent( - api.DERPMap(), - *api.TailnetCoordinator.Load(), - agent, - nil, - nil, - nil, - api.AgentInactiveDisconnectTimeout, - api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), - ) + // The agent-pushed context snapshot is the same inventory chats pin and + // read_skill resolves from, so the slash menu matches skill resolution. + // An agent that has not pushed a snapshot yet simply has no rows. + resources, err := api.Database.ListWorkspaceAgentContextResources(ctx, agent.ID) if err != nil { httpapi.InternalServerError(rw, err) return } - if apiAgent.Status != codersdk.WorkspaceAgentConnected { - writeWorkspaceSkills(ctx, rw, nil) - return - } - - dialCtx, cancel := context.WithTimeout(ctx, workspaceSkillsAgentConnTimeout) - conn, release, err := api.agentProvider.AgentConn(dialCtx, agent.ID) - cancel() - if err != nil { - logger.Debug(ctx, "failed to dial workspace skills agent", slog.F("agent_id", agent.ID), slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ - Message: "Failed to connect to workspace agent.", - Detail: err.Error(), - }) - return - } - defer release() - - configCtx, cancel := context.WithTimeout(ctx, workspaceSkillsContextConfigTimeout) - cfg, err := conn.ContextConfig(configCtx) - cancel() - if err != nil { - logger.Debug(ctx, "failed to fetch workspace skills context config", slog.F("agent_id", agent.ID), slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ - Message: "Failed to fetch workspace skills from agent.", - Detail: err.Error(), - }) - return - } - metas := chattool.SkillMetasFromContextParts(cfg.Parts) - skills := make([]codersdk.WorkspaceSkillMetadata, 0, len(metas)) - for _, meta := range metas { + skills := make([]codersdk.WorkspaceSkillMetadata, 0, len(resources)) + for _, resource := range resources { + if resource.BodyKind != database.WorkspaceAgentContextBodyKindSkill || + resource.Status != database.WorkspaceAgentContextResourceStatusOk { + continue + } + name, description, ok := chatd.SkillIdentityFromResourceBody(resource.Body) + if !ok || name == "" { + continue + } skills = append(skills, codersdk.WorkspaceSkillMetadata{ - Name: meta.Name, - Description: meta.Description, + Name: name, + Description: description, }) } writeWorkspaceSkills(ctx, rw, skills) diff --git a/coderd/workspaceskills_test.go b/coderd/workspaceskills_test.go index f35848df238..5902550b026 100644 --- a/coderd/workspaceskills_test.go +++ b/coderd/workspaceskills_test.go @@ -2,26 +2,18 @@ package coderd_test import ( "context" + "encoding/json" "net/http" - "sync" - "sync/atomic" "testing" - "time" "github.com/google/uuid" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - "golang.org/x/xerrors" - "github.com/coder/coder/v2/agent/agenttest" - "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/rbac" - "github.com/coder/coder/v2/coderd/workspaceapps" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/codersdk/workspacesdk" - "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/testutil" ) @@ -49,111 +41,34 @@ func TestGetWorkspaceSkills(t *testing.T) { workspace := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - _ = agenttest.New(t, client.URL, agentToken) - coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() + ws, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.NotEmpty(t, ws.LatestBuild.Resources) + require.NotEmpty(t, ws.LatestBuild.Resources[0].Agents) + agentID := ws.LatestBuild.Resources[0].Agents[0].ID + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _, err = codersdk.NewExperimentalClient(memberClient).WorkspaceSkills(ctx, workspace.ID) + requireWorkspaceSkillsSDKError(t, err, http.StatusNotFound) + + // The agent has not pushed a context snapshot yet. + skills, err := expClient.WorkspaceSkills(ctx, workspace.ID) + require.NoError(t, err) + require.Empty(t, skills) - readOnlyClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(user.OrganizationID)) - _, err := codersdk.NewExperimentalClient(readOnlyClient).WorkspaceSkills(ctx, workspace.ID) - requireWorkspaceSkillsSDKError(t, err, http.StatusForbidden, "", "") + insertWorkspaceSkillsSnapshot(ctx, t, api.Database, agentID) - expectedSkills := []codersdk.WorkspaceSkillMetadata{{ + skills, err = expClient.WorkspaceSkills(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, []codersdk.WorkspaceSkillMetadata{{ Name: "review-code", Description: "Review code", - }} - for _, tt := range []struct { - name string - provider func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider - wantSkills []codersdk.WorkspaceSkillMetadata - wantStatus int - wantMessage string - wantDetail string - wantRelease bool - wantConfigDeadline bool - }{ - { - name: "dial failure", - provider: func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider { - return workspaceSkillsAgentProvider{ - agentConn: func(ctx context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { - deadlines.recordDial(ctx) - return nil, nil, xerrors.New("dial failure") - }, - } - }, - wantStatus: http.StatusBadGateway, - wantMessage: "Failed to connect to workspace agent.", - wantDetail: "dial failure", - }, - { - name: "context config failure", - provider: func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider { - conn := agentconnmock.NewMockAgentConn(gomock.NewController(t)) - conn.EXPECT().ContextConfig(gomock.Any()).DoAndReturn(func(ctx context.Context) (workspacesdk.ContextConfigResponse, error) { - deadlines.recordConfig(ctx) - return workspacesdk.ContextConfigResponse{}, xerrors.New("context config failure") - }) - return workspaceSkillsAgentProvider{ - agentConn: func(ctx context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { - deadlines.recordDial(ctx) - return conn, func() { *releaseCalled = true }, nil - }, - } - }, - wantStatus: http.StatusBadGateway, - wantMessage: "Failed to fetch workspace skills from agent.", - wantDetail: "context config failure", - wantRelease: true, - wantConfigDeadline: true, - }, - { - name: "success", - provider: func(t *testing.T, releaseCalled *bool, deadlines *workspaceSkillsDeadlineRecorder) workspaceSkillsAgentProvider { - conn := agentconnmock.NewMockAgentConn(gomock.NewController(t)) - conn.EXPECT().ContextConfig(gomock.Any()).DoAndReturn(func(ctx context.Context) (workspacesdk.ContextConfigResponse, error) { - deadlines.recordConfig(ctx) - return workspacesdk.ContextConfigResponse{ - Parts: []codersdk.ChatMessagePart{{ - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "review-code", - SkillDescription: "Review code", - }}, - }, nil - }) - return workspaceSkillsAgentProvider{ - agentConn: func(ctx context.Context, _ uuid.UUID) (workspacesdk.AgentConn, func(), error) { - deadlines.recordDial(ctx) - return conn, func() { *releaseCalled = true }, nil - }, - } - }, - wantSkills: expectedSkills, - wantRelease: true, - wantConfigDeadline: true, - }, - } { - releaseCalled := false - deadlines := &workspaceSkillsDeadlineRecorder{} - restore := coderd.SetAgentProviderForTest(api, tt.provider(t, &releaseCalled, deadlines)) - skills, err := expClient.WorkspaceSkills(ctx, workspace.ID) - restore() - - if tt.wantStatus != 0 { - requireWorkspaceSkillsSDKError(t, err, tt.wantStatus, tt.wantMessage, tt.wantDetail) - } else { - require.NoError(t, err, tt.name) - require.Equal(t, tt.wantSkills, skills, tt.name) - } - require.Equal(t, tt.wantRelease, releaseCalled, tt.name) - deadlines.requireDial(t, tt.name, 30*time.Second) - if tt.wantConfigDeadline { - deadlines.requireConfig(t, tt.name, 5*time.Second) - } else { - deadlines.requireNoConfig(t, tt.name) - } - } + }}, skills) workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - requireWorkspaceSkillsEmptyWithoutDial(ctx, t, expClient, api, workspace.ID) + skills, err = expClient.WorkspaceSkills(ctx, workspace.ID) + require.NoError(t, err) + require.Empty(t, skills) badVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -170,107 +85,69 @@ func TestGetWorkspaceSkills(t *testing.T) { }) failedBuild = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, failedBuild.ID) require.Equal(t, codersdk.ProvisionerJobFailed, failedBuild.Job.Status) - requireWorkspaceSkillsEmptyWithoutDial(ctx, t, expClient, api, workspace.ID) -} - -type workspaceSkillsAgentProvider struct { - workspaceapps.AgentProvider - agentConn func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) -} - -func (p workspaceSkillsAgentProvider) AgentConn(ctx context.Context, agentID uuid.UUID) (workspacesdk.AgentConn, func(), error) { - return p.agentConn(ctx, agentID) -} - -type workspaceSkillsDeadlineRecorder struct { - mu sync.Mutex - dial workspaceSkillsDeadlineObservation - config workspaceSkillsDeadlineObservation -} - -type workspaceSkillsDeadlineObservation struct { - seen bool - ok bool - observed time.Time - deadline time.Time -} - -func (r *workspaceSkillsDeadlineRecorder) recordDial(ctx context.Context) { - r.record(ctx, &r.dial) -} - -func (r *workspaceSkillsDeadlineRecorder) recordConfig(ctx context.Context) { - r.record(ctx, &r.config) -} - -func (r *workspaceSkillsDeadlineRecorder) record(ctx context.Context, observation *workspaceSkillsDeadlineObservation) { - deadline, ok := ctx.Deadline() - r.mu.Lock() - defer r.mu.Unlock() - *observation = workspaceSkillsDeadlineObservation{ - seen: true, - ok: ok, - observed: time.Now(), - deadline: deadline, - } -} - -func (r *workspaceSkillsDeadlineRecorder) requireDial(t testing.TB, name string, want time.Duration) { - t.Helper() - r.requireDeadline(t, name, "dial", &r.dial, want) -} - -func (r *workspaceSkillsDeadlineRecorder) requireConfig(t testing.TB, name string, want time.Duration) { - t.Helper() - r.requireDeadline(t, name, "context config", &r.config, want) -} - -func (r *workspaceSkillsDeadlineRecorder) requireNoConfig(t testing.TB, name string) { - t.Helper() - r.mu.Lock() - observation := r.config - r.mu.Unlock() - require.False(t, observation.seen, "%s: context config deadline recorded", name) -} - -func (r *workspaceSkillsDeadlineRecorder) requireDeadline(t testing.TB, name string, label string, observed *workspaceSkillsDeadlineObservation, want time.Duration) { - t.Helper() - r.mu.Lock() - observation := *observed - r.mu.Unlock() - require.True(t, observation.seen, "%s: %s deadline was not recorded", name, label) - require.True(t, observation.ok, "%s: %s context has no deadline", name, label) - remaining := observation.deadline.Sub(observation.observed) - require.Greater(t, remaining, want-2*time.Second, "%s: %s deadline too short", name, label) - require.LessOrEqual(t, remaining, want, "%s: %s deadline too long", name, label) + skills, err = expClient.WorkspaceSkills(ctx, workspace.ID) + require.NoError(t, err) + require.Empty(t, skills) } -func requireWorkspaceSkillsEmptyWithoutDial(ctx context.Context, t testing.TB, expClient *codersdk.ExperimentalClient, api *coderd.API, workspaceID uuid.UUID) { +// insertWorkspaceSkillsSnapshot stores a pushed context snapshot with one +// healthy skill, one failed skill, and one instruction file. Only the +// healthy skill should surface through the endpoint. +func insertWorkspaceSkillsSnapshot(ctx context.Context, t *testing.T, db database.Store, agentID uuid.UUID) { t.Helper() - var called atomic.Bool - restore := coderd.SetAgentProviderForTest(api, workspaceSkillsAgentProvider{ - agentConn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { - called.Store(true) - return nil, nil, xerrors.New("workspace skills should not dial agent") - }, + sysCtx := dbauthz.AsSystemRestricted(ctx) + now := dbtime.Now() + + _, err := db.UpsertWorkspaceAgentContextSnapshot(sysCtx, database.UpsertWorkspaceAgentContextSnapshotParams{ + WorkspaceAgentID: agentID, + Version: 1, + AggregateHash: []byte("aggregate-hash"), + ReceivedAt: now, }) - skills, err := expClient.WorkspaceSkills(ctx, workspaceID) - restore() require.NoError(t, err) - require.Empty(t, skills) - require.False(t, called.Load()) + + for _, resource := range []database.UpsertWorkspaceAgentContextResourceParams{ + { + WorkspaceAgentID: agentID, + Source: "/workspace/.agents/skills/review-code", + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: json.RawMessage(`{"name":"review-code","description":"Review code"}`), + ContentHash: []byte("hash-review-code"), + SizeBytes: 64, + Status: database.WorkspaceAgentContextResourceStatusOk, + Now: now, + }, + { + WorkspaceAgentID: agentID, + Source: "/workspace/.agents/skills/broken", + BodyKind: database.WorkspaceAgentContextBodyKindSkill, + Body: json.RawMessage(`{}`), + ContentHash: []byte("hash-broken"), + SizeBytes: 0, + Status: database.WorkspaceAgentContextResourceStatusUnreadable, + Error: "read failed", + Now: now, + }, + { + WorkspaceAgentID: agentID, + Source: "/workspace/AGENTS.md", + BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, + Body: json.RawMessage(`{"content":"cnVsZXM="}`), + ContentHash: []byte("hash-agents-md"), + SizeBytes: 5, + Status: database.WorkspaceAgentContextResourceStatusOk, + Now: now, + }, + } { + _, err := db.UpsertWorkspaceAgentContextResource(sysCtx, resource) + require.NoError(t, err) + } } -func requireWorkspaceSkillsSDKError(t testing.TB, err error, statusCode int, message string, detail string) { +func requireWorkspaceSkillsSDKError(t testing.TB, err error, statusCode int) { t.Helper() require.Error(t, err) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, statusCode, sdkErr.StatusCode()) - if message != "" { - require.Equal(t, message, sdkErr.Message) - } - if detail != "" { - require.Equal(t, detail, sdkErr.Detail) - } } diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 7dd8fe90911..97fc910a3e0 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -157,6 +157,14 @@ func decodeInstructionContent(body json.RawMessage) (content string, decoded boo return SanitizePromptText(string(decodedBody.GetContent())), true } +// SkillIdentityFromResourceBody decodes a protojson skill resource body +// written by the agent context push path and returns its name and +// description. ok is false when the body cannot be decoded; callers should +// skip skills with an empty name. +func SkillIdentityFromResourceBody(body json.RawMessage) (name, description string, ok bool) { + return decodeSkillIdentity(body) +} + // decodeSkillIdentity decodes a skill resource body and returns its name and // description. decoded is false when the body cannot be decoded, letting the // prompt path count it as malformed; callers skip a skill with an empty name. diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 0d94676a68d..c93b2f3444c 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -227,6 +227,23 @@ export const PersonalTriggersQualifiedWhileWorkspaceSkillsUnknown: Story = { }, }; +export const QualifiedPersonalQueryMatchesBareTrigger: Story = { + args: { + workspaceId: "workspace-1", + // Workspace skills resolve without collisions, so personal items + // display bare triggers while the typed query stays qualified. + workspaceSkillsOverride: mockWorkspaceSkills, + }, + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/personal/rev"); + expect(await findVisibleText("/reviewer")).toBeDefined(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => { + expect(editor.textContent).toBe("/reviewer"); + }); + }, +}; + export const UniqueWorkspaceQualifiedPrefixStaysSearchable: Story = { args: { workspaceId: "workspace-1", diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 5685eb7ea57..fbde24f5f56 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -627,8 +627,8 @@ const ChatMessageInput = ({ const workspaceSkillsQuery = useQuery({ ...workspaceSkills(workspaceId ?? ""), enabled: workspaceSkillsQueryEnabled, - // An empty list is transient (workspace stopped or starting, agent - // not connected yet), so only cache non-empty results. + // An empty list is transient (workspace stopped or starting, or the + // agent has not pushed context yet), so only cache non-empty results. staleTime: (query) => query.state.data && query.state.data.length > 0 ? 60_000 : 0, }); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index 87948d580ae..c5a5d3481b1 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -31,6 +31,10 @@ type SkillMetadata = { export type SkillMenuItem = SkillMetadata & { source: SkillSource; triggerText: string; + // The qualified alias stays searchable even when the displayed + // trigger is bare, so a typed qualified query keeps matching after + // collision state changes mid-trigger. + altTriggerText: string; }; export const createSkillMenuItem = ( @@ -44,6 +48,7 @@ export const createSkillMenuItem = ( description: skill.description, source, triggerText: qualifyTrigger ? `/${source}/${skill.name}` : `/${skill.name}`, + altTriggerText: `/${source}/${skill.name}`, }); type SkillsTriggerMenuProps = { diff --git a/site/src/pages/AgentsPage/utils/personalSkills.test.ts b/site/src/pages/AgentsPage/utils/personalSkills.test.ts index 83f6c9bfb7c..bd4ccb828d6 100644 --- a/site/src/pages/AgentsPage/utils/personalSkills.test.ts +++ b/site/src/pages/AgentsPage/utils/personalSkills.test.ts @@ -87,6 +87,21 @@ describe("filterSkillsByQuery", () => { ), ).toEqual(["/workspace/test-runner"]); }); + + it("matches the alternate qualified trigger of a bare item", () => { + const skills = [ + { + name: "reviewer", + description: "Review changed files", + triggerText: "/reviewer", + altTriggerText: "/personal/reviewer", + }, + ]; + + expect( + filterSkillsByQuery(skills, "personal/rev").map(({ name }) => name), + ).toEqual(["reviewer"]); + }); }); describe("personal skill slash triggers", () => { diff --git a/site/src/pages/AgentsPage/utils/personalSkills.ts b/site/src/pages/AgentsPage/utils/personalSkills.ts index a5b1d9da646..435951bc852 100644 --- a/site/src/pages/AgentsPage/utils/personalSkills.ts +++ b/site/src/pages/AgentsPage/utils/personalSkills.ts @@ -18,6 +18,9 @@ type SkillSearchMetadata = { name: string; description: string; triggerText?: string; + // Alternate trigger form that stays searchable regardless of which + // form is displayed, e.g. the qualified /source/name alias. + altTriggerText?: string; }; type RankedSkill = { @@ -71,16 +74,21 @@ export const filterSkillsByQuery = ( const triggerText = skill.triggerText ?.replace(/^\//, "") .toLocaleLowerCase("en-US"); + const altTriggerText = skill.altTriggerText + ?.replace(/^\//, "") + .toLocaleLowerCase("en-US"); const description = skill.description.toLocaleLowerCase("en-US"); let rank: number | undefined; if ( name.startsWith(normalizedQuery) || - triggerText?.startsWith(normalizedQuery) + triggerText?.startsWith(normalizedQuery) || + altTriggerText?.startsWith(normalizedQuery) ) { rank = 0; } else if ( name.includes(normalizedQuery) || - triggerText?.includes(normalizedQuery) + triggerText?.includes(normalizedQuery) || + altTriggerText?.includes(normalizedQuery) ) { rank = 1; } else if (description.includes(normalizedQuery)) { From 4dd4682811945c3711462e2169b9be45ae1172e9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:38:41 +0000 Subject: [PATCH 05/13] fix(coderd): require SSH access for workspace skills listing --- coderd/workspaceskills.go | 7 +++++++ coderd/workspaceskills_test.go | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/coderd/workspaceskills.go b/coderd/workspaceskills.go index a3466a7df86..f9f65059793 100644 --- a/coderd/workspaceskills.go +++ b/coderd/workspaceskills.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/agentselect" "github.com/coder/coder/v2/codersdk" @@ -27,6 +28,12 @@ func (api *API) getWorkspaceSkills(rw http.ResponseWriter, r *http.Request) { // workspace := httpmw.WorkspaceParam(r) logger := api.Logger.With(slog.F("workspace_id", workspace.ID)) + // Match chat workspace binding: listing skills is part of attaching a + // workspace to a chat, which requires SSH-level access, not just read. + if !api.Authorize(r, policy.ActionSSH, workspace) { + httpapi.Forbidden(rw) + return + } if workspace.Deleted { writeWorkspaceSkills(ctx, rw, nil) return diff --git a/coderd/workspaceskills_test.go b/coderd/workspaceskills_test.go index 5902550b026..c2cc077c423 100644 --- a/coderd/workspaceskills_test.go +++ b/coderd/workspaceskills_test.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/testutil" @@ -51,6 +52,11 @@ func TestGetWorkspaceSkills(t *testing.T) { _, err = codersdk.NewExperimentalClient(memberClient).WorkspaceSkills(ctx, workspace.ID) requireWorkspaceSkillsSDKError(t, err, http.StatusNotFound) + // Read access without SSH access is not enough. + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(user.OrganizationID)) + _, err = codersdk.NewExperimentalClient(templateAdminClient).WorkspaceSkills(ctx, workspace.ID) + requireWorkspaceSkillsSDKError(t, err, http.StatusForbidden) + // The agent has not pushed a context snapshot yet. skills, err := expClient.WorkspaceSkills(ctx, workspace.ID) require.NoError(t, err) From 3cc5e382f40a4481b7296ffe0b2e69796e52be13 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:52:36 +0000 Subject: [PATCH 06/13] fix(site/src/pages/AgentsPage/components/ChatMessageInput): refetch workspace skills on each menu open --- .../components/ChatMessageInput/ChatMessageInput.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index fbde24f5f56..b76cacd3e19 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -624,13 +624,12 @@ const ChatMessageInput = ({ // Avoid refetching on each trigger toggle from caret movement. staleTime: 60_000, }); + // No staleTime: the list tracks the workspace's build and agent context + // snapshot, so each menu open refetches (a cheap DB-backed read) while + // the cached list keeps rendering during the refetch. const workspaceSkillsQuery = useQuery({ ...workspaceSkills(workspaceId ?? ""), enabled: workspaceSkillsQueryEnabled, - // An empty list is transient (workspace stopped or starting, or the - // agent has not pushed context yet), so only cache non-empty results. - staleTime: (query) => - query.state.data && query.state.data.length > 0 ? 60_000 : 0, }); const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? []; const loadedWorkspaceSkills = From 355323a706900933ae315be2fa6b06843518ccf2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:10:35 +0000 Subject: [PATCH 07/13] refactor: source slash menu workspace skills from chat context only Remove the /api/experimental/workspaces/{workspace}/skills endpoint. The slash menu now reads workspace skills from the chat's pinned context resources, which the chat page already fetches and which is the same inventory read_skill resolves from. Before a chat binds a workspace, the menu lists personal skills only, keeping their triggers qualified while collisions are unknown. --- coderd/apidoc/docs.go | 54 +----- coderd/apidoc/swagger.json | 50 +----- coderd/coderd.go | 7 - coderd/workspaceskills.go | 113 ------------- coderd/workspaceskills_test.go | 159 ------------------ coderd/x/chatd/chattool/skill.go | 19 --- coderd/x/chatd/chattool/skill_test.go | 27 --- coderd/x/chatd/context_prompt.go | 8 - codersdk/chats.go | 4 +- codersdk/workspaceskills.go | 35 ---- docs/reference/api/schemas.md | 18 +- site/src/api/api.ts | 11 -- site/src/api/queries/workspaceSkills.ts | 11 -- site/src/api/typesGenerated.ts | 18 +- .../AgentsPage/components/AgentChatInput.tsx | 15 +- .../ChatMessageInput.stories.tsx | 29 ++-- .../ChatMessageInput/ChatMessageInput.tsx | 59 ++----- .../SkillsTriggerMenu.stories.tsx | 16 +- .../ChatMessageInput/SkillsTriggerMenu.tsx | 12 +- .../AgentsPage/components/ChatPageContent.tsx | 7 +- 20 files changed, 60 insertions(+), 612 deletions(-) delete mode 100644 coderd/workspaceskills.go delete mode 100644 coderd/workspaceskills_test.go delete mode 100644 codersdk/workspaceskills.go delete mode 100644 site/src/api/queries/workspaceSkills.ts diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7db7aa0da46..54add36baea 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1272,47 +1272,6 @@ const docTemplate = `{ } } }, - "/api/experimental/workspaces/{workspace}/skills": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Workspaces" - ], - "summary": "List workspace skills", - "operationId": "list-workspace-skills", - "parameters": [ - { - "type": "string", - "format": "uuid", - "description": "Workspace ID", - "name": "workspace", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceSkillMetadata" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, "/api/v2/": { "get": { "produces": [ @@ -17362,7 +17321,7 @@ const docTemplate = `{ "type": "string" }, "context_file_agent_id": { - "description": "ContextFileAgentID is the workspace agent that provided\nthis context part. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", + "description": "ContextFileAgentID is the workspace agent that provided\nthis context file. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", "format": "uuid", "allOf": [ { @@ -27680,17 +27639,6 @@ const docTemplate = `{ } } }, - "codersdk.WorkspaceSkillMetadata": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d0ee62a7144..5687248ce4f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1127,43 +1127,6 @@ } } }, - "/api/experimental/workspaces/{workspace}/skills": { - "get": { - "produces": ["application/json"], - "tags": ["Workspaces"], - "summary": "List workspace skills", - "operationId": "list-workspace-skills", - "parameters": [ - { - "type": "string", - "format": "uuid", - "description": "Workspace ID", - "name": "workspace", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/codersdk.WorkspaceSkillMetadata" - } - } - } - }, - "security": [ - { - "CoderSessionToken": [] - } - ], - "x-apidocgen": { - "skip": true - } - } - }, "/api/v2/": { "get": { "produces": ["application/json"], @@ -15626,7 +15589,7 @@ "type": "string" }, "context_file_agent_id": { - "description": "ContextFileAgentID is the workspace agent that provided\nthis context part. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", + "description": "ContextFileAgentID is the workspace agent that provided\nthis context file. Used to detect when the agent changes\n(e.g. workspace rebuilt) so instruction files can be\nre-persisted with fresh content.", "format": "uuid", "allOf": [ { @@ -25504,17 +25467,6 @@ } } }, - "codersdk.WorkspaceSkillMetadata": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, "codersdk.WorkspaceStatus": { "type": "string", "enum": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 1690bd3f724..aabd12188c0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1305,13 +1305,6 @@ func New(options *Options) *API { r.Delete("/", api.deleteUserAIProviderKey) }) }) - r.Route("/workspaces/{workspace}/skills", func(r chi.Router) { - r.Use( - apiKeyMiddleware, - httpmw.ExtractWorkspaceParam(options.Database), - ) - r.Get("/", api.getWorkspaceSkills) - }) r.Route("/chats", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/workspaceskills.go b/coderd/workspaceskills.go deleted file mode 100644 index f9f65059793..00000000000 --- a/coderd/workspaceskills.go +++ /dev/null @@ -1,113 +0,0 @@ -package coderd - -import ( - "context" - "net/http" - - "cdr.dev/slog/v3" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac/policy" - "github.com/coder/coder/v2/coderd/x/chatd" - "github.com/coder/coder/v2/coderd/x/chatd/agentselect" - "github.com/coder/coder/v2/codersdk" -) - -// @Summary List workspace skills -// @ID list-workspace-skills -// @Security CoderSessionToken -// @Produce json -// @Tags Workspaces -// @Param workspace path string true "Workspace ID" format(uuid) -// @Success 200 {array} codersdk.WorkspaceSkillMetadata -// @Router /api/experimental/workspaces/{workspace}/skills [get] -// @x-apidocgen {"skip": true} -func (api *API) getWorkspaceSkills(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route. - ctx := r.Context() - workspace := httpmw.WorkspaceParam(r) - logger := api.Logger.With(slog.F("workspace_id", workspace.ID)) - - // Match chat workspace binding: listing skills is part of attaching a - // workspace to a chat, which requires SSH-level access, not just read. - if !api.Authorize(r, policy.ActionSSH, workspace) { - httpapi.Forbidden(rw) - return - } - if workspace.Deleted { - writeWorkspaceSkills(ctx, rw, nil) - return - } - - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - if build.Transition != database.WorkspaceTransitionStart { - writeWorkspaceSkills(ctx, rw, nil) - return - } - job, err := api.Database.GetProvisionerJobByID(ctx, build.JobID) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - if job.JobStatus != database.ProvisionerJobStatusSucceeded { - writeWorkspaceSkills(ctx, rw, nil) - return - } - - agents, err := api.Database.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - if len(agents) == 0 { - writeWorkspaceSkills(ctx, rw, nil) - return - } - - agent, err := agentselect.FindChatAgent(agents) - if err != nil { - logger.Debug(ctx, "failed to select workspace skills agent", slog.Error(err)) - httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ - Message: "Failed to select workspace skills agent.", - Detail: err.Error(), - }) - return - } - - // The agent-pushed context snapshot is the same inventory chats pin and - // read_skill resolves from, so the slash menu matches skill resolution. - // An agent that has not pushed a snapshot yet simply has no rows. - resources, err := api.Database.ListWorkspaceAgentContextResources(ctx, agent.ID) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - - skills := make([]codersdk.WorkspaceSkillMetadata, 0, len(resources)) - for _, resource := range resources { - if resource.BodyKind != database.WorkspaceAgentContextBodyKindSkill || - resource.Status != database.WorkspaceAgentContextResourceStatusOk { - continue - } - name, description, ok := chatd.SkillIdentityFromResourceBody(resource.Body) - if !ok || name == "" { - continue - } - skills = append(skills, codersdk.WorkspaceSkillMetadata{ - Name: name, - Description: description, - }) - } - writeWorkspaceSkills(ctx, rw, skills) -} - -func writeWorkspaceSkills(ctx context.Context, rw http.ResponseWriter, skills []codersdk.WorkspaceSkillMetadata) { - if skills == nil { - skills = []codersdk.WorkspaceSkillMetadata{} - } - httpapi.Write(ctx, rw, http.StatusOK, skills) -} diff --git a/coderd/workspaceskills_test.go b/coderd/workspaceskills_test.go deleted file mode 100644 index c2cc077c423..00000000000 --- a/coderd/workspaceskills_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package coderd_test - -import ( - "context" - "encoding/json" - "net/http" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "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/dbtime" - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/provisioner/echo" - "github.com/coder/coder/v2/testutil" -) - -func TestGetWorkspaceSkills(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitSuperLong) - client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - DeploymentValues: coderdtest.DeploymentValues(t), - IncludeProvisionerDaemon: true, - }) - user := coderdtest.CreateFirstUser(t, client) - expClient := codersdk.NewExperimentalClient(client) - - agentToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.PlanComplete, - ProvisionApply: echo.ApplyComplete, - ProvisionGraph: echo.ProvisionGraphWithAgent(agentToken), - }) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - ws, err := client.Workspace(ctx, workspace.ID) - require.NoError(t, err) - require.NotEmpty(t, ws.LatestBuild.Resources) - require.NotEmpty(t, ws.LatestBuild.Resources[0].Agents) - agentID := ws.LatestBuild.Resources[0].Agents[0].ID - - memberClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _, err = codersdk.NewExperimentalClient(memberClient).WorkspaceSkills(ctx, workspace.ID) - requireWorkspaceSkillsSDKError(t, err, http.StatusNotFound) - - // Read access without SSH access is not enough. - templateAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.ScopedRoleOrgTemplateAdmin(user.OrganizationID)) - _, err = codersdk.NewExperimentalClient(templateAdminClient).WorkspaceSkills(ctx, workspace.ID) - requireWorkspaceSkillsSDKError(t, err, http.StatusForbidden) - - // The agent has not pushed a context snapshot yet. - skills, err := expClient.WorkspaceSkills(ctx, workspace.ID) - require.NoError(t, err) - require.Empty(t, skills) - - insertWorkspaceSkillsSnapshot(ctx, t, api.Database, agentID) - - skills, err = expClient.WorkspaceSkills(ctx, workspace.ID) - require.NoError(t, err) - require.Equal(t, []codersdk.WorkspaceSkillMetadata{{ - Name: "review-code", - Description: "Review code", - }}, skills) - - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - skills, err = expClient.WorkspaceSkills(ctx, workspace.ID) - require.NoError(t, err) - require.Empty(t, skills) - - badVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.PlanComplete, - ProvisionApply: echo.ApplyFailed, - ProvisionGraph: echo.GraphComplete, - }, func(req *codersdk.CreateTemplateVersionRequest) { - req.TemplateID = template.ID - }) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, badVersion.ID) - coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, badVersion.ID) - failedBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart, func(req *codersdk.CreateWorkspaceBuildRequest) { - req.TemplateVersionID = badVersion.ID - }) - failedBuild = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, failedBuild.ID) - require.Equal(t, codersdk.ProvisionerJobFailed, failedBuild.Job.Status) - skills, err = expClient.WorkspaceSkills(ctx, workspace.ID) - require.NoError(t, err) - require.Empty(t, skills) -} - -// insertWorkspaceSkillsSnapshot stores a pushed context snapshot with one -// healthy skill, one failed skill, and one instruction file. Only the -// healthy skill should surface through the endpoint. -func insertWorkspaceSkillsSnapshot(ctx context.Context, t *testing.T, db database.Store, agentID uuid.UUID) { - t.Helper() - sysCtx := dbauthz.AsSystemRestricted(ctx) - now := dbtime.Now() - - _, err := db.UpsertWorkspaceAgentContextSnapshot(sysCtx, database.UpsertWorkspaceAgentContextSnapshotParams{ - WorkspaceAgentID: agentID, - Version: 1, - AggregateHash: []byte("aggregate-hash"), - ReceivedAt: now, - }) - require.NoError(t, err) - - for _, resource := range []database.UpsertWorkspaceAgentContextResourceParams{ - { - WorkspaceAgentID: agentID, - Source: "/workspace/.agents/skills/review-code", - BodyKind: database.WorkspaceAgentContextBodyKindSkill, - Body: json.RawMessage(`{"name":"review-code","description":"Review code"}`), - ContentHash: []byte("hash-review-code"), - SizeBytes: 64, - Status: database.WorkspaceAgentContextResourceStatusOk, - Now: now, - }, - { - WorkspaceAgentID: agentID, - Source: "/workspace/.agents/skills/broken", - BodyKind: database.WorkspaceAgentContextBodyKindSkill, - Body: json.RawMessage(`{}`), - ContentHash: []byte("hash-broken"), - SizeBytes: 0, - Status: database.WorkspaceAgentContextResourceStatusUnreadable, - Error: "read failed", - Now: now, - }, - { - WorkspaceAgentID: agentID, - Source: "/workspace/AGENTS.md", - BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile, - Body: json.RawMessage(`{"content":"cnVsZXM="}`), - ContentHash: []byte("hash-agents-md"), - SizeBytes: 5, - Status: database.WorkspaceAgentContextResourceStatusOk, - Now: now, - }, - } { - _, err := db.UpsertWorkspaceAgentContextResource(sysCtx, resource) - require.NoError(t, err) - } -} - -func requireWorkspaceSkillsSDKError(t testing.TB, err error, statusCode int) { - t.Helper() - require.Error(t, err) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, statusCode, sdkErr.StatusCode()) -} diff --git a/coderd/x/chatd/chattool/skill.go b/coderd/x/chatd/chattool/skill.go index 6f114c89461..f93786af464 100644 --- a/coderd/x/chatd/chattool/skill.go +++ b/coderd/x/chatd/chattool/skill.go @@ -12,7 +12,6 @@ import ( "golang.org/x/xerrors" skillspkg "github.com/coder/coder/v2/coderd/x/skills" - "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -49,24 +48,6 @@ type SkillMeta struct { Meta []byte } -// SkillMetasFromContextParts converts skill context parts into workspace skill -// metadata used by chat tools. Non-skill parts are ignored. -func SkillMetasFromContextParts(parts []codersdk.ChatMessagePart) []SkillMeta { - metas := make([]SkillMeta, 0, len(parts)) - for _, part := range parts { - if part.Type != codersdk.ChatMessagePartTypeSkill { - continue - } - metas = append(metas, SkillMeta{ - Name: part.SkillName, - Description: part.SkillDescription, - Dir: part.SkillDir, - MetaFile: part.ContextFileSkillMetaFile, - }) - } - return metas -} - // SkillContent is the full body of a skill, loaded on demand // when the model calls read_skill. type SkillContent struct { diff --git a/coderd/x/chatd/chattool/skill_test.go b/coderd/x/chatd/chattool/skill_test.go index 2ac18f6b9a8..717518cd3d4 100644 --- a/coderd/x/chatd/chattool/skill_test.go +++ b/coderd/x/chatd/chattool/skill_test.go @@ -15,7 +15,6 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chattool" skillspkg "github.com/coder/coder/v2/coderd/x/skills" - "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock" ) @@ -48,32 +47,6 @@ func responseDir(t *testing.T, resp fantasy.ToolResponse) string { return payload.Dir } -func TestSkillMetasFromContextParts(t *testing.T) { - t.Parallel() - - got := chattool.SkillMetasFromContextParts([]codersdk.ChatMessagePart{ - { - Type: codersdk.ChatMessagePartTypeContextFile, - ContextFilePath: "AGENTS.md", - ContextFileContent: "rules", - }, - { - Type: codersdk.ChatMessagePartTypeSkill, - SkillName: "review-code", - SkillDescription: "Review code", - SkillDir: "/workspace/.agents/skills/review-code", - ContextFileSkillMetaFile: "SKILL.md", - }, - }) - - require.Equal(t, []chattool.SkillMeta{{ - Name: "review-code", - Description: "Review code", - Dir: "/workspace/.agents/skills/review-code", - MetaFile: "SKILL.md", - }}, got) -} - func TestFormatResolvedSkillIndex(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/context_prompt.go b/coderd/x/chatd/context_prompt.go index 97fc910a3e0..7dd8fe90911 100644 --- a/coderd/x/chatd/context_prompt.go +++ b/coderd/x/chatd/context_prompt.go @@ -157,14 +157,6 @@ func decodeInstructionContent(body json.RawMessage) (content string, decoded boo return SanitizePromptText(string(decodedBody.GetContent())), true } -// SkillIdentityFromResourceBody decodes a protojson skill resource body -// written by the agent context push path and returns its name and -// description. ok is false when the body cannot be decoded; callers should -// skip skills with an empty name. -func SkillIdentityFromResourceBody(body json.RawMessage) (name, description string, ok bool) { - return decodeSkillIdentity(body) -} - // decodeSkillIdentity decodes a skill resource body and returns its name and // description. decoded is false when the body cannot be decoded, letting the // prompt path count it as malformed; callers skip a skill with an empty name. diff --git a/codersdk/chats.go b/codersdk/chats.go index 928cd9f0f86..aea0e0b4d7e 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -393,10 +393,10 @@ type ChatMessagePart struct { // instruction file limit and was truncated. ContextFileTruncated bool `json:"context_file_truncated,omitempty" variants:"context-file?"` // ContextFileAgentID is the workspace agent that provided - // this context part. Used to detect when the agent changes + // this context file. Used to detect when the agent changes // (e.g. workspace rebuilt) so instruction files can be // re-persisted with fresh content. - ContextFileAgentID uuid.NullUUID `json:"context_file_agent_id,omitempty" format:"uuid" variants:"context-file?,skill?"` + ContextFileAgentID uuid.NullUUID `json:"context_file_agent_id,omitempty" format:"uuid" variants:"context-file?"` // ContextFileOS 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 diff --git a/codersdk/workspaceskills.go b/codersdk/workspaceskills.go deleted file mode 100644 index 98ca99e3f6a..00000000000 --- a/codersdk/workspaceskills.go +++ /dev/null @@ -1,35 +0,0 @@ -package codersdk - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/google/uuid" -) - -// WorkspaceSkillMetadata represents a workspace skill without its raw Markdown content. -type WorkspaceSkillMetadata struct { - Name string `json:"name"` - Description string `json:"description"` -} - -func workspaceSkillsPath(workspaceID uuid.UUID) string { - return fmt.Sprintf("/api/experimental/workspaces/%s/skills", url.PathEscape(workspaceID.String())) -} - -// WorkspaceSkills lists workspace skill metadata for the specified workspace. -func (c *ExperimentalClient) WorkspaceSkills(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceSkillMetadata, error) { - res, err := c.Request(ctx, http.MethodGet, workspaceSkillsPath(workspaceID), nil) - if err != nil { - return nil, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) - } - var skills []WorkspaceSkillMetadata - return skills, json.NewDecoder(res.Body).Decode(&skills) -} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1250d594897..fcbe267ec20 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2880,7 +2880,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `args_delta` | string | false | | | | `completed_at` | string | 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](#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context part. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | +| `context_file_agent_id` | [uuid.NullUUID](#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. | | `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. | @@ -16502,22 +16502,6 @@ If the schedule is empty, the user will be updated to use the default schedule.| |------------------------------|----------------------------------------| | `shareable_workspace_owners` | `everyone`, `none`, `service_accounts` | -## codersdk.WorkspaceSkillMetadata - -```json -{ - "description": "string", - "name": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `description` | string | false | | | -| `name` | string | false | | | - ## codersdk.WorkspaceStatus ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8e6dc7618b9..e89a8f1994a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -439,8 +439,6 @@ const userSkillPath = (user: string, name: string) => `${userSkillsPath(user)}/${encodeURIComponent(name)}`; const userAIProviderKeysPath = (user = "me") => `/api/experimental/users/${encodeURIComponent(user)}/ai-provider-keys`; -const workspaceSkillsPath = (workspaceId: string) => - `/api/experimental/workspaces/${encodeURIComponent(workspaceId)}/skills`; const mcpServerConfigsPath = "/api/experimental/mcp/servers"; type ChatCostDateParams = { @@ -3860,15 +3858,6 @@ class ExperimentalApiMethods { return response.data; }; - getWorkspaceSkills = async ( - workspaceId: string, - ): Promise => { - const response = await this.axios.get( - workspaceSkillsPath(workspaceId), - ); - return response.data; - }; - getUserSkillByName = async ( user: string, name: string, diff --git a/site/src/api/queries/workspaceSkills.ts b/site/src/api/queries/workspaceSkills.ts deleted file mode 100644 index 69fdde501c6..00000000000 --- a/site/src/api/queries/workspaceSkills.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { API } from "#/api/api"; -import type * as TypesGen from "#/api/typesGenerated"; - -const workspaceSkillsKey = (workspaceId: string) => - ["workspace", workspaceId, "skills"] as const; - -export const workspaceSkills = (workspaceId: string) => ({ - queryKey: workspaceSkillsKey(workspaceId), - queryFn: (): Promise => - API.experimental.getWorkspaceSkills(workspaceId), -}); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8ccdc7bfedd..da54ce2ce04 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1798,7 +1798,7 @@ export interface ChatContextFilePart { readonly context_file_truncated?: boolean; /** * ContextFileAgentID is the workspace agent that provided - * this context part. Used to detect when the agent changes + * this context file. Used to detect when the agent changes * (e.g. workspace rebuilt) so instruction files can be * re-persisted with fresh content. */ @@ -3025,13 +3025,6 @@ export interface ChatSkillPart { * from the workspace's .agents/skills/ directory. */ readonly skill_name: string; - /** - * ContextFileAgentID is the workspace agent that provided - * this context part. Used to detect when the agent changes - * (e.g. workspace rebuilt) so instruction files can be - * re-persisted with fresh content. - */ - readonly context_file_agent_id?: string; /** * SkillDescription is the short description from the skill's * SKILL.md frontmatter. @@ -11039,15 +11032,6 @@ export interface WorkspaceSharingSettings { readonly shareable_workspace_owners: ShareableWorkspaceOwners; } -// From codersdk/workspaceskills.go -/** - * WorkspaceSkillMetadata represents a workspace skill without its raw Markdown content. - */ -export interface WorkspaceSkillMetadata { - readonly name: string; - readonly description: string; -} - // From codersdk/workspacebuilds.go export type WorkspaceStatus = | "canceled" diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index d4b7884c257..3fe1a3d9221 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -83,6 +83,7 @@ import { ChatMessageInput, type ChatMessageInputRef, } from "./ChatMessageInput/ChatMessageInput"; +import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu"; import type { AgentContextUsage } from "./ContextUsageIndicator"; import { ContextUsageIndicator } from "./ContextUsageIndicator"; import { ImageLightbox } from "./ImageLightbox"; @@ -190,7 +191,7 @@ interface AgentChatInputProps { selectedMCPServerIds?: readonly string[]; onMCPSelectionChange?: (ids: string[]) => void; onMCPAuthComplete?: (serverId: string) => void; - workspaceSkillsOverride?: readonly TypesGen.WorkspaceSkillMetadata[]; + workspaceSkills?: readonly SkillMetadata[]; workspace?: TypesGen.Workspace; workspaceAgent?: TypesGen.WorkspaceAgent; chatId?: string; @@ -399,7 +400,7 @@ export const AgentChatInput: FC = ({ selectedMCPServerIds, onMCPSelectionChange, onMCPAuthComplete, - workspaceSkillsOverride, + workspaceSkills, workspace, workspaceAgent, chatId, @@ -584,9 +585,9 @@ export const AgentChatInput: FC = ({ }); }; - const attachedWorkspaceId = attachedWorkspace?.id ?? workspace?.id; - const skillsWorkspaceId = - attachedWorkspaceId ?? selectedWorkspaceId ?? undefined; + const hasSkillsWorkspace = Boolean( + attachedWorkspace?.id ?? workspace?.id ?? selectedWorkspaceId, + ); const selectedWorkspace = workspaceOptions?.find( (ws) => ws.id === selectedWorkspaceId, @@ -1216,8 +1217,8 @@ export const AgentChatInput: FC = ({ onEnter={handleSubmit} sendShortcut={sendShortcut} disabled={isDisabled || isLoading} - workspaceId={skillsWorkspaceId} - workspaceSkillsOverride={workspaceSkillsOverride} + hasWorkspace={hasSkillsWorkspace} + workspaceSkills={workspaceSkills} autoFocus /> {/* Warn about invisible Unicode in the message text. diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index c93b2f3444c..e5ae23ea1fc 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -3,6 +3,7 @@ import { type PropsWithChildren, useEffect } from "react"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatMessageInput } from "./ChatMessageInput"; +import type { SkillMetadata } from "./SkillsTriggerMenu"; import { expectNoVisibleText, findVisibleText, @@ -11,7 +12,7 @@ import { } from "./storyHelpers"; // Override props keep skill menu stories deterministic without network calls. -const mockWorkspaceSkills: TypesGen.WorkspaceSkillMetadata[] = [ +const mockWorkspaceSkills: SkillMetadata[] = [ { name: "test-runner", description: "Run the workspace test command.", @@ -170,8 +171,8 @@ export const ClickSelectsSkill: Story = { export const OpensWithPersonalAndWorkspaceSkills: Story = { args: { - workspaceId: "workspace-1", - workspaceSkillsOverride: mockWorkspaceSkills, + hasWorkspace: true, + workspaceSkills: mockWorkspaceSkills, }, play: async ({ canvasElement }) => { await typeInEditor(canvasElement, "/"); @@ -184,8 +185,8 @@ export const OpensWithPersonalAndWorkspaceSkills: Story = { export const ArrowDownSelectsWorkspaceSkill: Story = { args: { - workspaceId: "workspace-1", - workspaceSkillsOverride: mockWorkspaceSkills, + hasWorkspace: true, + workspaceSkills: mockWorkspaceSkills, }, play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/"); @@ -199,8 +200,8 @@ export const ArrowDownSelectsWorkspaceSkill: Story = { export const CollidingPersonalSkillInsertsQualifiedTrigger: Story = { args: { - workspaceId: "workspace-1", - workspaceSkillsOverride: [ + hasWorkspace: true, + workspaceSkills: [ { name: "reviewer", description: "Workspace review process." }, ], }, @@ -217,9 +218,9 @@ export const CollidingPersonalSkillInsertsQualifiedTrigger: Story = { export const PersonalTriggersQualifiedWhileWorkspaceSkillsUnknown: Story = { args: { - // No workspaceSkillsOverride: the workspace skills query stays - // unresolved in the story environment, so collisions are unknown. - workspaceId: "workspace-unknown", + // No workspaceSkills: the chat's pinned context has not resolved, + // so collisions are unknown. + hasWorkspace: true, }, play: async ({ canvasElement }) => { await typeInEditor(canvasElement, "/rev"); @@ -229,10 +230,10 @@ export const PersonalTriggersQualifiedWhileWorkspaceSkillsUnknown: Story = { export const QualifiedPersonalQueryMatchesBareTrigger: Story = { args: { - workspaceId: "workspace-1", + hasWorkspace: true, // Workspace skills resolve without collisions, so personal items // display bare triggers while the typed query stays qualified. - workspaceSkillsOverride: mockWorkspaceSkills, + workspaceSkills: mockWorkspaceSkills, }, play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/personal/rev"); @@ -246,8 +247,8 @@ export const QualifiedPersonalQueryMatchesBareTrigger: Story = { export const UniqueWorkspaceQualifiedPrefixStaysSearchable: Story = { args: { - workspaceId: "workspace-1", - workspaceSkillsOverride: mockWorkspaceSkills, + hasWorkspace: true, + workspaceSkills: mockWorkspaceSkills, }, play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/workspace/t"); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index b76cacd3e19..fce0be2585b 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -34,7 +34,6 @@ import { } from "react"; import { useQuery } from "react-query"; import { userSkills } from "#/api/queries/userSkills"; -import { workspaceSkills } from "#/api/queries/workspaceSkills"; import type * as TypesGen from "#/api/typesGenerated"; import { cn } from "#/utils/cn"; import { isMobileViewport } from "#/utils/mobile"; @@ -62,6 +61,7 @@ import { import { createSkillMenuItem, type SkillMenuItem, + type SkillMetadata, SkillsTriggerMenu, } from "./SkillsTriggerMenu"; import { @@ -505,17 +505,21 @@ interface ChatMessageInputProps allowTextAttachmentPaste?: boolean; disabled?: boolean; autoFocus?: boolean; - workspaceId?: string; + /** + * True when a workspace is attached or selected, so workspace skills + * may exist even while workspaceSkills is still undefined. + */ + hasWorkspace?: boolean; /** * Story and test seam for deterministic personal skill menu data. */ personalSkillsOverride?: readonly TypesGen.UserSkillMetadata[]; /** - * Authoritative workspace skill menu data. Existing chats pass the - * chat's pinned context skills so the menu matches read_skill - * resolution. + * Workspace skill menu data from the chat's pinned context, so the + * menu matches read_skill resolution. Undefined until the chat's + * context resolves (or when no chat exists yet). */ - workspaceSkillsOverride?: readonly TypesGen.WorkspaceSkillMetadata[]; + workspaceSkills?: readonly SkillMetadata[]; "aria-label"?: string; } @@ -581,9 +585,9 @@ const ChatMessageInput = ({ allowTextAttachmentPaste, disabled, autoFocus, - workspaceId, + hasWorkspace, personalSkillsOverride, - workspaceSkillsOverride, + workspaceSkills, "aria-label": ariaLabel, ref, ...props @@ -613,49 +617,32 @@ const ChatMessageInput = ({ const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0); const hasSkillsTrigger = Boolean(skillsTrigger); const hasPersonalSkillsOverride = personalSkillsOverride !== undefined; - const hasWorkspaceSkillsOverride = workspaceSkillsOverride !== undefined; const personalSkillsQueryEnabled = hasSkillsTrigger && !hasPersonalSkillsOverride; - const workspaceSkillsQueryEnabled = - hasSkillsTrigger && Boolean(workspaceId) && !hasWorkspaceSkillsOverride; const skillsQuery = useQuery({ ...userSkills(), enabled: personalSkillsQueryEnabled, // Avoid refetching on each trigger toggle from caret movement. staleTime: 60_000, }); - // No staleTime: the list tracks the workspace's build and agent context - // snapshot, so each menu open refetches (a cheap DB-backed read) while - // the cached list keeps rendering during the refetch. - const workspaceSkillsQuery = useQuery({ - ...workspaceSkills(workspaceId ?? ""), - enabled: workspaceSkillsQueryEnabled, - }); const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? []; - const loadedWorkspaceSkills = - workspaceSkillsOverride ?? workspaceSkillsQuery.data ?? []; + const loadedWorkspaceSkills = workspaceSkills ?? []; // A stale empty cache with a refetch in flight must not dismiss the menu. const isResolvedEmptyPersonalSkills = hasPersonalSkillsOverride ? personalSkills.length === 0 : skillsQuery.isSuccess && !skillsQuery.isFetching && personalSkills.length === 0; - const isResolvedEmptyWorkspaceSkills = workspaceSkillsQueryEnabled - ? workspaceSkillsQuery.isSuccess && - !workspaceSkillsQuery.isFetching && - loadedWorkspaceSkills.length === 0 - : loadedWorkspaceSkills.length === 0; // When both loaded skills lists are empty, "/" is plain text. When // only the filtered result is empty, keep the menu open for the // no-match message. const skillsMenuOpen = hasSkillsTrigger && - !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills); + !(isResolvedEmptyPersonalSkills && loadedWorkspaceSkills.length === 0); const skillsSearchQuery = skillsTrigger?.query ?? ""; - // Until workspace skills resolve, collisions are unknown, so keep - // personal triggers qualified; a qualified alias always resolves. - const workspaceSkillsKnown = - !workspaceSkillsQueryEnabled || workspaceSkillsQuery.data !== undefined; + // Until the chat's pinned context resolves, collisions are unknown, so + // keep personal triggers qualified; a qualified alias always resolves. + const workspaceSkillsKnown = !hasWorkspace || workspaceSkills !== undefined; const workspaceSkillNames = new Set( loadedWorkspaceSkills.map((skill) => skill.name), ); @@ -966,7 +953,7 @@ const ChatMessageInput = ({ query={skillsSearchQuery} personalSkills={personalSkillItems} workspaceSkills={workspaceSkillItems} - workspaceSkillsEnabled={Boolean(workspaceId)} + workspaceSkillsEnabled={Boolean(hasWorkspace)} isPersonalLoading={ personalSkillsQueryEnabled && skillsQuery.isFetching && @@ -977,16 +964,6 @@ const ChatMessageInput = ({ skillsQuery.isError && skillsQuery.data === undefined } - isWorkspaceLoading={ - workspaceSkillsQueryEnabled && - workspaceSkillsQuery.isFetching && - workspaceSkillsQuery.data === undefined - } - isWorkspaceError={ - workspaceSkillsQueryEnabled && - workspaceSkillsQuery.isError && - workspaceSkillsQuery.data === undefined - } selectedIndex={selectedSkillIndex} onSelectedIndexChange={setSkillsMenuSelectedIndex} onSelect={replaceActiveSkillsTrigger} diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx index 871bdc81c53..3e131a4e1d1 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx @@ -1,15 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent } from "storybook/test"; -import type * as TypesGen from "#/api/typesGenerated"; import { filterSkillsByQuery } from "../../utils/personalSkills"; -import { createSkillMenuItem, SkillsTriggerMenu } from "./SkillsTriggerMenu"; +import { + createSkillMenuItem, + type SkillMetadata, + SkillsTriggerMenu, +} from "./SkillsTriggerMenu"; import { expectNoVisibleText, findVisibleText, MockSkills, } from "./storyHelpers"; -const mockWorkspaceSkills: TypesGen.WorkspaceSkillMetadata[] = [ +const mockWorkspaceSkills: SkillMetadata[] = [ { name: "test-runner", description: "Run the workspace test command.", @@ -91,18 +94,15 @@ export const Loading: Story = { }, }; -export const WorkspaceError: Story = { +export const EmptyWithWorkspace: Story = { args: { personalSkills: [], workspaceSkills: [], workspaceSkillsEnabled: true, - isWorkspaceError: true, }, play: async () => { expect( - await findVisibleText( - "Could not load workspace skills. Close and type / again to retry.", - ), + await findVisibleText("No personal or workspace skills found."), ).toBeDefined(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index c5a5d3481b1..c9653ab69d2 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -23,7 +23,7 @@ export type CaretAnchorRect = { type SkillSource = "personal" | "workspace"; -type SkillMetadata = { +export type SkillMetadata = { name: string; description: string; }; @@ -60,8 +60,6 @@ type SkillsTriggerMenuProps = { workspaceSkillsEnabled?: boolean; isPersonalLoading?: boolean; isPersonalError?: boolean; - isWorkspaceLoading?: boolean; - isWorkspaceError?: boolean; selectedIndex: number; onSelectedIndexChange: (index: number) => void; onSelect: (skill: SkillMenuItem) => void; @@ -125,8 +123,6 @@ export const SkillsTriggerMenu = ({ workspaceSkillsEnabled, isPersonalLoading, isPersonalError, - isWorkspaceLoading, - isWorkspaceError, selectedIndex, onSelectedIndexChange, onSelect, @@ -140,12 +136,6 @@ export const SkillsTriggerMenu = ({ isPersonalError && personalSkills.length === 0 ? "Could not load personal skills. Close and type / again to retry." : undefined, - isWorkspaceLoading && workspaceSkills.length === 0 - ? "Loading workspace skills..." - : undefined, - isWorkspaceError && workspaceSkills.length === 0 - ? "Could not load workspace skills. Close and type / again to retry." - : undefined, ].filter((item) => item !== undefined); const shouldRender = open && anchorRect; const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 9f163474dd5..7823e514122 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -39,6 +39,7 @@ import { } from "./ChatConversation/messageParsing"; import { useOnRenderProfiler } from "./ChatConversation/useOnRenderProfiler"; import type { ModelSelectorOption } from "./ChatElements"; +import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu"; type ChatStoreHandle = ReturnType["store"]; @@ -48,7 +49,7 @@ const isChatMessage = ( export const workspaceSkillsFromChatContext = ( context: TypesGen.ChatContext | undefined, -): TypesGen.WorkspaceSkillMetadata[] | undefined => +): SkillMetadata[] | undefined => context?.resources ? context.resources .filter( @@ -343,7 +344,7 @@ export const ChatPageInput: FC = ({ onError: () => toast.error("Failed to refresh context."), }) : undefined; - const workspaceSkillsOverride = workspaceSkillsFromChatContext(chatContext); + const workspaceSkills = workspaceSkillsFromChatContext(chatContext); const composeAttachments = useChatDraftAttachments(organizationId, chatId, { provider: getProviderForModelOption(modelOptions, selectedModel), }); @@ -536,7 +537,7 @@ export const ChatPageInput: FC = ({ selectedMCPServerIds={selectedMCPServerIds} onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} - workspaceSkillsOverride={workspaceSkillsOverride} + workspaceSkills={workspaceSkills} workspace={workspace} workspaceAgent={workspaceAgent} chatId={chatId} From aebea850f279b16879b66d7d936e52a277746208 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:31:28 +0000 Subject: [PATCH 08/13] fix(coderd/x/chatd): publish context event after first-turn pin A chat that binds its workspace agent on the first sent turn pins its context during generation preparation, after the client's send-time refetch already ran, so watching clients kept a cached chat detail without pinned resources until a manual reload. Broadcast the context watch event once first-turn hydration pins the chat so the open chat refetches its pinned resources. --- coderd/x/chatd/context_hydration.go | 39 +++++++++++--- .../chatd/context_hydration_internal_test.go | 53 ++++++++++++++++++- codersdk/chats.go | 5 +- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/coderd/x/chatd/context_hydration.go b/coderd/x/chatd/context_hydration.go index 147dbf36c75..9227efc7f35 100644 --- a/coderd/x/chatd/context_hydration.go +++ b/coderd/x/chatd/context_hydration.go @@ -94,13 +94,15 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store // 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 { +// would keep a later push from hydrating. hydrated reports whether a snapshot +// existed, meaning any NULL-hash chats bound to the agent are now pinned. +func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid.UUID) (hydrated bool, err error) { + err = database.ReadModifyUpdate(p.db, func(tx database.Store) error { aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, agentID) if err != nil { return err } + hydrated = ok if !ok { return nil } @@ -110,6 +112,10 @@ func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid ContextError: snapshotError, }) }) + if err != nil { + return false, err + } + return hydrated, nil } // hydrateChatContextOnCreate pins a newly created chat to its agent's latest @@ -125,7 +131,7 @@ 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) - if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); 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)) } @@ -140,20 +146,39 @@ func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.C // 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. +// pinned hash is non-NULL until the refresh endpoint re-pins. After a +// successful pin it publishes a context watch event so watching clients +// refetch the chat's now-populated pinned resources. 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 { + hydrated, err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID) + if 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)) + return + } + if !hydrated { + return + } + // The chat was unpinned when this turn started, so watching clients + // cached its detail without pinned resources. Publish a context event + // now that hydration pinned it so they refetch. Re-read the chat so + // the event payload carries the pinned state, not the stale row. + pinned, err := p.db.GetChatByID(ctx, chat.ID) + if err != nil { + p.logger.Warn(ctx, "read chat after first-turn context pin", + slog.F("chat_id", chat.ID), + slog.Error(err)) + return } + p.publishChatPubsubEvents([]database.Chat{pinned}, codersdk.ChatWatchEventKindContextDirty) } // repinChatContext re-pins a single chat to its agent's latest context diff --git a/coderd/x/chatd/context_hydration_internal_test.go b/coderd/x/chatd/context_hydration_internal_test.go index 7e71c053e31..357d4f233f2 100644 --- a/coderd/x/chatd/context_hydration_internal_test.go +++ b/coderd/x/chatd/context_hydration_internal_test.go @@ -1,15 +1,20 @@ package chatd import ( + "context" "database/sql" "testing" "github.com/google/uuid" + "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/dbmock" + dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub" + coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -99,14 +104,29 @@ func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) - server := &Server{db: db, logger: slogtest.Make(t, nil)} + ps := dbpubsub.NewInMemory() + server := &Server{db: db, logger: slogtest.Make(t, nil), pubsub: ps} + ownerID := uuid.New() agentID := uuid.New() - chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}} + chat := database.Chat{ID: uuid.New(), OwnerID: ownerID, AgentID: uuid.NullUUID{UUID: agentID, Valid: true}} snapshot := database.WorkspaceAgentContextSnapshot{ WorkspaceAgentID: agentID, AggregateHash: []byte{0x0a, 0x0b}, } + pinnedChat := chat + pinnedChat.ContextAggregateHash = snapshot.AggregateHash + + events := make(chan codersdk.ChatWatchEvent, 1) + cancelSub, err := ps.SubscribeWithErr( + coderdpubsub.ChatWatchEventChannel(ownerID), + coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) { + require.NoError(t, err) + events <- payload + }), + ) + require.NoError(t, err) + defer cancelSub() db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn( func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) }) @@ -119,8 +139,37 @@ func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) { AggregateHash: snapshot.AggregateHash, ContextError: snapshot.SnapshotError, }).Return(nil) + db.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(pinnedChat, nil) server.ensureChatContextPinnedOnFirstTurn(ctx, chat) + + // Watching clients cached the detail without pinned resources, so + // the pin must broadcast a context event to trigger their refetch. + event := testutil.RequireReceive(ctx, t, events) + require.Equal(t, codersdk.ChatWatchEventKindContextDirty, event.Kind) + require.Equal(t, chat.ID, event.Chat.ID) + }) + + t.Run("SkipsPublishWhenNoSnapshot", 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() + // ErrNoRows means the agent has not pushed yet: nothing is stamped + // and no event is published (GetChatByID has no EXPECT, so a + // post-hydration read would fail the test). + 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(database.WorkspaceAgentContextSnapshot{}, sql.ErrNoRows) + + server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{ + ID: uuid.New(), + AgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + }) }) t.Run("SkipsWhenAlreadyPinned", func(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index aea0e0b4d7e..27d05534bed 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1866,8 +1866,9 @@ const ( ChatWatchEventKindDiffStatusChange ChatWatchEventKind = "diff_status_change" ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" // ChatWatchEventKindContextDirty signals that the chat's pinned - // workspace context drifted from the agent's latest pushed snapshot. - // The chat stays usable; a refresh re-pins it to the latest snapshot. + // workspace context changed: it drifted from the agent's latest + // pushed snapshot, or a first-turn pin populated it. The chat stays + // usable; a refresh re-pins a drifted chat to the latest snapshot. ChatWatchEventKindContextDirty ChatWatchEventKind = "context_dirty" ) From de1491110115cf3bed6c635d51be6fc9e43da590 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:43:37 +0000 Subject: [PATCH 09/13] fix(site/src/pages/AgentsPage/components/ChatMessageInput): keep skills menu open while workspace skills load Unknown workspace skills (chat context still resolving) counted as an empty list, so the menu closed for users with no personal skills and the trigger plugin recorded the slash as dismissed, preventing the menu from reopening when workspace skills arrived. Treat unknown as still loading: keep the menu open and show a workspace loading row. --- .../ChatMessageInput.stories.tsx | 13 ++++++++++++ .../ChatMessageInput/ChatMessageInput.tsx | 21 ++++++++++++------- .../SkillsTriggerMenu.stories.tsx | 12 +++++++++++ .../ChatMessageInput/SkillsTriggerMenu.tsx | 5 +++++ 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index e5ae23ea1fc..1d0c4adcb03 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -228,6 +228,19 @@ export const PersonalTriggersQualifiedWhileWorkspaceSkillsUnknown: Story = { }, }; +export const EmptyPersonalKeepsMenuOpenWhileWorkspaceSkillsUnknown: Story = { + args: { + personalSkillsOverride: [], + // No workspaceSkills: closing the menu here would record the slash + // as dismissed, so skills arriving later could never reopen it. + hasWorkspace: true, + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + expect(await findVisibleText("Loading workspace skills...")).toBeDefined(); + }, +}; + export const QualifiedPersonalQueryMatchesBareTrigger: Story = { args: { hasWorkspace: true, diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index fce0be2585b..1d2963f64ad 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -627,22 +627,28 @@ const ChatMessageInput = ({ }); const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? []; const loadedWorkspaceSkills = workspaceSkills ?? []; + // Until the chat's pinned context resolves, workspace skills are + // unknown: keep personal triggers qualified (a qualified alias always + // resolves) and treat the workspace list as still loading. + const workspaceSkillsKnown = !hasWorkspace || workspaceSkills !== undefined; // A stale empty cache with a refetch in flight must not dismiss the menu. const isResolvedEmptyPersonalSkills = hasPersonalSkillsOverride ? personalSkills.length === 0 : skillsQuery.isSuccess && !skillsQuery.isFetching && personalSkills.length === 0; - // When both loaded skills lists are empty, "/" is plain text. When - // only the filtered result is empty, keep the menu open for the - // no-match message. + // Unknown workspace skills must not close the menu: the trigger plugin + // records a closed trigger as dismissed, so skills arriving later could + // never reopen it. + const isResolvedEmptyWorkspaceSkills = + workspaceSkillsKnown && loadedWorkspaceSkills.length === 0; + // When both skills lists resolve empty, "/" is plain text. When only + // the filtered result is empty, keep the menu open for the no-match + // message. const skillsMenuOpen = hasSkillsTrigger && - !(isResolvedEmptyPersonalSkills && loadedWorkspaceSkills.length === 0); + !(isResolvedEmptyPersonalSkills && isResolvedEmptyWorkspaceSkills); const skillsSearchQuery = skillsTrigger?.query ?? ""; - // Until the chat's pinned context resolves, collisions are unknown, so - // keep personal triggers qualified; a qualified alias always resolves. - const workspaceSkillsKnown = !hasWorkspace || workspaceSkills !== undefined; const workspaceSkillNames = new Set( loadedWorkspaceSkills.map((skill) => skill.name), ); @@ -964,6 +970,7 @@ const ChatMessageInput = ({ skillsQuery.isError && skillsQuery.data === undefined } + isWorkspaceLoading={!workspaceSkillsKnown} selectedIndex={selectedSkillIndex} onSelectedIndexChange={setSkillsMenuSelectedIndex} onSelect={replaceActiveSkillsTrigger} diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx index 3e131a4e1d1..c3e2bbf52f0 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx @@ -94,6 +94,18 @@ export const Loading: Story = { }, }; +export const WorkspaceLoading: Story = { + args: { + personalSkills: [], + workspaceSkills: [], + workspaceSkillsEnabled: true, + isWorkspaceLoading: true, + }, + play: async () => { + expect(await findVisibleText("Loading workspace skills...")).toBeDefined(); + }, +}; + export const EmptyWithWorkspace: Story = { args: { personalSkills: [], diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index c9653ab69d2..9e9a2ac4435 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -60,6 +60,7 @@ type SkillsTriggerMenuProps = { workspaceSkillsEnabled?: boolean; isPersonalLoading?: boolean; isPersonalError?: boolean; + isWorkspaceLoading?: boolean; selectedIndex: number; onSelectedIndexChange: (index: number) => void; onSelect: (skill: SkillMenuItem) => void; @@ -123,6 +124,7 @@ export const SkillsTriggerMenu = ({ workspaceSkillsEnabled, isPersonalLoading, isPersonalError, + isWorkspaceLoading, selectedIndex, onSelectedIndexChange, onSelect, @@ -136,6 +138,9 @@ export const SkillsTriggerMenu = ({ isPersonalError && personalSkills.length === 0 ? "Could not load personal skills. Close and type / again to retry." : undefined, + isWorkspaceLoading && workspaceSkills.length === 0 + ? "Loading workspace skills..." + : undefined, ].filter((item) => item !== undefined); const shouldRender = open && anchorRect; const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0; From 3ee54e9e1ef54d527072a223b56a2de610681bdc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:57:19 +0000 Subject: [PATCH 10/13] fix(site/src/pages/AgentsPage): resolve workspace skills only for bound chats An unbound workspace selection (new chat form, or a picked workspace before the first send) has no pinned context to resolve, so the menu stayed in the workspace loading state and personal triggers stayed qualified indefinitely. Count only a chat-bound workspace as having workspace skills, and derive the menu list from the resolved chat detail so an unpinned bound chat resolves to a known-empty list instead of loading forever. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 2 + .../pages/AgentsPage/AgentChatPageView.tsx | 4 ++ .../AgentsPage/components/AgentChatInput.tsx | 8 ++-- .../components/ChatPageContent.test.ts | 39 ++++++++++++------- .../AgentsPage/components/ChatPageContent.tsx | 15 ++++--- 5 files changed, 46 insertions(+), 22 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 90364d68f3e..b38bc4380e0 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -85,6 +85,7 @@ import { } from "./components/ChatConversation/chatStore"; import { useChatToolInvalidations } from "./components/ChatConversation/useChatToolInvalidations"; import type { PendingAttachment } from "./components/ChatPageContent"; +import { workspaceSkillsFromChat } from "./components/ChatPageContent"; import { getDefaultMCPSelection, getSavedMCPSelection, @@ -1756,6 +1757,7 @@ const AgentChatPage: FC = () => { onMCPSelectionChange={handleMCPSelectionChange} onMCPAuthComplete={handleMCPAuthComplete} chatContext={chatQuery.data?.context} + workspaceSkills={workspaceSkillsFromChat(chatQuery.data)} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index e61fcd12305..cbe48cc83a4 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -36,6 +36,7 @@ import { import type { useChatStore } from "./components/ChatConversation/chatStore"; import type { ModelSelectorOption } from "./components/ChatElements"; import { DesktopPanelContext } from "./components/ChatElements/tools/DesktopPanelContext"; +import type { SkillMetadata } from "./components/ChatMessageInput/SkillsTriggerMenu"; import type { PendingAttachment } from "./components/ChatPageContent"; import { ChatPageInput, ChatPageTimeline } from "./components/ChatPageContent"; import { ChatScrollContainer } from "./components/ChatScrollContainer"; @@ -222,6 +223,7 @@ interface AgentChatPageViewProps { desktopChatId?: string; chatContext?: TypesGen.ChatContext; + workspaceSkills?: readonly SkillMetadata[]; } const UnavailableTabMessage: FC<{ message: string }> = ({ message }) => ( @@ -386,6 +388,7 @@ export const AgentChatPageView: FC = ({ onMCPAuthComplete, desktopChatId, chatContext, + workspaceSkills, }) => { const queryClient = useQueryClient(); const { proxy } = useProxy(); @@ -972,6 +975,7 @@ export const AgentChatPageView: FC = ({ onMCPSelectionChange={onMCPSelectionChange} onMCPAuthComplete={onMCPAuthComplete} chatContext={chatContext} + workspaceSkills={workspaceSkills} workspace={workspace} workspaceAgent={workspaceAgent} chatId={agentId} diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 3fe1a3d9221..fec0e6a7f7f 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -585,9 +585,11 @@ export const AgentChatInput: FC = ({ }); }; - const hasSkillsWorkspace = Boolean( - attachedWorkspace?.id ?? workspace?.id ?? selectedWorkspaceId, - ); + // Only a chat-bound workspace counts: an unbound selection (new chat + // form, or a picked workspace before the first send) has no pinned + // context to resolve, so treating it as a workspace would leave the + // menu in the loading state forever. + const hasSkillsWorkspace = Boolean(attachedWorkspace?.id ?? workspace?.id); const selectedWorkspace = workspaceOptions?.find( (ws) => ws.id === selectedWorkspaceId, diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.test.ts b/site/src/pages/AgentsPage/components/ChatPageContent.test.ts index 5959272e0bc..337effa5d88 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.test.ts +++ b/site/src/pages/AgentsPage/components/ChatPageContent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type * as TypesGen from "#/api/typesGenerated"; -import { workspaceSkillsFromChatContext } from "./ChatPageContent"; +import { MockChat } from "#/testHelpers/chatEntities"; +import { workspaceSkillsFromChat } from "./ChatPageContent"; const skillResource = ( name: string, @@ -22,45 +23,55 @@ const instructionResource = (): TypesGen.ChatContextResource => ({ status: "ok", }); -describe("workspaceSkillsFromChatContext", () => { - it("returns undefined without pinned resources", () => { - expect(workspaceSkillsFromChatContext(undefined)).toBeUndefined(); - expect(workspaceSkillsFromChatContext({ dirty: false })).toBeUndefined(); +const chatWithContext = ( + context: TypesGen.ChatContext | undefined, +): TypesGen.Chat => ({ ...MockChat, context }); + +describe("workspaceSkillsFromChat", () => { + it("returns undefined while the chat detail is unresolved", () => { + expect(workspaceSkillsFromChat(undefined)).toBeUndefined(); + }); + + it("returns an empty authoritative list for a resolved unpinned chat", () => { + expect(workspaceSkillsFromChat(chatWithContext(undefined))).toEqual([]); + expect(workspaceSkillsFromChat(chatWithContext({ dirty: false }))).toEqual( + [], + ); }); it("maps healthy skill resources to workspace skills", () => { - const context: TypesGen.ChatContext = { + const chat = chatWithContext({ dirty: false, resources: [ instructionResource(), skillResource("reviewer"), skillResource("docs"), ], - }; - expect(workspaceSkillsFromChatContext(context)).toEqual([ + }); + expect(workspaceSkillsFromChat(chat)).toEqual([ { name: "reviewer", description: "reviewer description" }, { name: "docs", description: "docs description" }, ]); }); it("omits non-ok skill resources", () => { - const context: TypesGen.ChatContext = { + const chat = chatWithContext({ dirty: true, resources: [ skillResource("reviewer"), skillResource("broken", { status: "unreadable", skill_name: "" }), ], - }; - expect(workspaceSkillsFromChatContext(context)).toEqual([ + }); + expect(workspaceSkillsFromChat(chat)).toEqual([ { name: "reviewer", description: "reviewer description" }, ]); }); it("returns an empty authoritative list when pinned context has no skills", () => { - const context: TypesGen.ChatContext = { + const chat = chatWithContext({ dirty: false, resources: [instructionResource()], - }; - expect(workspaceSkillsFromChatContext(context)).toEqual([]); + }); + expect(workspaceSkillsFromChat(chat)).toEqual([]); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 7823e514122..2c9c1341db8 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -47,11 +47,13 @@ const isChatMessage = ( message: TypesGen.ChatMessage | undefined, ): message is TypesGen.ChatMessage => Boolean(message); -export const workspaceSkillsFromChatContext = ( - context: TypesGen.ChatContext | undefined, +// A resolved chat with no context (unpinned) or no resources authoritatively +// has no workspace skills; only an unresolved chat leaves them unknown. +export const workspaceSkillsFromChat = ( + chat: TypesGen.Chat | undefined, ): SkillMetadata[] | undefined => - context?.resources - ? context.resources + chat + ? (chat.context?.resources ?? []) .filter( (resource) => resource.kind === "skill" && resource.status === "ok", ) @@ -230,6 +232,9 @@ interface ChatPageInputProps { // Pinned workspace-context state for the chat, surfaced by the // context indicator (dirty marker and pinned resources). chatContext?: TypesGen.ChatContext; + // Workspace skill menu data derived from the resolved chat detail; + // undefined while the chat is still loading. + workspaceSkills?: readonly SkillMetadata[]; workspaceOptions: readonly TypesGen.Workspace[]; chatOrganizationId?: string; selectedWorkspaceId: string | null; @@ -288,6 +293,7 @@ export const ChatPageInput: FC = ({ onMCPSelectionChange, onMCPAuthComplete, chatContext, + workspaceSkills, workspaceOptions, chatOrganizationId, selectedWorkspaceId, @@ -344,7 +350,6 @@ export const ChatPageInput: FC = ({ onError: () => toast.error("Failed to refresh context."), }) : undefined; - const workspaceSkills = workspaceSkillsFromChatContext(chatContext); const composeAttachments = useChatDraftAttachments(organizationId, chatId, { provider: getProviderForModelOption(modelOptions, selectedModel), }); From 48eba19cba56a76af2e018539b346eb21a3f4fa5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:13:32 +0000 Subject: [PATCH 11/13] fix: broadcast first-turn context pins to all hydrated chats and dedupe menu skills HydrateAgentChatsContext pins every unpinned chat bound to the agent in one statement, but the first-turn path published a context event only for the current chat, leaving sibling chats with stale cached details. Return the hydrated chat IDs from the query and publish an event for each. The slash menu also rendered duplicate workspace skill names even though read_skill collapses duplicates first-wins; dedupe by name in workspaceSkillsFromChat to match resolution. --- coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbauthz/dbauthz_test.go | 5 +- coderd/database/dbmetrics/querymetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 7 +- coderd/database/querier.go | 4 +- coderd/database/querier_test.go | 9 ++- coderd/database/queries.sql.go | 75 ++++++++++++------- coderd/database/queries/chats.sql | 43 ++++++----- coderd/x/chatd/context_hydration.go | 56 +++++++------- .../chatd/context_hydration_internal_test.go | 26 +++++-- .../x/chatd/context_prompt_internal_test.go | 5 +- .../x/chatd/context_rebind_internal_test.go | 10 ++- .../components/ChatPageContent.test.ts | 18 +++++ .../AgentsPage/components/ChatPageContent.tsx | 33 +++++--- 14 files changed, 191 insertions(+), 110 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5a18c5fb8f3..e3abd19503b 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5793,12 +5793,12 @@ func (q *querier) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx context.Cont return q.db.HasTemplateVersionsUsingCachedModuleFileInOrg(ctx, arg) } -func (q *querier) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error { +func (q *querier) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) ([]uuid.UUID, error) { // System-level operation: an agent context push fans hydration out // across every not-yet-pinned chat for the agent, so it authorizes at // the resource level rather than per-chat. if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil { - return err + return nil, err } return q.db.HydrateAgentChatsContext(ctx, arg) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d3263972e6b..02dc4a46829 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -548,8 +548,9 @@ func (s *MethodTestSuite) TestConnectionLogs() { func (s *MethodTestSuite) TestChats() { s.Run("HydrateAgentChatsContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.HydrateAgentChatsContextParams{AgentID: uuid.New()} - dbm.EXPECT().HydrateAgentChatsContext(gomock.Any(), arg).Return(nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate) + hydrated := []uuid.UUID{uuid.New()} + dbm.EXPECT().HydrateAgentChatsContext(gomock.Any(), arg).Return(hydrated, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(hydrated) })) s.Run("MarkChatsContextDirtyByAgent", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { arg := database.MarkChatsContextDirtyByAgentParams{AgentID: uuid.New()} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 16ba3084ebd..017a0522cb6 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3921,12 +3921,12 @@ func (m queryMetricsStore) HasTemplateVersionsUsingCachedModuleFileInOrg(ctx con return r0, r1 } -func (m queryMetricsStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error { +func (m queryMetricsStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) ([]uuid.UUID, error) { start := time.Now() - r0 := m.s.HydrateAgentChatsContext(ctx, arg) + r0, r1 := m.s.HydrateAgentChatsContext(ctx, arg) m.queryLatencies.WithLabelValues("HydrateAgentChatsContext").Observe(time.Since(start).Seconds()) m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "HydrateAgentChatsContext").Inc() - return r0 + return r0, r1 } func (m queryMetricsStore) IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 67e42764a88..aa8507ab088 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7334,11 +7334,12 @@ func (mr *MockStoreMockRecorder) HasTemplateVersionsUsingCachedModuleFileInOrg(c } // HydrateAgentChatsContext mocks base method. -func (m *MockStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) error { +func (m *MockStore) HydrateAgentChatsContext(ctx context.Context, arg database.HydrateAgentChatsContextParams) ([]uuid.UUID, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HydrateAgentChatsContext", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 } // HydrateAgentChatsContext indicates an expected call of HydrateAgentChatsContext. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cd0a2bfa7fb..eeed742a50f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1014,7 +1014,9 @@ type sqlcQuerier interface { // not-yet-hydrated chat has no pinned rows, so it normally inserts. // Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch // sets chat_context_resources.updated_at on the rows it rewrites. - HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) error + // Returns the hydrated chat IDs so callers can notify watchers of every + // chat the statement pinned. + HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) ([]uuid.UUID, error) // Increments generation_attempt and returns the resulting value. IncrementChatGenerationAttempt(ctx context.Context, id uuid.UUID) (int64, error) // Adds cost_micros to the spend for (user_id, effective_group_id, day). diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 8d33dc78fac..c590dc06407 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1296,11 +1296,14 @@ func TestChatContextHydration(t *testing.T) { _, err := db.ArchiveChatByID(ctx, chatArchived.ID) require.NoError(t, err) - // Hydrate stamps only the NULL-hash chat for this agent. - require.NoError(t, db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + // Hydrate stamps only the NULL-hash chat for this agent and returns + // exactly the chats it pinned. + hydrated, err := db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ AgentID: agent.ID, AggregateHash: hashH, - })) + }) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{chatNull.ID}, hydrated) gotNull, err := db.GetChatByID(ctx, chatNull.ID) require.NoError(t, err) require.Equal(t, hashH, gotNull.ContextAggregateHash, "NULL-hash chat is hydrated") diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2d66e47a346..185199849ec 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9444,41 +9444,44 @@ func (q *sqlQuerier) GetUserGroupSpendLimit(ctx context.Context, arg GetUserGrou return limit_micros, err } -const hydrateAgentChatsContext = `-- name: HydrateAgentChatsContext :exec +const hydrateAgentChatsContext = `-- name: HydrateAgentChatsContext :many WITH hydrated AS ( UPDATE chats SET - context_aggregate_hash = $2, - context_error = $3 - WHERE agent_id = $1::uuid + context_aggregate_hash = $1, + context_error = $2 + WHERE agent_id = $3::uuid AND archived = false AND context_aggregate_hash IS NULL RETURNING id +), +copied AS ( + INSERT INTO chat_context_resources ( + chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path + ) + SELECT + hydrated.id, r.source, r.body_kind, r.body, r.content_hash, + r.size_bytes, r.status, r.error, r.source_path + FROM hydrated + CROSS JOIN workspace_agent_context_resources r + WHERE r.workspace_agent_id = $3::uuid + ON CONFLICT (chat_id, source) DO UPDATE SET + body_kind = EXCLUDED.body_kind, + body = EXCLUDED.body, + content_hash = EXCLUDED.content_hash, + size_bytes = EXCLUDED.size_bytes, + status = EXCLUDED.status, + error = EXCLUDED.error, + source_path = EXCLUDED.source_path, + updated_at = now() ) -INSERT INTO chat_context_resources ( - chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path -) -SELECT - hydrated.id, r.source, r.body_kind, r.body, r.content_hash, - r.size_bytes, r.status, r.error, r.source_path -FROM hydrated -CROSS JOIN workspace_agent_context_resources r -WHERE r.workspace_agent_id = $1::uuid -ON CONFLICT (chat_id, source) DO UPDATE SET - body_kind = EXCLUDED.body_kind, - body = EXCLUDED.body, - content_hash = EXCLUDED.content_hash, - size_bytes = EXCLUDED.size_bytes, - status = EXCLUDED.status, - error = EXCLUDED.error, - source_path = EXCLUDED.source_path, - updated_at = now() +SELECT id FROM hydrated ` type HydrateAgentChatsContextParams struct { - AgentID uuid.UUID `db:"agent_id" json:"agent_id"` AggregateHash []byte `db:"aggregate_hash" json:"aggregate_hash"` ContextError string `db:"context_error" json:"context_error"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` } // Stamps the pinned hash and error on every not-yet-hydrated chat for @@ -9491,9 +9494,29 @@ type HydrateAgentChatsContextParams struct { // not-yet-hydrated chat has no pinned rows, so it normally inserts. // Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch // sets chat_context_resources.updated_at on the rows it rewrites. -func (q *sqlQuerier) HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) error { - _, err := q.db.ExecContext(ctx, hydrateAgentChatsContext, arg.AgentID, arg.AggregateHash, arg.ContextError) - return err +// Returns the hydrated chat IDs so callers can notify watchers of every +// chat the statement pinned. +func (q *sqlQuerier) HydrateAgentChatsContext(ctx context.Context, arg HydrateAgentChatsContextParams) ([]uuid.UUID, error) { + rows, err := q.db.QueryContext(ctx, hydrateAgentChatsContext, arg.AggregateHash, arg.ContextError, arg.AgentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const incrementChatGenerationAttempt = `-- name: IncrementChatGenerationAttempt :one diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 1bfc7d04606..11c634c3b93 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1506,7 +1506,7 @@ SET context_dirty_since = NULL WHERE id = @id::uuid; --- name: HydrateAgentChatsContext :exec +-- name: HydrateAgentChatsContext :many -- Stamps the pinned hash and error on every not-yet-hydrated chat for -- an agent (context_aggregate_hash IS NULL) and copies the agent's -- current context resources onto those chats in the same statement, so @@ -1517,6 +1517,8 @@ WHERE id = @id::uuid; -- not-yet-hydrated chat has no pinned rows, so it normally inserts. -- Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch -- sets chat_context_resources.updated_at on the rows it rewrites. +-- Returns the hydrated chat IDs so callers can notify watchers of every +-- chat the statement pinned. WITH hydrated AS ( UPDATE chats SET @@ -1526,25 +1528,28 @@ WITH hydrated AS ( AND archived = false AND context_aggregate_hash IS NULL RETURNING id +), +copied AS ( + INSERT INTO chat_context_resources ( + chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path + ) + SELECT + hydrated.id, r.source, r.body_kind, r.body, r.content_hash, + r.size_bytes, r.status, r.error, r.source_path + FROM hydrated + CROSS JOIN workspace_agent_context_resources r + WHERE r.workspace_agent_id = @agent_id::uuid + ON CONFLICT (chat_id, source) DO UPDATE SET + body_kind = EXCLUDED.body_kind, + body = EXCLUDED.body, + content_hash = EXCLUDED.content_hash, + size_bytes = EXCLUDED.size_bytes, + status = EXCLUDED.status, + error = EXCLUDED.error, + source_path = EXCLUDED.source_path, + updated_at = now() ) -INSERT INTO chat_context_resources ( - chat_id, source, body_kind, body, content_hash, size_bytes, status, error, source_path -) -SELECT - hydrated.id, r.source, r.body_kind, r.body, r.content_hash, - r.size_bytes, r.status, r.error, r.source_path -FROM hydrated -CROSS JOIN workspace_agent_context_resources r -WHERE r.workspace_agent_id = @agent_id::uuid -ON CONFLICT (chat_id, source) DO UPDATE SET - body_kind = EXCLUDED.body_kind, - body = EXCLUDED.body, - content_hash = EXCLUDED.content_hash, - size_bytes = EXCLUDED.size_bytes, - status = EXCLUDED.status, - error = EXCLUDED.error, - source_path = EXCLUDED.source_path, - updated_at = now(); +SELECT id FROM hydrated; -- name: MarkChatsContextDirtyByAgent :many -- Flips active, already-hydrated chats for an agent to dirty when the diff --git a/coderd/x/chatd/context_hydration.go b/coderd/x/chatd/context_hydration.go index 9227efc7f35..7a732032736 100644 --- a/coderd/x/chatd/context_hydration.go +++ b/coderd/x/chatd/context_hydration.go @@ -46,7 +46,7 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store // Chats created before the agent's first push land with a NULL pinned // hash. Stamp them now so they start clean; this is their first // hydration, so no dirty event is emitted. - if err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + if _, err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ AgentID: agentID, AggregateHash: aggregateHash, ContextError: snapshotError, @@ -94,26 +94,29 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store // 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. hydrated reports whether a snapshot -// existed, meaning any NULL-hash chats bound to the agent are now pinned. -func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid.UUID) (hydrated bool, err error) { - err = database.ReadModifyUpdate(p.db, func(tx database.Store) error { +// would keep a later push from hydrating. It returns the IDs of the chats it +// pinned; empty when the agent has no snapshot or every chat was already +// pinned. +func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid.UUID) ([]uuid.UUID, error) { + var hydrated []uuid.UUID + err := database.ReadModifyUpdate(p.db, func(tx database.Store) error { aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, agentID) if err != nil { return err } - hydrated = ok if !ok { + hydrated = nil return nil } - return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + hydrated, err = tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ AgentID: agentID, AggregateHash: aggregateHash, ContextError: snapshotError, }) + return err }) if err != nil { - return false, err + return nil, err } return hydrated, nil } @@ -146,10 +149,12 @@ func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.C // 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. After a -// successful pin it publishes a context watch event so watching clients -// refetch the chat's now-populated pinned resources. Best-effort: failures -// are logged and swallowed so they never fail the turn. +// pinned hash is non-NULL until the refresh endpoint re-pins. Hydration +// pins every unpinned chat bound to the agent in one statement, so a +// context watch event is published for each pinned chat: watching clients +// cached those chats' details without pinned resources and need to +// refetch. 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 @@ -164,21 +169,20 @@ func (p *Server) ensureChatContextPinnedOnFirstTurn(ctx context.Context, chat da slog.Error(err)) return } - if !hydrated { - return - } - // The chat was unpinned when this turn started, so watching clients - // cached its detail without pinned resources. Publish a context event - // now that hydration pinned it so they refetch. Re-read the chat so - // the event payload carries the pinned state, not the stale row. - pinned, err := p.db.GetChatByID(ctx, chat.ID) - if err != nil { - p.logger.Warn(ctx, "read chat after first-turn context pin", - slog.F("chat_id", chat.ID), - slog.Error(err)) - return + pinnedChats := make([]database.Chat, 0, len(hydrated)) + for _, chatID := range hydrated { + // Re-read each chat so the event payload carries the pinned + // state, not the pre-hydration row. + pinned, err := p.db.GetChatByID(ctx, chatID) + if err != nil { + p.logger.Warn(ctx, "read chat after first-turn context pin", + slog.F("chat_id", chatID), + slog.Error(err)) + continue + } + pinnedChats = append(pinnedChats, pinned) } - p.publishChatPubsubEvents([]database.Chat{pinned}, codersdk.ChatWatchEventKindContextDirty) + p.publishChatPubsubEvents(pinnedChats, codersdk.ChatWatchEventKindContextDirty) } // repinChatContext re-pins a single chat to its agent's latest context diff --git a/coderd/x/chatd/context_hydration_internal_test.go b/coderd/x/chatd/context_hydration_internal_test.go index 357d4f233f2..97cb6f39288 100644 --- a/coderd/x/chatd/context_hydration_internal_test.go +++ b/coderd/x/chatd/context_hydration_internal_test.go @@ -51,7 +51,7 @@ func TestHydrateChatContextOnCreate(t *testing.T) { AgentID: agentID, AggregateHash: snapshot.AggregateHash, ContextError: snapshot.SnapshotError, - }).Return(nil) + }).Return([]uuid.UUID{chat.ID}, nil) server.hydrateChatContextOnCreate(ctx, chat) }) @@ -110,14 +110,19 @@ func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) { ownerID := uuid.New() agentID := uuid.New() chat := database.Chat{ID: uuid.New(), OwnerID: ownerID, AgentID: uuid.NullUUID{UUID: agentID, Valid: true}} + // A second unpinned chat bound to the same agent is hydrated by the + // same statement and must get its own watch event. + siblingChat := database.Chat{ID: uuid.New(), OwnerID: ownerID, AgentID: chat.AgentID} snapshot := database.WorkspaceAgentContextSnapshot{ WorkspaceAgentID: agentID, AggregateHash: []byte{0x0a, 0x0b}, } pinnedChat := chat pinnedChat.ContextAggregateHash = snapshot.AggregateHash + pinnedSibling := siblingChat + pinnedSibling.ContextAggregateHash = snapshot.AggregateHash - events := make(chan codersdk.ChatWatchEvent, 1) + events := make(chan codersdk.ChatWatchEvent, 2) cancelSub, err := ps.SubscribeWithErr( coderdpubsub.ChatWatchEventChannel(ownerID), coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) { @@ -138,16 +143,21 @@ func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) { AgentID: agentID, AggregateHash: snapshot.AggregateHash, ContextError: snapshot.SnapshotError, - }).Return(nil) + }).Return([]uuid.UUID{chat.ID, siblingChat.ID}, nil) db.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(pinnedChat, nil) + db.EXPECT().GetChatByID(gomock.Any(), siblingChat.ID).Return(pinnedSibling, nil) server.ensureChatContextPinnedOnFirstTurn(ctx, chat) - // Watching clients cached the detail without pinned resources, so - // the pin must broadcast a context event to trigger their refetch. - event := testutil.RequireReceive(ctx, t, events) - require.Equal(t, codersdk.ChatWatchEventKindContextDirty, event.Kind) - require.Equal(t, chat.ID, event.Chat.ID) + // Watching clients cached both details without pinned resources, so + // every hydrated chat must broadcast a context event. + gotChatIDs := make([]uuid.UUID, 0, 2) + for range 2 { + event := testutil.RequireReceive(ctx, t, events) + require.Equal(t, codersdk.ChatWatchEventKindContextDirty, event.Kind) + gotChatIDs = append(gotChatIDs, event.Chat.ID) + } + require.ElementsMatch(t, []uuid.UUID{chat.ID, siblingChat.ID}, gotChatIDs) }) t.Run("SkipsPublishWhenNoSnapshot", func(t *testing.T) { diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index 278d8ea1e17..ff5184ad14e 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -399,10 +399,11 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) { AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, Status: database.ChatStatusWaiting, }) - require.NoError(t, db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + _, err := db.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ AgentID: agent.ID, AggregateHash: hash, - })) + }) + require.NoError(t, err) rows, err := db.ListChatContextResourcesByChatID(ctx, chat.ID) require.NoError(t, err) require.Len(t, rows, 2, "the pin holds the agent's instruction file and skill") diff --git a/coderd/x/chatd/context_rebind_internal_test.go b/coderd/x/chatd/context_rebind_internal_test.go index 4c7d62e92ff..1c8dc55502d 100644 --- a/coderd/x/chatd/context_rebind_internal_test.go +++ b/coderd/x/chatd/context_rebind_internal_test.go @@ -48,10 +48,11 @@ func TestPersistBuildAgentBindingRepinsContext(t *testing.T) { // Pin the chat to agent A through the production hydrate path so it // starts with A's hash and A's resources, exactly as an agent push // would leave it. - require.NoError(t, fix.db.HydrateAgentChatsContext(fix.ctx, database.HydrateAgentChatsContextParams{ + _, err := fix.db.HydrateAgentChatsContext(fix.ctx, database.HydrateAgentChatsContextParams{ AgentID: fix.agentA, AggregateHash: fix.hashA, - })) + }) + require.NoError(t, err) preRes, err := fix.db.ListChatContextResourcesByChatID(fix.ctx, chat.ID) require.NoError(t, err) require.Len(t, preRes, 1) @@ -121,10 +122,11 @@ func TestPersistBuildAgentBindingRepinsContext(t *testing.T) { AgentID: uuid.NullUUID{UUID: fix.agentA, Valid: true}, Status: database.ChatStatusWaiting, }) - require.NoError(t, fix.db.HydrateAgentChatsContext(fix.ctx, database.HydrateAgentChatsContextParams{ + _, err := fix.db.HydrateAgentChatsContext(fix.ctx, database.HydrateAgentChatsContextParams{ AgentID: fix.agentA, AggregateHash: fix.hashA, - })) + }) + require.NoError(t, err) preRes, err := fix.db.ListChatContextResourcesByChatID(fix.ctx, chat.ID) require.NoError(t, err) require.Len(t, preRes, 1, "chat starts pinned to agent A") diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.test.ts b/site/src/pages/AgentsPage/components/ChatPageContent.test.ts index 337effa5d88..eb6b86cc965 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.test.ts +++ b/site/src/pages/AgentsPage/components/ChatPageContent.test.ts @@ -54,6 +54,24 @@ describe("workspaceSkillsFromChat", () => { ]); }); + it("keeps the first resource for duplicate skill names, matching read_skill", () => { + const chat = chatWithContext({ + dirty: false, + resources: [ + skillResource("reviewer", { + source: "/workspace/.agents/skills/reviewer", + }), + skillResource("reviewer", { + source: "/workspace/other/skills/reviewer", + skill_description: "shadowed duplicate", + }), + ], + }); + expect(workspaceSkillsFromChat(chat)).toEqual([ + { name: "reviewer", description: "reviewer description" }, + ]); + }); + it("omits non-ok skill resources", () => { const chat = chatWithContext({ dirty: true, diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 2c9c1341db8..bf35260abf5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -49,19 +49,30 @@ const isChatMessage = ( // A resolved chat with no context (unpinned) or no resources authoritatively // has no workspace skills; only an unresolved chat leaves them unknown. +// Duplicate names keep the first resource to match read_skill resolution, +// which also collapses duplicates first-wins in resource order. export const workspaceSkillsFromChat = ( chat: TypesGen.Chat | undefined, -): SkillMetadata[] | undefined => - chat - ? (chat.context?.resources ?? []) - .filter( - (resource) => resource.kind === "skill" && resource.status === "ok", - ) - .map((resource) => ({ - name: resource.skill_name ?? "", - description: resource.skill_description ?? "", - })) - : undefined; +): SkillMetadata[] | undefined => { + if (!chat) { + return undefined; + } + const skills = new Map(); + for (const resource of chat.context?.resources ?? []) { + if ( + resource.kind !== "skill" || + resource.status !== "ok" || + skills.has(resource.skill_name ?? "") + ) { + continue; + } + skills.set(resource.skill_name ?? "", { + name: resource.skill_name ?? "", + description: resource.skill_description ?? "", + }); + } + return [...skills.values()]; +}; interface ChatPageTimelineProps { store: ChatStoreHandle; From 5ed38af898a47d53baea756fb37b14ea53187985 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:24:49 +0000 Subject: [PATCH 12/13] fix(coderd/x/chatd): publish context events for chats hydrated by an agent push An agent's first context push hydrates already-bound NULL-hash chats, but only dirtied chats got a watch event, so watching clients kept a stale chat detail with no pinned resources and the slash menu missed the newly pushed workspace skills. Publish a context event for every chat the push hydrated or dirtied. --- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 2 +- coderd/database/queries/chats.sql | 2 +- coderd/x/chatd/context_hydration.go | 45 +++++++++------ .../chatd/context_hydration_internal_test.go | 57 +++++++++++++++++++ 5 files changed, 87 insertions(+), 21 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index eeed742a50f..e63ecbe96c0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1010,7 +1010,7 @@ type sqlcQuerier interface { // a chat's pinned hash and pinned bodies are always written together. // Runs as a side effect of an agent push and of chat-create hydration, // so chats created before the agent was ready pick up the snapshot - // without a dirty event. The ON CONFLICT upsert is defensive: a + // without a dirty marker. The ON CONFLICT upsert is defensive: a // not-yet-hydrated chat has no pinned rows, so it normally inserts. // Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch // sets chat_context_resources.updated_at on the rows it rewrites. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 185199849ec..d85ccf8b10a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9490,7 +9490,7 @@ type HydrateAgentChatsContextParams struct { // a chat's pinned hash and pinned bodies are always written together. // Runs as a side effect of an agent push and of chat-create hydration, // so chats created before the agent was ready pick up the snapshot -// without a dirty event. The ON CONFLICT upsert is defensive: a +// without a dirty marker. The ON CONFLICT upsert is defensive: a // not-yet-hydrated chat has no pinned rows, so it normally inserts. // Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch // sets chat_context_resources.updated_at on the rows it rewrites. diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 11c634c3b93..8131fe50d67 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1513,7 +1513,7 @@ WHERE id = @id::uuid; -- a chat's pinned hash and pinned bodies are always written together. -- Runs as a side effect of an agent push and of chat-create hydration, -- so chats created before the agent was ready pick up the snapshot --- without a dirty event. The ON CONFLICT upsert is defensive: a +-- without a dirty marker. The ON CONFLICT upsert is defensive: a -- not-yet-hydrated chat has no pinned rows, so it normally inserts. -- Does not bump chats.updated_at; the resource upsert's ON CONFLICT branch -- sets chat_context_resources.updated_at on the rows it rewrites. diff --git a/coderd/x/chatd/context_hydration.go b/coderd/x/chatd/context_hydration.go index 7a732032736..4e53f99013c 100644 --- a/coderd/x/chatd/context_hydration.go +++ b/coderd/x/chatd/context_hydration.go @@ -31,11 +31,13 @@ func latestAgentSnapshot(ctx context.Context, db database.Store, agentID uuid.UU // HydrateAndMarkChatsDirty implements agentapi.ContextDirtyMarker. It runs // inside the PushContextState transaction: it stamps the pushed snapshot hash -// on chats for the agent that have not been hydrated yet (no dirty event), -// then flips already-pinned chats whose hash differs to dirty. It returns a -// callback that publishes the dirty watch events; the caller invokes it only -// after the transaction commits, and the callback is a no-op when nothing -// transitioned to dirty. +// on chats for the agent that have not been hydrated yet, then flips +// already-pinned chats whose hash differs to dirty. It returns a callback +// that publishes a context watch event for every chat it touched; the caller +// invokes it only after the transaction commits, and the callback is a no-op +// when no chat was hydrated or dirtied. Hydrated chats start clean (no dirty +// marker), but still need the event: watching clients cached their details +// without pinned resources and refetch only on context events. // // The pinned hash on dirtied chats is intentionally left unchanged; the // refresh endpoint re-pins it. @@ -44,13 +46,13 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store ctx = dbauthz.AsChatd(ctx) // Chats created before the agent's first push land with a NULL pinned - // hash. Stamp them now so they start clean; this is their first - // hydration, so no dirty event is emitted. - if _, err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ + // hash. Stamp them now so they start clean. + hydrated, err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ AgentID: agentID, AggregateHash: aggregateHash, ContextError: snapshotError, - }); err != nil { + }) + if err != nil { return nil, xerrors.Errorf("hydrate agent chats context: %w", err) } @@ -62,26 +64,33 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store if err != nil { return nil, xerrors.Errorf("mark chats context dirty: %w", err) } - if len(dirtied) == 0 { + // Hydrated chats had a NULL hash and dirtied chats a non-NULL one, so + // the two sets never overlap. + touched := make([]uuid.UUID, 0, len(hydrated)+len(dirtied)) + touched = append(touched, hydrated...) + for _, d := range dirtied { + touched = append(touched, d.ID) + } + if len(touched) == 0 { return func() {}, nil } - // Read the dirtied chats inside the transaction and capture their rows so + // Read the touched chats inside the transaction and capture their rows so // the post-commit callback needs no database access: the published payload - // reflects the just-committed dirty state (no re-read a concurrent refresh + // reflects the just-committed state (no re-read a concurrent refresh // could race), and the callback does not depend on the request-scoped // context surviving past commit. Only the transitioned chats are read. - dirtyChats := make([]database.Chat, 0, len(dirtied)) - for _, d := range dirtied { - chat, err := tx.GetChatByID(ctx, d.ID) + touchedChats := make([]database.Chat, 0, len(touched)) + for _, id := range touched { + chat, err := tx.GetChatByID(ctx, id) if err != nil { - return nil, xerrors.Errorf("get dirtied chat %s: %w", d.ID, err) + return nil, xerrors.Errorf("get touched chat %s: %w", id, err) } - dirtyChats = append(dirtyChats, chat) + touchedChats = append(touchedChats, chat) } return func() { - p.publishChatPubsubEvents(dirtyChats, codersdk.ChatWatchEventKindContextDirty) + p.publishChatPubsubEvents(touchedChats, codersdk.ChatWatchEventKindContextDirty) }, nil } diff --git a/coderd/x/chatd/context_hydration_internal_test.go b/coderd/x/chatd/context_hydration_internal_test.go index 97cb6f39288..5155af536d8 100644 --- a/coderd/x/chatd/context_hydration_internal_test.go +++ b/coderd/x/chatd/context_hydration_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -89,6 +90,62 @@ func TestHydrateChatContextOnCreate(t *testing.T) { }) } +// TestHydrateAndMarkChatsDirtyPublishesForHydratedAndDirtied covers the +// agent-push path: a chat hydrated by the push (first pin, no dirty marker) +// and a chat flipped to dirty must both get a context watch event, because +// watching clients refetch pinned resources only on those events. +func TestHydrateAndMarkChatsDirtyPublishesForHydratedAndDirtied(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + ps := dbpubsub.NewInMemory() + server := &Server{db: db, logger: slogtest.Make(t, nil), pubsub: ps} + + ownerID := uuid.New() + agentID := uuid.New() + hash := []byte{0x01} + now := time.Now() + + hydratedChat := database.Chat{ID: uuid.New(), OwnerID: ownerID, ContextAggregateHash: hash} + dirtiedChat := database.Chat{ID: uuid.New(), OwnerID: ownerID, ContextAggregateHash: []byte{0x99}} + + events := make(chan codersdk.ChatWatchEvent, 2) + cancelSub, err := ps.SubscribeWithErr( + coderdpubsub.ChatWatchEventChannel(ownerID), + coderdpubsub.HandleChatWatchEvent(func(_ context.Context, payload codersdk.ChatWatchEvent, err error) { + require.NoError(t, err) + events <- payload + }), + ) + require.NoError(t, err) + defer cancelSub() + + db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{ + AgentID: agentID, + AggregateHash: hash, + }).Return([]uuid.UUID{hydratedChat.ID}, nil) + db.EXPECT().MarkChatsContextDirtyByAgent(gomock.Any(), database.MarkChatsContextDirtyByAgentParams{ + AgentID: agentID, + AggregateHash: hash, + DirtySince: sql.NullTime{Time: now, Valid: true}, + }).Return([]database.MarkChatsContextDirtyByAgentRow{{ID: dirtiedChat.ID, OwnerID: ownerID}}, nil) + db.EXPECT().GetChatByID(gomock.Any(), hydratedChat.ID).Return(hydratedChat, nil) + db.EXPECT().GetChatByID(gomock.Any(), dirtiedChat.ID).Return(dirtiedChat, nil) + + publish, err := server.HydrateAndMarkChatsDirty(ctx, db, agentID, hash, "", now) + require.NoError(t, err) + publish() + + gotChatIDs := make([]uuid.UUID, 0, 2) + for range 2 { + event := testutil.RequireReceive(ctx, t, events) + require.Equal(t, codersdk.ChatWatchEventKindContextDirty, event.Kind) + gotChatIDs = append(gotChatIDs, event.Chat.ID) + } + require.ElementsMatch(t, []uuid.UUID{hydratedChat.ID, dirtiedChat.ID}, gotChatIDs) +} + // 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 From 77a7ec9d8f9d6b6f3c1becf0c97f8b3f2fe136e3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:41:11 +0000 Subject: [PATCH 13/13] docs: correct stale comments after workspace skills review rounds The context_dirty docstring covered only first-turn pins after agent push hydration also began publishing it, and three composer comments still described the pre-review gating (selected workspaces counting as bound, the pinned context rather than the chat detail resolving). --- codersdk/chats.go | 6 ++++-- .../ChatMessageInput/ChatMessageInput.stories.tsx | 4 ++-- .../ChatMessageInput/ChatMessageInput.tsx | 14 +++++++------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/codersdk/chats.go b/codersdk/chats.go index 27d05534bed..7e4cf105b76 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1867,8 +1867,10 @@ const ( ChatWatchEventKindActionRequired ChatWatchEventKind = "action_required" // ChatWatchEventKindContextDirty signals that the chat's pinned // workspace context changed: it drifted from the agent's latest - // pushed snapshot, or a first-turn pin populated it. The chat stays - // usable; a refresh re-pins a drifted chat to the latest snapshot. + // pushed snapshot, or hydration first populated it (a first-turn + // pin or an agent push reaching a not-yet-pinned chat). The chat + // stays usable; a refresh re-pins a drifted chat to the latest + // snapshot. ChatWatchEventKindContextDirty ChatWatchEventKind = "context_dirty" ) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 1d0c4adcb03..a79e613b54b 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -218,8 +218,8 @@ export const CollidingPersonalSkillInsertsQualifiedTrigger: Story = { export const PersonalTriggersQualifiedWhileWorkspaceSkillsUnknown: Story = { args: { - // No workspaceSkills: the chat's pinned context has not resolved, - // so collisions are unknown. + // No workspaceSkills: the chat detail has not resolved, so + // collisions are unknown. hasWorkspace: true, }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 1d2963f64ad..c7586d184d9 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -506,8 +506,8 @@ interface ChatMessageInputProps disabled?: boolean; autoFocus?: boolean; /** - * True when a workspace is attached or selected, so workspace skills - * may exist even while workspaceSkills is still undefined. + * True when the chat has a bound workspace, so workspace skills may + * exist even while workspaceSkills is still undefined. */ hasWorkspace?: boolean; /** @@ -516,8 +516,8 @@ interface ChatMessageInputProps personalSkillsOverride?: readonly TypesGen.UserSkillMetadata[]; /** * Workspace skill menu data from the chat's pinned context, so the - * menu matches read_skill resolution. Undefined until the chat's - * context resolves (or when no chat exists yet). + * menu matches read_skill resolution. Undefined while the chat + * detail is still loading (or when no chat exists yet). */ workspaceSkills?: readonly SkillMetadata[]; "aria-label"?: string; @@ -627,9 +627,9 @@ const ChatMessageInput = ({ }); const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? []; const loadedWorkspaceSkills = workspaceSkills ?? []; - // Until the chat's pinned context resolves, workspace skills are - // unknown: keep personal triggers qualified (a qualified alias always - // resolves) and treat the workspace list as still loading. + // Until the chat detail resolves, workspace skills are unknown: keep + // personal triggers qualified (a qualified alias always resolves) and + // treat the workspace list as still loading. const workspaceSkillsKnown = !hasWorkspace || workspaceSkills !== undefined; // A stale empty cache with a refetch in flight must not dismiss the menu. const isResolvedEmptyPersonalSkills = hasPersonalSkillsOverride