feat: integrate agent context snapshots into chats (#26389)

Makes the chat context foundation from #26385 live. That PR added the
storage columns, writer queries, and a dormant
`agentapi.ContextDirtyMarker` trigger with no production callers; this
PR wires them together end to end.

When a workspace agent pushes a context snapshot, bound chats now
hydrate to that snapshot's hash, and a later push with a different hash
flips already-pinned chats to dirty (emitting a `context_dirty` watch
event after the transaction commits). Chat creation pins the agent's
latest snapshot when one already exists. The experimental chat API
exposes this as `Chat.Context` (`*ChatContext` with `dirty`,
`dirty_since`, `error`), and a new `PUT
/api/experimental/chats/{chat}/context` endpoint re-pins the agent's
latest snapshot and clears the dirty marker.

`context_dirty_resources` stays NULL (the resource-level diff is
deferred to the UI phase) and the live per-turn context pull is
unchanged.

The end-to-end test provisions a workspace agent via the echo
provisioner, connects it over the Agent API v2.10, and exercises the
full path: an initial push hydrates a bound chat (clean), a second push
with a different hash marks it dirty, the API reports the dirty state,
and the refresh endpoint clears it.

<details>
<summary>Decision log</summary>

- **API shape — sub-struct.** Dirty state is surfaced as
`codersdk.Chat.Context *ChatContext { Dirty bool; DirtySince *time.Time;
Error string }` rather than flat fields, matching the RFC's named
`ChatContext` type and leaving room for future fields (resource diff,
sources). `db2sdk.Chat` populates it when the chat is context-tracked
(`len(ContextAggregateHash) > 0`), dirty, or carries a snapshot error,
and leaves it nil (`omitempty`) otherwise. `Dirty` mirrors
`context_dirty_since` being set.
- **Marker wiring.** The chat daemon is injected directly as the
`agentapi.ContextDirtyMarker`. It is unconditionally constructed (only
its background worker is gated), so the marker is always non-nil and the
wiring matches every other `api.chatDaemon` call site. `agentapi` still
treats a nil marker as "chatd absent", so `PushContextState` stays a
pure write path for any future caller that does not wire chatd in.
- **Refresh is atomic.** `RefreshChatContext` reads the agent's latest
snapshot and re-pins the chat in one repeatable-read transaction, so a
concurrent push cannot land between the read and the write and leave the
chat pinned to a stale hash with the dirty marker cleared.
- **Hydrate + dirty run inside the push transaction.** The fan-out
shares the push's transaction so a concurrent refresh cannot interleave
with the version gate; `context_dirty` watch events publish only after
commit. The pinned hash on dirtied chats is intentionally left unchanged
— the refresh endpoint re-pins it.
- **Dirtied chats keep their pinned hash.** Drift is advisory: a dirty
chat stays usable, and refreshing is the only path that advances the
pinned hash.
- **Test binds `chats.agent_id` directly.** In production the binding is
set lazily during a chat turn (`chatd.persistBuildAgentBinding`); the
test sets it via `dbgen` so it exercises the context flow rather than
turn resolution.

Plan: `coderd/x/chatd` context integration + E2E (sub-struct API,
create-time + push-time hydration, refresh endpoint;
`context_dirty_resources` and the per-turn pull untouched).

</details>

🤖 Generated by Coder Agents on behalf of @kylecarbs
This commit is contained in:
Kyle Carberry
2026-06-16 17:46:47 +00:00
committed by GitHub
parent 9e7eedc9e9
commit bca0ce04ca
16 changed files with 1145 additions and 57 deletions
+62 -2
View File
@@ -482,6 +482,38 @@
}
}
},
"/api/experimental/chats/{chat}/context": {
"put": {
"description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Refresh chat context",
"operationId": "refresh-chat-context",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Chat"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/experimental/chats/{chat}/diff": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -14764,6 +14796,14 @@
"client_type": {
"$ref": "#/definitions/codersdk.ChatClientType"
},
"context": {
"description": "Context reports the chat's pinned workspace-context state and\nwhether it has drifted from the agent's latest pushed snapshot.\nNil when the chat has no pinned context yet.",
"allOf": [
{
"$ref": "#/definitions/codersdk.ChatContext"
}
]
},
"created_at": {
"type": "string",
"format": "date-time"
@@ -14907,6 +14947,24 @@
}
}
},
"codersdk.ChatContext": {
"type": "object",
"properties": {
"dirty": {
"description": "Dirty is true when the agent's latest snapshot hash differs from the\nchat's pinned hash.",
"type": "boolean"
},
"dirty_since": {
"description": "DirtySince is when drift was first detected; nil when not dirty.",
"type": "string",
"format": "date-time"
},
"error": {
"description": "Error is the snapshot-level error copied from the pinned snapshot\n(empty when healthy).",
"type": "string"
}
}
},
"codersdk.ChatDiffContents": {
"type": "object",
"properties": {
@@ -15816,7 +15874,8 @@
"created",
"deleted",
"diff_status_change",
"action_required"
"action_required",
"context_dirty"
],
"x-enum-varnames": [
"ChatWatchEventKindStatusChange",
@@ -15825,7 +15884,8 @@
"ChatWatchEventKindCreated",
"ChatWatchEventKindDeleted",
"ChatWatchEventKindDiffStatusChange",
"ChatWatchEventKindActionRequired"
"ChatWatchEventKindActionRequired",
"ChatWatchEventKindContextDirty"
]
},
"codersdk.ConnectionLatency": {