mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
c8db319579
commit
df8937ec4f
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user