fix(coderd/x/chatd): refresh context after workspace restart (#28441)

Workspace lifecycle tools clear a chat's agent binding while retaining
its pinned workspace context. When the chat binds to the replacement
agent, the missing previous agent ID caused chatd to skip re-pinning and
continue using stale instructions, skills, and MCP definitions.

Re-pin when an unbound chat still carries pinned context, while
preserving the existing first-time hydration path for new chats without
a pin. Adds regression coverage for the stop/start binding sequence.

### CI note

All required checks pass. The external `Pixel / Review` status reports
the same 3 pre-existing failures on this PR's merge base
(`610f321d52c16d43e73c489aa672ef60a92c20ea`) and recent `main` commits.
This backend-only change does not touch `site/` or Storybook snapshots.

---

Generated by Coder Agents on behalf of @kylecarbs.
This commit is contained in:
Kyle Carberry
2026-08-24 15:36:32 -07:00
committed by GitHub
parent 2f50b2d740
commit cf97b56479
2 changed files with 54 additions and 7 deletions
+7 -4
View File
@@ -560,10 +560,13 @@ func (c *turnWorkspaceContext) persistBuildAgentBinding(
// If the chat was rebound to a different agent (e.g. a workspace rebuild
// produced a new agent), re-pin its context to the new agent so it stops
// injecting the previous agent's resources. Best-effort: a context error
// must never fail the binding. The pinned context fields on updatedChat
// are background state, reloaded on the next snapshot fetch.
if chatSnapshot.AgentID.Valid && chatSnapshot.AgentID.UUID != agentID {
// injecting the previous agent's resources. Workspace lifecycle tools clear
// the agent binding while preserving the pin, so a missing prior agent also
// requires a re-pin when pinned context exists. Best-effort: a context error
// must never fail the binding. The pinned context fields on updatedChat are
// background state, reloaded on the next snapshot fetch.
hasStaleUnboundContext := !chatSnapshot.AgentID.Valid && chatSnapshot.ContextAggregateHash != nil
if hasStaleUnboundContext || (chatSnapshot.AgentID.Valid && chatSnapshot.AgentID.UUID != agentID) {
//nolint:gocritic // Chatd re-pins chats it does not own as the daemon subject.
repinCtx := dbauthz.AsChatd(ctx)
if repinErr := database.ReadModifyUpdate(c.server.db, func(tx database.Store) error {
+47 -3
View File
@@ -78,9 +78,53 @@ func TestPersistBuildAgentBindingRepinsContext(t *testing.T) {
require.JSONEq(t, string(fix.bodyB), string(postRes[0].Body), "the new agent's resource body is copied verbatim")
})
// SkipsRepinWhenNoPriorAgent: binding a chat that had no agent must not
// re-pin here (the create/push path owns first-time pinning), so the guard
// leaves the chat's context untouched.
// RepinsStaleContextWhenLifecycleToolClearedAgent: workspace lifecycle
// tools temporarily clear agent_id while preserving the existing pin. The
// subsequent bind must replace that stale pin with the new agent's context.
t.Run("RepinsStaleContextWhenLifecycleToolClearedAgent", func(t *testing.T) {
t.Parallel()
fix := newRebindFixture(t)
chat := dbgen.Chat(t, fix.db, database.Chat{
OwnerID: fix.user.ID,
OrganizationID: fix.org.ID,
LastModelConfigID: fix.model.ID,
WorkspaceID: uuid.NullUUID{UUID: fix.ws.ID, Valid: true},
AgentID: uuid.NullUUID{UUID: fix.agentA, Valid: true},
Status: database.ChatStatusWaiting,
})
_, err := fix.db.HydrateAgentChatsContext(fix.ctx, database.HydrateAgentChatsContextParams{
AgentID: fix.agentA,
AggregateHash: fix.hashA,
})
require.NoError(t, err)
unbound, err := fix.db.UpdateChatWorkspaceBinding(fix.ctx, database.UpdateChatWorkspaceBindingParams{
ID: chat.ID,
WorkspaceID: chat.WorkspaceID,
BuildID: uuid.NullUUID{UUID: fix.buildID, Valid: true},
AgentID: uuid.NullUUID{},
})
require.NoError(t, err)
require.False(t, unbound.AgentID.Valid, "lifecycle tool clears the agent binding")
require.Equal(t, fix.hashA, unbound.ContextAggregateHash, "lifecycle tool preserves the old pin")
wc := newRebindTurnContext(t, fix.db, unbound)
updated, err := wc.persistBuildAgentBinding(fix.ctx, unbound, fix.buildID, fix.agentB)
require.NoError(t, err)
require.Equal(t, fix.agentB, updated.AgentID.UUID, "the binding commits the new agent")
post, err := fix.db.GetChatByID(fix.ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, fix.hashB, post.ContextAggregateHash, "rebind replaces the stale pin")
postRes, err := fix.db.ListChatContextResourcesByChatID(fix.ctx, chat.ID)
require.NoError(t, err)
require.Len(t, postRes, 1)
require.Equal(t, fix.srcB, postRes[0].Source)
})
// SkipsRepinWhenNoPriorAgent: binding a chat that had no agent or pinned
// context must not re-pin here. The create/push path owns first-time pinning.
t.Run("SkipsRepinWhenNoPriorAgent", func(t *testing.T) {
t.Parallel()
fix := newRebindFixture(t)