refactor: remove legacy live-read and injected-history chat context paths (#26585)

This PR makes the agent-pushed pinned snapshot
(`chat_context_resources`) the sole source of workspace context for
chats, completing the "Release 5" cleanup. It removes legacy mechanisms
now superseded by the snapshot that agents push over dRPC
(`PushContextState`) and refresh via `chat-context/refresh`.

Removed:

- **Live-read at turn time.** MCP tool discovery, skill live-body reads,
and the instruction/skill history fallback that dialed the workspace on
every turn.
- **Context injected as message history.** The
`persist_workspace_context` generation action and its decision-loop
guard.
- **The legacy write path.** `POST`/`DELETE
/api/v2/workspaceagents/me/experimental/chat-context`, the agentsdk
`AddChatContext`/`ClearChatContext` methods, and the CLI one-shot
writer.
- **The `chats.last_injected_context` column** and all of its plumbing
(migration `000529`, queries, `db2sdk`, `dbauthz`, audit table, and the
frontend `ContextUsageIndicator` fallback).

Subagent context inheritance no longer copies parent context messages;
children now hydrate the parent's pinned `chat_context_resources` on
create, which yields an identical pin for the same workspace and agent.

What stays (still served by the live agent connection, not the
snapshot): `read_skill_file` supporting-file reads, `read_skill`
supporting-file listing, and MCP tool execution.

> [!NOTE]
> Migration `000529` drops `chats.last_injected_context` and recreates
the `chats_expanded` view without it. The down migration restores both.

<details>
<summary>Decision log (D1-D5)</summary>

- **D1 (subagent inheritance):** Re-point inheritance from the legacy
message copy to a pinned hydrate. Children call
`hydrateChatContextOnCreate` instead of copying parent context messages.
- **D2 (`persist_workspace_context`):** Remove the generation action
entirely along with the decision-loop guard it existed to satisfy, since
context is never injected into history anymore.
- **D3 (legacy HTTP + CLI):** Remove the experimental `chat-context`
POST/DELETE endpoints, the agentsdk methods, and the CLI one-shot. The
dRPC push + `chat-context/refresh` replace them.
- **D4 (frontend fallback):** Remove the `last_injected_context`
fallback in `ContextUsageIndicator`; pinned `resources` are the sole
source.
- **D5 (sequencing):** Ship as a single PR rather than a stacked pair.

</details>

---
Coder Agents generated on behalf of @kylecarbs.
This commit is contained in:
Kyle Carberry
2026-06-22 19:26:34 -06:00
committed by GitHub
parent ce190b3e62
commit cd56ab9e33
59 changed files with 916 additions and 8617 deletions
-7
View File
@@ -16651,13 +16651,6 @@ const docTemplate = `{
"last_error": {
"$ref": "#/definitions/codersdk.ChatError"
},
"last_injected_context": {
"description": "LastInjectedContext holds the most recently persisted\ninjected context parts (AGENTS.md files and skills). It\nis updated only when context changes, on first workspace\nattach or agent change.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatMessagePart"
}
},
"last_model_config_id": {
"type": "string",
"format": "uuid"
-7
View File
@@ -14957,13 +14957,6 @@
"last_error": {
"$ref": "#/definitions/codersdk.ChatError"
},
"last_injected_context": {
"description": "LastInjectedContext holds the most recently persisted\ninjected context parts (AGENTS.md files and skills). It\nis updated only when context changes, on first workspace\nattach or agent change.",
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatMessagePart"
}
},
"last_model_config_id": {
"type": "string",
"format": "uuid"
-2
View File
@@ -1794,8 +1794,6 @@ func New(options *Options) *API {
r.Post("/log-source", api.workspaceAgentPostLogSource)
r.Get("/reinit", api.workspaceAgentReinit)
r.Route("/experimental", func(r chi.Router) {
r.Post("/chat-context", api.workspaceAgentAddChatContext)
r.Delete("/chat-context", api.workspaceAgentClearChatContext)
r.Post("/chat-context/refresh", api.workspaceAgentRefreshChatContext)
})
r.Route("/tasks/{task}", func(r chi.Router) {
-11
View File
@@ -1752,17 +1752,6 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
})
}
}
if c.LastInjectedContext.Valid {
var parts []codersdk.ChatMessagePart
// Internal fields are stripped at write time in
// chatd.updateLastInjectedContext, so no
// StripInternal call is needed here. Unmarshal
// errors are suppressed — the column is written by
// us with a known schema.
if err := json.Unmarshal(c.LastInjectedContext.RawMessage, &parts); err == nil {
chat.LastInjectedContext = parts
}
}
// Report pinned-context state when the chat is context-tracked
// (has a pinned hash), dirty, or carries a snapshot error.
if len(c.ContextAggregateHash) > 0 || c.ContextDirtySince.Valid || c.ContextError != "" {
-7
View File
@@ -720,13 +720,6 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
PlanMode: database.NullChatPlanMode{ChatPlanMode: database.ChatPlanModePlan, Valid: true},
MCPServerIDs: []uuid.UUID{uuid.New()},
Labels: database.StringMap{"env": "prod"},
LastInjectedContext: pqtype.NullRawMessage{
// Use a context-file part to verify internal
// fields are not present (they are stripped at
// write time by chatd, not at read time).
RawMessage: json.RawMessage(`[{"type":"context-file","context_file_path":"/AGENTS.md"}]`),
Valid: true,
},
DynamicTools: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`[{"name":"tool1","description":"test tool","inputSchema":{"type":"object"}}]`),
Valid: true,
-11
View File
@@ -7123,17 +7123,6 @@ func (q *querier) UpdateChatLabelsByID(ctx context.Context, arg database.UpdateC
return q.db.UpdateChatLabelsByID(ctx, arg)
}
func (q *querier) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
return database.Chat{}, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return database.Chat{}, err
}
return q.db.UpdateChatLastInjectedContext(ctx, arg)
}
func (q *querier) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
chat, err := q.db.GetChatByID(ctx, arg.ID)
if err != nil {
-13
View File
@@ -1899,19 +1899,6 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatMCPServerIDs(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLastInjectedContext", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastInjectedContextParams{
ID: chat.ID,
LastInjectedContext: pqtype.NullRawMessage{
RawMessage: json.RawMessage(`[{"type":"text","text":"test"}]`),
Valid: true,
},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), arg).Return(chat, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(chat)
}))
s.Run("UpdateChatLastTurnSummary", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatLastTurnSummaryParams{
-8
View File
@@ -5114,14 +5114,6 @@ func (m queryMetricsStore) UpdateChatLabelsByID(ctx context.Context, arg databas
return r0, r1
}
func (m queryMetricsStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatLastInjectedContext(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateChatLastInjectedContext").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateChatLastInjectedContext").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
start := time.Now()
r0, r1 := m.s.UpdateChatLastModelConfigByID(ctx, arg)
-15
View File
@@ -9636,21 +9636,6 @@ func (mr *MockStoreMockRecorder) UpdateChatLabelsByID(ctx, arg any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLabelsByID", reflect.TypeOf((*MockStore)(nil).UpdateChatLabelsByID), ctx, arg)
}
// UpdateChatLastInjectedContext mocks base method.
func (m *MockStore) UpdateChatLastInjectedContext(ctx context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateChatLastInjectedContext", ctx, arg)
ret0, _ := ret[0].(database.Chat)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateChatLastInjectedContext indicates an expected call of UpdateChatLastInjectedContext.
func (mr *MockStoreMockRecorder) UpdateChatLastInjectedContext(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChatLastInjectedContext", reflect.TypeOf((*MockStore)(nil).UpdateChatLastInjectedContext), ctx, arg)
}
// UpdateChatLastModelConfigByID mocks base method.
func (m *MockStore) UpdateChatLastModelConfigByID(ctx context.Context, arg database.UpdateChatLastModelConfigByIDParams) (database.Chat, error) {
m.ctrl.T.Helper()
-2
View File
@@ -1999,7 +1999,6 @@ CREATE TABLE chats (
agent_id uuid,
pin_order integer DEFAULT 0 NOT NULL,
last_read_message_id bigint,
last_injected_context jsonb,
dynamic_tools jsonb,
organization_id uuid NOT NULL,
plan_mode chat_plan_mode,
@@ -2114,7 +2113,6 @@ CREATE VIEW chats_expanded AS
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.last_injected_context,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
@@ -0,0 +1,55 @@
-- Restores the last_injected_context column on chats and recreates the
-- view with that column in its original position between
-- last_read_message_id and dynamic_tools.
DROP VIEW IF EXISTS chats_expanded;
ALTER TABLE chats ADD COLUMN last_injected_context jsonb;
CREATE VIEW chats_expanded AS
SELECT c.id,
c.owner_id,
c.workspace_id,
c.title,
c.status,
c.worker_id,
c.started_at,
c.heartbeat_at,
c.created_at,
c.updated_at,
c.parent_chat_id,
c.root_chat_id,
c.last_model_config_id,
c.archived,
c.last_error,
c.mode,
c.mcp_server_ids,
c.labels,
c.build_id,
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.last_injected_context,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
c.client_type,
c.last_turn_summary,
c.snapshot_version,
c.history_version,
c.queue_version,
c.generation_attempt,
c.retry_state,
c.retry_state_version,
c.runner_id,
c.requires_action_deadline_at,
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
c.context_aggregate_hash,
c.context_dirty_since,
c.context_dirty_resources,
c.context_error
FROM ((chats c
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
JOIN visible_users owner ON ((owner.id = c.owner_id)));
@@ -0,0 +1,55 @@
-- Drops an unused column from chats. The view must be dropped before
-- the column it references can be removed, then recreated without it. A
-- view cannot have a column removed from the middle of its column list
-- in place.
DROP VIEW IF EXISTS chats_expanded;
ALTER TABLE chats DROP COLUMN last_injected_context;
CREATE VIEW chats_expanded AS
SELECT c.id,
c.owner_id,
c.workspace_id,
c.title,
c.status,
c.worker_id,
c.started_at,
c.heartbeat_at,
c.created_at,
c.updated_at,
c.parent_chat_id,
c.root_chat_id,
c.last_model_config_id,
c.archived,
c.last_error,
c.mode,
c.mcp_server_ids,
c.labels,
c.build_id,
c.agent_id,
c.pin_order,
c.last_read_message_id,
c.dynamic_tools,
c.organization_id,
c.plan_mode,
c.client_type,
c.last_turn_summary,
c.snapshot_version,
c.history_version,
c.queue_version,
c.generation_attempt,
c.retry_state,
c.retry_state_version,
c.runner_id,
c.requires_action_deadline_at,
COALESCE(root.user_acl, c.user_acl) AS user_acl,
COALESCE(root.group_acl, c.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
c.context_aggregate_hash,
c.context_dirty_since,
c.context_dirty_resources,
c.context_error
FROM ((chats c
LEFT JOIN chats root ON ((root.id = COALESCE(c.root_chat_id, c.parent_chat_id))))
JOIN visible_users owner ON ((owner.id = c.owner_id)));
-2
View File
@@ -819,7 +819,6 @@ func (q *sqlQuerier) GetAuthorizedChats(ctx context.Context, arg GetChatsParams,
&i.Chat.AgentID,
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.Chat.DynamicTools,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
@@ -898,7 +897,6 @@ func (q *sqlQuerier) GetAuthorizedChatsByChatFileID(ctx context.Context, fileID
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
+29 -31
View File
@@ -4787,7 +4787,6 @@ type Chat struct {
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
@@ -4977,36 +4976,35 @@ type ChatQueuedMessage struct {
}
type ChatTable struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
// Monotonic version for the full chat snapshot. Starts at 1 so stream loops and workers can use 0 to mean they have not loaded the chat yet.
SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"`
// Snapshot version of the latest durable history change. Starts at 0 until chat_messages triggers set it to the current snapshot_version.
-5
View File
@@ -1336,11 +1336,6 @@ type sqlcQuerier interface {
// caller can detect stolen or completed chats via set-difference.
UpdateChatHeartbeats(ctx context.Context, arg UpdateChatHeartbeatsParams) ([]uuid.UUID, error)
UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLabelsByIDParams) (Chat, error)
// Updates the cached injected context parts (AGENTS.md +
// skills) on the chat row. Called only when context changes
// (first workspace attach or agent change). updated_at is
// intentionally not touched to avoid reordering the chat list.
UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error)
UpdateChatLastModelConfigByID(ctx context.Context, arg UpdateChatLastModelConfigByIDParams) (Chat, error)
// Updates the last read message ID for a chat. This is used to track
// which messages the owner has seen, enabling unread indicators.
+50 -226
View File
@@ -5482,7 +5482,7 @@ WHERE
LIMIT
$3::int
)
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -5508,7 +5508,6 @@ chats_expanded AS (
acquired_chats.agent_id,
acquired_chats.pin_order,
acquired_chats.last_read_message_id,
acquired_chats.last_injected_context,
acquired_chats.dynamic_tools,
acquired_chats.organization_id,
acquired_chats.plan_mode,
@@ -5535,7 +5534,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(acquired_chats.root_chat_id, acquired_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = acquired_chats.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -5579,7 +5578,6 @@ func (q *sqlQuerier) AcquireChats(ctx context.Context, arg AcquireChatsParams) (
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -5739,7 +5737,7 @@ WITH updated_chats AS (
UPDATE chats
SET archived = true, pin_order = 0, updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -5765,7 +5763,6 @@ chats_expanded AS (
updated_chats.agent_id,
updated_chats.pin_order,
updated_chats.last_read_message_id,
updated_chats.last_injected_context,
updated_chats.dynamic_tools,
updated_chats.organization_id,
updated_chats.plan_mode,
@@ -5792,7 +5789,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
`
@@ -5829,7 +5826,6 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -5901,10 +5897,10 @@ archived AS (
FROM to_archive t
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
AND c.archived = false
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type, c.last_turn_summary, c.user_acl, c.group_acl, c.snapshot_version, c.history_version, c.queue_version, c.generation_attempt, c.retry_state, c.retry_state_version, c.runner_id, c.requires_action_deadline_at, c.context_aggregate_hash, c.context_dirty_since, c.context_dirty_resources, c.context_error
)
SELECT
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error,
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type, a.last_turn_summary, a.user_acl, a.group_acl, a.snapshot_version, a.history_version, a.queue_version, a.generation_attempt, a.retry_state, a.retry_state_version, a.runner_id, a.requires_action_deadline_at, a.context_aggregate_hash, a.context_dirty_since, a.context_dirty_resources, a.context_error,
-- Children inherit their root's activity so last_activity_at is never null.
COALESCE(
t.last_activity_at,
@@ -5944,7 +5940,6 @@ type AutoArchiveInactiveChatsRow struct {
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
@@ -6006,7 +6001,6 @@ func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchi
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -6302,7 +6296,7 @@ func (q *sqlQuerier) DeleteStaleChatHeartbeats(ctx context.Context, staleSeconds
}
const getActiveChatsByAgentID = `-- name: GetActiveChatsByAgentID :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
WHERE agent_id = $1::uuid
AND archived = false
@@ -6345,7 +6339,6 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -6383,7 +6376,7 @@ func (q *sqlQuerier) GetActiveChatsByAgentID(ctx context.Context, agentID uuid.U
const getAutoArchiveInactiveChatCandidates = `-- name: GetAutoArchiveInactiveChatCandidates :many
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
COALESCE(activity.last_activity_at, chats_expanded.created_at)::timestamptz AS last_activity_at
FROM chats_expanded
LEFT JOIN LATERAL (
@@ -6438,7 +6431,6 @@ type GetAutoArchiveInactiveChatCandidatesRow struct {
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
@@ -6498,7 +6490,6 @@ func (q *sqlQuerier) GetAutoArchiveInactiveChatCandidates(ctx context.Context, a
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -6558,7 +6549,7 @@ func (q *sqlQuerier) GetChatACLByID(ctx context.Context, id uuid.UUID) (GetChatA
}
const getChatByID = `-- name: GetChatByID :one
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
WHERE id = $1::uuid
`
@@ -6589,7 +6580,6 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -6617,7 +6607,7 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
const getChatByIDForShare = `-- name: GetChatByIDForShare :one
WITH shared_chat AS (
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats
WHERE id = $1::uuid
FOR SHARE
@@ -6646,7 +6636,6 @@ chats_expanded AS (
shared_chat.agent_id,
shared_chat.pin_order,
shared_chat.last_read_message_id,
shared_chat.last_injected_context,
shared_chat.dynamic_tools,
shared_chat.organization_id,
shared_chat.plan_mode,
@@ -6673,7 +6662,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(shared_chat.root_chat_id, shared_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = shared_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -6703,7 +6692,6 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -6731,7 +6719,7 @@ func (q *sqlQuerier) GetChatByIDForShare(ctx context.Context, id uuid.UUID) (Cha
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
WITH locked_chat AS (
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats
WHERE id = $1::uuid
FOR UPDATE
@@ -6760,7 +6748,6 @@ chats_expanded AS (
locked_chat.agent_id,
locked_chat.pin_order,
locked_chat.last_read_message_id,
locked_chat.last_injected_context,
locked_chat.dynamic_tools,
locked_chat.organization_id,
locked_chat.plan_mode,
@@ -6787,7 +6774,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(locked_chat.root_chat_id, locked_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = locked_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -6817,7 +6804,6 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -8287,7 +8273,7 @@ func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChat
const getChatWorkerAcquisitionCandidates = `-- name: GetChatWorkerAcquisitionCandidates :many
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
chat_heartbeats.heartbeat_at AS current_heartbeat_at,
NOT EXISTS (
SELECT 1
@@ -8346,7 +8332,6 @@ type GetChatWorkerAcquisitionCandidatesRow struct {
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
@@ -8415,7 +8400,6 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -8463,7 +8447,7 @@ WITH cursor_chat AS (
WHERE id = $7
)
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
@@ -8697,7 +8681,6 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
&i.Chat.AgentID,
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.Chat.DynamicTools,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
@@ -8736,7 +8719,7 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]GetCha
const getChatsByChatFileID = `-- name: GetChatsByChatFileID :many
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM
chats_expanded
WHERE
@@ -8781,7 +8764,6 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID)
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -8818,7 +8800,7 @@ func (q *sqlQuerier) GetChatsByChatFileID(ctx context.Context, fileID uuid.UUID)
}
const getChatsByIDsForRunnerSync = `-- name: GetChatsByIDsForRunnerSync :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
WHERE id = ANY($1::uuid[])
ORDER BY id ASC
@@ -8856,7 +8838,6 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid.
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -8893,7 +8874,7 @@ func (q *sqlQuerier) GetChatsByIDsForRunnerSync(ctx context.Context, ids []uuid.
}
const getChatsByWorkspaceIDs = `-- name: GetChatsByWorkspaceIDs :many
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
WHERE archived = false
AND workspace_id = ANY($1::uuid[])
@@ -8932,7 +8913,6 @@ func (q *sqlQuerier) GetChatsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -9038,7 +9018,7 @@ func (q *sqlQuerier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time
const getChildChatsByParentIDs = `-- name: GetChildChatsByParentIDs :many
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.last_injected_context, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error,
EXISTS (
SELECT 1 FROM chat_messages cm
WHERE cm.chat_id = chats_expanded.id
@@ -9105,7 +9085,6 @@ func (q *sqlQuerier) GetChildChatsByParentIDs(ctx context.Context, arg GetChildC
&i.Chat.AgentID,
&i.Chat.PinOrder,
&i.Chat.LastReadMessageID,
&i.Chat.LastInjectedContext,
&i.Chat.DynamicTools,
&i.Chat.OrganizationID,
&i.Chat.PlanMode,
@@ -9209,7 +9188,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh
const getStaleChats = `-- name: GetStaleChats :many
SELECT
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM
chats_expanded
WHERE
@@ -9264,7 +9243,6 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -9491,7 +9469,7 @@ INSERT INTO chats (
$15::jsonb,
$16::chat_client_type
)
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -9517,7 +9495,6 @@ chats_expanded AS (
inserted_chat.agent_id,
inserted_chat.pin_order,
inserted_chat.last_read_message_id,
inserted_chat.last_injected_context,
inserted_chat.dynamic_tools,
inserted_chat.organization_id,
inserted_chat.plan_mode,
@@ -9544,7 +9521,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(inserted_chat.root_chat_id, inserted_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = inserted_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -9610,7 +9587,6 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -10117,7 +10093,7 @@ WITH bumped_chat AS (
WHERE id = $1::uuid
FOR UPDATE
)
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -10143,7 +10119,6 @@ chats_expanded AS (
bumped_chat.agent_id,
bumped_chat.pin_order,
bumped_chat.last_read_message_id,
bumped_chat.last_injected_context,
bumped_chat.dynamic_tools,
bumped_chat.organization_id,
bumped_chat.plan_mode,
@@ -10169,7 +10144,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(bumped_chat.root_chat_id, bumped_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = bumped_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -10203,7 +10178,6 @@ func (q *sqlQuerier) LockChatAndBumpSnapshotVersion(ctx context.Context, id uuid
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -10555,7 +10529,7 @@ WITH updated_chats AS (
archived = false,
updated_at = NOW()
WHERE id = $1::uuid OR root_chat_id = $1::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -10581,7 +10555,6 @@ chats_expanded AS (
updated_chats.agent_id,
updated_chats.pin_order,
updated_chats.last_read_message_id,
updated_chats.last_injected_context,
updated_chats.dynamic_tools,
updated_chats.organization_id,
updated_chats.plan_mode,
@@ -10608,7 +10581,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chats.root_chat_id, updated_chats.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chats.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
ORDER BY (chats_expanded.id = $1::uuid) DESC, chats_expanded.created_at ASC, chats_expanded.id ASC
`
@@ -10649,7 +10622,6 @@ func (q *sqlQuerier) UnarchiveChatByID(ctx context.Context, id uuid.UUID) ([]Cha
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -10773,7 +10745,7 @@ UPDATE chats SET
updated_at = NOW()
WHERE
id = $3::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -10799,7 +10771,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -10826,7 +10797,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -10862,7 +10833,6 @@ func (q *sqlQuerier) UpdateChatBuildAgentBinding(ctx context.Context, arg Update
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -10897,7 +10867,7 @@ SET
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -10923,7 +10893,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -10950,7 +10919,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -10985,7 +10954,6 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -11024,7 +10992,7 @@ WITH updated_chat AS (
pin_order = CASE WHEN $2::boolean THEN 0 ELSE pin_order END,
updated_at = NOW()
WHERE id = $7::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -11050,7 +11018,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -11076,7 +11043,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -11128,7 +11095,6 @@ func (q *sqlQuerier) UpdateChatExecutionState(ctx context.Context, arg UpdateCha
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -11208,7 +11174,7 @@ SET
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -11234,7 +11200,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -11261,7 +11226,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -11296,131 +11261,6 @@ func (q *sqlQuerier) UpdateChatLabelsByID(ctx context.Context, arg UpdateChatLab
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.SnapshotVersion,
&i.HistoryVersion,
&i.QueueVersion,
&i.GenerationAttempt,
&i.RetryState,
&i.RetryStateVersion,
&i.RunnerID,
&i.RequiresActionDeadlineAt,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
&i.ContextAggregateHash,
&i.ContextDirtySince,
&i.ContextDirtyResources,
&i.ContextError,
)
return i, err
}
const updateChatLastInjectedContext = `-- name: UpdateChatLastInjectedContext :one
WITH updated_chat AS (
UPDATE chats SET
last_injected_context = $1::jsonb
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
type UpdateChatLastInjectedContextParams struct {
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
ID uuid.UUID `db:"id" json:"id"`
}
// Updates the cached injected context parts (AGENTS.md +
// skills) on the chat row. Called only when context changes
// (first workspace attach or agent change). updated_at is
// intentionally not touched to avoid reordering the chat list.
func (q *sqlQuerier) UpdateChatLastInjectedContext(ctx context.Context, arg UpdateChatLastInjectedContextParams) (Chat, error) {
row := q.db.QueryRowContext(ctx, updateChatLastInjectedContext, arg.LastInjectedContext, arg.ID)
var i Chat
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -11455,7 +11295,7 @@ SET
last_model_config_id = $1::uuid
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -11481,7 +11321,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -11508,7 +11347,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -11543,7 +11382,6 @@ func (q *sqlQuerier) UpdateChatLastModelConfigByID(ctx context.Context, arg Upda
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -11628,7 +11466,7 @@ SET
updated_at = NOW()
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -11654,7 +11492,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -11681,7 +11518,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -11716,7 +11553,6 @@ func (q *sqlQuerier) UpdateChatMCPServerIDs(ctx context.Context, arg UpdateChatM
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -11871,7 +11707,7 @@ SET
plan_mode = $1::chat_plan_mode
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -11897,7 +11733,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -11924,7 +11759,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -11959,7 +11794,6 @@ func (q *sqlQuerier) UpdateChatPlanModeByID(ctx context.Context, arg UpdateChatP
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -11992,7 +11826,7 @@ WITH updated_chat AS (
retry_state = $1::jsonb,
updated_at = NOW()
WHERE id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -12018,7 +11852,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -12044,7 +11877,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -12081,7 +11914,6 @@ func (q *sqlQuerier) UpdateChatRetryState(ctx context.Context, arg UpdateChatRet
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -12120,7 +11952,7 @@ SET
updated_at = NOW()
WHERE
id = $6::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -12146,7 +11978,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -12173,7 +12004,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -12219,7 +12050,6 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -12258,7 +12088,7 @@ SET
updated_at = $6::timestamptz
WHERE
id = $7::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -12284,7 +12114,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -12311,7 +12140,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -12359,7 +12188,6 @@ func (q *sqlQuerier) UpdateChatStatusPreserveUpdatedAt(ctx context.Context, arg
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -12396,7 +12224,7 @@ SET
title = $1::text
WHERE
id = $2::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -12422,7 +12250,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -12449,7 +12276,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -12484,7 +12311,6 @@ func (q *sqlQuerier) UpdateChatTitleByID(ctx context.Context, arg UpdateChatTitl
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
@@ -12518,7 +12344,7 @@ UPDATE chats SET
agent_id = $3::uuid,
updated_at = NOW()
WHERE id = $4::uuid
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
RETURNING id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, user_acl, group_acl, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
),
chats_expanded AS (
SELECT
@@ -12544,7 +12370,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -12571,7 +12396,7 @@ chats_expanded AS (
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, last_injected_context, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
SELECT id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error, mode, mcp_server_ids, labels, build_id, agent_id, pin_order, last_read_message_id, dynamic_tools, organization_id, plan_mode, client_type, last_turn_summary, snapshot_version, history_version, queue_version, generation_attempt, retry_state, retry_state_version, runner_id, requires_action_deadline_at, user_acl, group_acl, owner_username, owner_name, context_aggregate_hash, context_dirty_since, context_dirty_resources, context_error
FROM chats_expanded
`
@@ -12613,7 +12438,6 @@ func (q *sqlQuerier) UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateC
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.LastInjectedContext,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
-85
View File
@@ -29,7 +29,6 @@ chats_expanded AS (
updated_chats.agent_id,
updated_chats.pin_order,
updated_chats.last_read_message_id,
updated_chats.last_injected_context,
updated_chats.dynamic_tools,
updated_chats.organization_id,
updated_chats.plan_mode,
@@ -96,7 +95,6 @@ chats_expanded AS (
updated_chats.agent_id,
updated_chats.pin_order,
updated_chats.last_read_message_id,
updated_chats.last_injected_context,
updated_chats.dynamic_tools,
updated_chats.organization_id,
updated_chats.plan_mode,
@@ -762,7 +760,6 @@ chats_expanded AS (
inserted_chat.agent_id,
inserted_chat.pin_order,
inserted_chat.last_read_message_id,
inserted_chat.last_injected_context,
inserted_chat.dynamic_tools,
inserted_chat.organization_id,
inserted_chat.plan_mode,
@@ -911,7 +908,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -978,7 +974,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1043,7 +1038,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1108,7 +1102,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1173,7 +1166,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1237,7 +1229,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1301,73 +1292,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
updated_chat.client_type,
updated_chat.last_turn_summary,
updated_chat.snapshot_version,
updated_chat.history_version,
updated_chat.queue_version,
updated_chat.generation_attempt,
updated_chat.retry_state,
updated_chat.retry_state_version,
updated_chat.runner_id,
updated_chat.requires_action_deadline_at,
COALESCE(root.user_acl, updated_chat.user_acl) AS user_acl,
COALESCE(root.group_acl, updated_chat.group_acl) AS group_acl,
owner.username AS owner_username,
owner.name AS owner_name,
updated_chat.context_aggregate_hash,
updated_chat.context_dirty_since,
updated_chat.context_dirty_resources,
updated_chat.context_error
FROM
updated_chat
LEFT JOIN chats root ON root.id = COALESCE(updated_chat.root_chat_id, updated_chat.parent_chat_id)
JOIN visible_users owner ON owner.id = updated_chat.owner_id
)
SELECT *
FROM chats_expanded;
-- name: UpdateChatLastInjectedContext :one
WITH updated_chat AS (
-- Updates the cached injected context parts (AGENTS.md +
-- skills) on the chat row. Called only when context changes
-- (first workspace attach or agent change). updated_at is
-- intentionally not touched to avoid reordering the chat list.
UPDATE chats SET
last_injected_context = sqlc.narg('last_injected_context')::jsonb
WHERE
id = @id::uuid
RETURNING *
),
chats_expanded AS (
SELECT
updated_chat.id,
updated_chat.owner_id,
updated_chat.workspace_id,
updated_chat.title,
updated_chat.status,
updated_chat.worker_id,
updated_chat.started_at,
updated_chat.heartbeat_at,
updated_chat.created_at,
updated_chat.updated_at,
updated_chat.parent_chat_id,
updated_chat.root_chat_id,
updated_chat.last_model_config_id,
updated_chat.archived,
updated_chat.last_error,
updated_chat.mode,
updated_chat.mcp_server_ids,
updated_chat.labels,
updated_chat.build_id,
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1449,7 +1373,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1666,7 +1589,6 @@ chats_expanded AS (
acquired_chats.agent_id,
acquired_chats.pin_order,
acquired_chats.last_read_message_id,
acquired_chats.last_injected_context,
acquired_chats.dynamic_tools,
acquired_chats.organization_id,
acquired_chats.plan_mode,
@@ -1735,7 +1657,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -1804,7 +1725,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -2080,7 +2000,6 @@ chats_expanded AS (
locked_chat.agent_id,
locked_chat.pin_order,
locked_chat.last_read_message_id,
locked_chat.last_injected_context,
locked_chat.dynamic_tools,
locked_chat.organization_id,
locked_chat.plan_mode,
@@ -2141,7 +2060,6 @@ chats_expanded AS (
shared_chat.agent_id,
shared_chat.pin_order,
shared_chat.last_read_message_id,
shared_chat.last_injected_context,
shared_chat.dynamic_tools,
shared_chat.organization_id,
shared_chat.plan_mode,
@@ -2821,7 +2739,6 @@ chats_expanded AS (
bumped_chat.agent_id,
bumped_chat.pin_order,
bumped_chat.last_read_message_id,
bumped_chat.last_injected_context,
bumped_chat.dynamic_tools,
bumped_chat.organization_id,
bumped_chat.plan_mode,
@@ -2893,7 +2810,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
@@ -2957,7 +2873,6 @@ chats_expanded AS (
updated_chat.agent_id,
updated_chat.pin_order,
updated_chat.last_read_message_id,
updated_chat.last_injected_context,
updated_chat.dynamic_tools,
updated_chat.organization_id,
updated_chat.plan_mode,
+1 -24
View File
@@ -1982,13 +1982,6 @@ func TestWatchChats(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
lastInjectedContext, err := json.Marshal([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "large-skill",
SkillDescription: strings.Repeat("x", 9000),
}})
require.NoError(t, err)
// Insert a chat and a diff status row.
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: user.OrganizationID,
@@ -1996,20 +1989,9 @@ func TestWatchChats(t *testing.T) {
LastModelConfigID: modelConfig.ID,
Title: "diff status watch test",
})
chat, err = db.UpdateChatLastInjectedContext(
dbauthz.AsChatd(ctx),
database.UpdateChatLastInjectedContextParams{
ID: chat.ID,
LastInjectedContext: pqtype.NullRawMessage{
RawMessage: lastInjectedContext,
Valid: true,
},
},
)
require.NoError(t, err)
refreshedAt := time.Now().UTC().Truncate(time.Second)
staleAt := refreshedAt.Add(time.Hour)
_, err = db.UpsertChatDiffStatusReference(
_, err := db.UpsertChatDiffStatusReference(
dbauthz.AsSystemRestricted(ctx),
database.UpsertChatDiffStatusReferenceParams{
ChatID: chat.ID,
@@ -2035,10 +2017,6 @@ func TestWatchChats(t *testing.T) {
)
require.NoError(t, err)
storedChat, err := client.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.NotEmpty(t, storedChat.LastInjectedContext)
// Open the watch WebSocket.
conn, err := client.Dial(ctx, "/api/experimental/chats/watch", nil)
require.NoError(t, err)
@@ -2069,7 +2047,6 @@ func TestWatchChats(t *testing.T) {
require.EqualValues(t, 42, ds.Additions)
require.EqualValues(t, 7, ds.Deletions)
require.EqualValues(t, 5, ds.ChangedFiles)
require.Empty(t, received.Chat.LastInjectedContext)
})
t.Run("ArchiveAndUnarchiveEmitEventsForDescendants", func(t *testing.T) {
t.Parallel()
-3
View File
@@ -1,8 +1,5 @@
package coderd
// InsertAgentChatTestModelConfig exposes insertAgentChatTestModelConfig for external tests.
var InsertAgentChatTestModelConfig = insertAgentChatTestModelConfig
// ChatStartWorkspace exposes chatStartWorkspace for external tests.
//
// chatStartWorkspace is intentionally unexported to keep symmetry with
+1 -688
View File
@@ -42,9 +42,6 @@ import (
"github.com/coder/coder/v2/coderd/telemetry"
maputil "github.com/coder/coder/v2/coderd/util/maps"
"github.com/coder/coder/v2/coderd/wspubsub"
"github.com/coder/coder/v2/coderd/x/chatd"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/gitsync"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
@@ -2387,315 +2384,11 @@ func convertWorkspaceAgentLogs(logs []database.WorkspaceAgentLog) []codersdk.Wor
return sdk
}
// maxChatContextParts caps the number of parts per request to
// prevent unbounded message payloads.
const maxChatContextParts = 100
// maxChatContextFileBytes caps each context-file part to the same
// 64KiB budget used when the agent reads instruction files from disk.
const maxChatContextFileBytes = 64 * 1024
// maxChatContextRequestBodyBytes caps the JSON request body size for
// agent-added context to roughly the same per-part budget used when
// reading instruction files from disk.
const maxChatContextRequestBodyBytes int64 = maxChatContextParts * maxChatContextFileBytes
// sanitizeWorkspaceAgentContextFileContent applies prompt
// sanitization, then enforces the 64KiB per-file budget. The
// truncated flag is preserved when the caller already capped the
// file before sending it.
func sanitizeWorkspaceAgentContextFileContent(
content string,
truncated bool,
) (string, bool) {
content = chatd.SanitizePromptText(content)
if len(content) > maxChatContextFileBytes {
content = content[:maxChatContextFileBytes]
truncated = true
}
return content, truncated
}
// readChatContextBody reads and validates the request body for chat
// context endpoints. It handles MaxBytesReader wrapping, error
// responses, and body rewind. If the body is empty or whitespace-only
// and allowEmpty is true, it returns false without writing an error.
//
//nolint:revive // Add and clear endpoints only differ by empty-body handling.
func readChatContextBody(ctx context.Context, rw http.ResponseWriter, r *http.Request, dst any, allowEmpty bool) bool {
r.Body = http.MaxBytesReader(rw, r.Body, maxChatContextRequestBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "Request body too large.",
Detail: fmt.Sprintf("Maximum request body size is %d bytes.", maxChatContextRequestBodyBytes),
})
return false
}
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to read request body.",
Detail: err.Error(),
})
return false
}
if allowEmpty && len(bytes.TrimSpace(body)) == 0 {
r.Body = http.NoBody
return false
}
r.Body = io.NopCloser(bytes.NewReader(body))
return httpapi.Read(ctx, rw, r, dst)
}
// @x-apidocgen {"skip": true}
func (api *API) workspaceAgentAddChatContext(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceAgent := httpmw.WorkspaceAgent(r)
var req agentsdk.AddChatContextRequest
if !readChatContextBody(ctx, rw, r, &req, false) {
return
}
if len(req.Parts) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No context parts provided.",
})
return
}
if len(req.Parts) > maxChatContextParts {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Too many context parts (%d). Maximum is %d.", len(req.Parts), maxChatContextParts),
})
return
}
// Filter to only non-empty context-file and skill parts.
filtered := chatd.FilterContextParts(req.Parts, false)
if len(filtered) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No context-file or skill parts provided.",
})
return
}
req.Parts = filtered
responsePartCount := 0
// Use system context for chat operations since the
// workspace agent scope does not include chat resources.
// We verify agent-to-chat ownership explicitly below.
//nolint:gocritic // Agent needs system access to read/write chat resources.
sysCtx := dbauthz.AsSystemRestricted(ctx)
workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to determine workspace from agent token.",
Detail: err.Error(),
})
return
}
chat, err := resolveAgentChat(sysCtx, api.Database, workspaceAgent.ID, workspace.OwnerID, req.ChatID)
if err != nil {
writeAgentChatError(ctx, rw, err)
return
}
// Stamp each persisted part with the agent identity. Context-file
// parts also get server-authoritative workspace metadata.
directory := workspaceAgent.ExpandedDirectory
if directory == "" {
directory = workspaceAgent.Directory
}
for i := range req.Parts {
req.Parts[i].ContextFileAgentID = uuid.NullUUID{
UUID: workspaceAgent.ID,
Valid: true,
}
if req.Parts[i].Type != codersdk.ChatMessagePartTypeContextFile {
continue
}
req.Parts[i].ContextFileContent, req.Parts[i].ContextFileTruncated = sanitizeWorkspaceAgentContextFileContent(
req.Parts[i].ContextFileContent,
req.Parts[i].ContextFileTruncated,
)
req.Parts[i].ContextFileOS = workspaceAgent.OperatingSystem
req.Parts[i].ContextFileDirectory = directory
}
req.Parts = chatd.FilterContextParts(req.Parts, false)
if len(req.Parts) == 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "No context-file or skill parts provided.",
})
return
}
responsePartCount = len(req.Parts)
// Skill-only messages need a sentinel context-file part so the turn
// pipeline trusts the associated skill metadata.
req.Parts = prependAgentChatContextSentinelIfNeeded(
req.Parts,
workspaceAgent.ID,
workspaceAgent.OperatingSystem,
directory,
)
content, err := chatprompt.MarshalParts(req.Parts)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to marshal context parts.",
Detail: err.Error(),
})
return
}
machine := chatstate.NewChatMachine(api.Database, api.Pubsub, chat.ID)
err = machine.Update(sysCtx, func(tx *chatstate.Tx, store database.Store) error {
locked, err := store.GetChatByID(sysCtx, chat.ID)
if err != nil {
return xerrors.Errorf("load chat: %w", err)
}
if !isActiveAgentChat(locked) {
return errChatNotActive
}
if !locked.AgentID.Valid || locked.AgentID.UUID != workspaceAgent.ID {
return errChatDoesNotBelongToAgent
}
if locked.OwnerID != workspace.OwnerID {
return errChatDoesNotBelongToWorkspaceOwner
}
apiKeyID, err := resolveAgentChatContextAPIKeyID(sysCtx, store, locked)
if err != nil {
return err
}
sendResult, err := tx.SendMessage(chatstate.SendMessageInput{
Message: chatstate.Message{
Role: database.ChatMessageRoleUser,
Content: content,
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: locked.LastModelConfigID, Valid: locked.LastModelConfigID != uuid.Nil},
CreatedBy: uuid.NullUUID{UUID: locked.OwnerID, Valid: locked.OwnerID != uuid.Nil},
ContentVersion: chatprompt.CurrentContentVersion,
APIKeyID: sql.NullString{String: apiKeyID, Valid: apiKeyID != ""},
},
BusyBehavior: chatstate.BusyBehaviorInterrupt,
})
if err != nil {
return err
}
if len(sendResult.InsertedMessages) == 0 {
return nil
}
if err := updateAgentChatLastInjectedContextFromMessages(sysCtx, api.Logger, store, chat.ID); err != nil {
return xerrors.Errorf("rebuild injected context cache: %w", err)
}
return nil
})
if err != nil {
switch {
case errors.Is(err, errChatNotActive), errors.Is(err, errChatDoesNotBelongToAgent), errors.Is(err, errChatDoesNotBelongToWorkspaceOwner):
writeAgentChatError(ctx, rw, err)
case errors.Is(err, errChatAPIKeyAttributionUnavailable):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot modify context: chat has no API key attribution.",
})
case errors.Is(err, chatstate.ErrMessageQueueFull):
var queueFull *chatstate.MessageQueueFullError
detail := ""
if errors.As(err, &queueFull) {
detail = fmt.Sprintf("Maximum %d messages can be queued.", queueFull.Max)
}
httpapi.Write(ctx, rw, http.StatusTooManyRequests, codersdk.Response{
Message: "Message queue is full.",
Detail: detail,
})
case errors.Is(err, chatstate.ErrInvalidState):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is in an invalid state.",
})
case errors.Is(err, chatstate.ErrTransitionNotAllowed):
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Chat is not in a state that accepts new context.",
Detail: err.Error(),
})
case errors.Is(err, chatstate.ErrChatNotFound):
writeAgentChatError(ctx, rw, errChatNotFound)
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to persist context message.",
Detail: err.Error(),
})
}
return
}
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.AddChatContextResponse{
ChatID: chat.ID,
Count: responsePartCount,
})
}
// @x-apidocgen {"skip": true}
func (api *API) workspaceAgentClearChatContext(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceAgent := httpmw.WorkspaceAgent(r)
var req agentsdk.ClearChatContextRequest
populated := readChatContextBody(ctx, rw, r, &req, true)
if !populated && r.Body != http.NoBody {
return
}
// Use system context for chat operations since the
// workspace agent scope does not include chat resources.
//nolint:gocritic // Agent needs system access to read/write chat resources.
sysCtx := dbauthz.AsSystemRestricted(ctx)
workspace, err := api.Database.GetWorkspaceByAgentID(sysCtx, workspaceAgent.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to determine workspace from agent token.",
Detail: err.Error(),
})
return
}
chat, err := resolveAgentChat(sysCtx, api.Database, workspaceAgent.ID, workspace.OwnerID, req.ChatID)
if err != nil {
// Zero active chats is not an error for clear.
if errors.Is(err, errNoActiveChats) {
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ClearChatContextResponse{})
return
}
writeAgentChatError(ctx, rw, err)
return
}
err = clearAgentChatContext(sysCtx, api.Database, chat.ID, workspaceAgent.ID, workspace.OwnerID)
if err != nil {
if errors.Is(err, errChatNotActive) || errors.Is(err, errChatDoesNotBelongToAgent) || errors.Is(err, errChatDoesNotBelongToWorkspaceOwner) {
writeAgentChatError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to clear context from chat.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, agentsdk.ClearChatContextResponse{
ChatID: chat.ID,
})
}
// workspaceAgentRefreshChatContext re-pins every drifted chat bound to the
// calling agent to the agent's latest context snapshot, clearing their
// drift markers. It backs the in-workspace `coder exp chat context refresh`
// (no chat argument), which uses the agent token rather than a user
// session, mirroring workspaceAgentClearChatContext's auth model.
// session.
//
// @x-apidocgen {"skip": true}
func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http.Request) {
@@ -2753,383 +2446,3 @@ func (api *API) workspaceAgentRefreshChatContext(rw http.ResponseWriter, r *http
Refreshed: refreshed,
})
}
var (
errNoActiveChats = xerrors.New("no active chats found")
errChatNotFound = xerrors.New("chat not found")
errChatNotActive = xerrors.New("chat is not active")
errChatDoesNotBelongToAgent = xerrors.New("chat does not belong to this agent")
errChatDoesNotBelongToWorkspaceOwner = xerrors.New("chat does not belong to this workspace owner")
errChatAPIKeyAttributionUnavailable = xerrors.New("chat has no API key attribution")
)
type multipleActiveChatsError struct {
count int
}
func (e *multipleActiveChatsError) Error() string {
return fmt.Sprintf(
"multiple active chats (%d) found for this agent, specify a chat ID",
e.count,
)
}
func resolveDefaultAgentChat(chats []database.Chat) (database.Chat, error) {
switch len(chats) {
case 0:
return database.Chat{}, errNoActiveChats
case 1:
return chats[0], nil
}
var rootChat *database.Chat
for i := range chats {
chat := &chats[i]
if chat.ParentChatID.Valid {
continue
}
if rootChat != nil {
return database.Chat{}, &multipleActiveChatsError{count: len(chats)}
}
rootChat = chat
}
if rootChat != nil {
return *rootChat, nil
}
return database.Chat{}, &multipleActiveChatsError{count: len(chats)}
}
// resolveAgentChat finds the target chat from either an explicit ID
// or auto-detection via the agent's active chats.
func resolveAgentChat(
ctx context.Context,
db database.Store,
agentID uuid.UUID,
workspaceOwnerID uuid.UUID,
explicitChatID uuid.UUID,
) (database.Chat, error) {
if explicitChatID == uuid.Nil {
chats, err := db.GetActiveChatsByAgentID(ctx, agentID)
if err != nil {
return database.Chat{}, xerrors.Errorf("list active chats: %w", err)
}
ownerChats := make([]database.Chat, 0, len(chats))
for _, chat := range chats {
if chat.OwnerID != workspaceOwnerID {
continue
}
ownerChats = append(ownerChats, chat)
}
return resolveDefaultAgentChat(ownerChats)
}
chat, err := db.GetChatByID(ctx, explicitChatID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return database.Chat{}, errChatNotFound
}
return database.Chat{}, xerrors.Errorf("get chat by id: %w", err)
}
if !chat.AgentID.Valid || chat.AgentID.UUID != agentID {
return database.Chat{}, errChatDoesNotBelongToAgent
}
if chat.OwnerID != workspaceOwnerID {
return database.Chat{}, errChatDoesNotBelongToWorkspaceOwner
}
if !isActiveAgentChat(chat) {
return database.Chat{}, errChatNotActive
}
return chat, nil
}
func isActiveAgentChat(chat database.Chat) bool {
if chat.Archived {
return false
}
switch chat.Status {
case database.ChatStatusWaiting,
database.ChatStatusPending,
database.ChatStatusRunning,
database.ChatStatusPaused,
database.ChatStatusRequiresAction:
return true
default:
return false
}
}
func resolveAgentChatContextAPIKeyID(ctx context.Context, db database.Store, chat database.Chat) (string, error) {
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chat.ID,
AfterID: 0,
})
if err != nil {
return "", xerrors.Errorf("load chat messages for API key attribution: %w", err)
}
for i := len(messages) - 1; i >= 0; i-- {
message := messages[i]
if message.Role != database.ChatMessageRoleUser {
continue
}
if !message.APIKeyID.Valid || message.APIKeyID.String == "" {
continue
}
return message.APIKeyID.String, nil
}
loginTypes := []database.LoginType{
database.LoginTypePassword,
database.LoginTypeOIDC,
database.LoginTypeGithub,
database.LoginTypeToken,
database.LoginTypeNone,
}
var newest database.APIKey
hasNewest := false
for _, loginType := range loginTypes {
keys, err := db.GetAPIKeysByUserID(ctx, database.GetAPIKeysByUserIDParams{
LoginType: loginType,
UserID: chat.OwnerID,
IncludeExpired: false,
})
if err != nil {
return "", xerrors.Errorf("load owner API keys for attribution: %w", err)
}
for _, key := range keys {
if !hasNewest || key.CreatedAt.After(newest.CreatedAt) {
newest = key
hasNewest = true
}
}
}
if !hasNewest {
return "", errChatAPIKeyAttributionUnavailable
}
return newest.ID, nil
}
func clearAgentChatContext(
ctx context.Context,
db database.Store,
chatID uuid.UUID,
agentID uuid.UUID,
workspaceOwnerID uuid.UUID,
) error {
return db.InTx(func(tx database.Store) error {
locked, err := tx.GetChatByIDForUpdate(ctx, chatID)
if err != nil {
return xerrors.Errorf("lock chat: %w", err)
}
if !isActiveAgentChat(locked) {
return errChatNotActive
}
if !locked.AgentID.Valid || locked.AgentID.UUID != agentID {
return errChatDoesNotBelongToAgent
}
if locked.OwnerID != workspaceOwnerID {
return errChatDoesNotBelongToWorkspaceOwner
}
messages, err := tx.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chatID,
AfterID: 0,
})
if err != nil {
return xerrors.Errorf("get chat messages: %w", err)
}
hadInjectedContext := locked.LastInjectedContext.Valid
var skillOnlyMessageIDs []int64
for _, msg := range messages {
if !msg.Content.Valid {
continue
}
hasContextFile := messageHasPartTypes(msg.Content.RawMessage, codersdk.ChatMessagePartTypeContextFile)
hasSkill := messageHasPartTypes(msg.Content.RawMessage, codersdk.ChatMessagePartTypeSkill)
if hasContextFile || hasSkill {
hadInjectedContext = true
}
if hasSkill && !hasContextFile {
skillOnlyMessageIDs = append(skillOnlyMessageIDs, msg.ID)
}
}
if !hadInjectedContext {
return nil
}
if err := tx.SoftDeleteContextFileMessages(ctx, chatID); err != nil {
return xerrors.Errorf("soft delete context-file messages: %w", err)
}
for _, messageID := range skillOnlyMessageIDs {
if err := tx.SoftDeleteChatMessageByID(ctx, messageID); err != nil {
return xerrors.Errorf("soft delete context message %d: %w", messageID, err)
}
}
// Reset provider-side Responses chaining so the next turn replays
// the post-clear history instead of inheriting cleared context.
if err := tx.ClearChatMessageProviderResponseIDsByChatID(ctx, chatID); err != nil {
return xerrors.Errorf("clear provider response chain: %w", err)
}
// Clear the injected-context cache inside the transaction so it is
// atomic with the soft-deletes.
param, err := chatd.BuildLastInjectedContext(nil)
if err != nil {
return xerrors.Errorf("clear injected context cache: %w", err)
}
if _, err := tx.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
ID: chatID,
LastInjectedContext: param,
}); err != nil {
return xerrors.Errorf("clear injected context cache: %w", err)
}
return nil
}, nil)
}
// prependAgentChatContextSentinelIfNeeded adds an empty context-file
// part when the request only carries skills. The turn pipeline uses
// the sentinel's agent metadata to trust the skill parts.
func prependAgentChatContextSentinelIfNeeded(
parts []codersdk.ChatMessagePart,
agentID uuid.UUID,
operatingSystem string,
directory string,
) []codersdk.ChatMessagePart {
hasContextFile := false
hasSkill := false
for _, part := range parts {
switch part.Type {
case codersdk.ChatMessagePartTypeContextFile:
hasContextFile = true
case codersdk.ChatMessagePartTypeSkill:
hasSkill = true
}
if hasContextFile && hasSkill {
return parts
}
}
if !hasSkill || hasContextFile {
return parts
}
return append([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: chatd.AgentChatContextSentinelPath,
ContextFileAgentID: uuid.NullUUID{
UUID: agentID,
Valid: true,
},
ContextFileOS: operatingSystem,
ContextFileDirectory: directory,
}}, parts...)
}
func sortChatMessagesByCreatedAtAndID(messages []database.ChatMessage) {
sort.SliceStable(messages, func(i, j int) bool {
if messages[i].CreatedAt.Equal(messages[j].CreatedAt) {
return messages[i].ID < messages[j].ID
}
return messages[i].CreatedAt.Before(messages[j].CreatedAt)
})
}
// updateAgentChatLastInjectedContextFromMessages rebuilds the
// injected-context cache from all persisted context-file and skill parts.
func updateAgentChatLastInjectedContextFromMessages(
ctx context.Context,
logger slog.Logger,
db database.Store,
chatID uuid.UUID,
) error {
messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: chatID,
AfterID: 0,
})
if err != nil {
return xerrors.Errorf("load context messages for injected context: %w", err)
}
sortChatMessagesByCreatedAtAndID(messages)
parts, err := chatd.CollectContextPartsFromMessages(ctx, logger, messages, true)
if err != nil {
return xerrors.Errorf("collect injected context parts: %w", err)
}
parts = chatd.FilterContextPartsToLatestAgent(parts)
param, err := chatd.BuildLastInjectedContext(parts)
if err != nil {
return xerrors.Errorf("update injected context: %w", err)
}
if _, err := db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
ID: chatID,
LastInjectedContext: param,
}); err != nil {
return xerrors.Errorf("update injected context: %w", err)
}
return nil
}
func messageHasPartTypes(raw []byte, types ...codersdk.ChatMessagePartType) bool {
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(raw, &parts); err != nil {
return false
}
for _, part := range parts {
for _, typ := range types {
if part.Type == typ {
return true
}
}
}
return false
}
// writeAgentChatError translates resolveAgentChat errors to HTTP
// responses.
func writeAgentChatError(
ctx context.Context,
rw http.ResponseWriter,
err error,
) {
if errors.Is(err, errNoActiveChats) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "No active chats found for this agent.",
})
return
}
if errors.Is(err, errChatNotFound) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Chat not found.",
})
return
}
if errors.Is(err, errChatDoesNotBelongToAgent) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Chat does not belong to this agent.",
})
return
}
if errors.Is(err, errChatDoesNotBelongToWorkspaceOwner) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Chat does not belong to this workspace owner.",
})
return
}
if errors.Is(err, errChatNotActive) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot modify context: this chat is no longer active.",
})
return
}
var multipleErr *multipleActiveChatsError
if errors.As(err, &multipleErr) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to resolve chat.",
Detail: err.Error(),
})
}
@@ -1,7 +1,7 @@
package coderd
import (
"fmt"
"database/sql"
"testing"
"github.com/google/uuid"
@@ -16,66 +16,6 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestActiveAgentChatDefinitionsAgree(t *testing.T) {
t.Parallel()
ctx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitMedium))
db, _ := dbtestutil.NewDB(t)
org, err := db.GetDefaultOrganization(ctx)
require.NoError(t, err)
owner := dbgen.User(t, db, database.User{})
workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OrganizationID: org.ID,
OwnerID: owner.ID,
}).WithAgent().Do()
modelConfig := insertAgentChatTestModelConfig(t, db, owner.ID)
insertedChats := make([]database.Chat, 0, len(database.AllChatStatusValues())*2)
for _, archived := range []bool{false, true} {
for _, status := range database.AllChatStatusValues() {
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: org.ID,
Status: status,
OwnerID: owner.ID,
LastModelConfigID: modelConfig.ID,
Title: fmt.Sprintf("%s-archived-%t", status, archived),
AgentID: uuid.NullUUID{UUID: workspace.Agents[0].ID, Valid: true},
})
if archived {
_, err = db.ArchiveChatByID(ctx, chat.ID)
require.NoError(t, err)
chat, err = db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
}
insertedChats = append(insertedChats, chat)
}
}
activeChats, err := db.GetActiveChatsByAgentID(ctx, workspace.Agents[0].ID)
require.NoError(t, err)
activeByID := make(map[uuid.UUID]bool, len(activeChats))
for _, chat := range activeChats {
activeByID[chat.ID] = true
}
for _, chat := range insertedChats {
require.Equalf(
t,
isActiveAgentChat(chat),
activeByID[chat.ID],
"status=%s archived=%t",
chat.Status,
chat.Archived,
)
}
}
func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) {
t.Parallel()
@@ -157,3 +97,31 @@ func TestActiveAgentChatsIncludeInheritedACLs(t *testing.T) {
require.Equal(t, rootUserACL, fetchedChild.UserACL)
require.Equal(t, rootGroupACL, fetchedChild.GroupACL)
}
func insertAgentChatTestModelConfig(
t testing.TB,
db database.Store,
userID uuid.UUID,
) database.ChatModelConfig {
t.Helper()
createdBy := uuid.NullUUID{UUID: userID, Valid: true}
provider := dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AIProviderTypeOpenai,
Name: "test-openai",
DisplayName: sql.NullString{String: "OpenAI", Valid: true},
})
dbgen.AIProviderKey(t, db, database.AIProviderKey{
ProviderID: provider.ID,
APIKey: "test-api-key",
})
return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
CreatedBy: createdBy,
UpdatedBy: createdBy,
IsDefault: true,
})
}
@@ -1,117 +0,0 @@
package coderd
import (
"context"
"database/sql"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/codersdk"
)
func TestUpdateAgentChatLastInjectedContextFromMessagesUsesMessageIDTieBreaker(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
createdAt := time.Date(2026, time.April, 9, 13, 0, 0, 0, time.UTC)
oldAgentID := uuid.New()
newAgentID := uuid.New()
oldContent, err := json.Marshal([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/old/AGENTS.md",
ContextFileContent: "old instructions",
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
}})
require.NoError(t, err)
newContent, err := json.Marshal([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/new/AGENTS.md",
ContextFileContent: "new instructions",
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
}})
require.NoError(t, err)
db.EXPECT().GetChatMessagesByChatID(gomock.Any(), database.GetChatMessagesByChatIDParams{
ChatID: chatID,
AfterID: 0,
}).Return([]database.ChatMessage{
{
ID: 2,
CreatedAt: createdAt,
Content: pqtype.NullRawMessage{
RawMessage: newContent,
Valid: true,
},
},
{
ID: 1,
CreatedAt: createdAt,
Content: pqtype.NullRawMessage{
RawMessage: oldContent,
Valid: true,
},
},
}, nil)
db.EXPECT().UpdateChatLastInjectedContext(gomock.Any(), gomock.Any()).DoAndReturn(
func(_ context.Context, arg database.UpdateChatLastInjectedContextParams) (database.Chat, error) {
require.Equal(t, chatID, arg.ID)
require.True(t, arg.LastInjectedContext.Valid)
var cached []codersdk.ChatMessagePart
require.NoError(t, json.Unmarshal(arg.LastInjectedContext.RawMessage, &cached))
require.Len(t, cached, 1)
require.Equal(t, "/new/AGENTS.md", cached[0].ContextFilePath)
require.Equal(t, uuid.NullUUID{UUID: newAgentID, Valid: true}, cached[0].ContextFileAgentID)
return database.Chat{}, nil
},
)
err = updateAgentChatLastInjectedContextFromMessages(
context.Background(),
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
db,
chatID,
)
require.NoError(t, err)
}
func insertAgentChatTestModelConfig(
t testing.TB,
db database.Store,
userID uuid.UUID,
) database.ChatModelConfig {
t.Helper()
createdBy := uuid.NullUUID{UUID: userID, Valid: true}
provider := dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AIProviderTypeOpenai,
Name: "test-openai",
DisplayName: sql.NullString{String: "OpenAI", Valid: true},
})
dbgen.AIProviderKey(t, db, database.AIProviderKey{
ProviderID: provider.ID,
APIKey: "test-api-key",
})
return dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
Provider: "openai",
AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true},
CreatedBy: createdBy,
UpdatedBy: createdBy,
IsDefault: true,
})
}
File diff suppressed because it is too large Load Diff
+28 -525
View File
@@ -1,7 +1,6 @@
package chatd
import (
"bytes"
"cmp"
"context"
"database/sql"
@@ -68,31 +67,17 @@ const (
DefaultInFlightChatStaleAfter = 5 * time.Minute
homeInstructionLookupTimeout = 5 * time.Second
planPathLookupTimeout = 5 * time.Second
workspaceDialValidationDelay = 5 * time.Second
// Must exceed agent/x/agentmcp.connectTimeout (30s) so a
// cold-start agent's first MCP reload can settle before
// chatd gives up.
workspaceMCPDiscoveryTimeout = 35 * time.Second
// workspaceMCPPrimeMaxWait bounds the deadline used by the
// create_workspace / start_workspace post-ready cache primer
// loop. The primer checks the deadline only after each
// discoverWorkspaceMCPTools call returns, so total wall-clock
// time can exceed this by one such call (dialTimeout +
// workspaceMCPDiscoveryTimeout in the worst case). The constant
// caps when new retries can start, not when an in-flight call
// must finish. Empty results usually mean the agent's MCP
// Connect is still racing with agent startup. The agent-side
// budget is agent/x/agentmcp.connectTimeout (30s).
workspaceMCPPrimeMaxWait = 30 * time.Second
// workspaceMCPPrimeRetryInterval is the short backoff between
// re-attempts inside the primer when ListMCPTools returns an
// empty list without error.
workspaceMCPPrimeRetryInterval = 2 * time.Second
turnStatusLabelWriteTimeout = 5 * time.Second
turnStatusLabelWriteTimeout = 5 * time.Second
// defaultDialTimeout matches the timeout used by ~8 other
// server-side AgentConn callers.
defaultDialTimeout = 30 * time.Second
// planPathLookupTimeout bounds resolving the per-chat plan path, which
// dials the workspace agent to read its home directory. It must exceed
// defaultDialTimeout so a cold dial, bounded internally by that timeout,
// can finish before this outer budget fires, with a small margin for the
// follow-up LS call.
planPathLookupTimeout = defaultDialTimeout + 5*time.Second
// DefaultChatHeartbeatInterval is the default time between chat
// heartbeat updates while a chat is being processed.
DefaultChatHeartbeatInterval = 30 * time.Second
@@ -195,11 +180,6 @@ type Server struct {
configCache *chatConfigCache
configCacheUnsubscribe func()
// workspaceMCPToolsCache caches workspace MCP tool definitions
// per chat to avoid re-fetching on every turn. The cache is
// keyed by chat ID and invalidated when the agent changes.
workspaceMCPToolsCache sync.Map // uuid.UUID -> *cachedWorkspaceMCPTools
usageTracker *workspacestats.UsageTracker
clock quartz.Clock
metrics *chatloop.Metrics
@@ -444,226 +424,50 @@ func (p *Server) newAdvisorRuntime(
return rt, nil
}
// cachedWorkspaceMCPTools stores workspace MCP tools discovered
// from a workspace agent, keyed by the agent ID that provided them.
type cachedWorkspaceMCPTools struct {
agentID uuid.UUID
tools []workspacesdk.MCPToolInfo
}
// loadCachedWorkspaceContext checks the MCP tools cache for the
// given chat and agent. Returns non-nil tools when the cache hits,
// which signals the caller to skip the slow MCP discovery path.
func (p *Server) loadCachedWorkspaceContext(
chatID uuid.UUID,
agent database.WorkspaceAgent,
getConn func(context.Context) (workspacesdk.AgentConn, error),
) []fantasy.AgentTool {
cached, ok := p.workspaceMCPToolsCache.Load(chatID)
if !ok {
return nil
}
entry, ok := cached.(*cachedWorkspaceMCPTools)
if !ok || entry.agentID != agent.ID {
return nil
}
var tools []fantasy.AgentTool
invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) }
for _, t := range entry.tools {
tools = append(tools, chattool.NewWorkspaceMCPTool(t, getConn, invalidate))
}
return tools
}
// discoverWorkspaceMCPTools resolves the chat's workspace agent and
// lists the workspace MCP tools advertised by that agent. Results are
// cached per chat keyed on the agent ID so subsequent calls hit the
// cache. Returns nil (and never an error) on every failure mode so the
// caller can continue without MCP tools.
//
// This helper is shared between the initial discovery path and the
// mid-turn workspace binding path triggered after create_workspace or
// start_workspace bind a workspace to a chat that started without one.
func (p *Server) discoverWorkspaceMCPTools(
ctx context.Context,
logger slog.Logger,
chatID uuid.UUID,
workspaceCtx *turnWorkspaceContext,
) []fantasy.AgentTool {
// Fast path: check cache using the in-memory cached agent
// (ensureWorkspaceAgent is free when already loaded). This
// avoids a per-turn latest-build DB query on the common
// subsequent-turn path.
if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil {
if tools := p.loadCachedWorkspaceContext(
chatID, agent, workspaceCtx.getWorkspaceConn,
); tools != nil {
return tools
}
} // Cache miss, agent changed, or no cache: validate
// that the workspace still has a live agent before
// attempting a dial.
_, _, agentErr := workspaceCtx.workspaceAgentIDForConn(ctx)
if agentErr != nil {
if xerrors.Is(agentErr, errChatHasNoWorkspaceAgent) {
p.workspaceMCPToolsCache.Delete(chatID)
return nil
}
logger.Warn(ctx, "failed to resolve workspace agent for MCP tools",
slog.Error(agentErr))
return nil
}
// List workspace MCP tools via the agent conn.
conn, connErr := workspaceCtx.getWorkspaceConn(ctx)
if connErr != nil {
logger.Warn(ctx, "failed to get workspace conn for MCP tools",
slog.Error(connErr))
return nil
}
listCtx, cancel := context.WithTimeout(ctx, workspaceMCPDiscoveryTimeout)
defer cancel()
toolsResp, listErr := conn.ListMCPTools(listCtx)
if listErr != nil {
logger.Warn(ctx, "failed to list workspace MCP tools",
slog.Error(listErr))
return nil
}
// Cache the result for subsequent turns. Skip caching when
// the list is empty because the agent's MCP Connect may not
// have finished yet; caching an empty list would hide tools
// permanently.
if len(toolsResp.Tools) > 0 {
if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil {
p.workspaceMCPToolsCache.Store(chatID, &cachedWorkspaceMCPTools{
agentID: agent.ID,
tools: toolsResp.Tools,
})
}
}
invalidate := func() { p.workspaceMCPToolsCache.Delete(chatID) }
tools := make([]fantasy.AgentTool, 0, len(toolsResp.Tools))
for _, t := range toolsResp.Tools {
tools = append(tools, chattool.NewWorkspaceMCPTool(t, workspaceCtx.getWorkspaceConn, invalidate))
}
return tools
}
// resolveWorkspaceMCPTools selects the workspace MCP tool set for a turn. It
// prefers the chat's pinned context snapshot and falls back to the per-turn
// live discovery path for chats whose agent has not reported context yet. The
// two paths are mutually exclusive, mirroring resolveTurnWorkspaceContext for
// instructions and skills.
// resolveWorkspaceMCPTools builds the workspace MCP tool set for a turn from
// the chat's pinned context snapshot (chat_context_resources). The agent
// reports its MCP servers in the snapshot it pushes, so a chat with no pinned
// rows, or one whose workspace advertises no MCP servers, contributes no
// workspace MCP tools. A read failure is logged and yields no tools rather
// than aborting the turn.
func (p *Server) resolveWorkspaceMCPTools(
ctx context.Context,
logger slog.Logger,
chat database.Chat,
workspaceCtx *turnWorkspaceContext,
) []fantasy.AgentTool {
pinned, ok, err := p.pinnedWorkspaceMCPTools(ctx, chat, workspaceCtx.getWorkspaceConn)
tools, err := p.pinnedWorkspaceMCPTools(ctx, chat, workspaceCtx.getWorkspaceConn)
if err != nil {
// A pinned-read failure should not be more fatal than a live
// discovery failure (which returns nil tools), so log and fall back
// rather than aborting the turn.
logger.Warn(ctx, "failed to read pinned workspace MCP tools; falling back to live discovery",
logger.Warn(ctx, "failed to read pinned workspace MCP tools",
slog.F("chat_id", chat.ID), slog.Error(err))
} else if ok {
return pinned
return nil
}
return p.discoverWorkspaceMCPTools(ctx, logger, chat.ID, workspaceCtx)
return tools
}
// pinnedWorkspaceMCPTools builds workspace MCP tools from the chat's pinned
// context snapshot (chat_context_resources) instead of dialing the agent for
// a live tool list. ok reports whether the caller should use these tools
// instead of the live discovery path. It is false when the chat has no pinned
// rows (an older agent that never reported context, or a chat not yet
// hydrated), so the caller falls back. When rows exist ok is true even if none
// are MCP servers, because the pin is then authoritative: a workspace with no
// MCP servers contributes no tools.
//
// Each tool still proxies its calls back through the workspace agent
// connection; the snapshot carries tool definitions, not a way to execute
// them, so execution requires a reachable agent. There is no per-chat cache to
// invalidate on the pinned path: a server removed or renamed in the workspace
// surfaces as a dirty chat on the agent's next push, and the user refreshes to
// re-pin, so a nil invalidate callback (a 404 no-op) is correct here.
// context snapshot (chat_context_resources). Each tool still proxies its calls
// back through the workspace agent connection; the snapshot carries tool
// definitions, not a way to execute them, so execution requires a reachable
// agent. There is no per-chat cache to invalidate: a server removed or renamed
// in the workspace surfaces as a dirty chat on the agent's next push, and the
// user refreshes to re-pin, so a nil invalidate callback (a 404 no-op) is
// correct here.
func (p *Server) pinnedWorkspaceMCPTools(
ctx context.Context,
chat database.Chat,
getConn func(context.Context) (workspacesdk.AgentConn, error),
) (tools []fantasy.AgentTool, ok bool, err error) {
) ([]fantasy.AgentTool, error) {
resources, err := p.db.ListChatContextResourcesByChatID(ctx, chat.ID)
if err != nil {
return nil, false, xerrors.Errorf("list chat context resources: %w", err)
}
if len(resources) == 0 {
return nil, false, nil
return nil, xerrors.Errorf("list chat context resources: %w", err)
}
infos := workspaceMCPToolInfosFromResources(resources)
tools = make([]fantasy.AgentTool, 0, len(infos))
tools := make([]fantasy.AgentTool, 0, len(infos))
for _, info := range infos {
tools = append(tools, chattool.NewWorkspaceMCPTool(info, getConn, nil))
}
return tools, true, nil
}
// primeWorkspaceMCPCache populates workspaceMCPToolsCache after the
// create_workspace or start_workspace tool finishes waiting for the
// workspace agent to become reachable. By the time it runs the agent
// is already Ready, so a single ListMCPTools call usually succeeds.
// When the agent's MCP server is still racing with agent startup,
// ListMCPTools may return an empty list (no error) on the first call;
// the primer retries with a short backoff up to
// workspaceMCPPrimeMaxWait so the generation action that follows the
// tool call sees the workspace MCP tools in the cache and does not need
// to dial again.
//
// Returns silently on every failure mode. The chat continues without
// workspace MCP tools when the agent does not advertise any within
// the budget. The next user turn re-runs top-of-turn discovery from
// scratch.
func (p *Server) primeWorkspaceMCPCache(
ctx context.Context,
logger slog.Logger,
chatID uuid.UUID,
workspaceCtx *turnWorkspaceContext,
) {
deadline := p.clock.Now().Add(workspaceMCPPrimeMaxWait)
attempt := 0
for {
attempt++
tools := p.discoverWorkspaceMCPTools(ctx, logger, chatID, workspaceCtx)
if len(tools) > 0 {
logger.Debug(ctx, "primed workspace MCP cache",
slog.F("chat_id", chatID),
slog.F("tool_count", len(tools)),
slog.F("attempts", attempt),
)
return
}
if ctx.Err() != nil {
return
}
if !p.clock.Now().Before(deadline) {
logger.Debug(ctx,
"workspace MCP cache primer gave up waiting for tools",
slog.F("chat_id", chatID),
slog.F("attempts", attempt),
)
return
}
timer := p.clock.NewTimer(workspaceMCPPrimeRetryInterval, "chatd", "workspace-mcp-prime")
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return
}
}
return tools, nil
}
type turnWorkspaceContext struct {
@@ -3585,7 +3389,6 @@ func (p *Server) publishChatPubsubEvents(chats []database.Chat, kind codersdk.Ch
func chatWatchEventSDKChat(chat database.Chat, diffStatus *codersdk.ChatDiffStatus) codersdk.Chat {
sdkChat := db2sdk.Chat(chat, nil, nil)
sdkChat.Files = nil
sdkChat.LastInjectedContext = nil
if diffStatus != nil {
sdkChat.DiffStatus = diffStatus
}
@@ -4079,11 +3882,6 @@ type rootChatToolsOptions struct {
resolvePlanPath func(context.Context) (string, string, error)
storeFile chattool.StoreFileFunc
isPlanModeTurn bool
// primerCtx scopes the workspace MCP cache primer goroutines
// that onChatUpdated launches. runChat cancels it before
// workspaceCtx.close() so an in-flight primer cannot dial a
// fresh conn after the cached one was released.
primerCtx context.Context
}
func (p *Server) loadPlanModeInstructions(
@@ -4203,56 +4001,6 @@ func (p *Server) appendRootChatTools(
// Notify the frontend immediately so it can start streaming
// build logs before the tool completes.
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)
// Note: we intentionally do not insert AGENTS.md / workspace
// context here. Local tool callbacks must not mutate chat
// history while a local-tool generation task is in flight,
// because that advances history_version before the tool
// result is committed and exits the local-tool commit as
// stale. Workspace context is persisted by the
// persist_workspace_context generation action in a later
// pass.
// Prime the workspace MCP tools cache while the create_workspace
// or start_workspace tool is still running. The AgentID guard
// below restricts the primer to the post-ready callback, when
// the agent is reachable. ListMCPTools may still return an
// empty list on the first try when the agent's MCP Connect is
// racing with agent startup; primeWorkspaceMCPCache retries
// with a short backoff up to workspaceMCPPrimeMaxWait. Priming
// here lets the next assistant-generation action hit the cache
// instead of dialing again on a separate timeout budget.
//
// Run asynchronously: the tool itself must not block on the
// primer because the agent may not advertise any MCP tools at
// all (e.g. minimal templates), in which case the primer waits
// the full budget before giving up. The next assistant-generation
// action covers the cache miss path; the primer is purely an
// optimization that warms the cache while the LLM is thinking.
// inflight tracking ensures server shutdown still waits for any
// in-progress primer.
//
// Guard on both WorkspaceID and AgentID being valid:
// create_workspace and start_workspace each fire onChatUpdated
// twice for a new build (binding before waitForAgentReady;
// post-ready after it), and stop_workspace fires it with a nil
// agent. Only the post-ready callback has a live AgentID, so
// the pre-build and stop-side firings would otherwise spawn a
// primer goroutine that dials a missing or dying agent and
// burns the full budget for nothing.
snapshot := opts.workspaceCtx.currentChatSnapshot()
if snapshot.WorkspaceID.Valid && snapshot.AgentID.Valid {
if err := p.goInflight(func() {
p.primeWorkspaceMCPCache(opts.primerCtx, p.logger, snapshot.ID, opts.workspaceCtx)
}); err != nil {
p.logger.Error(context.WithoutCancel(ctx), "failed to schedule workspace MCP cache primer",
slog.F("chat_id", snapshot.ID),
slog.F("workspace_id", snapshot.WorkspaceID.UUID),
slog.F("agent_id", snapshot.AgentID.UUID),
slog.Error(err),
)
}
}
}
tools = append(tools,
@@ -4726,251 +4474,6 @@ func refreshChatWorkspaceSnapshot(
return refreshedChat, nil
}
// contextFileAgentID extracts the workspace agent ID from the most
// recent persisted instruction-file parts. The skill-only sentinel is
// ignored because it does not represent persisted instruction content.
// Returns uuid.Nil, false if no instruction-file parts exist.
func contextFileAgentID(messages []database.ChatMessage) (uuid.UUID, bool) {
var lastID uuid.UUID
found := false
for _, msg := range messages {
if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
continue
}
for _, p := range parts {
if p.Type != codersdk.ChatMessagePartTypeContextFile ||
!p.ContextFileAgentID.Valid ||
p.ContextFilePath == AgentChatContextSentinelPath {
continue
}
lastID = p.ContextFileAgentID.UUID
found = true
break
}
}
return lastID, found
}
// fetchWorkspaceContext retrieves fresh instruction files and
// skills from the workspace agent without persisting. It handles
// agent connection, context configuration fetching, content
// sanitization, and metadata stamping. Returns the workspace
// agent, the stamped parts, discovered skills, and whether the
// workspace connection succeeded. A nil agent means the chat has
// no valid workspace or the agent lookup failed;
// workspaceConnOK is false in that case.
func (p *Server) fetchWorkspaceContext(
ctx context.Context,
chat database.Chat,
getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error),
getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error),
) (agent *database.WorkspaceAgent, agentParts []codersdk.ChatMessagePart, discoveredSkills []chattool.SkillMeta, workspaceConnOK bool) {
if !chat.WorkspaceID.Valid || getWorkspaceAgent == nil {
return nil, nil, nil, false
}
loadedAgent, agentErr := getWorkspaceAgent(ctx)
if agentErr != nil {
return nil, nil, nil, false
}
directory := loadedAgent.ExpandedDirectory
if directory == "" {
directory = loadedAgent.Directory
}
// Fetch context configuration from the agent. Parts
// arrive pre-populated with context-file and skill entries
// so we don't need additional round-trips.
if getWorkspaceConn != nil {
instructionCtx, cancel := context.WithTimeout(ctx, p.instructionLookupTimeout)
defer cancel()
conn, connErr := getWorkspaceConn(instructionCtx)
if connErr != nil {
p.logger.Debug(ctx, "failed to resolve workspace connection for instruction files",
slog.F("chat_id", chat.ID),
slog.Error(connErr),
)
} else {
workspaceConnOK = true
agentCfg, cfgErr := conn.ContextConfig(instructionCtx)
if cfgErr != nil {
p.logger.Debug(ctx, "failed to fetch context config from agent",
slog.F("chat_id", chat.ID), slog.Error(cfgErr))
// Treat a transient ContextConfig failure the
// same as a failed connection so no sentinel is
// persisted. The next turn will retry.
workspaceConnOK = false
} else {
agentParts = agentCfg.Parts
}
}
}
// Stamp server-side fields and sanitize content. The
// agent cannot know its own UUID, OS metadata, or
// directory, those are added here at the trust boundary.
agentID := uuid.NullUUID{UUID: loadedAgent.ID, Valid: true}
for i := range agentParts {
agentParts[i].ContextFileAgentID = agentID
switch agentParts[i].Type {
case codersdk.ChatMessagePartTypeContextFile:
agentParts[i].ContextFileContent = SanitizePromptText(agentParts[i].ContextFileContent)
agentParts[i].ContextFileOS = loadedAgent.OperatingSystem
agentParts[i].ContextFileDirectory = directory
case codersdk.ChatMessagePartTypeSkill:
discoveredSkills = append(discoveredSkills, chattool.SkillMeta{
Name: agentParts[i].SkillName,
Description: agentParts[i].SkillDescription,
Dir: agentParts[i].SkillDir,
MetaFile: agentParts[i].ContextFileSkillMetaFile,
})
}
}
return &loadedAgent, agentParts, discoveredSkills, workspaceConnOK
}
func filterSkillParts(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart {
var filtered []codersdk.ChatMessagePart
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeSkill {
filtered = append(filtered, part)
}
}
return filtered
}
// persistInstructionFiles fetches AGENTS.md instruction files and
// skills from the workspace agent, persisting both as message
// parts. This is called once when a workspace is first attached
// to a chat (or when the agent changes). Returns the formatted
// instruction string and skill index for injection into the
// current turn's prompt.
func (p *Server) persistInstructionFiles(
ctx context.Context,
chat database.Chat,
modelConfigID uuid.UUID,
getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error),
getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error),
) (instruction string, skills []chattool.SkillMeta, err error) {
agent, agentParts, discoveredSkills, workspaceConnOK := p.fetchWorkspaceContext(
ctx, chat, getWorkspaceAgent, getWorkspaceConn,
)
if agent == nil {
return "", nil, nil
}
agentID := uuid.NullUUID{UUID: agent.ID, Valid: true}
hasContent := false
hasContextFilePart := false
for _, part := range agentParts {
if part.Type == codersdk.ChatMessagePartTypeContextFile {
hasContextFilePart = true
if part.ContextFileContent != "" {
hasContent = true
}
}
}
directory := agent.ExpandedDirectory
if directory == "" {
directory = agent.Directory
}
contextAPIKeyID, _ := aibridge.DelegatedAPIKeyIDFromContext(ctx)
if !hasContent {
if !workspaceConnOK {
return "", nil, nil
}
if !hasContextFilePart {
agentParts = append([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFileAgentID: agentID,
}}, agentParts...)
}
content, err := chatprompt.MarshalParts(agentParts)
if err != nil {
return "", nil, nil
}
msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage.
ChatID: chat.ID,
}
appendUserChatMessage(&msgParams, newUserChatMessage(
contextAPIKeyID,
content,
database.ChatMessageVisibilityBoth,
modelConfigID,
chatprompt.CurrentContentVersion,
))
_, _ = p.db.InsertChatMessages(ctx, msgParams)
skillParts := filterSkillParts(agentParts)
p.updateLastInjectedContext(ctx, chat.ID, skillParts)
return "", discoveredSkills, nil
}
content, err := chatprompt.MarshalParts(agentParts)
if err != nil {
return "", nil, xerrors.Errorf("marshal context-file parts: %w", err)
}
msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendUserChatMessage.
ChatID: chat.ID,
}
appendUserChatMessage(&msgParams, newUserChatMessage(
contextAPIKeyID,
content,
database.ChatMessageVisibilityBoth,
modelConfigID,
chatprompt.CurrentContentVersion,
))
if _, err := p.db.InsertChatMessages(ctx, msgParams); err != nil {
return "", nil, xerrors.Errorf("persist instruction files: %w", err)
}
stripped := make([]codersdk.ChatMessagePart, len(agentParts))
copy(stripped, agentParts)
for i := range stripped {
stripped[i].StripInternal()
}
p.updateLastInjectedContext(ctx, chat.ID, stripped)
return formatSystemInstructions(agent.OperatingSystem, directory, agentParts), discoveredSkills, nil
}
// updateLastInjectedContext persists the injected context
// parts (AGENTS.md files and skills) on the chat row so they
// are directly queryable without scanning messages. This is
// best-effort, a failure here is logged but does not block
// the turn.
func (p *Server) updateLastInjectedContext(ctx context.Context, chatID uuid.UUID, parts []codersdk.ChatMessagePart) {
param := pqtype.NullRawMessage{Valid: false}
if parts != nil {
raw, err := json.Marshal(parts)
if err != nil {
p.logger.Warn(ctx, "failed to marshal injected context",
slog.F("chat_id", chatID),
slog.Error(err),
)
return
}
param = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
if _, err := p.db.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
ID: chatID,
LastInjectedContext: param,
}); err != nil {
p.logger.Warn(ctx, "failed to update injected context",
slog.F("chat_id", chatID),
slog.Error(err),
)
}
}
// resolveUserCompactionThreshold looks up the user's per-model
// compaction threshold override. Returns the override value and
// true if one exists and is valid, or 0 and false otherwise.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -25
View File
@@ -19,23 +19,22 @@ import (
// CreateChatInput configures [CreateChat].
type CreateChatInput struct {
OrganizationID uuid.UUID
OwnerID uuid.UUID
WorkspaceID uuid.NullUUID
BuildID uuid.NullUUID
AgentID uuid.NullUUID
ParentChatID uuid.NullUUID
RootChatID uuid.NullUUID
LastModelConfigID uuid.UUID
Title string
Mode database.NullChatMode
PlanMode database.NullChatPlanMode
MCPServerIDs []uuid.UUID
Labels pqtype.NullRawMessage
DynamicTools pqtype.NullRawMessage
ClientType database.ChatClientType
InitialMessages []Message
LastInjectedContext pqtype.NullRawMessage
OrganizationID uuid.UUID
OwnerID uuid.UUID
WorkspaceID uuid.NullUUID
BuildID uuid.NullUUID
AgentID uuid.NullUUID
ParentChatID uuid.NullUUID
RootChatID uuid.NullUUID
LastModelConfigID uuid.UUID
Title string
Mode database.NullChatMode
PlanMode database.NullChatPlanMode
MCPServerIDs []uuid.UUID
Labels pqtype.NullRawMessage
DynamicTools pqtype.NullRawMessage
ClientType database.ChatClientType
InitialMessages []Message
}
// CreateChatResult is the value returned by [CreateChat]. It carries
@@ -106,14 +105,6 @@ func CreateChat(
if err != nil {
return xerrors.Errorf("insert initial messages: %w", err)
}
if input.LastInjectedContext.Valid {
if _, err := store.UpdateChatLastInjectedContext(ctx, database.UpdateChatLastInjectedContextParams{
ID: chat.ID,
LastInjectedContext: input.LastInjectedContext,
}); err != nil {
return xerrors.Errorf("set last injected context: %w", err)
}
}
refreshed, err := store.GetChatByID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("reload chat after initial messages: %w", err)
+5 -75
View File
@@ -135,57 +135,6 @@ func renderSkillIndex(entries []skillIndexEntry, opts skillIndexFormatOptions) s
return b.String()
}
// LoadSkillBody reads the full skill meta file for a discovered
// skill and lists the supporting files in its directory.
func LoadSkillBody(
ctx context.Context,
conn workspacesdk.AgentConn,
skill SkillMeta,
metaFile string,
) (SkillContent, error) {
metaPath := path.Join(skill.Dir, metaFile)
reader, _, err := conn.ReadFile(
ctx, metaPath, 0, maxSkillMetaBytes+1,
)
if err != nil {
return SkillContent{}, xerrors.Errorf(
"read skill body: %w", err,
)
}
raw, err := io.ReadAll(io.LimitReader(reader, maxSkillMetaBytes+1))
reader.Close()
if err != nil {
return SkillContent{}, xerrors.Errorf(
"read skill body bytes: %w", err,
)
}
if int64(len(raw)) > maxSkillMetaBytes {
raw = raw[:maxSkillMetaBytes]
}
_, _, body, err := workspacesdk.ParseSkillFrontmatter(string(raw))
if err != nil {
return SkillContent{}, xerrors.Errorf(
"parse skill frontmatter: %w", err,
)
}
// List supporting files so the model knows what it can
// request via read_skill_file.
files, err := listSkillFiles(ctx, conn, skill.Dir, metaFile)
if err != nil {
return SkillContent{}, err
}
return SkillContent{
SkillMeta: skill,
Body: body,
Files: files,
}, nil
}
// listSkillFiles lists the supporting files in a skill directory,
// excluding the skill meta file itself. Directory entries are
// suffixed with "/" so the model can tell them apart from files.
@@ -524,30 +473,11 @@ func readWorkspaceSkillBody(
return SkillContent{}, skillNotFoundResponse(requestedName), true
}
// Pinned path: the SKILL.md body travels in the workspace context
// snapshot, so it is served without dialing the workspace.
if len(skill.Meta) > 0 {
content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill)
if err != nil {
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
}
return content, fantasy.ToolResponse{}, false
}
// Legacy path: read the SKILL.md body live over the workspace
// connection for agents that have not pushed context.
if options.GetWorkspaceConn == nil {
return SkillContent{}, fantasy.NewTextErrorResponse(
"workspace connection resolver is not configured",
), true
}
conn, err := options.GetWorkspaceConn(ctx)
if err != nil {
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
}
content, err := LoadSkillBody(ctx, conn, skill, cmp.Or(skill.MetaFile, DefaultSkillMetaFile))
// The SKILL.md body travels in the workspace context snapshot, so it
// is served from the pin without dialing the workspace. The supporting
// file list is still a best-effort live lookup; see
// loadPinnedWorkspaceSkillContent.
content, err := loadPinnedWorkspaceSkillContent(ctx, options, skill)
if err != nil {
return SkillContent{}, fantasy.NewTextErrorResponse(err.Error()), true
}
+2 -103
View File
@@ -123,51 +123,6 @@ func TestFormatResolvedSkillIndex(t *testing.T) {
})
}
func TestLoadSkillBody(t *testing.T) {
t.Parallel()
t.Run("ReturnsBodyAndFiles", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
conn := agentconnmock.NewMockAgentConn(ctrl)
skill := chattool.SkillMeta{
Name: "my-skill",
Description: "desc",
Dir: "/work/.agents/skills/my-skill",
}
// Read the full SKILL.md.
conn.EXPECT().ReadFile(
gomock.Any(),
"/work/.agents/skills/my-skill/SKILL.md",
int64(0),
int64(64*1024+1),
).Return(
io.NopCloser(strings.NewReader(validSkillMD("my-skill", "desc"))),
"text/markdown",
nil,
)
// List supporting files.
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
workspacesdk.LSResponse{
Contents: []workspacesdk.LSFile{
{Name: "SKILL.md"},
{Name: "helper.md"},
{Name: "roles", IsDir: true},
},
}, nil,
)
content, err := chattool.LoadSkillBody(context.Background(), conn, skill, "SKILL.md")
require.NoError(t, err)
assert.Contains(t, content.Body, "Do the thing.")
assert.Equal(t, []string{"helper.md", "roles/"}, content.Files)
})
}
func TestLoadSkillFile(t *testing.T) {
t.Parallel()
@@ -309,50 +264,6 @@ func TestLoadSkillFile(t *testing.T) {
func TestReadSkillTool(t *testing.T) {
t.Parallel()
t.Run("ValidSkill", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
conn := agentconnmock.NewMockAgentConn(ctrl)
skills := []chattool.SkillMeta{{
Name: "my-skill",
Description: "test",
Dir: "/work/.agents/skills/my-skill",
}}
conn.EXPECT().ReadFile(
gomock.Any(), gomock.Any(), int64(0), gomock.Any(),
).Return(
io.NopCloser(strings.NewReader(validSkillMD("my-skill", "test"))),
"text/markdown",
nil,
)
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
workspacesdk.LSResponse{
Contents: []workspacesdk.LSFile{
{Name: "SKILL.md"},
},
}, nil,
)
tool := chattool.ReadSkill(chattool.ReadSkillOptions{
GetWorkspaceConn: func(context.Context) (workspacesdk.AgentConn, error) {
return conn, nil
},
GetSkills: func() []chattool.SkillMeta { return skills },
})
resp, err := tool.Run(context.Background(), fantasy.ToolCall{
ID: "call-1",
Name: "read_skill",
Input: `{"name":"my-skill"}`,
})
require.NoError(t, err)
assert.False(t, resp.IsError)
assert.Contains(t, resp.Content, "Do the thing.")
})
t.Run("PinnedBodyFromMeta", func(t *testing.T) {
t.Parallel()
@@ -514,15 +425,9 @@ func TestReadSkillTool(t *testing.T) {
Name: "my-skill",
Description: "test",
Dir: "/work/.agents/skills/my-skill",
Meta: []byte(validSkillMD("my-skill", "test")),
}}
conn.EXPECT().ReadFile(
gomock.Any(), gomock.Any(), int64(0), gomock.Any(),
).Return(
io.NopCloser(strings.NewReader(validSkillMD("my-skill", "test"))),
"text/markdown",
nil,
)
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
workspacesdk.LSResponse{}, nil,
)
@@ -566,15 +471,9 @@ func TestReadSkillTool(t *testing.T) {
Name: "deploy",
Description: "workspace deploy",
Dir: "/work/.agents/skills/deploy",
Meta: []byte(validSkillMD("deploy", "workspace deploy")),
}}
conn.EXPECT().ReadFile(
gomock.Any(), gomock.Any(), int64(0), gomock.Any(),
).Return(
io.NopCloser(strings.NewReader(validSkillMD("deploy", "workspace deploy"))),
"text/markdown",
nil,
)
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
workspacesdk.LSResponse{}, nil,
)
-82
View File
@@ -1,82 +0,0 @@
package chatd
import (
"bytes"
"encoding/json"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
// agentChatContextSentinelPath marks the synthetic empty context-file
// part used to record an attempted workspace-context fetch when no
// AGENTS.md content is available. It mirrors the constant of the same
// value in the chatd package so the worker can recognize sentinel
// parts without importing chatd (which would be a cycle).
const agentChatContextSentinelPath = ".coder/agent-chat-context-sentinel"
// contextFileAgentIDFromMessages returns the most recent workspace
// agent ID stamped on a persisted context-file part, ignoring the
// skill-only sentinel. Returns uuid.Nil, false when no stamped
// non-sentinel context-file parts exist.
//
// This mirrors chatd.contextFileAgentID. It is duplicated here as a
// small pure helper so chatworker can decide whether workspace
// context is current without importing chatd.
func contextFileAgentIDFromMessages(messages []database.ChatMessage) (uuid.UUID, bool) {
var lastID uuid.UUID
found := false
for _, msg := range messages {
if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
continue
}
for _, p := range parts {
if p.Type != codersdk.ChatMessagePartTypeContextFile ||
!p.ContextFileAgentID.Valid ||
p.ContextFilePath == agentChatContextSentinelPath {
continue
}
lastID = p.ContextFileAgentID.UUID
found = true
break
}
}
return lastID, found
}
// hasPersistedContextFileForAgent reports whether messages include
// any persisted context-file marker for the given agent, including
// the skill-only sentinel. This is true once the
// persist_workspace_context action has committed at least one
// context-file row for the agent (with or without content), so a
// subsequent decision pass will not loop on the same agent.
func hasPersistedContextFileForAgent(messages []database.ChatMessage, agentID uuid.UUID) bool {
if agentID == uuid.Nil {
return false
}
for _, msg := range messages {
if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
continue
}
for _, p := range parts {
if p.Type != codersdk.ChatMessagePartTypeContextFile ||
!p.ContextFileAgentID.Valid {
continue
}
if p.ContextFileAgentID.UUID == agentID {
return true
}
}
}
return false
}
+53 -21
View File
@@ -85,6 +85,33 @@ func (p *Server) HydrateAndMarkChatsDirty(ctx context.Context, tx database.Store
}, nil
}
// hydrateAgentChatsFromSnapshot stamps every chat bound to agentID that still
// carries a NULL pinned hash with the agent's latest pushed snapshot and copies
// that snapshot's resources. It runs in one repeatable-read transaction so a
// concurrent push cannot commit between the hash read and the resource copy and
// leave a chat stamped with one snapshot's hash but another snapshot's
// resources. It is the shared core of first-time pinning: idempotent because
// HydrateAgentChatsContext only touches NULL-hash chats (a concurrent push that
// already hydrated the chat is not clobbered), and snapshot-gated so it does
// nothing when the agent has not pushed yet, never stamping empty state that
// would keep a later push from hydrating.
func (p *Server) hydrateAgentChatsFromSnapshot(ctx context.Context, agentID uuid.UUID) error {
return database.ReadModifyUpdate(p.db, func(tx database.Store) error {
aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, agentID)
if err != nil {
return err
}
if !ok {
return nil
}
return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
AgentID: agentID,
AggregateHash: aggregateHash,
ContextError: snapshotError,
})
})
}
// hydrateChatContextOnCreate pins a newly created chat to its agent's latest
// context snapshot when one already exists. Best-effort: a chat whose agent
// has not pushed yet is hydrated later by that agent's next push. Failures
@@ -98,32 +125,37 @@ func (p *Server) hydrateChatContextOnCreate(ctx context.Context, chat database.C
}
//nolint:gocritic // Chatd stamps chats it does not own as the daemon subject.
ctx = dbauthz.AsChatd(ctx)
// Read the snapshot hash and copy the agent's resources in one
// repeatable-read transaction so a concurrent push cannot commit between
// the two and leave the chat stamped with one snapshot's hash but another
// snapshot's resources. The NULL-hash guard inside the statement still
// keeps a concurrent push that already hydrated the chat from being
// clobbered.
if err := database.ReadModifyUpdate(p.db, func(tx database.Store) error {
aggregateHash, snapshotError, ok, err := latestAgentSnapshot(ctx, tx, chat.AgentID.UUID)
if err != nil {
return err
}
if !ok {
return nil
}
return tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{
AgentID: chat.AgentID.UUID,
AggregateHash: aggregateHash,
ContextError: snapshotError,
})
}); err != nil {
if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); err != nil {
p.logger.Warn(ctx, "hydrate chat context on create",
slog.F("chat_id", chat.ID), slog.Error(err))
}
}
// ensureChatContextPinnedOnFirstTurn pins a chat to its freshly bound agent's
// latest pushed snapshot when the chat is still unpinned. API-created chats
// carry no agent at create, so hydrateChatContextOnCreate is a no-op for them;
// they bind their agent lazily on the first turn. Without this, such a chat
// reads empty pinned context on its first turn whenever the agent pushed before
// the chat existed, because that push could not hydrate a chat that did not yet
// exist. It reuses the create-path hydration, which is idempotent and
// snapshot-gated, so it never clobbers an already-pinned chat and never stamps
// empty state. The NULL-hash gate also leaves dirtied chats alone: their stale
// pinned hash is non-NULL until the refresh endpoint re-pins. Best-effort:
// failures are logged and swallowed so they never fail the turn.
func (p *Server) ensureChatContextPinnedOnFirstTurn(ctx context.Context, chat database.Chat) {
if !chat.AgentID.Valid || chat.ContextAggregateHash != nil {
return
}
//nolint:gocritic // Chatd stamps chats it does not own as the daemon subject.
ctx = dbauthz.AsChatd(ctx)
if err := p.hydrateAgentChatsFromSnapshot(ctx, chat.AgentID.UUID); err != nil {
p.logger.Warn(ctx, "ensure chat context pinned on first turn",
slog.F("chat_id", chat.ID),
slog.F("agent_id", chat.AgentID.UUID),
slog.Error(err))
}
}
// repinChatContext re-pins a single chat to its agent's latest context
// snapshot: it sets the pinned hash and error and rewrites the chat's pinned
// resources (clear-then-copy) so the two always agree. A chat with no bound
@@ -83,3 +83,71 @@ func TestHydrateChatContextOnCreate(t *testing.T) {
})
})
}
// TestEnsureChatContextPinnedOnFirstTurn covers the lazy-bind pinning path. An
// API-created chat carries no agent at create, binds its agent on the first
// turn, and must pin the agent's already-pushed snapshot then. This is the
// mechanism that lets a workspace created mid-turn have its context pinned on
// the next turn: the agent pushes its snapshot before the chat is bound to it,
// so HydrateAgentChatsContext on that push cannot reach the chat, and the
// rebind-only binding does not pin a first-time agent.
func TestEnsureChatContextPinnedOnFirstTurn(t *testing.T) {
t.Parallel()
t.Run("PinsWhenUnpinnedAndSnapshotExists", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
server := &Server{db: db, logger: slogtest.Make(t, nil)}
agentID := uuid.New()
chat := database.Chat{ID: uuid.New(), AgentID: uuid.NullUUID{UUID: agentID, Valid: true}}
snapshot := database.WorkspaceAgentContextSnapshot{
WorkspaceAgentID: agentID,
AggregateHash: []byte{0x0a, 0x0b},
}
db.EXPECT().InTx(gomock.Any(), gomock.Any()).DoAndReturn(
func(f func(database.Store) error, _ *database.TxOptions) error { return f(db) })
db.EXPECT().GetLatestWorkspaceAgentContextSnapshot(gomock.Any(), agentID).
Return(snapshot, nil)
// The guarded agent-scoped stamp, not an unconditional SetChatContextSnapshot,
// so a concurrent push that already hydrated the chat wins.
db.EXPECT().HydrateAgentChatsContext(gomock.Any(), database.HydrateAgentChatsContextParams{
AgentID: agentID,
AggregateHash: snapshot.AggregateHash,
ContextError: snapshot.SnapshotError,
}).Return(nil)
server.ensureChatContextPinnedOnFirstTurn(ctx, chat)
})
t.Run("SkipsWhenAlreadyPinned", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
// A non-NULL pinned hash means the chat is already pinned (or dirty
// awaiting refresh); the hook must touch the database zero times so it
// never clobbers existing bodies or a dirty chat's stale hash.
db := dbmock.NewMockStore(ctrl)
server := &Server{db: db, logger: slogtest.Make(t, nil)}
server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{
ID: uuid.New(),
AgentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
ContextAggregateHash: []byte{0x01},
})
})
t.Run("SkipsWhenAgentless", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
ctrl := gomock.NewController(t)
// No agent bound yet: the hook must touch the database zero times.
db := dbmock.NewMockStore(ctrl)
server := &Server{db: db, logger: slogtest.Make(t, nil)}
server.ensureChatContextPinnedOnFirstTurn(ctx, database.Chat{ID: uuid.New()})
})
}
+108
View File
@@ -1,16 +1,20 @@
package chatd_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/provisioner/echo"
@@ -396,3 +400,107 @@ func TestChatContextRefreshFromAgentToken(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 0, refresh.Refreshed, "nothing left to refresh")
}
// agentMCPToolContext specifies an mcp_server tool to seed into an agent's
// pushed context snapshot.
type agentMCPToolContext struct {
AgentID uuid.UUID
ServerName string
ToolName string
ToolDescription string
}
// seedAgentMCPToolContext upserts an mcp_server context snapshot and resource
// for the agent, mirroring what PushContextState writes, so a chat bound to the
// agent hydrates a pinned, execution-ready MCP tool. The model-facing tool name
// is "<ServerName>__<ToolName>". It seeds the raw store directly so unit tests
// can exercise pinned MCP execution without a live agent connection.
func seedAgentMCPToolContext(
ctx context.Context,
t *testing.T,
db database.Store,
tool agentMCPToolContext,
) {
t.Helper()
schema, err := structpb.NewStruct(map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{"type": "string"},
},
})
require.NoError(t, err)
body, err := protojson.Marshal(&agentproto.MCPServerBody{
ServerName: tool.ServerName,
Tools: []*agentproto.MCPTool{{
Name: tool.ToolName,
Description: tool.ToolDescription,
InputSchema: schema,
}},
})
require.NoError(t, err)
now := dbtime.Now()
hash := []byte(tool.ServerName + ":" + tool.ToolName)
_, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
WorkspaceAgentID: tool.AgentID,
Version: 1,
AggregateHash: hash,
ReceivedAt: now,
})
require.NoError(t, err)
_, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{
WorkspaceAgentID: tool.AgentID,
Source: tool.ServerName,
BodyKind: database.WorkspaceAgentContextBodyKindMcpServer,
Body: body,
ContentHash: hash,
SizeBytes: int64(len(body)),
Status: database.WorkspaceAgentContextResourceStatusOk,
Now: now,
})
require.NoError(t, err)
}
// seedAgentInstructionContext upserts an instruction_file context snapshot and
// resource for the agent, mirroring what PushContextState writes, so a chat
// bound to the agent hydrates a pinned instruction block. It seeds the raw
// store directly so unit tests can exercise pinned workspace context without a
// live agent connection.
func seedAgentInstructionContext(
ctx context.Context,
t *testing.T,
db database.Store,
agentID uuid.UUID,
source string,
content string,
) {
t.Helper()
body, err := protojson.Marshal(&agentproto.InstructionFileBody{Content: []byte(content)})
require.NoError(t, err)
now := dbtime.Now()
hash := []byte("instruction:" + source)
_, err = db.UpsertWorkspaceAgentContextSnapshot(ctx, database.UpsertWorkspaceAgentContextSnapshotParams{
WorkspaceAgentID: agentID,
Version: 1,
AggregateHash: hash,
ReceivedAt: now,
})
require.NoError(t, err)
_, err = db.UpsertWorkspaceAgentContextResource(ctx, database.UpsertWorkspaceAgentContextResourceParams{
WorkspaceAgentID: agentID,
Source: source,
BodyKind: database.WorkspaceAgentContextBodyKindInstructionFile,
Body: body,
ContentHash: hash,
SizeBytes: int64(len(body)),
Status: database.WorkspaceAgentContextResourceStatusOk,
Now: now,
})
require.NoError(t, err)
}
+20 -38
View File
@@ -17,6 +17,13 @@ import (
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// AgentChatContextSentinelPath is the canonical path of the synthetic empty
// context-file part that legacy chats used to mark skill-only workspace-agent
// context. New turns no longer emit it; it is retained as the canonical value
// so historical-message handling and the chatopenai chain-mode tests stay in
// sync.
const AgentChatContextSentinelPath = ".coder/agent-chat-context-sentinel"
// contextBodyUnmarshalOptions reads the protojson resource bodies written by
// the agent context push (coderd/agentapi/context.go). DiscardUnknown keeps
// the reader forward compatible as new body fields are added to the proto.
@@ -171,30 +178,24 @@ func decodeSkillIdentity(body json.RawMessage) (name, description string, decode
// pinnedWorkspaceContext builds the system-prompt instruction block and
// workspace skills from the chat's pinned context resources
// (chat_context_resources), populated at hydrate and refresh time.
//
// ok reports whether the caller should use these values instead of the
// per-turn, history-derived path. It is false when the chat has no pinned
// rows (an older agent that never reported context, or a chat not yet
// hydrated), so the caller falls back to the legacy path. When rows exist ok
// is true even if they all filter to empty content, because the pin is then
// the source of truth. A read error is returned rather than swallowed,
// matching the other prompt-input reads in prepareGeneration.
// (chat_context_resources), populated at hydrate and refresh time. A chat
// with no pinned rows yields no context. A read error is returned rather than
// swallowed, matching the other prompt-input reads in prepareGeneration.
//
// agent only decorates the instruction header with its OS and directory; an
// unresolved (zero-value) agent does not force a fallback, so the pin keeps
// unresolved (zero-value) agent does not blank the context, so the pin keeps
// working when the workspace is unreachable.
func (server *Server) pinnedWorkspaceContext(
ctx context.Context,
chat database.Chat,
agent database.WorkspaceAgent,
) (instruction string, skills []chattool.SkillMeta, ok bool, err error) {
) (instruction string, skills []chattool.SkillMeta, err error) {
resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID)
if err != nil {
return "", nil, false, xerrors.Errorf("list chat context resources: %w", err)
return "", nil, xerrors.Errorf("list chat context resources: %w", err)
}
if len(resources) == 0 {
return "", nil, false, nil
return "", nil, nil
}
directory := agent.ExpandedDirectory
@@ -218,42 +219,23 @@ func (server *Server) pinnedWorkspaceContext(
slog.F("skill_count", len(skills)),
slog.F("has_instruction", instruction != ""),
)
return instruction, skills, true, nil
return instruction, skills, nil
}
// resolveTurnWorkspaceContext selects the instruction block and workspace
// skills for a turn. It prefers the chat's pinned context copy when the
// workspace agent has reported context, and falls back to the per-turn,
// history-derived context-file and skill parts for older agents that have
// not. The two paths are mutually exclusive. agent is the chat's resolved
// workspace agent, used only to decorate the pinned instruction header. A
// non-workspace chat yields no context.
// skills for a turn from the chat's pinned context snapshot
// (chat_context_resources). agent is the chat's resolved workspace agent,
// used only to decorate the pinned instruction header. A non-workspace chat
// yields no context.
func (server *Server) resolveTurnWorkspaceContext(
ctx context.Context,
chat database.Chat,
agent database.WorkspaceAgent,
promptRows []database.ChatMessage,
) (instruction string, skills []chattool.SkillMeta, err error) {
if !chat.WorkspaceID.Valid {
return "", nil, nil
}
pinnedInstruction, pinnedSkills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent)
if err != nil {
return "", nil, err
}
if ok {
return pinnedInstruction, pinnedSkills, nil
}
// History fallback: re-derive the instruction and skills from the
// context-file and skill parts the per-turn pull persisted. Skills are
// included only when context files are present; the pinned path resolves
// them independently.
if _, found := contextFileAgentID(promptRows); found {
return instructionFromContextFiles(promptRows), skillsFromParts(promptRows), nil
}
return "", nil, nil
return server.pinnedWorkspaceContext(ctx, chat, agent)
}
// contextResourcesToPrompt converts a chat's pinned context resources into
+25 -82
View File
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
@@ -270,12 +269,11 @@ func TestPinnedWorkspaceContext(t *testing.T) {
Return(nil, xerrors.New("boom"))
server := newPinServer(t, db)
_, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
_, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.Error(t, err)
require.False(t, ok)
})
t.Run("NoRowsFallsBack", func(t *testing.T) {
t.Run("NoRowsYieldsNothing", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -285,9 +283,8 @@ func TestPinnedWorkspaceContext(t *testing.T) {
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
instruction, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.NoError(t, err)
require.False(t, ok)
require.Empty(t, instruction)
require.Empty(t, skills)
})
@@ -306,9 +303,8 @@ func TestPinnedWorkspaceContext(t *testing.T) {
server := newPinServer(t, db)
agent := database.WorkspaceAgent{OperatingSystem: "linux", ExpandedDirectory: "/home/coder"}
instruction, skills, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent)
instruction, skills, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, agent)
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, instruction, "Operating System: linux")
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
require.Contains(t, instruction, "be helpful")
@@ -330,9 +326,8 @@ func TestPinnedWorkspaceContext(t *testing.T) {
// Zero-value agent: the pin still resolves, just without the
// OS/directory header.
instruction, _, ok, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
instruction, _, err := server.pinnedWorkspaceContext(context.Background(), database.Chat{ID: chatID}, database.WorkspaceAgent{})
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, instruction, "Source: /home/coder/AGENTS.md")
require.NotContains(t, instruction, "Operating System:")
})
@@ -415,9 +410,8 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
server := &Server{db: db, logger: logger}
instruction, skills, ok, err := server.pinnedWorkspaceContext(ctx, chat, agent)
instruction, skills, err := server.pinnedWorkspaceContext(ctx, chat, agent)
require.NoError(t, err)
require.True(t, ok)
require.Contains(t, instruction, "Operating System: linux")
require.Contains(t, instruction, "Working Directory: /home/coder/ws")
require.Contains(t, instruction, "Source: /home/coder/ws/AGENTS.md")
@@ -428,8 +422,7 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
require.Equal(t, "/home/coder/ws/.coder/skills/deploy", skills[0].Dir)
// A chat created after hydration keeps a NULL pinned hash and no pinned
// rows, so the pin resolves to ok=false and the caller falls back to the
// per-turn history path.
// rows, so the pin yields no instruction or skills.
unpinnedChat := dbgen.Chat(t, db, database.Chat{
OwnerID: user.ID,
OrganizationID: org.ID,
@@ -438,37 +431,15 @@ func TestPinnedWorkspaceContextFromHydratedPin(t *testing.T) {
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
Status: database.ChatStatusWaiting,
})
_, _, ok, err = server.pinnedWorkspaceContext(ctx, unpinnedChat, agent)
emptyInstruction, emptySkills, err := server.pinnedWorkspaceContext(ctx, unpinnedChat, agent)
require.NoError(t, err)
require.False(t, ok)
}
func historyContextMessage(t *testing.T, agentID uuid.UUID) database.ChatMessage {
t.Helper()
parts := []codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
ContextFilePath: "/home/coder/AGENTS.md",
ContextFileContent: "history content",
ContextFileOS: "linux",
ContextFileDirectory: "/home/coder",
},
{
Type: codersdk.ChatMessagePartTypeSkill,
ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true},
SkillName: "history-skill",
SkillDescription: "from history",
},
}
raw, err := json.Marshal(parts)
require.NoError(t, err)
return database.ChatMessage{Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}}
require.Empty(t, emptyInstruction)
require.Empty(t, emptySkills)
}
// TestResolveTurnWorkspaceContext covers the dispatch that prepareGeneration
// wires up: the pinned copy when the chat has pinned rows, otherwise the
// per-turn history-derived parts, and nothing for a non-workspace chat.
// wires up: the pinned copy when the chat has pinned rows, and nothing for a
// non-workspace chat or a chat without pinned rows.
func TestResolveTurnWorkspaceContext(t *testing.T) {
t.Parallel()
@@ -483,7 +454,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
db := dbmock.NewMockStore(ctrl)
server := newPinServer(t, db)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{}, nil)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), database.Chat{ID: uuid.New()}, database.WorkspaceAgent{})
require.NoError(t, err)
require.Empty(t, instruction)
require.Empty(t, skills)
@@ -495,7 +466,6 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
agentID := uuid.New()
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return([]database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "pinned content", database.WorkspaceAgentContextResourceStatusOk),
@@ -503,48 +473,25 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
}, nil)
server := newPinServer(t, db)
// History rows are present too; the pinned path must take precedence.
promptRows := []database.ChatMessage{historyContextMessage(t, agentID)}
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"}, promptRows)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{OperatingSystem: "linux"})
require.NoError(t, err)
require.Contains(t, instruction, "pinned content")
require.NotContains(t, instruction, "history content")
require.Len(t, skills, 1)
require.Equal(t, "deploy", skills[0].Name)
})
t.Run("HistoryFallbackWhenNoPin", func(t *testing.T) {
t.Run("NoPinYieldsNothing", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
// No pinned rows: the resolver falls back to the per-turn history path.
// No pinned rows: the turn carries no context.
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
agentID := uuid.New()
promptRows := []database.ChatMessage{historyContextMessage(t, agentID)}
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, promptRows)
require.NoError(t, err)
require.Contains(t, instruction, "history content")
require.Len(t, skills, 1)
require.Equal(t, "history-skill", skills[0].Name)
})
t.Run("NoContextWhenHistoryEmpty", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
db := dbmock.NewMockStore(ctrl)
chat := workspaceChat()
// No pinned rows and no history parts: the turn carries no context.
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chat.ID).
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil)
instruction, skills, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{})
require.NoError(t, err)
require.Empty(t, instruction)
require.Empty(t, skills)
@@ -560,7 +507,7 @@ func TestResolveTurnWorkspaceContext(t *testing.T) {
Return(nil, xerrors.New("boom"))
server := newPinServer(t, db)
_, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{}, nil)
_, _, err := server.resolveTurnWorkspaceContext(context.Background(), chat, database.WorkspaceAgent{})
require.Error(t, err)
})
}
@@ -807,7 +754,7 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
return nil, xerrors.New("not dialed in this test")
}
t.Run("NoRowsFallsBack", func(t *testing.T) {
t.Run("NoRowsYieldsNoTools", func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
@@ -817,9 +764,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
Return([]database.ChatContextResource{}, nil)
server := newPinServer(t, db)
tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
require.NoError(t, err)
require.False(t, ok)
require.Empty(t, tools)
})
@@ -833,9 +779,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
Return(nil, xerrors.New("boom"))
server := newPinServer(t, db)
_, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
_, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
require.Error(t, err)
require.False(t, ok)
})
t.Run("BuildsToolsFromMCPServers", func(t *testing.T) {
@@ -857,9 +802,8 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
}, nil)
server := newPinServer(t, db)
tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
require.NoError(t, err)
require.True(t, ok)
require.Len(t, tools, 2)
require.Equal(t, "github__create_issue", tools[0].Info().Name)
require.Equal(t, "github__search", tools[1].Info().Name)
@@ -872,17 +816,16 @@ func TestPinnedWorkspaceMCPTools(t *testing.T) {
db := dbmock.NewMockStore(ctrl)
chatID := uuid.New()
// The chat is pinned (an instruction file is present) but the agent
// reported no MCP servers: ok is true with zero tools so the caller does
// not fall back to a live pull that could resurrect stale tools.
// reported no MCP servers: the pin is authoritative, yielding zero
// tools without a live pull that could resurrect stale tools.
db.EXPECT().ListChatContextResourcesByChatID(gomock.Any(), chatID).
Return([]database.ChatContextResource{
instructionResource(t, "/home/coder/AGENTS.md", "be helpful", database.WorkspaceAgentContextResourceStatusOk),
}, nil)
server := newPinServer(t, db)
tools, ok, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
tools, err := server.pinnedWorkspaceMCPTools(context.Background(), database.Chat{ID: chatID}, getConn)
require.NoError(t, err)
require.True(t, ok)
require.Empty(t, tools)
})
}
-153
View File
@@ -1,153 +0,0 @@
package chatd
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
)
// AgentChatContextSentinelPath marks the synthetic empty context-file
// part used to preserve skill-only workspace-agent additions across
// turns without treating them as persisted instruction files.
const AgentChatContextSentinelPath = ".coder/agent-chat-context-sentinel"
// FilterContextParts keeps only context-file and skill parts from parts.
// When keepEmptyContextFiles is false, context-file parts with empty
// content are dropped. When keepEmptyContextFiles is true, empty
// context-file parts are preserved.
// revive:disable-next-line:flag-parameter // Required by shared helper callers.
func FilterContextParts(
parts []codersdk.ChatMessagePart,
keepEmptyContextFiles bool,
) []codersdk.ChatMessagePart {
var filtered []codersdk.ChatMessagePart
for _, part := range parts {
switch part.Type {
case codersdk.ChatMessagePartTypeContextFile:
if !keepEmptyContextFiles && part.ContextFileContent == "" {
continue
}
case codersdk.ChatMessagePartTypeSkill:
default:
continue
}
filtered = append(filtered, part)
}
return filtered
}
// CollectContextPartsFromMessages unmarshals chat message content and
// collects the context-file and skill parts it contains. When
// keepEmptyContextFiles is false, empty context-file parts are skipped.
// When it is true, empty context-file parts are included in the result.
func CollectContextPartsFromMessages(
ctx context.Context,
logger slog.Logger,
messages []database.ChatMessage,
keepEmptyContextFiles bool,
) ([]codersdk.ChatMessagePart, error) {
var collected []codersdk.ChatMessagePart
for _, msg := range messages {
if !msg.Content.Valid {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
logger.Warn(ctx, "skipping malformed chat context message",
slog.F("chat_message_id", msg.ID),
slog.Error(err),
)
continue
}
collected = append(
collected,
FilterContextParts(parts, keepEmptyContextFiles)...,
)
}
return collected, nil
}
func latestContextAgentIDFromParts(parts []codersdk.ChatMessagePart) (uuid.UUID, bool) {
var lastID uuid.UUID
found := false
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeContextFile ||
!part.ContextFileAgentID.Valid {
continue
}
lastID = part.ContextFileAgentID.UUID
found = true
}
return lastID, found
}
// FilterContextPartsToLatestAgent keeps parts stamped with the latest
// workspace-agent ID seen in the slice, plus legacy unstamped parts.
// When no stamped context-file parts exist, it returns the original
// slice unchanged.
func FilterContextPartsToLatestAgent(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart {
latestAgentID, ok := latestContextAgentIDFromParts(parts)
if !ok {
return parts
}
filtered := make([]codersdk.ChatMessagePart, 0, len(parts))
for _, part := range parts {
switch part.Type {
case codersdk.ChatMessagePartTypeContextFile,
codersdk.ChatMessagePartTypeSkill:
if part.ContextFileAgentID.Valid &&
part.ContextFileAgentID.UUID != latestAgentID {
continue
}
default:
continue
}
filtered = append(filtered, part)
}
return filtered
}
// BuildLastInjectedContext filters parts down to non-empty context-file
// and skill parts, strips their internal fields, and marshals the
// result for LastInjectedContext. A nil or fully filtered input returns
// an invalid NullRawMessage.
func BuildLastInjectedContext(
parts []codersdk.ChatMessagePart,
) (pqtype.NullRawMessage, error) {
if parts == nil {
return pqtype.NullRawMessage{Valid: false}, nil
}
filtered := FilterContextParts(parts, false)
if len(filtered) == 0 {
return pqtype.NullRawMessage{Valid: false}, nil
}
stripped := make([]codersdk.ChatMessagePart, 0, len(filtered))
for _, part := range filtered {
cp := part
cp.StripInternal()
stripped = append(stripped, cp)
}
raw, err := json.Marshal(stripped)
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf(
"marshal injected context: %w",
err,
)
}
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}, nil
}
+5 -127
View File
@@ -69,12 +69,6 @@ type generationPrepared struct {
Cleanup func()
Debug *generationDebug
// WorkspaceContextEligible reports whether the current turn is allowed
// by policy to inject workspace context. The decision helper combines
// this fact with committed chat metadata and history to decide whether
// the persist_workspace_context action should run.
WorkspaceContextEligible bool
}
// generationCompaction contains compaction inputs prepared for generation.
@@ -94,16 +88,6 @@ type generationDebug struct {
ModelConfig database.ChatModelConfig
}
type workspaceContextBuildInput struct {
Chat database.Chat
Messages []database.ChatMessage
ActiveAPIKeyID string
}
type workspaceContextBuildResult struct {
Messages []chatstate.Message
}
// generationOutcome describes a completed generation outcome.
type generationOutcome struct {
Chat database.Chat
@@ -117,12 +101,11 @@ type generationOutcome struct {
type generationActionKind string
const (
generationActionExecuteLocalTools generationActionKind = "execute_local_tools"
generationActionEnterRequiresAction generationActionKind = "enter_requires_action"
generationActionFinishTurn generationActionKind = "finish_turn"
generationActionCompact generationActionKind = "compact"
generationActionGenerateAssistant generationActionKind = "generate_assistant"
generationActionPersistWorkspaceContext generationActionKind = "persist_workspace_context"
generationActionExecuteLocalTools generationActionKind = "execute_local_tools"
generationActionEnterRequiresAction generationActionKind = "enter_requires_action"
generationActionFinishTurn generationActionKind = "finish_turn"
generationActionCompact generationActionKind = "compact"
generationActionGenerateAssistant generationActionKind = "generate_assistant"
)
type generationFinishReason string
@@ -193,34 +176,6 @@ type generationDecisionInput struct {
compactionNeeded bool
compactionThresholdPercent int32
compactionContextLimit int64
workspaceContextEligible bool
}
// shouldPersistWorkspaceContext reports whether the committed chat
// state and history indicate that the persistWorkspaceContext
// generation action should run before the next assistant call. The
// decision uses two facts:
// - chat metadata says a workspace and selected agent are attached;
// - committed history either has no context-file marker for the
// currently selected workspace agent, or the latest non-sentinel
// marker points to a different agent.
//
// The decision is intentionally pure so generation can choose the
// action without dialing the workspace. Once the action commits a
// context-file marker for the agent (with or without content), this
// helper returns false on the next pass and the loop is broken.
func shouldPersistWorkspaceContext(chat database.Chat, messages []database.ChatMessage) bool {
if !chat.WorkspaceID.Valid || !chat.AgentID.Valid {
return false
}
if hasPersistedContextFileForAgent(messages, chat.AgentID.UUID) {
return false
}
persistedAgentID, found := contextFileAgentIDFromMessages(messages)
if !found {
return true
}
return persistedAgentID != chat.AgentID.UUID
}
func decideGenerationAction(input generationDecisionInput) (generationDecision, error) {
@@ -262,9 +217,6 @@ func decideGenerationAction(input generationDecisionInput) (generationDecision,
if input.maxSteps > 0 && currentTurnStepCount(input.messages) >= input.maxSteps {
return generationDecision{kind: generationActionFinishTurn, finishReason: generationFinishReasonMaxSteps}, nil
}
if input.workspaceContextEligible && shouldPersistWorkspaceContext(input.chat, input.messages) {
return generationDecision{kind: generationActionPersistWorkspaceContext}, nil
}
compactionRequirement := compactionRequirementNotNeeded
if input.compactionEnabled && input.compactionNeeded {
compactionRequirement = compactionRequirementNeeded
@@ -382,7 +334,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS
compactionNeeded: prepared.Compaction != nil && prepared.Compaction.Required,
compactionThresholdPercent: generationCompactionThreshold(prepared.Compaction),
compactionContextLimit: prepared.ContextLimitFallback,
workspaceContextEligible: prepared.WorkspaceContextEligible,
})
})
if err != nil {
@@ -415,8 +366,6 @@ func (s *taskStarter) StartGeneration(ctx context.Context, input chatWorkerTaskS
actionErr = s.executeLocalTools(ctx, machine, input, prepared, decision)
case generationActionCompact:
actionErr = s.generateCompaction(ctx, machine, input, prepared)
case generationActionPersistWorkspaceContext:
actionErr = s.persistWorkspaceContext(ctx, machine, input, prepared.Chat)
default:
return s.finishGenerationError(ctx, machine, input, 0, xerrors.Errorf("unknown generation action %q", decision.kind), generationAttemptNotRequired)
}
@@ -805,77 +754,6 @@ func compactionModel(opts chatloop.GenerateCompactionOptions) string {
return opts.Model.Model()
}
// persistWorkspaceContext is the generation action that commits durable
// workspace context messages (e.g. AGENTS.md, workspace skills) into
// chat history. It records a generation attempt, calls the injected
// workspace context builder without holding the DB lock, then commits
// the returned messages fenced to the attempt. If context cannot be
// fetched, it commits a marker for the selected agent so the generation
// loop can continue.
func (s *taskStarter) persistWorkspaceContext(
ctx context.Context,
machine *chatstate.ChatMachine,
input chatWorkerTaskStartInput,
locked database.Chat,
) error {
messages, err := s.opts.Store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: input.ChatID,
AfterID: 0,
})
if err != nil {
return taskRetryableError{err: xerrors.Errorf("load chat messages for workspace context: %w", err)}
}
attempt, _, _, closeEpisode, err := s.beginGenerationAttempt(ctx, machine, input)
if err != nil {
return xerrors.Errorf("beginGenerationAttempt: %w", err)
}
defer closeEpisode()
modelOpts := modelBuildOptionsFromMessages(messages)
result, err := s.server.buildWorkspaceContext(ctx, workspaceContextBuildInput{
Chat: locked,
Messages: messages,
ActiveAPIKeyID: modelOpts.ActiveAPIKeyID,
})
if err != nil {
s.opts.Logger.Warn(ctx, "failed to build workspace context, committing marker",
slog.F("chat_id", input.ChatID),
slog.F("worker_id", input.WorkerID),
slogError(err),
)
marker, err := workspaceContextMarkerMessage(locked, modelOpts.ActiveAPIKeyID)
if err != nil {
return xerrors.Errorf("build workspace context marker: %w", err)
}
result.Messages = []chatstate.Message{marker}
}
return s.commitGenerationStep(ctx, machine, input, attempt, generationActionPersistWorkspaceContext, stepMessagesForCommit{
Messages: result.Messages,
VisibleIndexes: visibleMessageIndexes(result.Messages),
})
}
// workspaceContextMarkerMessage builds an empty context-file sentinel
// for the chat's selected agent. Committing this marker lets the
// generation loop proceed when the agent is unreachable.
func workspaceContextMarkerMessage(chat database.Chat, activeAPIKeyID string) (chatstate.Message, error) {
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFileAgentID: chat.AgentID,
}})
if err != nil {
return chatstate.Message{}, xerrors.Errorf("marshal workspace context marker: %w", err)
}
modelConfigID := chat.LastModelConfigID
return chatstate.Message{
Role: database.ChatMessageRoleUser,
Content: content,
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
ContentVersion: chatprompt.CurrentContentVersion,
APIKeyID: sql.NullString{String: activeAPIKeyID, Valid: activeAPIKeyID != ""},
}, nil
}
func (s *taskStarter) beginGenerationAttempt(
ctx context.Context,
machine *chatstate.ChatMachine,
+26 -11
View File
@@ -213,6 +213,7 @@ func (server *Server) prepareGeneration(
workspaceSkills []chattool.SkillMeta
personalSkills []skillspkg.Skill
resolvedUserPrompt string
planPathBlock string
)
if chat.WorkspaceID.Valid {
@@ -225,8 +226,16 @@ func (server *Server) prepareGeneration(
// history; only metadata is mutated here.
agent, _ := workspaceCtx.getWorkspaceAgent(ctx)
// API-created chats bind their agent lazily here, after
// hydrateChatContextOnCreate ran with no agent. Pin the chat to the
// bound agent's pushed snapshot now if it is still unpinned, so the
// first turn reads workspace context instead of waiting for the
// agent's next push. Idempotent and snapshot-gated; runs before the
// pinned context is read below.
server.ensureChatContextPinnedOnFirstTurn(ctx, workspaceCtx.currentChatSnapshot())
var resolveErr error
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent, promptRows)
instruction, workspaceSkills, resolveErr = server.resolveTurnWorkspaceContext(ctx, chat, agent)
if resolveErr != nil {
cleanup()
return generationPrepared{}, resolveErr
@@ -271,6 +280,17 @@ func (server *Server) prepareGeneration(
return nil
})
}
// Resolve the per-chat plan path block in the parallel phase. It dials
// the workspace agent to read the home directory, so running it here lets
// the cold dial overlap with the rest of turn preparation instead of
// blocking system prompt assembly on a sequential dial. Best-effort:
// resolvePlanPathBlock logs and returns an empty block on failure.
if chat.WorkspaceID.Valid && !chat.ParentChatID.Valid {
g2.Go(func() error {
planPathBlock = resolvePlanPathBlock(ctx)
return nil
})
}
if err := g2.Wait(); err != nil {
cleanup()
return generationPrepared{}, err
@@ -322,7 +342,7 @@ func (server *Server) prepareGeneration(
if advisorRuntime != nil {
prompt = chatprompt.InsertSystem(prompt, chatadvisor.ParentGuidanceBlock)
}
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
prompt = renderPlanPathPrompt(prompt, planPathBlock)
setAdvisorPromptSnapshot(prompt)
storeChatAttachment := server.newStoreChatAttachmentFunc(&workspaceCtx)
@@ -359,7 +379,6 @@ func (server *Server) prepareGeneration(
resolvePlanPath: resolvePlanPathForTools,
storeFile: storeChatAttachment,
isPlanModeTurn: isPlanModeTurn,
primerCtx: ctx,
})
}
@@ -563,13 +582,10 @@ func (server *Server) prepareGeneration(
compactionOptions.StepUsage = latestPromptUsage(promptRows)
compactionNeeded := shouldCompactPromptUsage(compactionOptions.StepUsage, modelConfig.ContextLimit, effectiveThreshold)
workspaceContextEligible := chat.WorkspaceID.Valid && isRootChat && !isPlanModeTurn && !isExploreSubagent
// workspaceCtx.currentChatSnapshot may carry a freshly persisted
// AgentID/BuildID binding from the getWorkspaceAgent call above.
// Return that snapshot so the chatworker decision helper sees
// the up-to-date metadata when deciding whether to run
// persist_workspace_context.
// Return that snapshot so downstream consumers see the up-to-date
// metadata.
refreshedChat := workspaceCtx.currentChatSnapshot()
if refreshedChat.ID == uuid.Nil {
refreshedChat = chat
@@ -601,9 +617,8 @@ func (server *Server) prepareGeneration(
Required: compactionNeeded,
Options: compactionOptions,
},
Cleanup: cleanup,
Debug: debug,
WorkspaceContextEligible: workspaceContextEligible,
Cleanup: cleanup,
Debug: debug,
}, nil
}
-112
View File
@@ -1,14 +1,8 @@
package chatd
import (
"bytes"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
@@ -58,109 +52,3 @@ func formatSystemInstructions(
_, _ = b.WriteString("</workspace-context>")
return b.String()
}
// latestContextAgentID returns the most recent workspace-agent ID seen
// on any persisted context-file part, including the skill-only sentinel.
// Returns uuid.Nil, false when no stamped context-file parts exist.
func latestContextAgentID(messages []database.ChatMessage) (uuid.UUID, bool) {
var lastID uuid.UUID
found := false
for _, msg := range messages {
if !msg.Content.Valid ||
!bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
continue
}
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeContextFile ||
!part.ContextFileAgentID.Valid {
continue
}
lastID = part.ContextFileAgentID.UUID
found = true
break
}
}
return lastID, found
}
// instructionFromContextFiles reconstructs the formatted instruction
// string from persisted context-file parts. This is used on non-first
// turns so the instruction can be re-injected after compaction
// without re-dialing the workspace agent.
func instructionFromContextFiles(
messages []database.ChatMessage,
) string {
filterAgentID, filterByAgent := latestContextAgentID(messages)
var contextParts []codersdk.ChatMessagePart
var os, dir string
for _, msg := range messages {
if !msg.Content.Valid ||
!bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
continue
}
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeContextFile {
continue
}
if filterByAgent && part.ContextFileAgentID.Valid &&
part.ContextFileAgentID.UUID != filterAgentID {
continue
}
if part.ContextFileOS != "" {
os = part.ContextFileOS
}
if part.ContextFileDirectory != "" {
dir = part.ContextFileDirectory
}
if part.ContextFileContent != "" {
contextParts = append(contextParts, part)
}
}
}
return formatSystemInstructions(os, dir, contextParts)
}
// skillsFromParts reconstructs skill metadata from persisted
// skill parts. This is analogous to instructionFromContextFiles
// so the skill index can be re-injected after compaction without
// re-dialing the workspace agent.
func skillsFromParts(
messages []database.ChatMessage,
) []chattool.SkillMeta {
filterAgentID, filterByAgent := latestContextAgentID(messages)
var skills []chattool.SkillMeta
for _, msg := range messages {
if !msg.Content.Valid ||
!bytes.Contains(msg.Content.RawMessage, []byte(`"skill"`)) {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
continue
}
for _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeSkill {
continue
}
if filterByAgent && part.ContextFileAgentID.Valid &&
part.ContextFileAgentID.UUID != filterAgentID {
continue
}
skills = append(skills, chattool.SkillMeta{
Name: part.SkillName,
Description: part.SkillDescription,
Dir: part.SkillDir,
MetaFile: part.ContextFileSkillMetaFile,
})
}
}
return skills
}
@@ -1,15 +1,12 @@
package chatd
import (
"encoding/json"
"strings"
"testing"
"charm.land/fantasy"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
@@ -290,55 +287,3 @@ func TestFormatSystemInstructions(t *testing.T) {
require.Contains(t, got, "Source: /real/AGENTS.md")
})
}
func TestInstructionFromContextFiles(t *testing.T) {
t.Parallel()
makeMsg := func(parts []codersdk.ChatMessagePart) database.ChatMessage {
raw, _ := json.Marshal(parts)
return database.ChatMessage{
Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true},
}
}
t.Run("EmptyMessages", func(t *testing.T) {
t.Parallel()
got := instructionFromContextFiles(nil)
require.Empty(t, got)
})
t.Run("NoContextFileParts", func(t *testing.T) {
t.Parallel()
msgs := []database.ChatMessage{
makeMsg([]codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "test",
SkillDescription: "test skill",
},
}),
}
got := instructionFromContextFiles(msgs)
require.Empty(t, got)
})
t.Run("ReconstructsFromContextFileParts", func(t *testing.T) {
t.Parallel()
msgs := []database.ChatMessage{
makeMsg([]codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFileOS: "linux",
ContextFileDirectory: "/home/coder/project",
ContextFileContent: "project rules",
ContextFilePath: "/home/coder/project/AGENTS.md",
},
}),
}
got := instructionFromContextFiles(msgs)
require.Contains(t, got, "Operating System: linux")
require.Contains(t, got, "Working Directory: /home/coder/project")
require.Contains(t, got, "Source: /home/coder/project/AGENTS.md")
require.Contains(t, got, "project rules")
})
}
+13 -73
View File
@@ -1029,27 +1029,10 @@ func (p *Server) createChildSubagentChatWithOptions(
}
initialMessages = append(initialMessages, systemMessage(workspaceAwarenessContent, modelConfigID))
copiedContextParts, err := copyParentContextMessages(ctx, p.logger, p.db, parent)
if err != nil {
return database.Chat{}, xerrors.Errorf("copy parent context messages: %w", err)
}
var lastInjectedContext pqtype.NullRawMessage
if len(copiedContextParts) > 0 {
filteredContent, err := chatprompt.MarshalParts(copiedContextParts)
if err != nil {
return database.Chat{}, xerrors.Errorf("marshal copied context parts: %w", err)
}
initialMessages = append(initialMessages, userMessageWithAPIKeyID(
filteredContent,
modelConfigID,
parent.OwnerID,
childAPIKeyID,
))
lastInjectedContext, err = BuildLastInjectedContext(FilterContextPartsToLatestAgent(copiedContextParts))
if err != nil {
return database.Chat{}, xerrors.Errorf("build inherited injected context: %w", err)
}
}
// The child shares the parent's workspace and agent, so it inherits
// workspace context the same way a top-level chat does: pinned from the
// agent's latest snapshot (see hydrateChatContextOnCreate below). The
// parent's context is not copied into child history.
initialMessages = append(initialMessages, userMessageWithAPIKeyID(userContent, modelConfigID, parent.OwnerID, childAPIKeyID))
publisher := p.pubsub
@@ -1073,10 +1056,9 @@ func (p *Server) createChildSubagentChatWithOptions(
RawMessage: labelsJSON,
Valid: true,
},
DynamicTools: pqtype.NullRawMessage{},
ClientType: parent.ClientType,
InitialMessages: initialMessages,
LastInjectedContext: lastInjectedContext,
DynamicTools: pqtype.NullRawMessage{},
ClientType: parent.ClientType,
InitialMessages: initialMessages,
})
if err != nil {
return database.Chat{}, xerrors.Errorf("create child chat: %w", err)
@@ -1084,58 +1066,16 @@ func (p *Server) createChildSubagentChatWithOptions(
child := result.Chat
// Pin the child to its agent's latest context snapshot, mirroring the
// top-level create path. The child shares the parent's workspace agent,
// so this reproduces the parent's workspace context without copying it
// through chat history.
p.hydrateChatContextOnCreate(ctx, child)
p.publishChatPubsubEvent(child, codersdk.ChatWatchEventKindCreated, nil)
return child, nil
}
// copyParentContextMessages reads persisted context-file and skill
// messages from the parent chat. This ensures sub-agents inherit the
// same instruction and skill context as their parent without
// independently re-fetching from the agent.
func copyParentContextMessages(
ctx context.Context,
logger slog.Logger,
store database.Store,
parent database.Chat,
) ([]codersdk.ChatMessagePart, error) {
parentMessages, err := store.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: parent.ID,
AfterID: 0,
})
if err != nil {
return nil, xerrors.Errorf("get parent messages: %w", err)
}
var copiedParts []codersdk.ChatMessagePart
for _, msg := range parentMessages {
if !msg.Content.Valid {
continue
}
var parts []codersdk.ChatMessagePart
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
logger.Warn(ctx, "failed to unmarshal parent context message",
slog.F("parent_chat_id", parent.ID),
slog.F("message_id", msg.ID),
slog.Error(err),
)
continue
}
messageContextParts := FilterContextParts(parts, true)
if len(messageContextParts) == 0 {
continue
}
copiedParts = append(copiedParts, messageContextParts...)
}
if len(copiedParts) == 0 {
return nil, nil
}
copiedParts = FilterContextPartsToLatestAgent(copiedParts)
return copiedParts, nil
}
func (p *Server) sendSubagentMessage(
ctx context.Context,
parentChatID uuid.UUID,
+77 -448
View File
@@ -2,484 +2,97 @@ package chatd
import (
"context"
"database/sql"
"encoding/json"
"testing"
"charm.land/fantasy"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/aibridge"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
"github.com/coder/coder/v2/codersdk"
)
func TestCollectContextPartsFromMessagesSkipsSentinelContextFiles(t *testing.T) {
t.Parallel()
content, err := json.Marshal([]codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/project/.agents/skills/my-skill/SKILL.md",
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "my-skill",
SkillDescription: "A test skill",
},
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/project/AGENTS.md",
ContextFileContent: "# Project instructions",
},
codersdk.ChatMessageText("ignored"),
})
require.NoError(t, err)
parts, err := CollectContextPartsFromMessages(context.Background(), slog.Make(), []database.ChatMessage{ //nolint:exhaustruct // Only content fields matter for this unit test.
{
ID: 1,
Content: pqtype.NullRawMessage{
RawMessage: content,
Valid: true,
},
},
}, false)
require.NoError(t, err)
require.Len(t, parts, 2)
require.Equal(t, codersdk.ChatMessagePartTypeSkill, parts[0].Type)
require.Equal(t, "my-skill", parts[0].SkillName)
require.Equal(t, codersdk.ChatMessagePartTypeContextFile, parts[1].Type)
require.Equal(t, "/home/coder/project/AGENTS.md", parts[1].ContextFilePath)
require.Equal(t, "# Project instructions", parts[1].ContextFileContent)
}
func TestCollectContextPartsFromMessagesKeepsEmptyContextFilesWhenRequested(t *testing.T) {
t.Parallel()
content, err := json.Marshal([]codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: AgentChatContextSentinelPath,
ContextFileAgentID: uuid.NullUUID{
UUID: uuid.New(),
Valid: true,
},
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "my-skill",
},
})
require.NoError(t, err)
parts, err := CollectContextPartsFromMessages(context.Background(), slog.Make(), []database.ChatMessage{ //nolint:exhaustruct // Only content fields matter for this unit test.
{
ID: 1,
Content: pqtype.NullRawMessage{
RawMessage: content,
Valid: true,
},
},
}, true)
require.NoError(t, err)
require.Len(t, parts, 2)
require.Equal(t, AgentChatContextSentinelPath, parts[0].ContextFilePath)
require.Equal(t, "my-skill", parts[1].SkillName)
}
func TestFilterContextPartsToLatestAgent(t *testing.T) {
t.Parallel()
oldAgentID := uuid.New()
newAgentID := uuid.New()
parts := []codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/legacy/AGENTS.md",
ContextFileContent: "legacy instructions",
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "repo-helper-legacy",
},
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/old/AGENTS.md",
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "repo-helper-old",
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
},
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: AgentChatContextSentinelPath,
ContextFileAgentID: uuid.NullUUID{
UUID: newAgentID,
Valid: true,
},
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "repo-helper-new",
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
},
}
got := FilterContextPartsToLatestAgent(parts)
require.Len(t, got, 4)
require.Equal(t, "/legacy/AGENTS.md", got[0].ContextFilePath)
require.Equal(t, "repo-helper-legacy", got[1].SkillName)
require.Equal(t, AgentChatContextSentinelPath, got[2].ContextFilePath)
require.Equal(t, "repo-helper-new", got[3].SkillName)
}
func createParentChatWithInheritedContext(
// createWorkspaceBoundParentChat creates a parent chat bound to a workspace
// agent that has already pushed a context snapshot, so CreateChat pins the chat
// to that snapshot at create time. It returns the pinned parent and the source
// of its single pinned resource, letting a spawned child be asserted to inherit
// the same pin.
func createWorkspaceBoundParentChat(
ctx context.Context,
t *testing.T,
db database.Store,
server *Server,
) database.Chat {
) (database.Chat, string) {
t.Helper()
user, org, model := seedInternalChatDeps(t, db)
tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tmpl := dbgen.Template(t, db, database.Template{
OrganizationID: org.ID,
ActiveVersionID: tv.ID,
CreatedBy: user.ID,
})
ws := dbgen.Workspace(t, db, database.WorkspaceTable{
OwnerID: user.ID,
OrganizationID: org.ID,
TemplateID: tmpl.ID,
})
pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: org.ID,
CompletedAt: sql.NullTime{Valid: true, Time: dbtime.Now()},
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: ws.ID,
TemplateVersionID: tv.ID,
JobID: pj.ID,
Transition: database.WorkspaceTransitionStart,
})
res := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
Transition: database.WorkspaceTransitionStart,
JobID: pj.ID,
})
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res.ID})
const source = "/home/coder/project/AGENTS.md"
seedAgentContext(ctx, t, db, agent.ID, source, []byte("instruction:parent"),
database.WorkspaceAgentContextBodyKindInstructionFile,
json.RawMessage(`{"instruction_file":{"content":"parent"}}`))
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-with-context",
ModelConfigID: model.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
BuildID: uuid.NullUUID{UUID: build.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
inheritedParts := []codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/project/AGENTS.md",
ContextFileContent: "# Project instructions",
ContextFileOS: "linux",
ContextFileDirectory: "/home/coder/project",
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "my-skill",
SkillDescription: "A test skill",
SkillDir: "/home/coder/project/.agents/skills/my-skill",
ContextFileSkillMetaFile: "SKILL.md",
},
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/project/.agents/skills/my-skill/SKILL.md",
},
}
content, err := json.Marshal(inheritedParts)
require.NoError(t, err)
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: parent.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Content: pqtype.NullRawMessage{RawMessage: content, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
})
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
return parentChat
return parentChat, source
}
func assertChildInheritedContext(
ctx context.Context,
t *testing.T,
db database.Store,
childID uuid.UUID,
prompt string,
) {
t.Helper()
childChat, err := db.GetChatByID(ctx, childID)
require.NoError(t, err)
require.True(t, childChat.LastInjectedContext.Valid)
var cached []codersdk.ChatMessagePart
require.NoError(t, json.Unmarshal(childChat.LastInjectedContext.RawMessage, &cached))
require.Len(t, cached, 2)
var sawContextFile bool
var sawSkill bool
for _, part := range cached {
switch part.Type {
case codersdk.ChatMessagePartTypeContextFile:
sawContextFile = true
require.Equal(t, "/home/coder/project/AGENTS.md", part.ContextFilePath)
require.Empty(t, part.ContextFileContent)
require.Empty(t, part.ContextFileOS)
require.Empty(t, part.ContextFileDirectory)
case codersdk.ChatMessagePartTypeSkill:
sawSkill = true
require.Equal(t, "my-skill", part.SkillName)
require.Equal(t, "A test skill", part.SkillDescription)
require.Empty(t, part.SkillDir)
require.Empty(t, part.ContextFileSkillMetaFile)
default:
t.Fatalf("unexpected cached part type %q", part.Type)
}
}
require.True(t, sawContextFile)
require.True(t, sawSkill)
childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: childID,
AfterID: 0,
})
require.NoError(t, err)
var (
contextMessageIndexes []int
userPromptIndex = -1
sawDBAgentsContextFile bool
sawDBSkillCompanionContext bool
sawDBSkill bool
)
for i, msg := range childMessages {
if !msg.Content.Valid {
continue
}
var parts []codersdk.ChatMessagePart
require.NoError(t, json.Unmarshal(msg.Content.RawMessage, &parts))
if len(parts) == 1 && parts[0].Type == codersdk.ChatMessagePartTypeText && parts[0].Text == prompt {
require.Equal(t, database.ChatMessageRoleUser, msg.Role)
userPromptIndex = i
continue
}
hasInheritedContext := false
for _, part := range parts {
switch part.Type {
case codersdk.ChatMessagePartTypeContextFile:
hasInheritedContext = true
switch part.ContextFilePath {
case "/home/coder/project/AGENTS.md":
sawDBAgentsContextFile = true
require.Equal(t, "# Project instructions", part.ContextFileContent)
require.Equal(t, "linux", part.ContextFileOS)
require.Equal(t, "/home/coder/project", part.ContextFileDirectory)
case "/home/coder/project/.agents/skills/my-skill/SKILL.md":
sawDBSkillCompanionContext = true
require.Empty(t, part.ContextFileContent)
require.Empty(t, part.ContextFileOS)
require.Empty(t, part.ContextFileDirectory)
default:
t.Fatalf("unexpected child inherited context file path %q", part.ContextFilePath)
}
case codersdk.ChatMessagePartTypeSkill:
hasInheritedContext = true
sawDBSkill = true
require.Equal(t, "my-skill", part.SkillName)
require.Equal(t, "A test skill", part.SkillDescription)
require.Equal(t, "/home/coder/project/.agents/skills/my-skill", part.SkillDir)
require.Equal(t, "SKILL.md", part.ContextFileSkillMetaFile)
default:
t.Fatalf("unexpected child inherited part type %q", part.Type)
}
}
if hasInheritedContext {
require.Equal(t, database.ChatMessageRoleUser, msg.Role)
contextMessageIndexes = append(contextMessageIndexes, i)
}
}
require.NotEmpty(t, contextMessageIndexes)
require.NotEqual(t, -1, userPromptIndex)
for _, idx := range contextMessageIndexes {
require.Less(t, idx, userPromptIndex)
}
require.True(t, sawDBAgentsContextFile)
require.True(t, sawDBSkillCompanionContext)
require.True(t, sawDBSkill)
}
func createParentChatWithRotatedInheritedContext(
ctx context.Context,
t *testing.T,
db database.Store,
server *Server,
) database.Chat {
t.Helper()
user, org, model := seedInternalChatDeps(t, db)
parent, err := server.CreateChat(ctx, CreateOptions{
OrganizationID: org.ID,
OwnerID: user.ID,
APIKeyID: testAPIKeyID(t, db, user.ID),
Title: "parent-with-rotated-context",
ModelConfigID: model.ID,
InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")},
})
require.NoError(t, err)
oldAgentID := uuid.New()
newAgentID := uuid.New()
oldContent, err := json.Marshal([]codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/project-old/AGENTS.md",
ContextFileContent: "# Old instructions",
ContextFileOS: "darwin",
ContextFileDirectory: "/home/coder/project-old",
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "old-skill",
SkillDescription: "Old skill",
SkillDir: "/home/coder/project-old/.agents/skills/old-skill",
ContextFileAgentID: uuid.NullUUID{UUID: oldAgentID, Valid: true},
},
})
require.NoError(t, err)
newContent, err := json.Marshal([]codersdk.ChatMessagePart{
{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: "/home/coder/project-new/AGENTS.md",
ContextFileContent: "# New instructions",
ContextFileOS: "linux",
ContextFileDirectory: "/home/coder/project-new",
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
},
{
Type: codersdk.ChatMessagePartTypeSkill,
SkillName: "new-skill",
SkillDescription: "New skill",
SkillDir: "/home/coder/project-new/.agents/skills/new-skill",
ContextFileAgentID: uuid.NullUUID{UUID: newAgentID, Valid: true},
},
})
require.NoError(t, err)
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: parent.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Content: pqtype.NullRawMessage{RawMessage: oldContent, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
})
_ = dbgen.ChatMessage(t, db, database.ChatMessage{
ChatID: parent.ID,
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
Role: database.ChatMessageRoleUser,
Content: pqtype.NullRawMessage{RawMessage: newContent, Valid: true},
ContentVersion: chatprompt.CurrentContentVersion,
})
parentChat, err := db.GetChatByID(ctx, parent.ID)
require.NoError(t, err)
return parentChat
}
func TestCreateChildSubagentChatCopiesOnlyLatestAgentContext(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
parentChat := createParentChatWithRotatedInheritedContext(ctx, t, db, server)
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, server.db, parentChat.OwnerID))
child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "")
require.NoError(t, err)
childChat, err := db.GetChatByID(ctx, child.ID)
require.NoError(t, err)
require.True(t, childChat.LastInjectedContext.Valid)
var cached []codersdk.ChatMessagePart
require.NoError(t, json.Unmarshal(childChat.LastInjectedContext.RawMessage, &cached))
require.Len(t, cached, 2)
require.Equal(t, "/home/coder/project-new/AGENTS.md", cached[0].ContextFilePath)
require.Equal(t, "new-skill", cached[1].SkillName)
childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: child.ID,
AfterID: 0,
})
require.NoError(t, err)
var inherited [][]codersdk.ChatMessagePart
for _, msg := range childMessages {
if !msg.Content.Valid {
continue
}
var parts []codersdk.ChatMessagePart
require.NoError(t, json.Unmarshal(msg.Content.RawMessage, &parts))
if len(parts) == 0 || parts[0].Type == codersdk.ChatMessagePartTypeText {
continue
}
inherited = append(inherited, parts)
}
require.Len(t, inherited, 1)
require.Len(t, inherited[0], 2)
require.Equal(t, "/home/coder/project-new/AGENTS.md", inherited[0][0].ContextFilePath)
require.Equal(t, "# New instructions", inherited[0][0].ContextFileContent)
require.Equal(t, "new-skill", inherited[0][1].SkillName)
}
func TestCreateChildSubagentChatUpdatesInheritedLastInjectedContext(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
parentChat := createParentChatWithInheritedContext(ctx, t, db, server)
// Set a delegated API key so that copied user-role context messages
// are stamped with api_key_id, preserving AI Gateway routing.
apiKey, _ := dbgen.APIKey(t, db, database.APIKey{UserID: parentChat.OwnerID})
ctx = aibridge.WithDelegatedAPIKeyID(ctx, apiKey.ID)
child, err := server.createChildSubagentChat(ctx, parentChat, "inspect bindings", "")
require.NoError(t, err)
assertChildInheritedContext(ctx, t, db, child.ID, "inspect bindings")
// Verify that all user-role messages in the child chat carry
// api_key_id so activeTurnAPIKeyIDFromMessages resolves correctly.
childMessages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{
ChatID: child.ID,
AfterID: 0,
})
require.NoError(t, err)
var userMsgCount int
for _, msg := range childMessages {
if msg.Role != database.ChatMessageRoleUser {
continue
}
userMsgCount++
require.True(t, msg.APIKeyID.Valid, "child user message (id=%d) should have api_key_id set", msg.ID)
require.Equal(t, apiKey.ID, msg.APIKeyID.String, "child user message (id=%d) api_key_id mismatch", msg.ID)
}
require.Greater(t, userMsgCount, 0, "expected at least one user-role message in child chat")
}
func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
// TestSpawnComputerUseAgentInheritsPinnedContext verifies that a spawned
// subagent inherits its parent's pinned workspace context. The child shares the
// parent's workspace agent, so create-time hydration pins it to the same
// snapshot instead of copying any context through chat history. It also asserts
// the child is created in computer_use mode.
func TestSpawnComputerUseAgentInheritsPinnedContext(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
@@ -487,13 +100,19 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
server := newInternalTestServer(t, db, ps, chatprovider.ProviderAPIKeys{})
ctx := chatdTestContext(t)
parentChat := createParentChatWithInheritedContext(ctx, t, db, server)
parentChat, wantSource := createWorkspaceBoundParentChat(ctx, t, db, server)
parentRes, err := db.ListChatContextResourcesByChatID(ctx, parentChat.ID)
require.NoError(t, err)
require.Len(t, parentRes, 1, "parent is pinned to its agent's snapshot at create")
require.Equal(t, wantSource, parentRes[0].Source)
require.NotNil(t, parentChat.ContextAggregateHash)
insertEnabledAnthropicProvider(t, db, parentChat.OwnerID)
// The direct DB insert above bypasses the pubsub event that
// production uses to invalidate the provider cache. Explicitly
// invalidate here so the background processing goroutine does
// not serve a stale provider list (OpenAI only) that was cached
// before the Anthropic provider was inserted.
// The direct DB insert above bypasses the pubsub event that production
// uses to invalidate the provider cache. Explicitly invalidate here so the
// background processing goroutine does not serve a stale provider list
// (OpenAI only) that was cached before the Anthropic provider was inserted.
server.configCache.InvalidateProviders()
ctx = aibridge.WithDelegatedAPIKeyID(ctx, testAPIKeyID(t, db, parentChat.OwnerID))
@@ -522,5 +141,15 @@ func TestSpawnComputerUseAgentInheritsContext(t *testing.T) {
require.True(t, childChat.Mode.Valid)
require.Equal(t, database.ChatModeComputerUse, childChat.Mode.ChatMode)
assertChildInheritedContext(ctx, t, db, childID, "inspect bindings")
// The child shares the parent's workspace agent, so create-time hydration
// pins it to the same snapshot: it inherits the parent's pinned resources
// verbatim, with nothing copied through chat history.
childRes, err := db.ListChatContextResourcesByChatID(ctx, childID)
require.NoError(t, err)
require.Len(t, childRes, len(parentRes), "child inherits the parent's pinned resource set")
require.Equal(t, parentRes[0].Source, childRes[0].Source)
require.Equal(t, parentRes[0].ContentHash, childRes[0].ContentHash)
require.JSONEq(t, string(parentRes[0].Body), string(childRes[0].Body))
require.Equal(t, parentChat.ContextAggregateHash, childChat.ContextAggregateHash,
"child pins the same aggregate hash as the parent")
}
-148
View File
@@ -1,148 +0,0 @@
package chatd
import (
"context"
"database/sql"
"sync"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
// errWorkspaceContextUnavailable is returned by buildWorkspaceContext
// when there is nothing safe to persist for the current committed
// metadata, e.g. the chat has no bound workspace agent or the agent is
// no longer resolvable.
var errWorkspaceContextUnavailable = xerrors.New("workspace context unavailable")
// buildWorkspaceContext fetches workspace context for the chat's
// bound workspace agent and returns durable chatstate.Message values
// for the generation action to commit. It returns
// errWorkspaceContextUnavailable when there is nothing safe to
// persist for the current committed metadata.
func (server *Server) buildWorkspaceContext(
ctx context.Context,
input workspaceContextBuildInput,
) (workspaceContextBuildResult, error) {
chat := input.Chat
if !chat.WorkspaceID.Valid || !chat.AgentID.Valid {
return workspaceContextBuildResult{}, errWorkspaceContextUnavailable
}
logger := server.logger.With(
slog.F("chat_id", chat.ID),
slog.F("owner_id", chat.OwnerID),
)
// Build a per-call workspace context with the latest committed
// chat snapshot so getWorkspaceAgent and getWorkspaceConn dial
// the agent we actually want to fetch context from.
currentChat := chat
var chatStateMu sync.Mutex
wsCtx := turnWorkspaceContext{
server: server,
chatStateMu: &chatStateMu,
currentChat: &currentChat,
loadChatSnapshot: server.db.GetChatByID,
}
defer wsCtx.close()
parts, expectedAgentID := server.fetchContextForBuild(ctx, chat, &wsCtx, logger)
// If the workspace or agent is gone, report unavailable.
if expectedAgentID == uuid.Nil {
return workspaceContextBuildResult{}, errWorkspaceContextUnavailable
}
hasContent := false
hasContextFilePart := false
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeContextFile {
hasContextFilePart = true
if part.ContextFileContent != "" {
hasContent = true
}
}
}
agentID := uuid.NullUUID{UUID: expectedAgentID, Valid: true}
// If we have no content but the agent is known, commit a blank
// context-file marker (sentinel) so subsequent turns skip the
// workspace-agent dial and the decision helper observes the
// attempt in committed history. This applies whether the
// workspace connection succeeded but returned no AGENTS.md, or
// the agent's context config fetch failed: in both cases we
// have a known agent and committing a sentinel breaks the
// otherwise-infinite decision loop.
if !hasContent {
if !hasContextFilePart {
parts = append([]codersdk.ChatMessagePart{{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFileAgentID: agentID,
}}, parts...)
}
}
content, err := chatprompt.MarshalParts(parts)
if err != nil {
return workspaceContextBuildResult{}, xerrors.Errorf("marshal workspace context parts: %w", err)
}
modelConfigID := chat.LastModelConfigID
msg := chatstate.Message{
Role: database.ChatMessageRoleUser,
Content: content,
Visibility: database.ChatMessageVisibilityBoth,
ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil},
ContentVersion: chatprompt.CurrentContentVersion,
APIKeyID: sql.NullString{String: input.ActiveAPIKeyID, Valid: input.ActiveAPIKeyID != ""},
}
// Update the cache column so subsequent turns can read the last
// injected context without scanning messages. This is a
// best-effort write that does not mutate chat history; the
// generation action separately commits the durable message
// below.
stripped := make([]codersdk.ChatMessagePart, len(parts))
copy(stripped, parts)
for i := range stripped {
stripped[i].StripInternal()
}
server.updateLastInjectedContext(ctx, chat.ID, stripped)
return workspaceContextBuildResult{Messages: []chatstate.Message{msg}}, nil
}
// fetchContextForBuild fetches workspace context parts from the
// agent, returning the parts to persist. expectedAgentID is the agent
// ID the fetch was bound to, or uuid.Nil if the agent could not be
// resolved.
func (server *Server) fetchContextForBuild(
ctx context.Context,
chat database.Chat,
wsCtx *turnWorkspaceContext,
logger slog.Logger,
) (parts []codersdk.ChatMessagePart, expectedAgentID uuid.UUID) {
agent, agentParts, _, _ := server.fetchWorkspaceContext(
ctx, chat, wsCtx.getWorkspaceAgent,
func(instructionCtx context.Context) (workspacesdk.AgentConn, error) {
if _, _, err := wsCtx.workspaceAgentIDForConn(instructionCtx); err != nil {
return nil, err
}
return wsCtx.getWorkspaceConn(instructionCtx)
},
)
if agent == nil {
// fetchWorkspaceContext returns nil for the agent when the
// chat has no valid workspace or the agent lookup fails.
logger.Debug(ctx, "workspace context build: workspace agent not resolvable")
return nil, uuid.Nil
}
return agentParts, agent.ID
}