From 8768da84c4b7b4d1f36ec99bc96c5bd3e3ac84e2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:23:16 +0000 Subject: [PATCH 01/10] fix: repair stale chat agent bindings after a workspace rebuild After an attached workspace is rebuilt, the chat's persisted agent_id references an agent from the previous build until the next turn rebinds it. The chat UI resolves the agent by exact ID against the latest build, so the right panel lost its Terminal, Desktop, Browser, and app tabs even though the workspace was running. Repair stale bindings in chat read responses using the same agent selection chatd uses, and refetch the chat once per build when the binding no longer resolves in a running workspace. --- coderd/exp_chats.go | 33 +++++++--- coderd/exp_chats_internal_test.go | 32 ++++++++- .../pages/AgentsPage/AgentChatPage.test.ts | 66 +++++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 53 +++++++++++++++ 4 files changed, 172 insertions(+), 12 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 72196d418b7..d09fea9b2b2 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -502,24 +502,29 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkChats) } -// enrichChatWithWorkspaceAgentIDs fills missing AgentIDs for chats with a bound -// workspace, since chatd persists the binding lazily. Best-effort and -// response-only; on error the field stays null. +// enrichChatWithWorkspaceAgentIDs fills missing AgentIDs and repairs stale +// ones for chats with a bound workspace: chatd persists the binding lazily and +// only rebinds on the next turn, so after a workspace rebuild the persisted +// agent can reference a previous build. Best-effort and response-only; on +// error the field keeps its persisted value. func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []codersdk.Chat) { - missingChats := make([]*codersdk.Chat, 0, len(chats)) + candidateChats := make([]*codersdk.Chat, 0, len(chats)) var workspaceIDs []uuid.UUID - addMissing := func(chat *codersdk.Chat) { - if chat.AgentID == nil && chat.WorkspaceID != nil { - missingChats = append(missingChats, chat) + addCandidate := func(chat *codersdk.Chat) { + if chat.WorkspaceID != nil { + candidateChats = append(candidateChats, chat) workspaceIDs = append(workspaceIDs, *chat.WorkspaceID) } } for i := range chats { - addMissing(&chats[i]) + addCandidate(&chats[i]) for j := range chats[i].Children { - addMissing(&chats[i].Children[j]) + addCandidate(&chats[i].Children[j]) } } + if len(candidateChats) == 0 { + return + } slices.SortFunc(workspaceIDs, func(a, b uuid.UUID) int { return cmp.Compare(a.String(), b.String()) @@ -544,7 +549,15 @@ func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []cod agentIDs[workspaceID] = agent.ID } - for _, chat := range missingChats { + for _, chat := range candidateChats { + // A binding that still exists in the latest build is authoritative: + // chatd bound it, so do not second-guess the selection. + if chat.AgentID != nil && slices.ContainsFunc( + agentsByWorkspace[*chat.WorkspaceID], + func(agent database.WorkspaceAgent) bool { return agent.ID == *chat.AgentID }, + ) { + continue + } if agentID, ok := agentIDs[*chat.WorkspaceID]; ok { id := agentID chat.AgentID = &id diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index 85ad42dbdda..efef6f60220 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -139,7 +139,7 @@ func TestGetChatCostFallsBackToParentChat(t *testing.T) { require.Equal(t, int64(125), cost.TotalCostMicros) } -func TestEnrichMissingChatAgentIDs(t *testing.T) { +func TestEnrichChatAgentIDs(t *testing.T) { t.Parallel() newAPI := func(t *testing.T) (*API, *dbmock.MockStore) { t.Helper() @@ -183,7 +183,7 @@ func TestEnrichMissingChatAgentIDs(t *testing.T) { require.Nil(t, chats[0].AgentID) require.Nil(t, chats[1].AgentID) }) - t.Run("selection error and skips bound or unbound", func(t *testing.T) { + t.Run("selection error keeps persisted values", func(t *testing.T) { t.Parallel() api, mDB := newAPI(t) mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{row(workspaceID, uuid.New(), uuid.NullUUID{UUID: rootAgentID, Valid: true}, "sub")}, nil) @@ -193,6 +193,34 @@ func TestEnrichMissingChatAgentIDs(t *testing.T) { require.Nil(t, chats[1].AgentID) require.Equal(t, bound, *chats[2].AgentID) }) + t.Run("repairs stale and keeps valid bindings", func(t *testing.T) { + t.Parallel() + api, mDB := newAPI(t) + secondRootAgentID := uuid.New() + mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{ + row(workspaceID, rootAgentID, uuid.NullUUID{}, "a"), + row(workspaceID, secondRootAgentID, uuid.NullUUID{}, "b"), + }, nil) + stale, valid := uuid.New(), secondRootAgentID + chats := []codersdk.Chat{ + {WorkspaceID: &workspaceID, AgentID: &stale}, + {WorkspaceID: &workspaceID, AgentID: &valid}, + } + api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + // The stale binding is repaired to the selected agent, while a + // binding still present in the latest build is kept even though + // selection would prefer another agent. + require.Equal(t, rootAgentID, *chats[0].AgentID) + require.Equal(t, secondRootAgentID, *chats[1].AgentID) + }) + t.Run("no bound workspaces skips the query", func(t *testing.T) { + t.Parallel() + api, _ := newAPI(t) + chats := []codersdk.Chat{{AgentID: &rootAgentID}, {}} + api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + require.Equal(t, rootAgentID, *chats[0].AgentID) + require.Nil(t, chats[1].AgentID) + }) } func TestValidateChatModelProviderOptions_AnthropicThinkingDisplay(t *testing.T) { diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 676f315e937..68add84a6f1 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -23,6 +23,7 @@ import { draftInputStorageKeyPrefix, getPersistedDraftInputValue, getWorkspaceOptionsWithLinkedWorkspace, + isChatAgentBindingUnresolved, isWatchedWorkspaceViewUnchanged, reconcilePromotedQueueHead, restoreOptimisticRequestSnapshot, @@ -1460,4 +1461,69 @@ describe("isWatchedWorkspaceViewUnchanged", () => { ), ).toBe(false); }); + + it("is false when the latest build changes", () => { + const next: Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspace.latest_build, id: "new-build-id" }, + }; + + expect( + isWatchedWorkspaceViewUnchanged( + MockWorkspace, + next, + MockWorkspaceAgent.id, + ), + ).toBe(false); + }); +}); + +describe("isChatAgentBindingUnresolved", () => { + it("is true when the bound agent is missing from the running build", () => { + expect(isChatAgentBindingUnresolved(MockWorkspace, "stale-agent-id")).toBe( + true, + ); + }); + + it("is true when the chat has no binding yet", () => { + expect(isChatAgentBindingUnresolved(MockWorkspace, undefined)).toBe(true); + }); + + it("is false when the bound agent resolves", () => { + expect( + isChatAgentBindingUnresolved(MockWorkspace, MockWorkspaceAgent.id), + ).toBe(false); + }); + + it("is false when the workspace is not running", () => { + const stopped: Workspace = { + ...MockWorkspace, + latest_build: { ...MockWorkspace.latest_build, status: "stopped" }, + }; + + expect(isChatAgentBindingUnresolved(stopped, "stale-agent-id")).toBe(false); + }); + + it("is false when the running build has no agents", () => { + const noAgents: Workspace = { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + resources: MockWorkspace.latest_build.resources.map((resource) => ({ + ...resource, + agents: [], + })), + }, + }; + + expect(isChatAgentBindingUnresolved(noAgents, "stale-agent-id")).toBe( + false, + ); + }); + + it("is false while the workspace is loading", () => { + expect(isChatAgentBindingUnresolved(undefined, "stale-agent-id")).toBe( + false, + ); + }); }); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 4fd0b0d09a3..57a822b47cc 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -69,6 +69,7 @@ import { isMobileViewport } from "#/utils/mobile"; import { pageTitle } from "#/utils/page"; import { rewriteLocalhostURL } from "#/utils/portForward"; import { createReconnectingWebSocket } from "#/utils/reconnectingWebSocket"; +import { getWorkspaceAgents } from "#/utils/workspace"; import { AgentChatPageErrorView } from "./AgentChatPageErrorView"; import { AgentChatPageLoadingView, @@ -450,6 +451,7 @@ export const isWatchedWorkspaceViewUnchanged = ( const prevApps = prevAgent?.apps ?? []; const nextApps = nextAgent?.apps ?? []; return ( + prev.latest_build.id === next.latest_build.id && prev.latest_build.status === next.latest_build.status && prev.health.healthy === next.health.healthy && prev.name === next.name && @@ -469,6 +471,29 @@ export const isWatchedWorkspaceViewUnchanged = ( ); }; +/** + * True when the chat's persisted agent binding does not resolve in the + * running workspace, which happens between a workspace rebuild and the next + * chat turn (or before the first binding is persisted). Chat reads repair the + * binding server-side, so the chat should be refetched. Requires agents in + * the latest build so a refetch is only requested when the server can + * actually re-resolve the binding. + * + * @internal Exported for testing. + */ +export const isChatAgentBindingUnresolved = ( + workspace: TypesGen.Workspace | undefined, + chatAgentId: string | undefined, +): boolean => { + if (!workspace || workspace.latest_build.status !== "running") { + return false; + } + if (getWorkspaceAgents(workspace).length === 0) { + return false; + } + return getWorkspaceAgent(workspace, chatAgentId) === undefined; +}; + const buildAttachmentMediaTypes = ( attachments?: readonly PendingAttachment[], ): ReadonlyMap | undefined => { @@ -1059,6 +1084,34 @@ const AgentChatPage: FC = () => { const workspaceAgent = getWorkspaceAgent(workspace, chatAgentId); const { proxy } = useProxy(); + // After a workspace rebuild the chat's persisted agent binding can + // reference an agent from a previous build until the next turn rebinds + // it. Chat reads repair the binding, so refetch the chat, once per + // build/binding pair to stay loop-safe when repair is impossible. + const workspaceBuildId = workspace?.latest_build.id; + const agentBindingUnresolved = isChatAgentBindingUnresolved( + workspace, + chatAgentId, + ); + const agentBindingRefetchKeyRef = useRef(undefined); + useEffect(() => { + if (!agentId || !workspaceBuildId || !agentBindingUnresolved) { + return; + } + const refetchKey = `${agentId}:${workspaceBuildId}:${chatAgentId ?? ""}`; + if (agentBindingRefetchKeyRef.current === refetchKey) { + return; + } + agentBindingRefetchKeyRef.current = refetchKey; + void invalidateChatEntity(queryClient, agentId); + }, [ + agentId, + workspaceBuildId, + chatAgentId, + agentBindingUnresolved, + queryClient, + ]); + const chatRecord = chatQuery.data; const isArchived = chatRecord?.archived ?? false; const isSharedChat = chatRecord?.shared ?? false; From 99295034b2b7dab8f8f350990dd548a20995d441 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:35:02 +0000 Subject: [PATCH 02/10] refactor: tighten chat agent binding comments and agent lookup Reword comments that overclaimed chatd authorship and persisted-binding repair, drop a test comment that restated its assertions, and flatten workspace agents once in isChatAgentBindingUnresolved. --- coderd/exp_chats.go | 13 ++++++------ coderd/exp_chats_internal_test.go | 3 --- site/src/pages/AgentsPage/AgentChatPage.tsx | 22 ++++++++------------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index d09fea9b2b2..da2584f9628 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -502,11 +502,10 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkChats) } -// enrichChatWithWorkspaceAgentIDs fills missing AgentIDs and repairs stale -// ones for chats with a bound workspace: chatd persists the binding lazily and -// only rebinds on the next turn, so after a workspace rebuild the persisted -// agent can reference a previous build. Best-effort and response-only; on -// error the field keeps its persisted value. +// enrichChatWithWorkspaceAgentIDs fills missing AgentIDs and repairs ones +// that no longer resolve in the workspace's latest build (chatd persists +// bindings lazily and a rebuild replaces agents). Best-effort and +// response-only; on error the field keeps its persisted value. func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []codersdk.Chat) { candidateChats := make([]*codersdk.Chat, 0, len(chats)) var workspaceIDs []uuid.UUID @@ -550,8 +549,8 @@ func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []cod } for _, chat := range candidateChats { - // A binding that still exists in the latest build is authoritative: - // chatd bound it, so do not second-guess the selection. + // Preserve bindings that still resolve in the latest build instead + // of replacing them with the selected agent. if chat.AgentID != nil && slices.ContainsFunc( agentsByWorkspace[*chat.WorkspaceID], func(agent database.WorkspaceAgent) bool { return agent.ID == *chat.AgentID }, diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index efef6f60220..466acc6c1f8 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -207,9 +207,6 @@ func TestEnrichChatAgentIDs(t *testing.T) { {WorkspaceID: &workspaceID, AgentID: &valid}, } api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) - // The stale binding is repaired to the selected agent, while a - // binding still present in the latest build is kept even though - // selection would prefer another agent. require.Equal(t, rootAgentID, *chats[0].AgentID) require.Equal(t, secondRootAgentID, *chats[1].AgentID) }) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 57a822b47cc..04895aa045a 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -472,12 +472,9 @@ export const isWatchedWorkspaceViewUnchanged = ( }; /** - * True when the chat's persisted agent binding does not resolve in the - * running workspace, which happens between a workspace rebuild and the next - * chat turn (or before the first binding is persisted). Chat reads repair the - * binding server-side, so the chat should be refetched. Requires agents in - * the latest build so a refetch is only requested when the server can - * actually re-resolve the binding. + * True when a running workspace has agents but the chat's agent ID is absent + * from the latest build (stale after a rebuild, or not yet persisted). Chat + * reads can return a repaired ID, so callers should refetch the chat. * * @internal Exported for testing. */ @@ -488,10 +485,8 @@ export const isChatAgentBindingUnresolved = ( if (!workspace || workspace.latest_build.status !== "running") { return false; } - if (getWorkspaceAgents(workspace).length === 0) { - return false; - } - return getWorkspaceAgent(workspace, chatAgentId) === undefined; + const agents = getWorkspaceAgents(workspace); + return agents.length > 0 && !agents.some((agent) => agent.id === chatAgentId); }; const buildAttachmentMediaTypes = ( @@ -1084,10 +1079,9 @@ const AgentChatPage: FC = () => { const workspaceAgent = getWorkspaceAgent(workspace, chatAgentId); const { proxy } = useProxy(); - // After a workspace rebuild the chat's persisted agent binding can - // reference an agent from a previous build until the next turn rebinds - // it. Chat reads repair the binding, so refetch the chat, once per - // build/binding pair to stay loop-safe when repair is impossible. + // A rebuild can leave the chat's persisted agent ID absent from the + // latest build. Chat reads can return a repaired ID, so refetch, once + // per chat/build/binding key to stay loop-safe when repair fails. const workspaceBuildId = workspace?.latest_build.id; const agentBindingUnresolved = isChatAgentBindingUnresolved( workspace, From 6c61c74a9fa5d74d681a6af7e9b90963cc7ea199 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:11:28 +0000 Subject: [PATCH 03/10] fix: address Codex review of stale chat agent binding repair Scope binding repair to single-chat reads so chat-list reads keep the nil-fill-only behavior and avoid a per-workspace authorization lookup per listed chat. Move the frontend stale-binding detection from an effect into the workspace watch update handler per FE8, and add a Storybook interaction story covering rebuild recovery. --- coderd/exp_chats.go | 38 ++++++--- coderd/exp_chats_internal_test.go | 25 ++++-- .../AgentsPage/AgentChatPage.stories.tsx | 77 +++++++++++++++++++ site/src/pages/AgentsPage/AgentChatPage.tsx | 43 ++++------- 4 files changed, 141 insertions(+), 42 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index da2584f9628..f6e8b3fb582 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -498,22 +498,40 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { } sdkChats := db2sdk.ChatRowsWithChildren(chatRows, childRows, diffStatusesByChatID) - api.enrichChatWithWorkspaceAgentIDs(ctx, sdkChats) + api.enrichChatsWithMissingAgentIDs(ctx, sdkChats) httpapi.Write(ctx, rw, http.StatusOK, sdkChats) } -// enrichChatWithWorkspaceAgentIDs fills missing AgentIDs and repairs ones -// that no longer resolve in the workspace's latest build (chatd persists -// bindings lazily and a rebuild replaces agents). Best-effort and -// response-only; on error the field keeps its persisted value. -func (api *API) enrichChatWithWorkspaceAgentIDs(ctx context.Context, chats []codersdk.Chat) { +// enrichChatsWithMissingAgentIDs fills nil AgentIDs from each workspace's +// latest build. List reads use it because validating existing bindings +// costs a per-workspace authorization lookup per listed chat. +func (api *API) enrichChatsWithMissingAgentIDs(ctx context.Context, chats []codersdk.Chat) { + api.enrichChatAgentIDs(ctx, chats, func(chat *codersdk.Chat) bool { + return chat.AgentID == nil + }) +} + +// repairChatAgentIDs fills missing AgentIDs and repairs ones that no +// longer resolve in the workspace's latest build (chatd persists bindings +// lazily and a rebuild replaces agents). Reserved for single-chat reads +// because of the per-workspace authorization cost. +func (api *API) repairChatAgentIDs(ctx context.Context, chats []codersdk.Chat) { + api.enrichChatAgentIDs(ctx, chats, func(*codersdk.Chat) bool { + return true + }) +} + +// enrichChatAgentIDs is best-effort and response-only; on error each +// AgentID keeps its persisted value. shouldEnrich selects candidates. +func (api *API) enrichChatAgentIDs(ctx context.Context, chats []codersdk.Chat, shouldEnrich func(*codersdk.Chat) bool) { candidateChats := make([]*codersdk.Chat, 0, len(chats)) var workspaceIDs []uuid.UUID addCandidate := func(chat *codersdk.Chat) { - if chat.WorkspaceID != nil { - candidateChats = append(candidateChats, chat) - workspaceIDs = append(workspaceIDs, *chat.WorkspaceID) + if chat.WorkspaceID == nil || !shouldEnrich(chat) { + return } + candidateChats = append(candidateChats, chat) + workspaceIDs = append(workspaceIDs, *chat.WorkspaceID) } for i := range chats { addCandidate(&chats[i]) @@ -1651,7 +1669,7 @@ func (api *API) getChat(rw http.ResponseWriter, r *http.Request) { } enriched := []codersdk.Chat{sdkChat} - api.enrichChatWithWorkspaceAgentIDs(ctx, enriched) + api.repairChatAgentIDs(ctx, enriched) sdkChat = enriched[0] httpapi.Write(ctx, rw, http.StatusOK, sdkChat) diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index 466acc6c1f8..aeba015cc15 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -169,7 +169,7 @@ func TestEnrichChatAgentIDs(t *testing.T) { }, nil }).Times(1) chats := []codersdk.Chat{{WorkspaceID: &workspaceID, Children: []codersdk.Chat{{WorkspaceID: &workspaceID}}}, {WorkspaceID: &otherWorkspaceID}} - api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + api.enrichChatsWithMissingAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Equal(t, rootAgentID, *chats[0].AgentID) require.Equal(t, rootAgentID, *chats[0].Children[0].AgentID) require.Equal(t, otherAgentID, *chats[1].AgentID) @@ -179,7 +179,7 @@ func TestEnrichChatAgentIDs(t *testing.T) { api, mDB := newAPI(t) mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), gomock.Any()).Return(nil, xerrors.New("boom")) chats := []codersdk.Chat{{WorkspaceID: &workspaceID}, {WorkspaceID: &otherWorkspaceID}} - api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + api.enrichChatsWithMissingAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Nil(t, chats[0].AgentID) require.Nil(t, chats[1].AgentID) }) @@ -189,7 +189,7 @@ func TestEnrichChatAgentIDs(t *testing.T) { mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{row(workspaceID, uuid.New(), uuid.NullUUID{UUID: rootAgentID, Valid: true}, "sub")}, nil) bound := otherAgentID chats := []codersdk.Chat{{}, {WorkspaceID: &workspaceID}, {WorkspaceID: &workspaceID, AgentID: &bound}} - api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Nil(t, chats[1].AgentID) require.Equal(t, bound, *chats[2].AgentID) }) @@ -206,15 +206,30 @@ func TestEnrichChatAgentIDs(t *testing.T) { {WorkspaceID: &workspaceID, AgentID: &stale}, {WorkspaceID: &workspaceID, AgentID: &valid}, } - api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Equal(t, rootAgentID, *chats[0].AgentID) require.Equal(t, secondRootAgentID, *chats[1].AgentID) }) + t.Run("list mode skips bound chats entirely", func(t *testing.T) { + t.Parallel() + api, mDB := newAPI(t) + mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{ + row(workspaceID, rootAgentID, uuid.NullUUID{}, "root"), + }, nil).Times(1) + stale := uuid.New() + chats := []codersdk.Chat{ + {WorkspaceID: &workspaceID}, + {WorkspaceID: &otherWorkspaceID, AgentID: &stale}, + } + api.enrichChatsWithMissingAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + require.Equal(t, rootAgentID, *chats[0].AgentID) + require.Equal(t, stale, *chats[1].AgentID) + }) t.Run("no bound workspaces skips the query", func(t *testing.T) { t.Parallel() api, _ := newAPI(t) chats := []codersdk.Chat{{AgentID: &rootAgentID}, {}} - api.enrichChatWithWorkspaceAgentIDs(testutil.Context(t, testutil.WaitShort), chats) + api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Equal(t, rootAgentID, *chats[0].AgentID) require.Nil(t, chats[1].AgentID) }) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 581d9b5ba4b..484ec8986d4 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -35,6 +35,7 @@ import { MockOrganizationMember2, MockUserOwner, MockWorkspace, + MockWorkspaceAgent, mockApiError, } from "#/testHelpers/entities"; import { @@ -2208,6 +2209,82 @@ export const SidebarWithSingleRepo: Story = { }, }, }; + +const rebuiltWorkspaceAgent: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: "rebuilt-agent-1", +}; +const rebuiltWorkspace: TypesGen.Workspace = { + ...mockWorkspace, + latest_build: { + ...mockWorkspace.latest_build, + id: "rebuilt-build-1", + resources: [ + { + ...mockWorkspace.latest_build.resources[0], + agents: [rebuiltWorkspaceAgent], + }, + ], + }, +}; +const rebuildRecoveryChat: TypesGen.Chat = { + id: CHAT_ID, + ...baseChatFields, + agent_id: "stale-agent-1", + title: "Rebuild recovery", + status: "waiting", +}; + +/** + * A rebuild replaces workspace agents, leaving the chat's persisted agent ID + * stale. The workspace watch stream delivers the rebuilt workspace, the page + * refetches the chat, and the repaired binding restores agent-backed controls + * such as the Terminal tab. + */ +export const RecoversSidebarAfterWorkspaceRebuild: Story = { + beforeEach: () => { + localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); + spyOn(API.experimental, "getChat").mockResolvedValue({ + ...rebuildRecoveryChat, + agent_id: rebuiltWorkspaceAgent.id, + }); + return () => localStorage.removeItem(RIGHT_PANEL_OPEN_KEY); + }, + parameters: { + queries: [ + ...withoutQuery( + buildQueries( + rebuildRecoveryChat, + { messages: [], queued_messages: [], has_more: false }, + { diffUrl: undefined }, + ), + workspaceByIdKey(mockWorkspace.id), + ), + { key: workspaceByIdKey(mockWorkspace.id), data: rebuiltWorkspace }, + ], + webSocket: { + "watch-ws": [ + { + event: "message", + data: JSON.stringify({ + type: "data", + data: rebuiltWorkspace, + } satisfies TypesGen.ServerSentEvent), + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const terminalTab = await canvas.findByRole( + "tab", + { name: "Terminal" }, + { timeout: 5000 }, + ); + expect(terminalTab).toBeVisible(); + }, +}; + /** * Streaming reasoning part via WebSocket, renders inline text. */ diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 04895aa045a..155947f0235 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1022,6 +1022,7 @@ const AgentChatPage: FC = () => { chatModelsQuery.data, ); + const agentBindingRefetchKeyRef = useRef(undefined); // Subscribe to live workspace updates so that agent status changes // (e.g. connected/disconnected) are reflected without a page refresh. const applyWatchedWorkspaceUpdate = useEffectEvent( @@ -1042,6 +1043,21 @@ const AgentChatPage: FC = () => { return next; }, ); + // A rebuild can leave the chat's persisted agent ID absent from + // the latest build. Chat reads return a repaired ID, so refetch, + // once per chat/build/binding key to stay loop-safe when repair + // fails. The watch stream replays the current workspace on every + // (re)connect, so this also covers rebuilds missed while + // disconnected. + if (!agentId || !isChatAgentBindingUnresolved(next, chatAgentId)) { + return; + } + const refetchKey = `${agentId}:${next.latest_build.id}:${chatAgentId ?? ""}`; + if (agentBindingRefetchKeyRef.current === refetchKey) { + return; + } + agentBindingRefetchKeyRef.current = refetchKey; + void invalidateChatEntity(queryClient, agentId); }, ); useEffect(() => { @@ -1079,33 +1095,6 @@ const AgentChatPage: FC = () => { const workspaceAgent = getWorkspaceAgent(workspace, chatAgentId); const { proxy } = useProxy(); - // A rebuild can leave the chat's persisted agent ID absent from the - // latest build. Chat reads can return a repaired ID, so refetch, once - // per chat/build/binding key to stay loop-safe when repair fails. - const workspaceBuildId = workspace?.latest_build.id; - const agentBindingUnresolved = isChatAgentBindingUnresolved( - workspace, - chatAgentId, - ); - const agentBindingRefetchKeyRef = useRef(undefined); - useEffect(() => { - if (!agentId || !workspaceBuildId || !agentBindingUnresolved) { - return; - } - const refetchKey = `${agentId}:${workspaceBuildId}:${chatAgentId ?? ""}`; - if (agentBindingRefetchKeyRef.current === refetchKey) { - return; - } - agentBindingRefetchKeyRef.current = refetchKey; - void invalidateChatEntity(queryClient, agentId); - }, [ - agentId, - workspaceBuildId, - chatAgentId, - agentBindingUnresolved, - queryClient, - ]); - const chatRecord = chatQuery.data; const isArchived = chatRecord?.archived ?? false; const isSharedChat = chatRecord?.shared ?? false; From ad482a67f70119e7594f696e30fe7b144cf2a82f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:37:30 +0000 Subject: [PATCH 04/10] chore: tighten chat agent binding comments --- coderd/exp_chats.go | 14 +++++--------- .../src/pages/AgentsPage/AgentChatPage.stories.tsx | 6 ------ site/src/pages/AgentsPage/AgentChatPage.tsx | 9 +++------ 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index f6e8b3fb582..bf2859f6f6e 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -502,27 +502,23 @@ func (api *API) listChats(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, sdkChats) } -// enrichChatsWithMissingAgentIDs fills nil AgentIDs from each workspace's -// latest build. List reads use it because validating existing bindings -// costs a per-workspace authorization lookup per listed chat. +// enrichChatsWithMissingAgentIDs skips existing bindings on list reads to avoid +// one authorization check per bound workspace. func (api *API) enrichChatsWithMissingAgentIDs(ctx context.Context, chats []codersdk.Chat) { api.enrichChatAgentIDs(ctx, chats, func(chat *codersdk.Chat) bool { return chat.AgentID == nil }) } -// repairChatAgentIDs fills missing AgentIDs and repairs ones that no -// longer resolve in the workspace's latest build (chatd persists bindings -// lazily and a rebuild replaces agents). Reserved for single-chat reads -// because of the per-workspace authorization cost. +// repairChatAgentIDs handles stale bindings left by workspace rebuilds. List +// reads skip this work to avoid authorization checks for bound workspaces. func (api *API) repairChatAgentIDs(ctx context.Context, chats []codersdk.Chat) { api.enrichChatAgentIDs(ctx, chats, func(*codersdk.Chat) bool { return true }) } -// enrichChatAgentIDs is best-effort and response-only; on error each -// AgentID keeps its persisted value. shouldEnrich selects candidates. +// enrichChatAgentIDs performs best-effort response-only updates. func (api *API) enrichChatAgentIDs(ctx context.Context, chats []codersdk.Chat, shouldEnrich func(*codersdk.Chat) bool) { candidateChats := make([]*codersdk.Chat, 0, len(chats)) var workspaceIDs []uuid.UUID diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 484ec8986d4..dc4fadf5b15 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2235,12 +2235,6 @@ const rebuildRecoveryChat: TypesGen.Chat = { status: "waiting", }; -/** - * A rebuild replaces workspace agents, leaving the chat's persisted agent ID - * stale. The workspace watch stream delivers the rebuilt workspace, the page - * refetches the chat, and the repaired binding restores agent-backed controls - * such as the Terminal tab. - */ export const RecoversSidebarAfterWorkspaceRebuild: Story = { beforeEach: () => { localStorage.setItem(RIGHT_PANEL_OPEN_KEY, "true"); diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 155947f0235..276afb1ddc1 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1043,12 +1043,9 @@ const AgentChatPage: FC = () => { return next; }, ); - // A rebuild can leave the chat's persisted agent ID absent from - // the latest build. Chat reads return a repaired ID, so refetch, - // once per chat/build/binding key to stay loop-safe when repair - // fails. The watch stream replays the current workspace on every - // (re)connect, so this also covers rebuilds missed while - // disconnected. + // Key refetches by chat, build, and binding so a failed repair cannot loop. + // Workspace watches send current state after reconnecting, so rebuilds missed + // while disconnected still trigger a refetch. if (!agentId || !isChatAgentBindingUnresolved(next, chatAgentId)) { return; } From 2d22d804fa5b74421f1223e75278929942c07016 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:52:09 +0000 Subject: [PATCH 05/10] fix: retry chat binding repair after a failed refetch The query client disables retries, so a transient refetch failure permanently latched the dedupe key and blocked sidebar recovery until a reload or another rebuild. Clear the key when the refetch errors so the next workspace watch event retries. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 276afb1ddc1..820e4237f34 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -28,6 +28,7 @@ import { checkAuthorization } from "#/api/queries/authCheck"; import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits"; import { chat, + chatEntityKey, chatMessagesForInfiniteScroll, chatModelConfigs, chatModels, @@ -1054,7 +1055,16 @@ const AgentChatPage: FC = () => { return; } agentBindingRefetchKeyRef.current = refetchKey; - void invalidateChatEntity(queryClient, agentId); + void invalidateChatEntity(queryClient, agentId).then(() => { + // The query client does not retry, so clear the key after a + // failed refetch to let the next watch event try again. + if ( + agentBindingRefetchKeyRef.current === refetchKey && + queryClient.getQueryState(chatEntityKey(agentId))?.error + ) { + agentBindingRefetchKeyRef.current = undefined; + } + }); }, ); useEffect(() => { From 7396f1287683e72cbcf8920fdc2651d0c2b7e9a6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:06:42 +0000 Subject: [PATCH 06/10] fix: rate-limit chat binding repair instead of latching the key Server-side repair is best-effort, so a transient failure returns HTTP 200 with the stale binding and no query error, which permanently latched the dedupe key. Replace the latch and the error-state check with a 30s cooldown per chat/build/binding key so any failed repair retries on a later watch event without refetching on every event. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 37 +++++++++++---------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 820e4237f34..26b88746471 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -28,7 +28,6 @@ import { checkAuthorization } from "#/api/queries/authCheck"; import { buildOptimisticEditedMessage } from "#/api/queries/chatMessageEdits"; import { chat, - chatEntityKey, chatMessagesForInfiniteScroll, chatModelConfigs, chatModels, @@ -128,6 +127,9 @@ import { export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; + +const AGENT_BINDING_REFETCH_COOLDOWN_MS = 30_000; + class CompactCommandPendingError extends Error {} /** @internal Exported for testing. */ @@ -1023,7 +1025,9 @@ const AgentChatPage: FC = () => { chatModelsQuery.data, ); - const agentBindingRefetchKeyRef = useRef(undefined); + const agentBindingRefetchRef = useRef< + { key: string; at: number } | undefined + >(undefined); // Subscribe to live workspace updates so that agent status changes // (e.g. connected/disconnected) are reflected without a page refresh. const applyWatchedWorkspaceUpdate = useEffectEvent( @@ -1044,27 +1048,26 @@ const AgentChatPage: FC = () => { return next; }, ); - // Key refetches by chat, build, and binding so a failed repair cannot loop. - // Workspace watches send current state after reconnecting, so rebuilds missed - // while disconnected still trigger a refetch. + // Cool down refetches per chat/build/binding key: repair can fail + // with no query error (server enrichment is best-effort and can + // return the stale binding), so a bare latch would block retries + // while an unconditional refetch would fire on every watch event. + // Workspace watches send current state after reconnecting, so + // rebuilds missed while disconnected still trigger a refetch. if (!agentId || !isChatAgentBindingUnresolved(next, chatAgentId)) { return; } const refetchKey = `${agentId}:${next.latest_build.id}:${chatAgentId ?? ""}`; - if (agentBindingRefetchKeyRef.current === refetchKey) { + const lastRefetch = agentBindingRefetchRef.current; + const now = Date.now(); + if ( + lastRefetch?.key === refetchKey && + now - lastRefetch.at < AGENT_BINDING_REFETCH_COOLDOWN_MS + ) { return; } - agentBindingRefetchKeyRef.current = refetchKey; - void invalidateChatEntity(queryClient, agentId).then(() => { - // The query client does not retry, so clear the key after a - // failed refetch to let the next watch event try again. - if ( - agentBindingRefetchKeyRef.current === refetchKey && - queryClient.getQueryState(chatEntityKey(agentId))?.error - ) { - agentBindingRefetchKeyRef.current = undefined; - } - }); + agentBindingRefetchRef.current = { key: refetchKey, at: now }; + void invalidateChatEntity(queryClient, agentId); }, ); useEffect(() => { From 1c48be9c9191b66a1fc89fadb74d10b10987fd67 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:22:35 +0000 Subject: [PATCH 07/10] chore: tighten chat binding cooldown comment --- site/src/pages/AgentsPage/AgentChatPage.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 26b88746471..2fb698a1c27 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1048,12 +1048,9 @@ const AgentChatPage: FC = () => { return next; }, ); - // Cool down refetches per chat/build/binding key: repair can fail - // with no query error (server enrichment is best-effort and can - // return the stale binding), so a bare latch would block retries - // while an unconditional refetch would fire on every watch event. - // Workspace watches send current state after reconnecting, so - // rebuilds missed while disconnected still trigger a refetch. + // Cool down each chat/build/binding key because best-effort repair can return + // a stale binding without a query error, while unconditional refetches would run + // on every event. Reconnects replay state, so missed rebuilds still refetch. if (!agentId || !isChatAgentBindingUnresolved(next, chatAgentId)) { return; } From fd6e1b513158543b954679850c4c431cfa52ac29 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:41:14 +0000 Subject: [PATCH 08/10] fix: poll chat binding repair while unresolved An idle workspace publishes no watch events (the stats reporter returns early without publishing when there are no active sessions), so event-driven retries alone cannot recover from a transiently failed repair. Give the chat query a conditional refetchInterval that polls every 30s only while the binding is unresolved, and simplify the watch handler back to a once-per-key latch that only provides immediate repair after a rebuild. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 36 +++++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 2fb698a1c27..1df0687c03d 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -128,7 +128,7 @@ export const RIGHT_PANEL_OPEN_KEY = "agents.right-panel-open"; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; -const AGENT_BINDING_REFETCH_COOLDOWN_MS = 30_000; +const AGENT_BINDING_REPAIR_POLL_MS = 30_000; class CompactCommandPendingError extends Error {} @@ -943,6 +943,21 @@ const AgentChatPage: FC = () => { const chatQuery = useQuery({ ...chat(agentId ?? ""), enabled: Boolean(agentId), + // Poll while the binding is unresolved: repair happens on chat reads + // and watch events cannot be relied on for retries because an idle + // workspace publishes none. + refetchInterval: ({ state }) => { + const workspaceId = state.data?.workspace_id; + const workspace = workspaceId + ? queryClient.getQueryData( + workspaceByIdKey(workspaceId), + ) + : undefined; + return isChatAgentBindingUnresolved(workspace, state.data?.agent_id) + ? AGENT_BINDING_REPAIR_POLL_MS + : false; + }, + refetchIntervalInBackground: false, }); const chatMessagesQuery = useInfiniteQuery({ ...chatMessagesForInfiniteScroll(agentId ?? ""), @@ -1025,9 +1040,7 @@ const AgentChatPage: FC = () => { chatModelsQuery.data, ); - const agentBindingRefetchRef = useRef< - { key: string; at: number } | undefined - >(undefined); + const agentBindingRefetchKeyRef = useRef(undefined); // Subscribe to live workspace updates so that agent status changes // (e.g. connected/disconnected) are reflected without a page refresh. const applyWatchedWorkspaceUpdate = useEffectEvent( @@ -1048,22 +1061,17 @@ const AgentChatPage: FC = () => { return next; }, ); - // Cool down each chat/build/binding key because best-effort repair can return - // a stale binding without a query error, while unconditional refetches would run - // on every event. Reconnects replay state, so missed rebuilds still refetch. + // Refetch once per chat/build/binding key for immediate repair + // after a rebuild; the chat query's refetchInterval owns retries + // when repair fails, so the latch never blocks recovery. if (!agentId || !isChatAgentBindingUnresolved(next, chatAgentId)) { return; } const refetchKey = `${agentId}:${next.latest_build.id}:${chatAgentId ?? ""}`; - const lastRefetch = agentBindingRefetchRef.current; - const now = Date.now(); - if ( - lastRefetch?.key === refetchKey && - now - lastRefetch.at < AGENT_BINDING_REFETCH_COOLDOWN_MS - ) { + if (agentBindingRefetchKeyRef.current === refetchKey) { return; } - agentBindingRefetchRef.current = { key: refetchKey, at: now }; + agentBindingRefetchKeyRef.current = refetchKey; void invalidateChatEntity(queryClient, agentId); }, ); From 17b88f070dce3d5a19c41816ba01d64dcf2ee5f1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:44:18 +0000 Subject: [PATCH 09/10] fix(coderd): repair chat build ID together with stale agent ID --- coderd/database/queries.sql.go | 3 +++ coderd/database/queries/workspaceagents.sql | 1 + coderd/exp_chats.go | 6 ++++++ coderd/exp_chats_internal_test.go | 18 +++++++++++++++--- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e15e05da2cb..4149388d6f2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -34028,6 +34028,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Co const getWorkspaceAgentsInLatestBuildByWorkspaceIDs = `-- name: GetWorkspaceAgentsInLatestBuildByWorkspaceIDs :many SELECT workspace_builds.workspace_id, + workspace_builds.id AS build_id, workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted FROM workspace_agents @@ -34054,6 +34055,7 @@ WHERE type GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow struct { WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + BuildID uuid.UUID `db:"build_id" json:"build_id"` WorkspaceAgent WorkspaceAgent `db:"workspace_agent" json:"workspace_agent"` } @@ -34068,6 +34070,7 @@ func (q *sqlQuerier) GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(ctx context.C var i GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow if err := rows.Scan( &i.WorkspaceID, + &i.BuildID, &i.WorkspaceAgent.ID, &i.WorkspaceAgent.CreatedAt, &i.WorkspaceAgent.UpdatedAt, diff --git a/coderd/database/queries/workspaceagents.sql b/coderd/database/queries/workspaceagents.sql index e5280252da0..1d3ff2c4ac8 100644 --- a/coderd/database/queries/workspaceagents.sql +++ b/coderd/database/queries/workspaceagents.sql @@ -340,6 +340,7 @@ WHERE -- name: GetWorkspaceAgentsInLatestBuildByWorkspaceIDs :many SELECT workspace_builds.workspace_id, + workspace_builds.id AS build_id, sqlc.embed(workspace_agents) FROM workspace_agents diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index bf2859f6f6e..3f83fa00708 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -549,8 +549,10 @@ func (api *API) enrichChatAgentIDs(ctx context.Context, chats []codersdk.Chat, s } agentsByWorkspace := make(map[uuid.UUID][]database.WorkspaceAgent) + latestBuildIDs := make(map[uuid.UUID]uuid.UUID) for _, row := range rows { agentsByWorkspace[row.WorkspaceID] = append(agentsByWorkspace[row.WorkspaceID], row.WorkspaceAgent) + latestBuildIDs[row.WorkspaceID] = row.BuildID } agentIDs := make(map[uuid.UUID]uuid.UUID, len(agentsByWorkspace)) for workspaceID, agents := range agentsByWorkspace { @@ -574,6 +576,10 @@ func (api *API) enrichChatAgentIDs(ctx context.Context, chats []codersdk.Chat, s if agentID, ok := agentIDs[*chat.WorkspaceID]; ok { id := agentID chat.AgentID = &id + // Pair the agent with its build so the response never mixes + // the latest build's agent with a previous build's ID. + buildID := latestBuildIDs[*chat.WorkspaceID] + chat.BuildID = &buildID } } } diff --git a/coderd/exp_chats_internal_test.go b/coderd/exp_chats_internal_test.go index aeba015cc15..da8ba38286c 100644 --- a/coderd/exp_chats_internal_test.go +++ b/coderd/exp_chats_internal_test.go @@ -149,9 +149,12 @@ func TestEnrichChatAgentIDs(t *testing.T) { } workspaceID, otherWorkspaceID := uuid.New(), uuid.New() rootAgentID, otherAgentID := uuid.New(), uuid.New() + latestBuildID, otherLatestBuildID := uuid.New(), uuid.New() + latestBuildIDs := map[uuid.UUID]uuid.UUID{workspaceID: latestBuildID, otherWorkspaceID: otherLatestBuildID} row := func(workspaceID, id uuid.UUID, parentID uuid.NullUUID, name string) database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow { return database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{ WorkspaceID: workspaceID, + BuildID: latestBuildIDs[workspaceID], WorkspaceAgent: database.WorkspaceAgent{ ID: id, ParentID: parentID, @@ -173,6 +176,9 @@ func TestEnrichChatAgentIDs(t *testing.T) { require.Equal(t, rootAgentID, *chats[0].AgentID) require.Equal(t, rootAgentID, *chats[0].Children[0].AgentID) require.Equal(t, otherAgentID, *chats[1].AgentID) + require.Equal(t, latestBuildID, *chats[0].BuildID) + require.Equal(t, latestBuildID, *chats[0].Children[0].BuildID) + require.Equal(t, otherLatestBuildID, *chats[1].BuildID) }) t.Run("query error", func(t *testing.T) { t.Parallel() @@ -188,10 +194,13 @@ func TestEnrichChatAgentIDs(t *testing.T) { api, mDB := newAPI(t) mDB.EXPECT().GetWorkspaceAgentsInLatestBuildByWorkspaceIDs(gomock.Any(), []uuid.UUID{workspaceID}).Return([]database.GetWorkspaceAgentsInLatestBuildByWorkspaceIDsRow{row(workspaceID, uuid.New(), uuid.NullUUID{UUID: rootAgentID, Valid: true}, "sub")}, nil) bound := otherAgentID - chats := []codersdk.Chat{{}, {WorkspaceID: &workspaceID}, {WorkspaceID: &workspaceID, AgentID: &bound}} + boundBuildID := uuid.New() + chats := []codersdk.Chat{{}, {WorkspaceID: &workspaceID}, {WorkspaceID: &workspaceID, AgentID: &bound, BuildID: &boundBuildID}} api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Nil(t, chats[1].AgentID) + require.Nil(t, chats[1].BuildID) require.Equal(t, bound, *chats[2].AgentID) + require.Equal(t, boundBuildID, *chats[2].BuildID) }) t.Run("repairs stale and keeps valid bindings", func(t *testing.T) { t.Parallel() @@ -202,13 +211,16 @@ func TestEnrichChatAgentIDs(t *testing.T) { row(workspaceID, secondRootAgentID, uuid.NullUUID{}, "b"), }, nil) stale, valid := uuid.New(), secondRootAgentID + staleBuildID, validBuildID := uuid.New(), uuid.New() chats := []codersdk.Chat{ - {WorkspaceID: &workspaceID, AgentID: &stale}, - {WorkspaceID: &workspaceID, AgentID: &valid}, + {WorkspaceID: &workspaceID, AgentID: &stale, BuildID: &staleBuildID}, + {WorkspaceID: &workspaceID, AgentID: &valid, BuildID: &validBuildID}, } api.repairChatAgentIDs(testutil.Context(t, testutil.WaitShort), chats) require.Equal(t, rootAgentID, *chats[0].AgentID) require.Equal(t, secondRootAgentID, *chats[1].AgentID) + require.Equal(t, latestBuildID, *chats[0].BuildID) + require.Equal(t, validBuildID, *chats[1].BuildID) }) t.Run("list mode skips bound chats entirely", func(t *testing.T) { t.Parallel() From 74cd0979e8e27fde90152f9536ed24fd1b0d55e5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:00:28 +0000 Subject: [PATCH 10/10] fix(site/src/api/queries): keep repaired chat build binding across watch events --- site/src/api/queries/chats.test.ts | 42 ++++++++++++++++++++++++++++++ site/src/api/queries/chats.ts | 10 ++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index fde7d34325c..05d0397d5d7 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2486,6 +2486,48 @@ describe("mergeWatchedChatSummary", () => { ).toBe(context); }); + it("keeps the repaired build_id when the event snapshot carries a stale binding", () => { + const cachedChat = makeChat("chat-1", { + workspace_id: "workspace-1", + agent_id: "agent-new", + build_id: "build-new", + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + workspace_id: "workspace-1", + agent_id: "agent-old", + build_id: "build-old", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + const merged = mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "diff_status_change", + }); + expect(merged.build_id).toBe("build-new"); + expect(merged.agent_id).toBe("agent-new"); + }); + + it("adopts a fresh build_id when the event snapshot agrees on the agent", () => { + const cachedChat = makeChat("chat-1", { + workspace_id: "workspace-1", + agent_id: "agent-1", + build_id: "build-old", + updated_at: "2025-01-01T00:00:00.000Z", + }); + const watchedChat = makeChat("chat-1", { + workspace_id: "workspace-1", + agent_id: "agent-1", + build_id: "build-new", + updated_at: "2025-01-01T00:05:00.000Z", + }); + + expect( + mergeWatchedChatSummary(cachedChat, watchedChat, { + eventKind: "status_change", + }).build_id, + ).toBe("build-new"); + }); + it("merges fresh status updates without clobbering a newer title snapshot", () => { const cachedChat = makeChat("chat-1", { status: "waiting", diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a092b552494..e3231513920 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -551,9 +551,13 @@ export const mergeWatchedChatSummary = ( const nextWorkspaceId = isFreshEnough ? (watchedChat.workspace_id ?? cachedChat.workspace_id) : cachedChat.workspace_id; - const nextBuildId = isFreshEnough - ? (watchedChat.build_id ?? cachedChat.build_id) - : cachedChat.build_id; + // Single-chat reads repair agent/build bindings response-only, so watch + // events can replay stale DB pairs. Adopting build_id with a mismatched + // agent would split the repaired pair because merge never adopts agent_id. + const nextBuildId = + isFreshEnough && watchedChat.agent_id === cachedChat.agent_id + ? (watchedChat.build_id ?? cachedChat.build_id) + : cachedChat.build_id; // All event types carry the current model config from the DB. const nextLastModelConfigId = isFreshEnough ? watchedChat.last_model_config_id