From df8937ec4fc338a541322af0fd559ec7aa0e80e5 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 29 Jun 2026 14:54:33 -0700 Subject: [PATCH] fix(desktop): harden agent transcript reducer Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../agents/ui/AgentSessionTranscriptList.tsx | 1 - .../agents/ui/ManagedAgentSessionPanel.tsx | 6 - .../agents/ui/agentSessionTranscript.test.mjs | 187 +++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 18 +- ...gentSessionTranscriptPresentation.test.mjs | 67 ------ .../ui/agentSessionTranscriptPresentation.ts | 222 ------------------ .../channels/ui/AgentSessionThreadPanel.tsx | 1 - 7 files changed, 202 insertions(+), 300 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 45c25a23b..3afea9ca7 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -73,7 +73,6 @@ export function AgentSessionTranscriptList({ profiles, }: AgentTranscriptIdentityProps & { emptyDescription: string; - isWorking?: boolean; items: TranscriptItem[]; profiles?: UserProfileLookup; }) { diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index b2b848243..8051c4798 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -36,7 +36,6 @@ type ManagedAgentSessionPanelProps = { channelId?: string | null; className?: string; emptyDescription?: string; - isWorking?: boolean; rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showRaw?: boolean; @@ -48,7 +47,6 @@ export function ManagedAgentSessionPanel({ channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", - isWorking = false, rawLayout = "responsive", showHeader = true, showRaw = true, @@ -101,7 +99,6 @@ export function ManagedAgentSessionPanel({ errorMessage={errorMessage} events={scopedEvents} hasObserver={hasObserver} - isWorking={isWorking} profiles={profiles} rawLayout={rawLayout} showRaw={showRaw} @@ -155,7 +152,6 @@ function SessionBody({ errorMessage, events, hasObserver, - isWorking, profiles, rawLayout, showRaw, @@ -169,7 +165,6 @@ function SessionBody({ errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; - isWorking: boolean; profiles?: UserProfileLookup; rawLayout: "responsive" | "exclusive"; showRaw: boolean; @@ -211,7 +206,6 @@ function SessionBody({ agentName={agentName} agentPubkey={agentPubkey} emptyDescription={emptyDescription} - isWorking={isWorking} items={transcript} profiles={profiles} /> diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index d984b0c19..d61529305 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -194,3 +194,190 @@ test("buildTranscript categorizes explicit Buzz tool calls for the activity bar" assert.deepEqual(item.args, { limit: 20 }); assert.equal(item.status, "completed"); }); + +function sessionUpdate(seq, update, overrides = {}) { + return { + ...baseEvent, + ...overrides, + seq, + kind: "acp_read", + payload: { + method: "session/update", + params: { + sessionId: overrides.sessionId ?? baseEvent.sessionId, + update, + }, + }, + }; +} + +function assistantChunk(seq, messageId, text, overrides = {}) { + return sessionUpdate( + seq, + { + sessionUpdate: "agent_message_chunk", + messageId, + content: { type: "text", text }, + }, + overrides, + ); +} + +test("buildTranscript de-duplicates repeated tool updates into one canonical row", () => { + const items = toolItems([ + acpToolUpdate(40, { + sessionUpdate: "tool_call", + toolCallId: "call-dupe", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo hi" }, + }), + acpToolUpdate(41, { + sessionUpdate: "tool_call_update", + toolCallId: "call-dupe", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + acpToolUpdate(42, { + sessionUpdate: "tool_call_update", + toolCallId: "call-dupe", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + ]); + + assert.equal(items.length, 1); + assert.equal(items[0].id, `tool:${baseEvent.channelId}:call-dupe`); + assert.equal(items[0].status, "completed"); + assert.equal(items[0].result, "hi"); +}); + +test("buildTranscript keeps a completed tool terminal when a late executing call arrives", () => { + const [item] = toolItems([ + acpToolUpdate(50, { + sessionUpdate: "tool_call_update", + toolCallId: "call-regression", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "done", + }), + acpToolUpdate(51, { + sessionUpdate: "tool_call", + toolCallId: "call-regression", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo done" }, + }), + ]); + + assert.equal(item.status, "completed"); + assert.equal(item.completedAt, baseEvent.timestamp); + assert.deepEqual(item.args, { command: "echo done" }); + assert.equal(item.result, "done"); +}); + +test("buildTranscript rebuilds out-of-order tool frames as one canonical row with retained ids", () => { + const [item] = toolItems([ + sessionUpdate( + 60, + { + sessionUpdate: "tool_call_update", + toolCallId: "call-out-of-order", + status: "completed", + title: "read_file", + kind: "read_file", + rawOutput: "file contents", + }, + { + channelId: "22222222-2222-2222-2222-222222222222", + sessionId: "sess-2", + turnId: "turn-2", + timestamp: "2026-06-18T00:00:05Z", + }, + ), + sessionUpdate( + 61, + { + sessionUpdate: "tool_call", + toolCallId: "call-out-of-order", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "AGENTS.md" }, + }, + { + channelId: "22222222-2222-2222-2222-222222222222", + sessionId: "sess-2", + turnId: "turn-2", + timestamp: "2026-06-18T00:00:04Z", + }, + ), + ]); + + assert.equal( + item.id, + "tool:22222222-2222-2222-2222-222222222222:call-out-of-order", + ); + assert.equal(item.status, "completed"); + assert.deepEqual(item.args, { path: "AGENTS.md" }); + assert.equal(item.channelId, "22222222-2222-2222-2222-222222222222"); + assert.equal(item.turnId, "turn-2"); + assert.equal(item.sessionId, "sess-2"); +}); + +test("buildTranscript coalesces assistant chunks until the message is sealed", () => { + const messages = buildTranscript([ + assistantChunk(70, "msg-1", "Hello "), + assistantChunk(71, "msg-1", "world"), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(messages.length, 1); + assert.equal(messages[0].text, "Hello world"); + assert.equal(messages[0].id, `assistant:${baseEvent.channelId}:msg-1`); +}); + +test("buildTranscript starts a continuation for same-message chunks after sealing", () => { + const messages = buildTranscript([ + assistantChunk(80, "msg-2", "First"), + acpToolUpdate(81, { + sessionUpdate: "tool_call", + toolCallId: "call-seal", + status: "executing", + title: "shell", + kind: "shell", + }), + assistantChunk(82, "msg-2", "Second"), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(messages.length, 2); + assert.equal(messages[0].text, "First"); + assert.equal(messages[1].text, "Second"); + assert.match(messages[1].id, /:c\d+$/); +}); + +test("buildTranscript preserves channel, turn, and session ids through message updates", () => { + const [message] = buildTranscript([ + assistantChunk(90, "msg-identity", "One ", { + channelId: "33333333-3333-3333-3333-333333333333", + sessionId: "sess-identity", + turnId: "turn-identity", + }), + assistantChunk(91, "msg-identity", "Two", { + channelId: "33333333-3333-3333-3333-333333333333", + sessionId: null, + turnId: null, + }), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(message.text, "One Two"); + assert.equal(message.channelId, "33333333-3333-3333-3333-333333333333"); + assert.equal(message.turnId, "turn-identity"); + assert.equal(message.sessionId, "sess-identity"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index f500aab93..cc3546439 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -234,6 +234,18 @@ function upsertMetadata( }); } +function isTerminalToolStatus(status: ToolStatus) { + return status === "completed" || status === "failed"; +} + +function mergeToolStatus(existing: ToolStatus, next: ToolStatus): ToolStatus { + if (isTerminalToolStatus(existing) && !isTerminalToolStatus(next)) { + return existing; + } + + return next; +} + function upsertTool( d: TranscriptDraft, id: string, @@ -261,18 +273,18 @@ function upsertTool( } else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) { updatedToolName = toolName; } + const mergedStatus = mergeToolStatus(existing.status, status); replaceItem(d, id, { ...existing, title: updatedTitle, toolName: updatedToolName, buzzToolName: updatedBuzzToolName, - status, + status: mergedStatus, args: Object.keys(args).length > 0 ? args : existing.args, result: result || existing.result, isError: isError || existing.isError, completedAt: - (status === "completed" || status === "failed") && - existing.completedAt == null + isTerminalToolStatus(mergedStatus) && existing.completedAt == null ? timestamp : existing.completedAt, channelId: ctx.channelId, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs index a13736189..790951a9f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildTranscriptPresentation, getActivityHeadline, isMeaningfulItem, } from "./agentSessionTranscriptPresentation.ts"; @@ -80,69 +79,3 @@ test("isMeaningfulItem ignores lifecycle noise and metadata", () => { true, ); }); - -test("buildTranscriptPresentation marks running tools as active while working", () => { - const items = [ - makeMessage({ id: "msg:user", role: "user", text: "Please help" }), - makeTool({ id: "tool:running", status: "executing" }), - ]; - - const presentation = buildTranscriptPresentation(items, true); - - assert.equal(presentation.state, "tool_running"); - assert.equal(presentation.headline, "Send Message"); - assert.equal(presentation.counts.tools, 1); - assert.equal(presentation.counts.messages, 1); - assert.ok(presentation.activeItemIds.has("tool:running")); -}); - -test("buildTranscriptPresentation highlights assistant streaming while working", () => { - const items = [ - makeMessage({ id: "msg:assistant", role: "assistant", text: "Drafting" }), - ]; - - const presentation = buildTranscriptPresentation(items, true); - - assert.equal(presentation.state, "responding"); - assert.equal(presentation.headline, "Drafting"); - assert.ok(presentation.activeItemIds.has("msg:assistant")); -}); - -test("buildTranscriptPresentation surfaces lifecycle errors", () => { - const items = [ - makeTool({ - id: "tool:done", - status: "completed", - completedAt: "2026-06-14T19:00:05.000Z", - }), - { - id: "life:error", - type: "lifecycle", - title: "Turn error", - text: "timeout", - timestamp: "2026-06-14T19:00:06.000Z", - }, - ]; - - const presentation = buildTranscriptPresentation(items, false); - - assert.equal(presentation.state, "error"); - assert.equal(presentation.hasError, true); - assert.equal(presentation.headline, "Turn error"); -}); - -test("buildTranscriptPresentation returns idle state when not working", () => { - const items = [ - makeTool({ - id: "tool:done", - status: "completed", - completedAt: "2026-06-14T19:00:05.000Z", - }), - ]; - - const presentation = buildTranscriptPresentation(items, false); - - assert.equal(presentation.state, "idle"); - assert.equal(presentation.activeItemIds.size, 0); - assert.equal(presentation.headline, "Send Message"); -}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts index bb6fd4c9f..bf1d4988f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -1,33 +1,6 @@ import { formatToolTitle } from "./agentSessionToolCatalog"; import type { TranscriptItem } from "./agentSessionTypes"; -export type TranscriptActivityCounts = { - tools: number; - toolErrors: number; - thoughts: number; - messages: number; - lifecycle: number; - metadata: number; -}; - -export type TranscriptActivityState = - | "idle" - | "responding" - | "thinking" - | "tool_running" - | "error"; - -export type TranscriptPresentation = { - headline: string; - state: TranscriptActivityState; - counts: TranscriptActivityCounts; - latestMeaningfulItem: TranscriptItem | null; - latestMeaningfulItemId: string | null; - activeItemIds: ReadonlySet; - lastUpdatedAt: string | null; - hasError: boolean; -}; - const LIFECYCLE_NOISE = new Set([ "turn started", "session ready", @@ -83,198 +56,3 @@ export function isMeaningfulItem(item: TranscriptItem): boolean { } return true; } - -function isToolRunning(item: Extract) { - return item.status === "executing" || item.status === "pending"; -} - -function isLifecycleError( - item: Extract, -) { - return item.title.toLowerCase().includes("error"); -} - -function countItems(items: TranscriptItem[]): TranscriptActivityCounts { - const counts: TranscriptActivityCounts = { - tools: 0, - toolErrors: 0, - thoughts: 0, - messages: 0, - lifecycle: 0, - metadata: 0, - }; - - for (const item of items) { - switch (item.type) { - case "tool": - counts.tools += 1; - if (item.isError || item.status === "failed") { - counts.toolErrors += 1; - } - break; - case "thought": - counts.thoughts += 1; - break; - case "message": - counts.messages += 1; - break; - case "lifecycle": - counts.lifecycle += 1; - break; - case "metadata": - counts.metadata += 1; - break; - } - } - - return counts; -} - -function findLatestMeaningfulItem( - items: TranscriptItem[], -): TranscriptItem | null { - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (isMeaningfulItem(item)) { - return item; - } - } - return null; -} - -function resolveActivityState( - latest: TranscriptItem | null, - hasError: boolean, - isWorking: boolean, -): TranscriptActivityState { - if (!isWorking) { - return hasError ? "error" : "idle"; - } - - if (hasError && latest?.type === "lifecycle" && isLifecycleError(latest)) { - return "error"; - } - - if (latest?.type === "tool" && isToolRunning(latest)) { - return "tool_running"; - } - - if (latest?.type === "thought") { - return "thinking"; - } - - if (latest?.type === "message" && latest.role === "assistant") { - return "responding"; - } - - if (latest?.type === "tool") { - return "tool_running"; - } - - return "idle"; -} - -function resolveHeadline( - latest: TranscriptItem | null, - state: TranscriptActivityState, - isWorking: boolean, -): string { - if (latest) { - const headline = getActivityHeadline(latest); - if (headline) { - return headline; - } - } - - if (isWorking) { - switch (state) { - case "tool_running": - return "Running a tool"; - case "thinking": - return "Thinking"; - case "responding": - return "Responding"; - case "error": - return "Encountered an error"; - default: - return "Working"; - } - } - - if (state === "error") { - return "Last turn ended with an error"; - } - - return "Waiting for activity"; -} - -function collectActiveItemIds( - items: TranscriptItem[], - isWorking: boolean, -): ReadonlySet { - if (!isWorking || items.length === 0) { - return new Set(); - } - - const active = new Set(); - - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - - if (item.type === "tool" && isToolRunning(item)) { - active.add(item.id); - break; - } - - if (item.type === "thought") { - active.add(item.id); - break; - } - - if (item.type === "message" && item.role === "assistant") { - active.add(item.id); - break; - } - } - - return active; -} - -function detectError(items: TranscriptItem[]): boolean { - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (!isMeaningfulItem(item)) { - continue; - } - if (item.type === "lifecycle" && isLifecycleError(item)) { - return true; - } - if (item.type === "tool" && (item.isError || item.status === "failed")) { - return true; - } - break; - } - return false; -} - -/** Derive presentation metadata for a transcript list. */ -export function buildTranscriptPresentation( - items: TranscriptItem[], - isWorking = false, -): TranscriptPresentation { - const latestMeaningfulItem = findLatestMeaningfulItem(items); - const hasError = detectError(items); - const state = resolveActivityState(latestMeaningfulItem, hasError, isWorking); - - return { - headline: resolveHeadline(latestMeaningfulItem, state, isWorking), - state, - counts: countItems(items), - latestMeaningfulItem, - latestMeaningfulItemId: latestMeaningfulItem?.id ?? null, - activeItemIds: collectActiveItemIds(items, isWorking), - lastUpdatedAt: - items.length > 0 ? (items[items.length - 1]?.timestamp ?? null) : null, - hasError, - }; -} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 31996f24c..04e0edff8 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -211,7 +211,6 @@ export function AgentSessionThreadPanel({ ? `Mention ${agent.name} in the channel to see its work here.` : `Mention ${agent.name} in any channel to see its work here.` } - isWorking={isWorking} profiles={profiles} rawLayout="exclusive" showHeader={false}