feat: integrate agent context snapshots into chats#26389
Conversation
Docs preview📖 View docs preview for |
57341f8 to
9c176dc
Compare
Makes the chat context foundation (#26385) live. Agent context pushes now hydrate bound chats and mark them dirty on drift, chat creation pins the agent's latest snapshot, the experimental chat API reports the pinned context state, and a new refresh endpoint re-pins the latest snapshot and clears the dirty marker. - coderd/x/chatd/context_hydration.go: HydrateAndMarkChatsDirty (push-time hydrate + dirty fan-out, publishes context_dirty after commit), hydrateChatContextOnCreate (create-time pin, best-effort), and RefreshChatContext (re-pin latest snapshot + clear dirty). - coderd/workspaceagentsrpc.go: wire the chat daemon in as the agentapi ContextDirtyMarker, nil-guarded so a disabled daemon stays a pure write path. - codersdk + db2sdk: expose Chat.Context (*ChatContext) carrying dirty state and snapshot error; re-add the context_dirty watch event. - coderd: PUT /api/experimental/chats/{chat}/context refresh endpoint. context_dirty_resources stays NULL and the live per-turn context pull is unchanged. Adds an end-to-end test where an echo-provisioned agent pushes an initial snapshot (hydrate), re-pushes a different hash (drift -> dirty), the API reports dirty, and refresh clears it.
9c176dc to
dd9a1ee
Compare
|
/coder-agents-review |
|
Chat: Review posted | View chat Review historydeep-review v0.7.1 | Round 2 | Last posted: Round 2, 23 findings (1 P2, 10 P3, 1 P4, 7 Nit, 4 Note), COMMENT. Review Finding inventoryFinding inventoryFindings
Contested and acknowledgedCRF-3 (Note, coderd/x/chatd/context_hydration.go:116) - RefreshChatContext does not emit watch event on dirty clear
CRF-16 (Note, coderd/database/queries/chats.sql:1494) - HydrateAgentChatsContext stamps terminal chats
CRF-17 (Note, coderd/x/chatd/chatd.go:1464) - Created event published before hydration
Round logRound 1Panel. 19 reviewers (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Ging-Go, Ryosuke, Chopper, Robin, Knov, Kurapika, Kite, Meruem, Knuckle, Zoro, Razor, Killua). 1 P2, 7 P3, 1 P4, 4 Nit, 4 Note. Reviewed against 210261b..dd9a1ee. Round 2Churn guard: PROCEED. 14 addressed, 3 acknowledged. Panel: 11 reviewers (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Gon, Leorio, Meruem, Kite, Chopper, Knov). 2 P3, 3 Nit, 2 Note new. Reviewed against 210261b..bedf763. | CRF-18 | P3 | Open | coderd/x/chatd/context_hydration.go:82 | Post-commit callback uses request-scoped ctx; gRPC stream death drops dirty events | R2 | Hisoka | Yes | About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
The transactional design of HydrateAndMarkChatsDirty is strong: running hydration and dirty marking inside the push's ReadModifyUpdate transaction prevents phantom events from rolled-back retries, and the post-commit callback pattern ensures watch events only fire for committed state. The SDK type design (nullable ChatContext sub-struct, omitempty) is clean.
1 P2, 7 P3, 1 P4, 4 Nit, 4 Note across 19 reviewers.
"I tried to build a case against this change and could not." (Pariston)
🤖 This review was automatically generated with Coder Agents.
| // Pin the chat to the agent's latest context snapshot if one exists. | ||
| // Best-effort: a chat created before its agent has pushed is hydrated | ||
| // by that agent's next push. | ||
| p.hydrateChatContextOnCreate(ctx, chat) |
There was a problem hiding this comment.
Note [CRF-17] The created pubsub event is published (line 1459) before hydrateChatContextOnCreate runs. The event payload carries the pre-hydration chat struct, so Context is nil. Watch subscribers see a chat created with no context and have no follow-up event for the hydration. The HTTP handler re-reads after CreateChat returns, so the creator sees the hydrated state. Worth noting for the UI phase where watch events may drive real-time context badges. (Mafuuu, Meruem)
🤖
There was a problem hiding this comment.
Acknowledged — deferring to the UI phase as the Note suggests. No watcher consumes Context yet, and the HTTP creator re-reads after CreateChat, so the creator sees the hydrated state. When watch events drive context badges we'll reorder (or emit a follow-up event).
— 🤖 Coder Agents, on behalf of @kylecarbs
| // clears the dirty marker. It backs PUT /chats/{chat}/context (no body). A | ||
| // chat with no bound agent, or whose agent has no snapshot, simply has its | ||
| // pinned hash and dirty marker cleared. | ||
| func (p *Server) RefreshChatContext(ctx context.Context, chat database.Chat) (database.Chat, error) { |
There was a problem hiding this comment.
Note [CRF-3] RefreshChatContext does not emit a watch event when it clears the dirty marker. HydrateAndMarkChatsDirty publishes ChatWatchEventKindContextDirty when a chat becomes dirty, but no corresponding event signals the transition back to clean. The initiating HTTP client sees the cleared state in the 200 response, but other watchers continue to see dirty until they re-fetch. (Netero)
🤖
There was a problem hiding this comment.
Acknowledged — deferring. There is no "context clean" watch event kind or consumer yet; the initiating client sees the cleared state in the 200 response. The UI phase will define the clean-transition event for other watchers.
— 🤖 Coder Agents, on behalf of @kylecarbs
| // Chats created before the agent finished its initial push land with a | ||
| // NULL pinned hash. Stamp them now so they start clean. This is their | ||
| // first hydration, so no dirty event is emitted. | ||
| if err := tx.HydrateAgentChatsContext(ctx, database.HydrateAgentChatsContextParams{ |
There was a problem hiding this comment.
Note [CRF-16] HydrateAgentChatsContext filters only by archived = false, while MarkChatsContextDirtyByAgent also filters by active statuses (waiting, running, paused, pending, requires_action). Terminal chats (completed, errored) receive a pinned hash, causing db2sdk.Chat to emit a non-nil Context sub-struct for finished chats. Harmless today, but the UI may later key context badge visibility off Context != nil. (Chopper)
🤖
There was a problem hiding this comment.
Acknowledged — harmless today as noted. HydrateAgentChatsContext lives in the merged foundation migration (#26385); gating it by status to match the dirty query is a reasonable follow-up there rather than widening this PR's scope.
— 🤖 Coder Agents, on behalf of @kylecarbs
- RefreshChatContext now reads the snapshot and re-pins in one repeatable-read transaction, closing a TOCTOU window where a concurrent push could leave the chat pinned to a stale hash with the dirty marker cleared (CRF-4). - hydrateChatContextOnCreate pins via the NULL-guarded HydrateAgentChatsContext so a concurrent push that already hydrated the chat is not clobbered (CRF-6). - The dirty fan-out resolves only the dirtied chats after commit instead of scanning every active chat for the agent (CRF-10), and reuses publishChatPubsubEvents (CRF-15). - Extract a shared latestAgentSnapshot helper (CRF-11), rename flipped->dirtied (CRF-14), align the no-op callback contract with its doc (CRF-13), and trim restated comments (CRF-12). - Add @produce json to the refresh endpoint (CRF-1). - Tests: exercise the snapshot-error path end-to-end, assert an agent-less chat is untouched by the fan-out, and re-push the pinned hash to prove refresh advanced the pin (CRF-7/8/9); add an internal test for create-time hydration (CRF-2).
|
/coder-agents-review |
There was a problem hiding this comment.
All 14 fixed R1 findings verified against bedf763. The R1 P2 (TOCTOU in RefreshChatContext) is properly closed: snapshot read and re-pin now share a single ReadModifyUpdate transaction with serialization-error retry. Create-time hydration correctly uses the NULL-guarded HydrateAgentChatsContext. The shared latestAgentSnapshot helper eliminated the duplication. Test coverage is substantially improved.
3 P3, 2 Nit across 11 reviewers. R1 acknowledged Notes (CRF-3, CRF-16, CRF-17) remain deferred to the UI phase.
"This is a gem." (Bisky, on the integration test)
Process note: the fix commit subject (fix(chatd): address deep-review feedback on chat context) names the process, not the fix. If not squash-merged, consider fix(chatd): close TOCTOU in context refresh, tighten create-time hydration. (Leorio)
🤖 This review was automatically generated with Coder Agents.
| return func() { | ||
| dirtyChats := make([]database.Chat, 0, len(dirtiedIDs)) | ||
| for _, id := range dirtiedIDs { | ||
| chat, err := p.db.GetChatByID(ctx, id) |
There was a problem hiding this comment.
P3 [CRF-18] The publishDirty closure captures the RPC-derived ctx and uses it for p.db.GetChatByID calls after the transaction commits. If the agent's gRPC stream dies between commit and callback invocation, context cancellation silently drops every dirty pubsub notification. Database state is correct; watchers never learn about the drift.
The rest of chatd.go uses context.WithoutCancel(ctx) for exactly this class of post-commit fire-and-forget work (8 call sites: lines 2371, 2505, 2635, 2665, 2765, 5110, 5207, 5220). The fix is one line: detach ctx before the closure or inside it. (Hisoka)
🤖
| // The publish runs after commit, so resolve the dirtied chats then, | ||
| // reading only the transitioned IDs rather than scanning every active | ||
| // chat for the agent. | ||
| return func() { |
There was a problem hiding this comment.
P3 [CRF-19] The publishDirty callback re-reads each dirtied chat from p.db after commit. Between commit and re-read, a concurrent RefreshChatContext can clear context_dirty_since. The callback then publishes a context_dirty event whose Chat.Context.Dirty == false. Event kind says "dirty"; payload says "clean."
The window is narrow (callback runs synchronously after commit), but structurally real. No consumer exists today, but when the UI phase wires watchers to context badges, they need to decide whether to trust the event payload or always re-fetch on context_dirty.
Capturing the chat data needed for the event payload inside the transaction (e.g., via RETURNING * or a SELECT inside the TX) would close both this and CRF-18, since the callback would not need a DB read at all. (Hisoka P3, Chopper Note)
🤖
| got, err = expClient.GetChat(ctx, chat.ID) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, got.Context) | ||
| require.True(t, got.Context.Dirty, "drift should mark the chat dirty") |
There was a problem hiding this comment.
P3 [CRF-20] After push #2 marks the chat dirty, the test asserts Dirty and DirtySince but not Context.Error. MarkChatsContextDirtyByAgent sets only context_dirty_since; the pinned error should be unchanged (empty, from push #1). Adding require.Empty(t, got.Context.Error, "dirty chat's error reflects the original pin") verifies the design invariant that dirtied chats' pinned error is not overwritten by the dirty fan-out.
The complementary assertion after refresh (require.Equal(t, snapshotError, refreshed.Context.Error)) already exists. The dirty-step negative case is what distinguishes "error unchanged" from "error updated." (Chopper)
🤖
| "github.com/coder/coder/v2/codersdk" | ||
| ) | ||
|
|
||
| // latestAgentSnapshot returns the agent's most recent pinned context snapshot. |
There was a problem hiding this comment.
Nit [CRF-21] Two of the three doc sentences restate the signature. Sentence 1 ("returns the agent's most recent pinned context snapshot") restates the function name. Sentence 3 ("It takes a Store so callers can run it...") restates the db database.Store parameter. Only sentence 2 (the ok/nil-error contract) carries information the code does not show.
Trim to: // ok is false (nil error) when no snapshot exists. (Gon)
🤖
| // has not pushed yet is hydrated later by that agent's next push. Failures | ||
| // are logged and swallowed so they never block chat creation. | ||
| // | ||
| // It stamps via the NULL-guarded HydrateAgentChatsContext so a concurrent |
There was a problem hiding this comment.
Nit [CRF-22] "stamps via the NULL-guarded HydrateAgentChatsContext" leaks the SQL WHERE context_aggregate_hash IS NULL clause into the Go doc. The invariant (concurrent push that already hydrated the chat is not clobbered) is valuable; the SQL mechanism is not.
Trim to: // A concurrent push that already hydrated the chat is not clobbered. (Gon)
🤖
| // ok is false (with a nil error) when the agent has not pushed a snapshot yet. | ||
| // It takes a Store so callers can run it against either the daemon database or | ||
| // an open transaction. | ||
| func latestAgentSnapshot(ctx context.Context, db database.Store, agentID uuid.UUID) (aggregateHash []byte, snapshotError string, ok bool, err error) { |
There was a problem hiding this comment.
Nit [CRF-23] The fix commit subject (fix(chatd): address deep-review feedback on chat context) names the process, not the fix. If not squash-merged, consider: fix(chatd): close TOCTOU in context refresh, tighten create-time hydration. (Leorio)
🤖
Makes the chat context foundation from #26385 live. That PR added the storage columns, writer queries, and a dormant
agentapi.ContextDirtyMarkertrigger 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_dirtywatch event after the transaction commits). Chat creation pins the agent's latest snapshot when one already exists. The experimental chat API exposes this asChat.Context(*ChatContextwithdirty,dirty_since,error), and a newPUT /api/experimental/chats/{chat}/contextendpoint re-pins the agent's latest snapshot and clears the dirty marker.context_dirty_resourcesstays 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.
Decision log
codersdk.Chat.Context *ChatContext { Dirty bool; DirtySince *time.Time; Error string }rather than flat fields, matching the RFC's namedChatContexttype and leaving room for future fields (resource diff, sources).db2sdk.Chatpopulates it when the chat is context-tracked (len(ContextAggregateHash) > 0), dirty, or carries a snapshot error, and leaves it nil (omitempty) otherwise.Dirtymirrorscontext_dirty_sincebeing set.agentapi.ContextDirtyMarker. It is unconditionally constructed (only its background worker is gated), so the marker is always non-nil and the wiring matches every otherapi.chatDaemoncall site.agentapistill treats a nil marker as "chatd absent", soPushContextStatestays a pure write path for any future caller that does not wire chatd in.RefreshChatContextreads 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.context_dirtywatch events publish only after commit. The pinned hash on dirtied chats is intentionally left unchanged — the refresh endpoint re-pins it.chats.agent_iddirectly. In production the binding is set lazily during a chat turn (chatd.persistBuildAgentBinding); the test sets it viadbgenso it exercises the context flow rather than turn resolution.Plan:
coderd/x/chatdcontext integration + E2E (sub-struct API, create-time + push-time hydration, refresh endpoint;context_dirty_resourcesand the per-turn pull untouched).🤖 Generated by Coder Agents on behalf of @kylecarbs