fix(desktop): harden agent transcript reducer

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-06-29 14:54:33 -07:00
co-authored by Taylor Ho
parent c8db319579
commit df8937ec4f
7 changed files with 202 additions and 300 deletions
@@ -73,7 +73,6 @@ export function AgentSessionTranscriptList({
profiles,
}: AgentTranscriptIdentityProps & {
emptyDescription: string;
isWorking?: boolean;
items: TranscriptItem[];
profiles?: UserProfileLookup;
}) {
@@ -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}
/>
@@ -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");
});
@@ -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,
@@ -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");
});
@@ -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<string>;
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<TranscriptItem, { type: "tool" }>) {
return item.status === "executing" || item.status === "pending";
}
function isLifecycleError(
item: Extract<TranscriptItem, { type: "lifecycle" }>,
) {
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<string> {
if (!isWorking || items.length === 0) {
return new Set();
}
const active = new Set<string>();
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,
};
}
@@ -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}