From 79b492643bc508e22766e7f4e5f9a42cf1afa67e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 10:53:59 +0000 Subject: [PATCH 1/6] fix(site/src/pages/AgentsPage): stop gating chat stream parts on client status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat stream handler dropped message_part events whenever the client-side chat status read "waiting". The server only forwards parts for the chat's current episode, so the gate was redundant with the server-side episode filtering. Its only effect was to convert client/server status skew into permanent output loss: dropped parts are never re-sent, and once the status caught up to "running" with no stream state, the UI showed a "Thinking" indicator that never progressed until the page was reloaded. The typical trigger was a reconnect replaying a stale waiting status from a just-completed turn, followed by a fast re-send whose first parts arrived before the new status event. Apply parts whenever they arrive. Interrupt semantics are preserved: status:waiting still discards parts buffered before it in the stream, and the durable message commit still clears applied stream state. 🤖 Generated with Coder Agents --- .../ChatConversation/chatStore.test.tsx | 14 +++++----- .../ChatConversation/useChatStore.ts | 27 +++++-------------- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 80c4ad6c7ec19..7bd7276f7c942 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1409,7 +1409,7 @@ describe("useChatStore", () => { }); }); - it("ignores message_part updates while chat is waiting", async () => { + it("applies message_part updates while a stale chat status reads waiting", async () => { immediateAnimationFrame(); const chatID = "chat-1"; @@ -1479,14 +1479,15 @@ describe("useChatStore", () => { await waitFor(() => { // Stream state is preserved after status=waiting (the // durable message event handles cleanup via - // needsStreamReset). Only new message_parts should be - // blocked by the shouldApplyMessagePart gate. + // needsStreamReset). expect(result.current.streamState).not.toBeNull(); expect(result.current.streamState?.blocks).toEqual([ { type: "response", text: "first" }, ]); }); + // A late part arriving while the status still reads "waiting" + // is current content (the status has not caught up yet). act(() => { mockSocket.emitData({ type: "message_part", @@ -1502,11 +1503,8 @@ describe("useChatStore", () => { }); await waitFor(() => { - // The late message_part should not be applied because - // shouldApplyMessagePart gates on waiting. - // Stream state still shows the original "first". expect(result.current.streamState?.blocks).toEqual([ - { type: "response", text: "first" }, + { type: "response", text: "firstlate" }, ]); }); }); @@ -4384,7 +4382,7 @@ describe("thinking indicator event ordering", () => { }); }); - it("discards buffered parts when status transitions to pending", async () => { + it("discards buffered parts when status transitions to waiting", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); immediateAnimationFrame(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index b60efc0dec03b..cc13d39e510b8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -143,8 +143,8 @@ export const useChatStore = ( // source for chatStatus and the REST-fetched chatRecord.status // must not overwrite it. Without this guard, a React Query // refetch (e.g. on window focus) can regress chatStatus to a - // stale value like "waiting", causing shouldApplyMessagePart() - // to drop all incoming parts. + // stale value like "waiting", hiding live status from the user + // until the next server event arrives. const wsStatusReceivedRef = useRef(false); const [pendingStatusResync, setPendingStatusResync] = useState(false); const pendingStatusResyncUpdatedAtRef = useRef(null); @@ -423,10 +423,6 @@ export const useChatStore = ( let historyResetPending = false; const historyReplacementBuf: TypesGen.ChatMessage[] = []; - const shouldApplyMessagePart = (): boolean => { - return store.getSnapshot().chatStatus !== "waiting"; - }; - const schedulePartsFlush = () => { if (partsFlushTimer !== null || partsBuf.length === 0) { return; @@ -436,11 +432,7 @@ export const useChatStore = ( if (disposed || activeChatIDRef.current !== chatID) { return; } - const parts = partsBuf.splice(0); - if (parts.length === 0 || !shouldApplyMessagePart()) { - return; - } - store.applyMessageParts(parts); + store.applyMessageParts(partsBuf.splice(0)); }, 0); }; @@ -455,11 +447,10 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); partsFlushTimer = null; } - const parts = partsBuf.splice(0); - if (activeChatIDRef.current !== chatID || !shouldApplyMessagePart()) { + if (activeChatIDRef.current !== chatID) { return; } - store.applyMessageParts(parts); + store.applyMessageParts(partsBuf.splice(0)); }; // Discard buffered parts without applying them. Used when @@ -521,9 +512,6 @@ export const useChatStore = ( continue; } commitHistoryReplacement(); - if (!shouldApplyMessagePart()) { - continue; - } const part = streamEvent.message_part?.part; if (part) { store.clearRetryState(); @@ -698,10 +686,7 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); partsFlushTimer = null; } - const nextParts = partsBuf.splice(0); - if (shouldApplyMessagePart()) { - store.applyMessageParts(nextParts); - } + store.applyMessageParts(partsBuf.splice(0)); } } }); From 4b56c112bc5ba058a79a679d8d9a8d655a92b146 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 12:12:01 +0000 Subject: [PATCH 2/6] test(site/src/pages/AgentsPage): cover stream parts arriving under stale waiting status Adds a page-level story for the scenario fixed in the previous commit: a chat record still reading "waiting" when a message_part arrives over the stream. The play function asserts the thinking disclosure renders, which fails against the pre-fix code and passes with it. The play asserts on the disclosure header rather than body text because the smoothing engine's requestAnimationFrame loop is suspended in the test iframe, so smoothed body text never reveals there. --- .../AgentsPage/AgentChatPage.stories.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index cc6b3784b62df..917a0c21338a8 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2398,6 +2398,67 @@ export const DurableUpdateFansOutToOlderPage: Story = { }, }; +/** + * A message_part arriving over the stream while the chat record still + * reads "waiting" (status lagging a new turn). The streamed thinking + * block must render. The play asserts on the disclosure header because + * smoothed body text never reveals in the test iframe + * (requestAnimationFrame is suspended). + */ +export const StreamedPartWhileStatusWaiting: Story = { + parameters: { + queries: buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Stale status stream", + status: "waiting", + }, + { + messages: [ + { + id: 1, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:05:00.000Z", + role: "user", + content: [{ type: "text", text: "Start the next turn" }], + }, + ], + queued_messages: [], + has_more: false, + }, + { diffUrl: undefined }, + ), + webSocket: { + "/chats/": [ + { + event: "message", + data: JSON.stringify([ + { + type: "message_part", + chat_id: CHAT_ID, + message_part: { + part: { + type: "reasoning", + text: "Streaming while the chat still reads waiting", + }, + }, + }, + ] satisfies TypesGen.ChatStreamEvent[]), + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Start the next turn"); + // The disclosure header is the only "Thinking" text in the DOM + // at this point: the generic indicator is suppressed at status + // "waiting". + expect(await canvas.findByText("Thinking")).toBeVisible(); + }, +}; + /** * Live agent turn with streaming reasoning and a back-to-back flurry of * in-progress file tool calls. The persisted history establishes context From 36aeb2c489ef1590ebc067e237f18e8a9285c857 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 13:10:44 +0000 Subject: [PATCH 3/6] fix(site/src/pages/AgentsPage): drop stale parts after stream-reported waiting The ungated stream applied any arriving part, and a closed episode can keep draining parts to the client for up to 15s server-side. With a truly idle chat, those drain parts repopulated the stream and stayed visible until the next turn or a reload. Gate parts on a latch instead of on the status value alone: the latch sets when the stream delivers an authoritative "waiting" status and clears on any other stream status or error, and never moves on REST hydration. So a part is dropped only when the server itself said the turn ended, and still flows while a stale REST status lags a live turn. --- .../ChatConversation/chatStore.test.tsx | 125 +++++++++++++++--- .../ChatConversation/useChatStore.ts | 27 +++- 2 files changed, 134 insertions(+), 18 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 7bd7276f7c942..d3513ce81e1ad 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1409,7 +1409,7 @@ describe("useChatStore", () => { }); }); - it("applies message_part updates while a stale chat status reads waiting", async () => { + it("applies message_part updates while a REST-hydrated chat status reads waiting", async () => { immediateAnimationFrame(); const chatID = "chat-1"; @@ -1427,7 +1427,10 @@ describe("useChatStore", () => { const { store } = useChatStore({ chatID, chatMessages: [existingMessage], - chatRecord: buildChat(chatID), + // REST hydrates the store with a waiting status, but the + // stream has not delivered any status event, so the + // server's view may already be ahead. + chatRecord: { ...buildChat(chatID), status: "waiting" }, chatMessagesData: { messages: [existingMessage], queued_messages: [], @@ -1456,7 +1459,7 @@ describe("useChatStore", () => { role: "assistant", part: { type: "text", - text: "first", + text: "live output", }, }, }); @@ -1464,47 +1467,137 @@ describe("useChatStore", () => { await waitFor(() => { expect(result.current.streamState?.blocks).toEqual([ - { type: "response", text: "first" }, + { type: "response", text: "live output" }, ]); }); + }); + + it("drops message_part updates after the stream reports waiting", async () => { + immediateAnimationFrame(); + + const chatID = "chat-1"; + const existingMessage = buildMessage(chatID, 1, "user", "hello"); + const mockSocket = createMockSocket(); + mockWatchChatReturn(mockSocket); + + const queryClient = createTestQueryClient(); + const wrapper = createWrapper(queryClient); + const setChatErrorReason = vi.fn(); + const clearChatErrorReason = vi.fn(); + + const { result } = renderHook( + () => { + const { store } = useChatStore({ + chatID, + chatMessages: [existingMessage], + chatRecord: { ...buildChat(chatID), status: "waiting" }, + chatMessagesData: { + messages: [existingMessage], + queued_messages: [], + has_more: false, + }, + chatQueuedMessages: [], + setChatErrorReason, + clearChatErrorReason, + }); + return { + streamState: useChatSelector(store, selectStreamState), + chatStatus: useChatSelector(store, selectChatStatus), + }; + }, + { wrapper }, + ); + + await waitFor(() => { + expect(watchChat).toHaveBeenCalledWith(chatID, 1); + }); act(() => { mockSocket.emitData({ type: "status", chat_id: chatID, - status: { status: "waiting" }, + status: { status: "running" }, + }); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { type: "text", text: "first" }, + }, }); }); await waitFor(() => { - // Stream state is preserved after status=waiting (the - // durable message event handles cleanup via - // needsStreamReset). - expect(result.current.streamState).not.toBeNull(); expect(result.current.streamState?.blocks).toEqual([ { type: "response", text: "first" }, ]); }); - // A late part arriving while the status still reads "waiting" - // is current content (the status has not caught up yet). + // The stream reports waiting: the turn is over server-side. + // A part arriving now comes from the closed episode draining + // (the server keeps closed episodes subscribed for replay for + // up to 15s) and must not repopulate the stream. + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "waiting" }, + }); + }); + + await waitFor(() => { + expect(result.current.chatStatus).toBe("waiting"); + }); + act(() => { mockSocket.emitData({ type: "message_part", chat_id: chatID, message_part: { role: "assistant", - part: { - type: "text", - text: "late", - }, + part: { type: "text", text: "late drain" }, + }, + }); + }); + + // Wait past the coalesced flush window so the drop is + // observable rather than "not yet flushed". + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "first" }, + ]); + + // The stream reporting running reopens the window: the next + // turn's parts must flow again. + act(() => { + mockSocket.emitData({ + type: "status", + chat_id: chatID, + status: { status: "running" }, + }); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { type: "text", text: "next turn" }, }, }); }); await waitFor(() => { expect(result.current.streamState?.blocks).toEqual([ - { type: "response", text: "firstlate" }, + { type: "response", text: "firstnext turn" }, ]); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index cc13d39e510b8..8b583f0cb6511 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -423,6 +423,17 @@ export const useChatStore = ( let historyResetPending = false; const historyReplacementBuf: TypesGen.ChatMessage[] = []; + // Latch set when the stream delivers an authoritative "waiting" + // status, cleared on any other stream status. A part arriving + // while the latch is set belongs to an episode the server already + // closed (closed episodes drain for up to 15s server-side) and is + // dropped; a REST-hydrated "waiting" never sets the latch, so + // parts still flow when the REST status lags a live turn. + let streamReportedWaiting = false; + + const shouldKeepMessagePart = (): boolean => + store.getSnapshot().chatStatus !== "waiting" || !streamReportedWaiting; + const schedulePartsFlush = () => { if (partsFlushTimer !== null || partsBuf.length === 0) { return; @@ -432,6 +443,10 @@ export const useChatStore = ( if (disposed || activeChatIDRef.current !== chatID) { return; } + if (!shouldKeepMessagePart()) { + partsBuf.length = 0; + return; + } store.applyMessageParts(partsBuf.splice(0)); }, 0); }; @@ -447,7 +462,8 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); partsFlushTimer = null; } - if (activeChatIDRef.current !== chatID) { + if (activeChatIDRef.current !== chatID || !shouldKeepMessagePart()) { + partsBuf.length = 0; return; } store.applyMessageParts(partsBuf.splice(0)); @@ -512,6 +528,9 @@ export const useChatStore = ( continue; } commitHistoryReplacement(); + if (!shouldKeepMessagePart()) { + continue; + } const part = streamEvent.message_part?.part; if (part) { store.clearRetryState(); @@ -610,6 +629,7 @@ export const useChatStore = ( continue; } + streamReportedWaiting = nextStatus === "waiting"; wsStatusReceivedRef.current = true; store.clearRetryState(); store.applyServerChatStatus(nextStatus); @@ -631,6 +651,7 @@ export const useChatStore = ( kind: "generic", message: "Chat processing failed.", }; + streamReportedWaiting = false; wsStatusReceivedRef.current = true; store.applyServerChatStatus("error"); store.setStreamError(reason); @@ -686,7 +707,9 @@ export const useChatStore = ( clearTimeout(partsFlushTimer); partsFlushTimer = null; } - store.applyMessageParts(partsBuf.splice(0)); + if (shouldKeepMessagePart()) { + store.applyMessageParts(partsBuf.splice(0)); + } } } }); From 4c61ee0ce55d8402ea284a31cee1053a4450d4b3 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 13:25:08 +0000 Subject: [PATCH 4/6] docs(site/src/pages/AgentsPage): trim story comment to non-obvious constraint FE4: drop the scenario restatement from the story doc; the fixture itself is the code. Keep the one non-obvious constraint a future editor needs: why the assertion targets the disclosure header rather than streamed body text. --- site/src/pages/AgentsPage/AgentChatPage.stories.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 917a0c21338a8..c0679d8afb709 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -2399,10 +2399,8 @@ export const DurableUpdateFansOutToOlderPage: Story = { }; /** - * A message_part arriving over the stream while the chat record still - * reads "waiting" (status lagging a new turn). The streamed thinking - * block must render. The play asserts on the disclosure header because - * smoothed body text never reveals in the test iframe + * The streamed thinking block is asserted via its disclosure header + * because smoothed body text never reveals in the test iframe * (requestAnimationFrame is suspended). */ export const StreamedPartWhileStatusWaiting: Story = { From e4fb6ec3239d48314b190d4c79fc6629639e1bd2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 18:42:23 +0000 Subject: [PATCH 5/6] fix(site/src/pages/AgentsPage): keep the waiting latch authoritative An optimistic setChatStatus("running") on send flips the store status before the stream's authoritative status event arrives. With the gate keyed on chatStatus, that reopened the window while the latch was still set, letting drain parts from the just-closed episode append to the new turn. Key the gate on the latch alone: an optimistic write carries no signal about episode liveness, and only a stream status event clears it. --- .../ChatConversation/chatStore.test.tsx | 26 +++++++++++++++++++ .../ChatConversation/useChatStore.ts | 7 ++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index d3513ce81e1ad..4de73c5731f40 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -1503,6 +1503,7 @@ describe("useChatStore", () => { return { streamState: useChatSelector(store, selectStreamState), chatStatus: useChatSelector(store, selectChatStatus), + store, }; }, { wrapper }, @@ -1574,6 +1575,31 @@ describe("useChatStore", () => { { type: "response", text: "first" }, ]); + // An optimistic send status must not reopen the window; drain + // parts from the closed episode are still dropped. + act(() => { + result.current.store.setChatStatus("running"); + }); + + act(() => { + mockSocket.emitData({ + type: "message_part", + chat_id: chatID, + message_part: { + role: "assistant", + part: { type: "text", text: "drain after send" }, + }, + }); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(result.current.streamState?.blocks).toEqual([ + { type: "response", text: "first" }, + ]); + // The stream reporting running reopens the window: the next // turn's parts must flow again. act(() => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 8b583f0cb6511..6e225e1b1be21 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -428,11 +428,12 @@ export const useChatStore = ( // while the latch is set belongs to an episode the server already // closed (closed episodes drain for up to 15s server-side) and is // dropped; a REST-hydrated "waiting" never sets the latch, so - // parts still flow when the REST status lags a live turn. + // parts still flow when the REST status lags a live turn. An + // optimistic status write carries no liveness signal; only a + // stream status event clears the latch. let streamReportedWaiting = false; - const shouldKeepMessagePart = (): boolean => - store.getSnapshot().chatStatus !== "waiting" || !streamReportedWaiting; + const shouldKeepMessagePart = (): boolean => !streamReportedWaiting; const schedulePartsFlush = () => { if (partsFlushTimer !== null || partsBuf.length === 0) { From a853378b4854412fa82c442afc322e735a72068c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 18:51:38 +0000 Subject: [PATCH 6/6] docs(site/src/pages/AgentsPage): simplify waiting-latch comment --- .../components/ChatConversation/useChatStore.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 6e225e1b1be21..06da6a8bf87e6 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -423,14 +423,10 @@ export const useChatStore = ( let historyResetPending = false; const historyReplacementBuf: TypesGen.ChatMessage[] = []; - // Latch set when the stream delivers an authoritative "waiting" - // status, cleared on any other stream status. A part arriving - // while the latch is set belongs to an episode the server already - // closed (closed episodes drain for up to 15s server-side) and is - // dropped; a REST-hydrated "waiting" never sets the latch, so - // parts still flow when the REST status lags a live turn. An - // optimistic status write carries no liveness signal; only a - // stream status event clears the latch. + // Set when the stream reports "waiting", cleared by any other + // stream status. While set, parts are dropped: they are late + // leftovers from the finished turn. REST and optimistic + // statuses never set it, because they can lag a live turn. let streamReportedWaiting = false; const shouldKeepMessagePart = (): boolean => !streamReportedWaiting;