diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index ac396eefdaa48..bbd5c661adb13 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("normalizes a fully filtered state to null", () => { + const filtered = filterPendingStreamState( + pendingReadFileState(), + new Set(["tc-1"]), + ); + expect(filtered).toBeNull(); + }); + + 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: [], + }; + 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 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: [ + { 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(); + 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(); + }); + + 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("keeps a delta-streamed result after streaming completes until commit", () => { + 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).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" }]); + }); +}); + 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 b093617abb41d..a5fda9d8e0f2c 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, }, @@ -234,6 +238,64 @@ const getStreamToolStatus = ( return result.isError ? "error" : "completed"; }; +/** + * 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. 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, + 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] && + !result.isStreaming && + !result.streamedDelta + ) { + dropped = true; + continue; + } + toolResults[id] = result; + } + 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 && + !streamState.toolResults[block.id]?.streamedDelta + ), + ); + if ( + blocks.length === 0 && + Object.keys(streamState.toolCalls).length === 0 && + Object.keys(toolResults).length === 0 && + streamState.sources.length === 0 + ) { + return null; + } + return { + ...streamState, + blocks, + toolResults, + }; +}; + export const buildStreamTools = ( toolCalls: StreamState["toolCalls"] | null | undefined, toolResults: StreamState["toolResults"] | null | undefined, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index 06b1128722ce4..44de74c73d1a8 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 0ffe7e873c1db..8eef1f402cb2d 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -88,6 +88,120 @@ 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(); + }, +}; + +// 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(); + 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.", + }, + { + 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 ; + }, + 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(); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index fb08c2e30323e..06aafe8916026 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} />