From 536a0bf2f011a65abcc14f74417bc336a8c1924b Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 12 Aug 2026 10:41:08 +0000 Subject: [PATCH 1/6] fix(site/src/pages/AgentsPage): suppress duplicate streaming tool-result row for pending calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a local tool result streams before its tool step commits, the durable transcript already renders the pending call while the cleared stream state repopulates a result-only row. This produced a transient second row (for example "Read file") next to the durable pending row (for example "Reading AgentChatPage.tsx…") until the tool step commit merged them. Filter result-only streaming entries in the live tail whose tool_call_id matches a durable pending call, keyed on id so a parallel second call to the same tool is not hidden. Both the result entry and its tool block are dropped so the tail does not render a generic "Tool" placeholder for the dropped id, and when every block is dropped the tail behaves as if no output has accumulated yet while the durable pending row stays visible. --- .../ChatConversation/LiveStreamTail.tsx | 18 ++- .../ChatConversation/streamState.test.ts | 127 ++++++++++++++++++ .../ChatConversation/streamState.ts | 49 +++++++ .../AgentsPage/components/ChatPageContent.tsx | 1 + 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index ac396eefdaa..bbd5c661adb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -15,7 +15,7 @@ import { } from "./chatStore"; import { deriveLiveStatus, type LiveStatusModel } from "./liveStatusModel"; import { StreamingOutput } from "./StreamingOutput"; -import { buildStreamTools } from "./streamState"; +import { buildStreamTools, filterPendingStreamState } from "./streamState"; import type { MergedTool, StreamState } from "./types"; const shouldRenderStreamingSection = (liveStatus: LiveStatusModel): boolean => @@ -101,6 +101,7 @@ interface LiveStreamTailProps { subagentVariants?: Map; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; + pendingToolCallIDs?: ReadonlySet; } export const LiveStreamTail = ({ @@ -111,6 +112,7 @@ export const LiveStreamTail = ({ subagentVariants, urlTransform, mcpServers, + pendingToolCallIDs, }: LiveStreamTailProps) => { const streamState = useChatSelector(store, selectStreamState); const streamError = useChatSelector(store, selectStreamError); @@ -124,12 +126,18 @@ export const LiveStreamTail = ({ store, selectSubagentStatusOverrides, ); + // Hide result-only rows whose pending call is already rendered in the + // durable transcript, so the live tail does not flash a duplicate row. + const visibleStreamState = filterPendingStreamState( + streamState, + pendingToolCallIDs, + ); const streamTools = buildStreamTools( - streamState?.toolCalls, - streamState?.toolResults, + visibleStreamState?.toolCalls, + visibleStreamState?.toolResults, ); const liveStatus = deriveLiveStatus({ - streamState, + streamState: visibleStreamState, retryState, reconnectState, streamError, @@ -140,7 +148,7 @@ export const LiveStreamTail = ({ return ( { }); }); +describe("filterPendingStreamState", () => { + const pendingReadFileState = (): StreamState => ({ + blocks: [{ type: "tool", id: "tc-1" }], + toolCalls: {}, + toolResults: { + "tc-1": { + id: "tc-1", + name: "read_file", + result: { content: "file body" }, + isError: false, + }, + }, + sources: [], + }); + + it("drops a result-only entry and its tool block for a pending durable call", () => { + const filtered = filterPendingStreamState( + pendingReadFileState(), + new Set(["tc-1"]), + ); + expect(filtered).not.toBeNull(); + expect(filtered!.toolResults).toEqual({}); + // The block referencing the dropped id must also be removed so the + // tail does not render a generic "Tool" placeholder. + expect(filtered!.blocks).toEqual([]); + expect( + buildStreamTools(filtered!.toolCalls, filtered!.toolResults), + ).toEqual([]); + }); + + it("keeps a result and block that share an id with an in-stream call", () => { + const state: StreamState = { + blocks: [{ type: "tool", id: "tc-1" }], + toolCalls: { + "tc-1": { id: "tc-1", name: "read_file", args: { path: "a.ts" } }, + }, + toolResults: { + "tc-1": { + id: "tc-1", + name: "read_file", + result: { content: "file body" }, + isError: false, + }, + }, + sources: [], + }; + // The normal streaming merge path wins even when the same id is + // pending in the durable transcript. + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(filtered).toBe(state); + const tools = buildStreamTools(filtered!.toolCalls, filtered!.toolResults); + expect(tools).toHaveLength(1); + expect(tools[0].status).toBe("completed"); + }); + + it("keeps a result-only entry for a parallel call to the same tool with a different id", () => { + const state: StreamState = { + blocks: [ + { type: "tool", id: "tc-1" }, + { type: "tool", id: "tc-2" }, + ], + toolCalls: {}, + toolResults: { + "tc-1": { + id: "tc-1", + name: "read_file", + result: { content: "first" }, + isError: false, + }, + "tc-2": { + id: "tc-2", + name: "read_file", + result: { content: "second" }, + isError: false, + }, + }, + sources: [], + }; + // Only tc-1 is pending in the durable transcript. + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(Object.keys(filtered!.toolResults)).toEqual(["tc-2"]); + expect(filtered!.blocks).toEqual([{ type: "tool", id: "tc-2" }]); + }); + + it("keeps non-tool blocks and other tool blocks while dropping the pending one", () => { + const state: StreamState = { + blocks: [ + { type: "response", text: "Reading the file now." }, + { type: "tool", id: "tc-1" }, + { type: "tool", id: "tc-2" }, + ], + toolCalls: { + "tc-2": { id: "tc-2", name: "bash", args: { command: "ls" } }, + }, + toolResults: { + "tc-1": { + id: "tc-1", + name: "read_file", + result: { content: "file body" }, + isError: false, + }, + }, + sources: [], + }; + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(filtered!.blocks).toEqual([ + { type: "response", text: "Reading the file now." }, + { type: "tool", id: "tc-2" }, + ]); + expect(filtered!.toolResults).toEqual({}); + expect(filtered!.toolCalls).toBe(state.toolCalls); + }); + + it("returns the input reference when nothing is dropped", () => { + const state = pendingReadFileState(); + // No pending ids, and a pending id that matches no stream entry. + expect(filterPendingStreamState(state, undefined)).toBe(state); + expect(filterPendingStreamState(state, new Set())).toBe(state); + expect(filterPendingStreamState(state, new Set(["tc-other"]))).toBe(state); + }); + + it("returns null for null stream state", () => { + expect(filterPendingStreamState(null, new Set(["tc-1"]))).toBeNull(); + }); +}); + describe("buildStreamTools", () => { it("returns empty array for null toolCalls", () => { expect(buildStreamTools(null, null)).toEqual([]); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index b093617abb4..833162df257 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -234,6 +234,55 @@ const getStreamToolStatus = ( return result.isError ? "error" : "completed"; }; +/** + * Drops result-only stream entries whose tool call is already rendered as + * pending in the durable transcript. After the assistant message commits, + * the stream state is cleared but the tool result still streams before the + * tool step commits. Without this filter the result renders as a second row + * next to the durable pending row: first an arg-less tool row (for example + * "Read file"), or a generic "Tool" placeholder if only the result entry is + * dropped while its block remains. + * + * Both the result entry and its `{ type: "tool", id }` block are removed so + * nothing references the dropped id. Entries whose id also has an in-stream + * tool call are kept: that is the normal streaming merge path, and keying on + * the id (not the tool name) leaves a parallel second call to the same tool + * untouched. When every block is dropped the tail behaves as if no output + * has accumulated yet (the durable pending row above is already visible). + * + * Returns the input reference unchanged when nothing is dropped so memoized + * consumers do not re-render on every pending-call change. + */ +export const filterPendingStreamState = ( + streamState: StreamState | null, + pendingToolCallIDs: ReadonlySet | undefined, +): StreamState | null => { + if (!streamState || !pendingToolCallIDs || pendingToolCallIDs.size === 0) { + return streamState; + } + const toolResults: StreamState["toolResults"] = {}; + let dropped = false; + for (const [id, result] of Object.entries(streamState.toolResults)) { + if (pendingToolCallIDs.has(id) && !streamState.toolCalls[id]) { + dropped = true; + continue; + } + toolResults[id] = result; + } + if (!dropped) { + return streamState; + } + return { + ...streamState, + blocks: streamState.blocks.filter( + (block) => + block.type !== "tool" || + !(pendingToolCallIDs.has(block.id) && !streamState.toolCalls[block.id]), + ), + toolResults, + }; +}; + export const buildStreamTools = ( toolCalls: StreamState["toolCalls"] | null | undefined, toolResults: StreamState["toolResults"] | null | undefined, diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index fb08c2e3032..06aafe89160 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -169,6 +169,7 @@ export const ChatPageTimeline: FC = ({ subagentVariants={subagentVariants} urlTransform={urlTransform} mcpServers={mcpServers} + pendingToolCallIDs={pendingToolCallIDs} /> From 64e6939692bd5f51656bb53094c2e76941eeea37 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 12 Aug 2026 11:54:50 +0000 Subject: [PATCH 2/6] test(site/src/pages/AgentsPage): add story for pending tool result filtering Add a ChatPageTimeline story covering the streamed-result flicker window: a durable pending read_file call plus its streamed result-only row, which the live tail filters, and a parallel in-stream call that still renders. The play function asserts the durable row appears once and the parallel row stays visible. Replace non-null assertions in the filter tests with optional-chaining assertions and drop two comments that restated test inputs. --- .../ChatConversation/streamState.test.ts | 20 +++---- .../components/ChatPageContent.stories.tsx | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts index 1c453ef5347..9ced7647686 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts @@ -724,12 +724,12 @@ describe("filterPendingStreamState", () => { new Set(["tc-1"]), ); expect(filtered).not.toBeNull(); - expect(filtered!.toolResults).toEqual({}); + expect(filtered?.toolResults).toEqual({}); // The block referencing the dropped id must also be removed so the // tail does not render a generic "Tool" placeholder. - expect(filtered!.blocks).toEqual([]); + expect(filtered?.blocks).toEqual([]); expect( - buildStreamTools(filtered!.toolCalls, filtered!.toolResults), + buildStreamTools(filtered?.toolCalls, filtered?.toolResults), ).toEqual([]); }); @@ -753,7 +753,7 @@ describe("filterPendingStreamState", () => { // pending in the durable transcript. const filtered = filterPendingStreamState(state, new Set(["tc-1"])); expect(filtered).toBe(state); - const tools = buildStreamTools(filtered!.toolCalls, filtered!.toolResults); + const tools = buildStreamTools(filtered?.toolCalls, filtered?.toolResults); expect(tools).toHaveLength(1); expect(tools[0].status).toBe("completed"); }); @@ -781,10 +781,9 @@ describe("filterPendingStreamState", () => { }, sources: [], }; - // Only tc-1 is pending in the durable transcript. const filtered = filterPendingStreamState(state, new Set(["tc-1"])); - expect(Object.keys(filtered!.toolResults)).toEqual(["tc-2"]); - expect(filtered!.blocks).toEqual([{ type: "tool", id: "tc-2" }]); + expect(Object.keys(filtered?.toolResults ?? {})).toEqual(["tc-2"]); + expect(filtered?.blocks).toEqual([{ type: "tool", id: "tc-2" }]); }); it("keeps non-tool blocks and other tool blocks while dropping the pending one", () => { @@ -808,17 +807,16 @@ describe("filterPendingStreamState", () => { sources: [], }; const filtered = filterPendingStreamState(state, new Set(["tc-1"])); - expect(filtered!.blocks).toEqual([ + expect(filtered?.blocks).toEqual([ { type: "response", text: "Reading the file now." }, { type: "tool", id: "tc-2" }, ]); - expect(filtered!.toolResults).toEqual({}); - expect(filtered!.toolCalls).toBe(state.toolCalls); + expect(filtered?.toolResults).toEqual({}); + expect(filtered?.toolCalls).toBe(state.toolCalls); }); it("returns the input reference when nothing is dropped", () => { const state = pendingReadFileState(); - // No pending ids, and a pending id that matches no stream entry. expect(filterPendingStreamState(state, undefined)).toBe(state); expect(filterPendingStreamState(state, new Set())).toBe(state); expect(filterPendingStreamState(state, new Set(["tc-other"]))).toBe(state); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 0ffe7e873c1..de76c767251 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -88,6 +88,63 @@ export const DurableUnresolvedWorkspaceToolRuns: Story = { }, }; +// Reproduces the tool-result flicker window: a durable pending read_file +// call (tc-1) is already rendered while its result streams before the tool +// step commits, so the result-only row must be filtered out. A parallel +// in-stream read_file call (tc-2) is not pending in the durable transcript, +// so its streamed call and result merge normally and stay visible. +export const StreamedResultForPendingToolDoesNotDuplicate: Story = { + render: () => { + const store = createChatStore(); + store.replaceMessages([ + buildMessage(1, "user", [{ type: "text", text: "Read the files" }]), + buildMessage(2, "assistant", [ + { + type: "tool-call", + tool_call_id: "tc-1", + tool_name: "read_file", + args: { path: "src/Alpha.ts" }, + }, + ]), + ]); + store.setChatStatus("running"); + + // tc-1 result streams before its tool step commits. tc-2 is a new + // in-stream call whose result merges with its own call. + store.applyMessageParts([ + { + type: "tool-result", + tool_call_id: "tc-1", + tool_name: "read_file", + result: { content: "alpha body" }, + }, + { + type: "tool-call", + tool_call_id: "tc-2", + tool_name: "read_file", + args: { path: "src/Beta.ts" }, + }, + { + type: "tool-result", + tool_call_id: "tc-2", + tool_name: "read_file", + result: { content: "beta body" }, + }, + ]); + + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // tc-1 renders once as the durable pending row; its streamed result-only + // duplicate ("Read file") is suppressed. + expect(canvas.getAllByText("Reading Alpha.ts…")).toHaveLength(1); + expect(canvas.queryByText("Read file")).toBeNull(); + // tc-2 was never durable pending, so its streamed row still renders. + expect(canvas.getByText("Read Beta.ts")).toBeInTheDocument(); + }, +}; + export const HiddenAssistantPlaceholderDoesNotRender: Story = { render: () => { const store = createChatStore(); From d55db27a97b644016348de2748d6b1a418de08c8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 12 Aug 2026 12:29:03 +0000 Subject: [PATCH 3/6] fix(site/src/pages/AgentsPage): keep streaming tool results visible for pending calls filterPendingStreamState dropped result-only stream entries for durable pending calls, which also suppressed the advisor's streamed result_delta parts and hid the progressive advice UI until the final tool message committed. Keep entries while they are still streaming; the final result part is still suppressed once it arrives. --- .../ChatConversation/streamState.test.ts | 56 +++++++++++++++++++ .../ChatConversation/streamState.ts | 19 +++++-- .../components/ChatPageContent.stories.tsx | 45 +++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts index 9ced7647686..d2857e20548 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts @@ -825,6 +825,62 @@ describe("filterPendingStreamState", () => { it("returns null for null stream state", () => { expect(filterPendingStreamState(null, new Set(["tc-1"]))).toBeNull(); }); + + it("keeps a still-streaming result for a pending durable call", () => { + const state: StreamState = { + blocks: [{ type: "tool", id: "tc-1" }], + toolCalls: {}, + toolResults: { + "tc-1": { + id: "tc-1", + name: "advisor", + result: "Use small steps.", + resultRaw: "Use small steps.", + isError: false, + isStreaming: true, + }, + }, + sources: [], + }; + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(filtered).toBe(state); + const tools = buildStreamTools(filtered?.toolCalls, filtered?.toolResults); + expect(tools).toHaveLength(1); + expect(tools[0].status).toBe("running"); + }); + + it("drops the final result for a pending durable call once streaming completes", () => { + let state: StreamState | null = null; + state = applyMessagePartToStreamState(state, { + type: "tool-result", + tool_name: "advisor", + tool_call_id: "tc-1", + result_delta: "Use ", + }); + state = applyMessagePartToStreamState(state, { + type: "tool-result", + tool_name: "advisor", + tool_call_id: "tc-1", + result_delta: "small steps.", + }); + // While the delta is still streaming the entry is kept. + expect(filterPendingStreamState(state, new Set(["tc-1"]))).toBe(state); + + state = applyMessagePartToStreamState(state, { + type: "tool-result", + tool_name: "advisor", + tool_call_id: "tc-1", + result: { + type: "advice", + advice: "Use small steps.", + advisor_model: "test-provider/test-model", + remaining_uses: "2", + }, + }); + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(filtered?.toolResults).toEqual({}); + expect(filtered?.blocks).toEqual([]); + }); }); describe("buildStreamTools", () => { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index 833162df257..cc7b2ae1fbf 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -247,8 +247,11 @@ const getStreamToolStatus = ( * nothing references the dropped id. Entries whose id also has an in-stream * tool call are kept: that is the normal streaming merge path, and keying on * the id (not the tool name) leaves a parallel second call to the same tool - * untouched. When every block is dropped the tail behaves as if no output - * has accumulated yet (the durable pending row above is already visible). + * untouched. Results still streaming (the advisor's result_delta parts) are + * kept while they accumulate so the progressive advice UI keeps rendering; + * the final result part is suppressed once it arrives. When every block is + * dropped the tail behaves as if no output has accumulated yet (the durable + * pending row above is already visible). * * Returns the input reference unchanged when nothing is dropped so memoized * consumers do not re-render on every pending-call change. @@ -263,7 +266,11 @@ export const filterPendingStreamState = ( const toolResults: StreamState["toolResults"] = {}; let dropped = false; for (const [id, result] of Object.entries(streamState.toolResults)) { - if (pendingToolCallIDs.has(id) && !streamState.toolCalls[id]) { + if ( + pendingToolCallIDs.has(id) && + !streamState.toolCalls[id] && + !result.isStreaming + ) { dropped = true; continue; } @@ -277,7 +284,11 @@ export const filterPendingStreamState = ( blocks: streamState.blocks.filter( (block) => block.type !== "tool" || - !(pendingToolCallIDs.has(block.id) && !streamState.toolCalls[block.id]), + !( + pendingToolCallIDs.has(block.id) && + !streamState.toolCalls[block.id] && + !streamState.toolResults[block.id]?.isStreaming + ), ), toolResults, }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index de76c767251..62f7a3d6168 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -145,6 +145,51 @@ export const StreamedResultForPendingToolDoesNotDuplicate: Story = { }, }; +// A durable pending advisor call keeps streaming its result_delta parts +// into the live tail: filterPendingStreamState preserves still-streaming +// results so the progressive advice UI keeps rendering for the pending id. +export const StreamedAdvisorAdviceForPendingToolStillRenders: Story = { + render: () => { + const store = createChatStore(); + store.replaceMessages([ + buildMessage(1, "user", [{ type: "text", text: "Plan the change" }]), + buildMessage(2, "assistant", [ + { + type: "tool-call", + tool_call_id: "advisor-1", + tool_name: "advisor", + args: { question: "Is this migration safe?" }, + }, + ]), + ]); + store.setChatStatus("running"); + + store.applyMessageParts([ + { + type: "tool-result", + tool_call_id: "advisor-1", + tool_name: "advisor", + result_delta: "Use ", + }, + { + type: "tool-result", + tool_call_id: "advisor-1", + tool_name: "advisor", + result_delta: "small steps.", + }, + ]); + + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The accumulated advice text is the progressive surface the filter + // must keep, and it renders exactly once. + await canvas.findByText("Use small steps."); + expect(canvas.getAllByText("Use small steps.")).toHaveLength(1); + }, +}; + export const HiddenAssistantPlaceholderDoesNotRender: Story = { render: () => { const store = createChatStore(); From d6e89c9e5306c3e26235cc2d0debefdd0981f465 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 12 Aug 2026 13:03:08 +0000 Subject: [PATCH 4/6] fix(site/src/pages/AgentsPage): normalize fully filtered stream state to null When filtering drops the only live content for a durable pending call, the live tail rendered a generic Thinking shimmer beneath the pending row because a non-null empty state reads as streaming. Return null when no visible content remains so the tail renders nothing until the preview reset arrives. --- .../ChatConversation/streamState.test.ts | 39 +++++++++++++------ .../ChatConversation/streamState.ts | 37 ++++++++++++------ 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts index d2857e20548..ea7ad999c93 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts @@ -718,19 +718,12 @@ describe("filterPendingStreamState", () => { sources: [], }); - it("drops a result-only entry and its tool block for a pending durable call", () => { + it("normalizes a fully filtered state to null", () => { const filtered = filterPendingStreamState( pendingReadFileState(), new Set(["tc-1"]), ); - expect(filtered).not.toBeNull(); - expect(filtered?.toolResults).toEqual({}); - // The block referencing the dropped id must also be removed so the - // tail does not render a generic "Tool" placeholder. - expect(filtered?.blocks).toEqual([]); - expect( - buildStreamTools(filtered?.toolCalls, filtered?.toolResults), - ).toEqual([]); + expect(filtered).toBeNull(); }); it("keeps a result and block that share an id with an in-stream call", () => { @@ -786,6 +779,31 @@ describe("filterPendingStreamState", () => { expect(filtered?.blocks).toEqual([{ type: "tool", id: "tc-2" }]); }); + it("keeps a partially filtered state non-null when other content remains", () => { + const state: StreamState = { + blocks: [ + { type: "response", text: "Reading the file now." }, + { type: "tool", id: "tc-1" }, + ], + toolCalls: {}, + toolResults: { + "tc-1": { + id: "tc-1", + name: "read_file", + result: { content: "file body" }, + isError: false, + }, + }, + sources: [], + }; + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(filtered).not.toBeNull(); + expect(filtered?.toolResults).toEqual({}); + expect(filtered?.blocks).toEqual([ + { type: "response", text: "Reading the file now." }, + ]); + }); + it("keeps non-tool blocks and other tool blocks while dropping the pending one", () => { const state: StreamState = { blocks: [ @@ -878,8 +896,7 @@ describe("filterPendingStreamState", () => { }, }); const filtered = filterPendingStreamState(state, new Set(["tc-1"])); - expect(filtered?.toolResults).toEqual({}); - expect(filtered?.blocks).toEqual([]); + expect(filtered).toBeNull(); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index cc7b2ae1fbf..00a82c39faf 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -249,9 +249,14 @@ const getStreamToolStatus = ( * the id (not the tool name) leaves a parallel second call to the same tool * untouched. Results still streaming (the advisor's result_delta parts) are * kept while they accumulate so the progressive advice UI keeps rendering; - * the final result part is suppressed once it arrives. When every block is - * dropped the tail behaves as if no output has accumulated yet (the durable - * pending row above is already visible). + * the final result part is suppressed once it arrives. + * + * A state left with no visible content is normalized to null: every surviving + * toolResult has a tool block, so empty blocks with no in-stream calls and no + * sources means nothing remains. The live tail then renders nothing (no stray + * generic Thinking shimmer) while the durable pending row stays visible, + * instead of a non-null empty state that deriveLiveStatus would treat as + * streaming. * * Returns the input reference unchanged when nothing is dropped so memoized * consumers do not re-render on every pending-call change. @@ -279,17 +284,25 @@ export const filterPendingStreamState = ( if (!dropped) { return streamState; } + const blocks = streamState.blocks.filter( + (block) => + block.type !== "tool" || + !( + pendingToolCallIDs.has(block.id) && + !streamState.toolCalls[block.id] && + !streamState.toolResults[block.id]?.isStreaming + ), + ); + if ( + blocks.length === 0 && + Object.keys(streamState.toolCalls).length === 0 && + streamState.sources.length === 0 + ) { + return null; + } return { ...streamState, - blocks: streamState.blocks.filter( - (block) => - block.type !== "tool" || - !( - pendingToolCallIDs.has(block.id) && - !streamState.toolCalls[block.id] && - !streamState.toolResults[block.id]?.isStreaming - ), - ), + blocks, toolResults, }; }; From 5e34550f4cc24e7f03935929fedb5bb518477c1a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 12 Aug 2026 13:19:02 +0000 Subject: [PATCH 5/6] refactor(site/src/pages/AgentsPage): trim filterPendingStreamState docs and guard Shorten the doc comment to the behavior contract and point at the tests for the full detail. Make the empty-state guard self-evident by checking the filtered toolResults directly instead of relying on the block/result invariant. --- .../ChatConversation/streamState.ts | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index 00a82c39faf..93b63da55b4 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -236,30 +236,11 @@ const getStreamToolStatus = ( /** * Drops result-only stream entries whose tool call is already rendered as - * pending in the durable transcript. After the assistant message commits, - * the stream state is cleared but the tool result still streams before the - * tool step commits. Without this filter the result renders as a second row - * next to the durable pending row: first an arg-less tool row (for example - * "Read file"), or a generic "Tool" placeholder if only the result entry is - * dropped while its block remains. - * - * Both the result entry and its `{ type: "tool", id }` block are removed so - * nothing references the dropped id. Entries whose id also has an in-stream - * tool call are kept: that is the normal streaming merge path, and keying on - * the id (not the tool name) leaves a parallel second call to the same tool - * untouched. Results still streaming (the advisor's result_delta parts) are - * kept while they accumulate so the progressive advice UI keeps rendering; - * the final result part is suppressed once it arrives. - * - * A state left with no visible content is normalized to null: every surviving - * toolResult has a tool block, so empty blocks with no in-stream calls and no - * sources means nothing remains. The live tail then renders nothing (no stray - * generic Thinking shimmer) while the durable pending row stays visible, - * instead of a non-null empty state that deriveLiveStatus would treat as - * streaming. - * - * Returns the input reference unchanged when nothing is dropped so memoized - * consumers do not re-render on every pending-call change. + * pending in the durable transcript, so the live tail does not show a + * duplicate row before the tool step commits. Still-streaming results are + * kept so progressive output keeps rendering, and a state left with no + * visible content becomes null so the tail renders nothing. See the + * filterPendingStreamState tests for the full behavior contract. */ export const filterPendingStreamState = ( streamState: StreamState | null, @@ -271,6 +252,8 @@ export const filterPendingStreamState = ( const toolResults: StreamState["toolResults"] = {}; let dropped = false; for (const [id, result] of Object.entries(streamState.toolResults)) { + // Still-streaming results stay visible while they accumulate; the + // duplicate is only suppressed once the final result arrives. if ( pendingToolCallIDs.has(id) && !streamState.toolCalls[id] && @@ -293,9 +276,11 @@ export const filterPendingStreamState = ( !streamState.toolResults[block.id]?.isStreaming ), ); + // Nothing visible remains. if ( blocks.length === 0 && Object.keys(streamState.toolCalls).length === 0 && + Object.keys(toolResults).length === 0 && streamState.sources.length === 0 ) { return null; From 211434515746d43216acb44a817b18605aa20d3a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 12 Aug 2026 14:01:35 +0000 Subject: [PATCH 6/6] fix(site/src/pages/AgentsPage): keep delta-streamed results visible until commit When a delta-streamed tool result finishes, its final part clears the streaming flag before the durable tool message commits, so the filter dropped the row and the streamed content disappeared while the durable pending row only showed its placeholder copy. Track whether any result delta was applied and keep those entries until the durable commit; single-shot final results are still suppressed as duplicates. Also drop a comment that paraphrased the empty-state guard. --- .../ChatConversation/streamState.test.ts | 41 ++++++++++++++++++- .../ChatConversation/streamState.ts | 22 ++++++---- .../components/ChatConversation/types.ts | 2 + .../components/ChatPageContent.stories.tsx | 18 ++++++-- 4 files changed, 69 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts index ea7ad999c93..b15fd2fb889 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.test.ts @@ -867,7 +867,7 @@ describe("filterPendingStreamState", () => { expect(tools[0].status).toBe("running"); }); - it("drops the final result for a pending durable call once streaming completes", () => { + it("keeps a delta-streamed result after streaming completes until commit", () => { let state: StreamState | null = null; state = applyMessagePartToStreamState(state, { type: "tool-result", @@ -896,7 +896,44 @@ describe("filterPendingStreamState", () => { }, }); const filtered = filterPendingStreamState(state, new Set(["tc-1"])); - expect(filtered).toBeNull(); + expect(filtered).not.toBeNull(); + expect(filtered?.toolResults["tc-1"]).toMatchObject({ + result: { + type: "advice", + advice: "Use small steps.", + advisor_model: "test-provider/test-model", + remaining_uses: "2", + }, + isError: false, + streamedDelta: true, + }); + const tools = buildStreamTools(filtered?.toolCalls, filtered?.toolResults); + expect(tools).toHaveLength(1); + expect(tools[0].status).toBe("completed"); + }); + + it("keeps a delta-streamed result that ends with an error", () => { + let state: StreamState | null = null; + state = applyMessagePartToStreamState(state, { + type: "tool-result", + tool_name: "advisor", + tool_call_id: "tc-1", + result_delta: "partial advice", + }); + state = applyMessagePartToStreamState(state, { + type: "tool-result", + tool_name: "advisor", + tool_call_id: "tc-1", + result_delta: "", + is_error: true, + }); + const filtered = filterPendingStreamState(state, new Set(["tc-1"])); + expect(filtered).not.toBeNull(); + expect(filtered?.toolResults["tc-1"]).toMatchObject({ + isError: true, + streamedDelta: true, + }); + expect(filtered?.blocks).toEqual([{ type: "tool", id: "tc-1" }]); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts index 93b63da55b4..a5fda9d8e0f 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamState.ts @@ -154,6 +154,10 @@ export const applyMessagePartToStreamState = ( resultRaw: nextResult.rawText, isError: nextIsError, isStreaming: isStreaming || undefined, + streamedDelta: + Boolean(part.result_delta) || + existing?.streamedDelta || + undefined, mcpServerConfigId: part.mcp_server_config_id || existing?.mcpServerConfigId, }, @@ -237,10 +241,11 @@ const getStreamToolStatus = ( /** * Drops result-only stream entries whose tool call is already rendered as * pending in the durable transcript, so the live tail does not show a - * duplicate row before the tool step commits. Still-streaming results are - * kept so progressive output keeps rendering, and a state left with no - * visible content becomes null so the tail renders nothing. See the - * filterPendingStreamState tests for the full behavior contract. + * duplicate row before the tool step commits. Results that streamed deltas + * stay visible until the durable commit so progressive output keeps rendering, + * and a state left with no visible content becomes null so the tail renders + * nothing. See the filterPendingStreamState tests for the full behavior + * contract. */ export const filterPendingStreamState = ( streamState: StreamState | null, @@ -252,12 +257,11 @@ export const filterPendingStreamState = ( const toolResults: StreamState["toolResults"] = {}; let dropped = false; for (const [id, result] of Object.entries(streamState.toolResults)) { - // Still-streaming results stay visible while they accumulate; the - // duplicate is only suppressed once the final result arrives. if ( pendingToolCallIDs.has(id) && !streamState.toolCalls[id] && - !result.isStreaming + !result.isStreaming && + !result.streamedDelta ) { dropped = true; continue; @@ -273,10 +277,10 @@ export const filterPendingStreamState = ( !( pendingToolCallIDs.has(block.id) && !streamState.toolCalls[block.id] && - !streamState.toolResults[block.id]?.isStreaming + !streamState.toolResults[block.id]?.isStreaming && + !streamState.toolResults[block.id]?.streamedDelta ), ); - // Nothing visible remains. if ( blocks.length === 0 && Object.keys(streamState.toolCalls).length === 0 && diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index 06b1128722c..44de74c73d1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -97,6 +97,8 @@ type StreamToolResult = { isError: boolean; /** True while result deltas are still accumulating before the final result. */ isStreaming?: boolean; + /** True when any result_delta part was applied, even after streaming completes. */ + streamedDelta?: boolean; mcpServerConfigId?: string; }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 62f7a3d6168..8eef1f402cb 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -145,9 +145,10 @@ export const StreamedResultForPendingToolDoesNotDuplicate: Story = { }, }; -// A durable pending advisor call keeps streaming its result_delta parts -// into the live tail: filterPendingStreamState preserves still-streaming -// results so the progressive advice UI keeps rendering for the pending id. +// A durable pending advisor call streams its result_delta parts into the +// live tail, then its final result part. filterPendingStreamState keeps +// results that streamed deltas so the progressive advice UI keeps rendering +// for the pending id until the durable commit. export const StreamedAdvisorAdviceForPendingToolStillRenders: Story = { render: () => { const store = createChatStore(); @@ -177,6 +178,17 @@ export const StreamedAdvisorAdviceForPendingToolStillRenders: Story = { tool_name: "advisor", result_delta: "small steps.", }, + { + type: "tool-result", + tool_call_id: "advisor-1", + tool_name: "advisor", + result: { + type: "advice", + advice: "Use small steps.", + advisor_model: "test-provider/test-model", + remaining_uses: "2", + }, + }, ]); return ;