mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
- Add #[derive(Debug)] to PermissionEntry so test assertions can format entry_state:? in the paused-time test - Update NIP-AO.md Authorization Envelope section: document the one-write/one-observe contract, enumerate terminal reason values (applied / timed_out / cancelled), and define the uncertain path (cancel-during-write = no acp_write, process respawned) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2395 lines
74 KiB
JavaScript
2395 lines
74 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { buildTranscript } from "./agentSessionTranscript.ts";
|
|
import {
|
|
buildTranscriptDisplayBlocks,
|
|
flattenDisplayBlocks,
|
|
} from "./agentSessionTranscriptGrouping.ts";
|
|
import { formatToolTitle } from "./agentSessionToolCatalog.ts";
|
|
|
|
const baseEvent = {
|
|
seq: 1,
|
|
timestamp: "2026-06-18T00:00:00Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "11111111-1111-1111-1111-111111111111",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
};
|
|
const PROMPT_EVENT_ID = "c".repeat(64);
|
|
|
|
function acpToolUpdate(seq, update) {
|
|
return {
|
|
...baseEvent,
|
|
seq,
|
|
kind: "acp_read",
|
|
payload: {
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: baseEvent.sessionId,
|
|
update,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function toolItems(events) {
|
|
return buildTranscript(events).filter((item) => item.type === "tool");
|
|
}
|
|
|
|
function activityTitle(item) {
|
|
return formatToolTitle(item.buzzToolName ?? item.toolName, item.title);
|
|
}
|
|
|
|
// --- stub-overflow vanish (pins the pre-existing degraded-frame behavior) ---
|
|
|
|
test("buildTranscript drops a session/prompt turn whose frame was stubbed by the size trimmer", () => {
|
|
// When fit_observer_event_to_budget cannot shrink a frame below the cap it
|
|
// replaces the whole payload with {elided, originalBytes} (no `method`), so
|
|
// the method-keyed acp_write dispatch matches no arm and there is no terminal
|
|
// else: the turn produces ZERO transcript items. This is worse than a
|
|
// "1 section" collapse (the item vanishes entirely) and is pre-existing,
|
|
// outside the format_prompt seam. Pin it so a later change can't silently
|
|
// regress the vanish-vs-degrade behavior without updating this test.
|
|
const stubbed = {
|
|
...baseEvent,
|
|
payload: {
|
|
elided: "acp_write payload too large",
|
|
originalBytes: 123456,
|
|
},
|
|
};
|
|
|
|
assert.deepEqual(buildTranscript([stubbed]), []);
|
|
});
|
|
|
|
// --- positive control: a well-formed multi-block prompt DOES render ---
|
|
|
|
test("buildTranscript renders Prompt context + user message for a multi-block session/prompt frame", () => {
|
|
// Guards the vanish assertion above against a false pass from a broken
|
|
// import or dispatch: a normal per-section prompt frame must still produce a
|
|
// user message and a "Prompt context" metadata item.
|
|
const event = {
|
|
...baseEvent,
|
|
payload: {
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
prompt: [
|
|
{ type: "text", text: "[Agent Memory — core]\nremember this" },
|
|
{ type: "text", text: "[Context]\nScope: thread" },
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${PROMPT_EVENT_ID.toUpperCase()}\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const items = buildTranscript([event]);
|
|
const titles = items.map((i) => i.title);
|
|
assert.ok(
|
|
items.some((i) => i.type === "metadata" && i.title === "Prompt context"),
|
|
`expected a Prompt context metadata item, got titles: ${titles.join(", ")}`,
|
|
);
|
|
const promptContext = items.find((i) => i.title === "Prompt context");
|
|
assert.deepEqual(
|
|
promptContext.sections.map((s) => s.title),
|
|
["Agent Memory — core", "Context", "Buzz event: @mention"],
|
|
"every section header is counted",
|
|
);
|
|
const userMessage = items.find((i) => i.type === "message");
|
|
assert.equal(userMessage.messageId, PROMPT_EVENT_ID);
|
|
});
|
|
|
|
test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => {
|
|
const promptEvent = {
|
|
...baseEvent,
|
|
seq: 2,
|
|
payload: {
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const [userMessage] = buildTranscript([
|
|
{
|
|
...baseEvent,
|
|
seq: 1,
|
|
kind: "turn_started",
|
|
payload: {
|
|
triggeringEventIds: [PROMPT_EVENT_ID],
|
|
},
|
|
},
|
|
promptEvent,
|
|
]).filter((candidate) => candidate.type === "message");
|
|
|
|
assert.equal(userMessage.messageId, PROMPT_EVENT_ID);
|
|
});
|
|
|
|
test("buildTranscript keeps read_file activity categorized by the actual tool when output names Buzz tools", () => {
|
|
const [item] = toolItems([
|
|
acpToolUpdate(10, {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call-read-file",
|
|
status: "executing",
|
|
title: "read_file",
|
|
kind: "read_file",
|
|
rawInput: {
|
|
path: "desktop/src/features/agents/ui/agentSessionToolCatalog.ts",
|
|
},
|
|
}),
|
|
acpToolUpdate(11, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "call-read-file",
|
|
status: "completed",
|
|
title: "read_file",
|
|
kind: "read_file",
|
|
rawInput: {
|
|
path: "desktop/src/features/agents/ui/agentSessionToolCatalog.ts",
|
|
},
|
|
content: {
|
|
type: "text",
|
|
text: 'const BUZZ_READ_TOOLS = new Set(["get_feed", "get_event"]);\nconst BUZZ_WRITE_TOOLS = new Set(["delete_message"]);',
|
|
},
|
|
}),
|
|
]);
|
|
|
|
assert.equal(item.toolName, "read_file");
|
|
assert.equal(item.buzzToolName, null);
|
|
assert.equal(item.title, "read_file");
|
|
assert.equal(activityTitle(item), "read_file");
|
|
assert.equal(item.status, "completed");
|
|
assert.match(item.result, /get_feed/);
|
|
assert.match(item.result, /delete_message/);
|
|
});
|
|
|
|
test("buildTranscript keeps shell activity categorized by the actual tool when grep output names Buzz tools", () => {
|
|
const [item] = toolItems([
|
|
acpToolUpdate(20, {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call-shell-rg",
|
|
status: "executing",
|
|
title: "shell",
|
|
kind: "shell",
|
|
rawInput: {
|
|
command: 'rg -n "get_event|delete_message" desktop/src',
|
|
},
|
|
}),
|
|
acpToolUpdate(21, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "call-shell-rg",
|
|
status: "completed",
|
|
title: "shell",
|
|
kind: "shell",
|
|
rawInput: {
|
|
command: 'rg -n "get_event|delete_message" desktop/src',
|
|
},
|
|
rawOutput:
|
|
'desktop/src/features/agents/ui/agentSessionToolCatalog.ts:83: "get_event",\n' +
|
|
'desktop/src/features/agents/ui/agentSessionToolCatalog.ts:92: "delete_message",',
|
|
}),
|
|
]);
|
|
|
|
assert.equal(item.toolName, "shell");
|
|
assert.equal(item.buzzToolName, null);
|
|
assert.equal(activityTitle(item), "shell");
|
|
assert.equal(item.status, "completed");
|
|
assert.match(item.result, /get_event/);
|
|
assert.match(item.result, /delete_message/);
|
|
});
|
|
|
|
test("buildTranscript categorizes explicit Buzz tool calls for the activity bar", () => {
|
|
const [item] = toolItems([
|
|
acpToolUpdate(30, {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call-get-feed",
|
|
status: "executing",
|
|
title: "Tool call",
|
|
toolName: "get_feed",
|
|
rawInput: { limit: 20 },
|
|
}),
|
|
acpToolUpdate(31, {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "call-get-feed",
|
|
status: "completed",
|
|
title: "Tool call",
|
|
toolName: "get_feed",
|
|
content: { type: "text", text: "[]" },
|
|
}),
|
|
]);
|
|
|
|
assert.equal(item.toolName, "get_feed");
|
|
assert.equal(item.buzzToolName, "get_feed");
|
|
assert.equal(activityTitle(item), "Get Feed");
|
|
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 preserves author pubkeys on user message chunks", () => {
|
|
const authorPubkey = "b".repeat(64);
|
|
const [item] = buildTranscript([
|
|
sessionUpdate(25, {
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId: "user-chunk-1",
|
|
authorPubkey,
|
|
content: { type: "text", text: "please keep this visible" },
|
|
}),
|
|
]).filter((candidate) => candidate.type === "message");
|
|
|
|
assert.equal(item.role, "user");
|
|
assert.equal(item.authorPubkey, authorPubkey);
|
|
assert.equal(item.messageId, null);
|
|
});
|
|
|
|
test("buildTranscript preserves real event ids on user message chunks", () => {
|
|
const authorPubkey = "b".repeat(64);
|
|
const messageId = "d".repeat(64);
|
|
const [item] = buildTranscript([
|
|
sessionUpdate(26, {
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId,
|
|
authorPubkey,
|
|
content: { type: "text", text: "this came from a channel message" },
|
|
}),
|
|
]).filter((candidate) => candidate.type === "message");
|
|
|
|
assert.equal(item.role, "user");
|
|
assert.equal(item.messageId, messageId);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
test("buildTranscript promotes ACP plan updates to first-class plan items", () => {
|
|
const items = buildTranscript([
|
|
sessionUpdate(90, {
|
|
sessionUpdate: "plan",
|
|
content: { type: "text", text: "- [ ] Build registry" },
|
|
}),
|
|
]);
|
|
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].type, "plan");
|
|
assert.equal(items[0].renderClass, "plan");
|
|
assert.match(items[0].text, /Build registry/);
|
|
});
|
|
|
|
test("buildTranscript parses standard ACP plan entries[] into a checklist", () => {
|
|
const items = buildTranscript([
|
|
sessionUpdate(90, {
|
|
sessionUpdate: "plan",
|
|
entries: [
|
|
{ status: "completed", content: "Read the ticket", priority: "medium" },
|
|
{ status: "in_progress", content: "Write the fix", priority: "medium" },
|
|
{ status: "pending", content: "Open the PR", priority: "medium" },
|
|
],
|
|
}),
|
|
]);
|
|
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].type, "plan");
|
|
assert.equal(items[0].renderClass, "plan");
|
|
assert.equal(
|
|
items[0].text,
|
|
[
|
|
"- [x] Read the ticket",
|
|
"- [ ] Write the fix (in progress)",
|
|
"- [ ] Open the PR",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
test("buildTranscript summarizes plan entries[] updates with correct N/M complete", () => {
|
|
const items = buildTranscript([
|
|
sessionUpdate(90, {
|
|
sessionUpdate: "plan",
|
|
entries: [
|
|
{ status: "pending", content: "Read the ticket" },
|
|
{ status: "pending", content: "Write the fix" },
|
|
],
|
|
}),
|
|
sessionUpdate(91, {
|
|
sessionUpdate: "plan",
|
|
entries: [
|
|
{ status: "completed", content: "Read the ticket" },
|
|
{ status: "in_progress", content: "Write the fix" },
|
|
],
|
|
}),
|
|
]);
|
|
|
|
const updateMarker = items.find(
|
|
(item) => item.type === "plan" && item.isUpdate,
|
|
);
|
|
assert.ok(updateMarker, "expected a plan update marker item");
|
|
assert.equal(updateMarker.title, "Plan updated");
|
|
assert.equal(updateMarker.text, "1/2 complete");
|
|
});
|
|
|
|
test("buildTranscript treats an empty plan entries[] as an empty checklist, not a JSON fallback", () => {
|
|
const items = buildTranscript([
|
|
sessionUpdate(90, {
|
|
sessionUpdate: "plan",
|
|
entries: [],
|
|
}),
|
|
]);
|
|
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].type, "plan");
|
|
assert.equal(items[0].text, "");
|
|
});
|
|
|
|
test("buildTranscript falls back to raw JSON when a plan update has neither entries nor content", () => {
|
|
const items = buildTranscript([
|
|
sessionUpdate(90, {
|
|
sessionUpdate: "plan",
|
|
}),
|
|
]);
|
|
|
|
assert.equal(items.length, 1);
|
|
assert.equal(items[0].type, "plan");
|
|
assert.match(items[0].text, /"sessionUpdate":\s*"plan"/);
|
|
});
|
|
|
|
test("buildTranscript stores first-class render class descriptors for tool items", () => {
|
|
const [item] = toolItems([
|
|
acpToolUpdate(91, {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call-edit",
|
|
status: "completed",
|
|
title: "str_replace",
|
|
kind: "str_replace",
|
|
rawInput: { path: "src/app.ts" },
|
|
}),
|
|
]);
|
|
|
|
assert.equal(item.renderClass, "file-edit");
|
|
assert.equal(item.descriptor.label, "Edited file");
|
|
assert.equal(item.descriptor.preview, "src/app.ts");
|
|
});
|
|
|
|
test("buildTranscript surfaces session/request_permission as a permission lifecycle item", () => {
|
|
const transcript = buildTranscript([
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-06-30T09:00:00.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId: "channel-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
method: "session/request_permission",
|
|
params: {
|
|
toolCallId: "tool-1",
|
|
title: "Confirm force-with-lease push to block/buzz.",
|
|
options: [
|
|
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
|
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
assert.equal(transcript[0].type, "lifecycle");
|
|
assert.equal(transcript[0].renderClass, "permission");
|
|
assert.equal(transcript[0].title, "Permission requested");
|
|
assert.match(transcript[0].text, /Confirm force-with-lease push/);
|
|
});
|
|
|
|
test("buildTranscript stamps completedAt when a terminal tool update is inserted first", () => {
|
|
const transcript = buildTranscript([
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-06-30T09:00:00.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId: "channel-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "tool-1",
|
|
toolName: "dev__shell",
|
|
status: "completed",
|
|
rawInput: { command: "echo hi" },
|
|
content: [{ type: "text", text: "hi" }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
assert.equal(transcript[0].type, "tool");
|
|
assert.equal(transcript[0].completedAt, "2026-06-30T09:00:00.000Z");
|
|
});
|
|
|
|
test("buildTranscript preserves permission, free-form status, and raw rail render classes", () => {
|
|
const transcript = buildTranscript([
|
|
{
|
|
...baseEvent,
|
|
seq: 1,
|
|
kind: "acp_read",
|
|
payload: {
|
|
method: "session/request_permission",
|
|
params: {
|
|
title: "Confirm force-with-lease push",
|
|
toolCallId: "tool-push",
|
|
options: [
|
|
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
|
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
...baseEvent,
|
|
seq: 2,
|
|
kind: "acp_read",
|
|
payload: {
|
|
type: "observer_connected",
|
|
title: "Observer connected",
|
|
text: "ACP stream attached",
|
|
},
|
|
},
|
|
{
|
|
...baseEvent,
|
|
seq: 3,
|
|
kind: "raw_json_rpc",
|
|
payload: {
|
|
method: "workspace/diagnostic",
|
|
params: { ok: true },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const permissionItem = transcript.find(
|
|
(item) =>
|
|
item.id.startsWith("permission:") && item.renderClass === "permission",
|
|
);
|
|
assert.ok(
|
|
permissionItem,
|
|
"permission request should flow through the reducer",
|
|
);
|
|
assert.doesNotMatch(
|
|
permissionItem.text,
|
|
/^Permission requested\b/,
|
|
"permission detail should not duplicate the row title",
|
|
);
|
|
assert.ok(
|
|
transcript.some(
|
|
(item) =>
|
|
item.renderClass === "status" && item.title === "Observer connected",
|
|
),
|
|
"free-form status fixture should flow through the reducer",
|
|
);
|
|
assert.ok(
|
|
transcript.some(
|
|
(item) => item.type === "metadata" && item.renderClass === "raw-rail",
|
|
),
|
|
"raw_json_rpc fixture should flow through the reducer",
|
|
);
|
|
});
|
|
|
|
test("buildTranscript separates repeated lifecycle text", () => {
|
|
const events = [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-06-30T09:00:00.000Z",
|
|
kind: "turn_error",
|
|
agentIndex: 0,
|
|
channelId: "channel-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: { outcome: "recovered", error: "first" },
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-06-30T09:00:01.000Z",
|
|
kind: "turn_error",
|
|
agentIndex: 0,
|
|
channelId: "channel-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: { outcome: "recovered", error: "second" },
|
|
},
|
|
];
|
|
|
|
const [item] = buildTranscript(events);
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.text, "recovered: first\nrecovered: second");
|
|
});
|
|
|
|
// --- permission outcome (Fix #3) ---
|
|
|
|
function makePermissionRequest(seq, requestId, turnId = "turn-1") {
|
|
return {
|
|
seq,
|
|
timestamp: "2026-06-30T10:00:00.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId: "channel-1",
|
|
sessionId: "session-1",
|
|
turnId,
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: requestId,
|
|
method: "session/request_permission",
|
|
params: {
|
|
title: "Confirm push",
|
|
toolCallId: "tool-1",
|
|
options: [
|
|
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
|
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function makePermissionResponse(seq, requestId, outcome, optionId = null) {
|
|
const resultOutcome =
|
|
outcome === "selected" ? { outcome: "selected", optionId } : { outcome };
|
|
return {
|
|
seq,
|
|
timestamp: "2026-06-30T10:00:01.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "channel-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: requestId,
|
|
result: { outcome: resultOutcome },
|
|
},
|
|
};
|
|
}
|
|
|
|
test("buildTranscript appends Approved outcome when allow_once is selected", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, "req-1"),
|
|
makePermissionResponse(2, "req-1", "selected", "allow_once"),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.equal(item.outcome, "Approved (allow_once)");
|
|
assert.doesNotMatch(item.text ?? "", /Approved/);
|
|
});
|
|
|
|
test("buildTranscript appends Denied outcome when reject_once is selected", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, "req-2"),
|
|
makePermissionResponse(2, "req-2", "selected", "reject_once"),
|
|
]);
|
|
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.outcome, "Denied (reject_once)");
|
|
assert.doesNotMatch(item.text ?? "", /Denied/);
|
|
});
|
|
|
|
test("buildTranscript appends Cancelled outcome on cancelled response", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, "req-3"),
|
|
makePermissionResponse(2, "req-3", "cancelled"),
|
|
]);
|
|
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.outcome, "Cancelled");
|
|
assert.doesNotMatch(item.text ?? "", /Cancelled/);
|
|
});
|
|
|
|
test("buildTranscript no-ops on a permission response with an unmatched id", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, "req-4"),
|
|
makePermissionResponse(2, "req-WRONG", "selected", "allow_once"),
|
|
]);
|
|
|
|
// The permission item exists but has no outcome appended — the mismatched
|
|
// response id must not crash or attach to the wrong item.
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.equal(item.outcome, undefined);
|
|
assert.doesNotMatch(item.text ?? "", /Approved/);
|
|
assert.doesNotMatch(item.text ?? "", /Denied/);
|
|
});
|
|
|
|
test("buildTranscript appends Approved outcome for a numeric JSON-RPC id (selected allow_once)", () => {
|
|
// JSON-RPC 2.0 allows numeric ids; the ACP runtime preserves them as
|
|
// serde_json::Value. asString() drops numbers, so this exercises the
|
|
// jsonRpcId() helper path that handles finite-number ids.
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, 42),
|
|
makePermissionResponse(2, 42, "selected", "allow_once"),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.equal(item.outcome, "Approved (allow_once)");
|
|
assert.doesNotMatch(item.text ?? "", /Approved/);
|
|
});
|
|
|
|
test("buildTranscript appends Cancelled outcome for a numeric JSON-RPC id (cancelled)", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, 99),
|
|
makePermissionResponse(2, 99, "cancelled"),
|
|
]);
|
|
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.outcome, "Cancelled");
|
|
assert.doesNotMatch(item.text ?? "", /Cancelled/);
|
|
});
|
|
|
|
test('buildTranscript does not collide between numeric id 1 and string id "1"', () => {
|
|
// JSON.stringify produces "1" for number 1 and "\"1\"" for string "1",
|
|
// so requests with these two different id types must NOT cross-attach.
|
|
const transcriptNumeric = buildTranscript([
|
|
makePermissionRequest(1, 1),
|
|
makePermissionResponse(2, 1, "selected", "allow_once"),
|
|
]);
|
|
const transcriptString = buildTranscript([
|
|
makePermissionRequest(1, "1"),
|
|
makePermissionResponse(2, "1", "selected", "reject_once"),
|
|
]);
|
|
|
|
assert.equal(transcriptNumeric[0].outcome, "Approved (allow_once)");
|
|
assert.equal(transcriptString[0].outcome, "Denied (reject_once)");
|
|
});
|
|
|
|
// ─── observer parity: new session/update classifier cases ────────────────────
|
|
|
|
test("buildTranscript renders current_mode_update as a lifecycle status line", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "current_mode_update",
|
|
currentModeId: "plan",
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "status");
|
|
assert.equal(item.title, "Mode");
|
|
assert.equal(item.text, "plan");
|
|
assert.equal(item.acpSource, "current_mode_update");
|
|
});
|
|
|
|
test("buildTranscript suppresses current_mode_update when currentModeId is missing", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, { sessionUpdate: "current_mode_update" }),
|
|
]);
|
|
assert.equal(transcript.length, 0);
|
|
});
|
|
|
|
test("buildTranscript renders usage_update as a lifecycle status line with tokens", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "usage_update",
|
|
used: 1500,
|
|
size: 8192,
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "status");
|
|
assert.equal(item.title, "Usage");
|
|
assert.equal(item.text, "Tokens: 1500/8192");
|
|
assert.equal(item.acpSource, "usage_update");
|
|
});
|
|
|
|
test("buildTranscript renders usage_update with cost when present", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "usage_update",
|
|
used: 800,
|
|
size: 4096,
|
|
cost: { amount: 0.0025, currency: "USD" },
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
assert.equal(transcript[0].text, "Tokens: 800/4096 ($0.0025 USD)");
|
|
});
|
|
|
|
test("buildTranscript coalesces usage_update to latest-per-turn (replace, not append)", () => {
|
|
// Three usage frames in the same turn must produce exactly ONE lifecycle item
|
|
// showing the LAST value — not an accumulation of all three.
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, { sessionUpdate: "usage_update", used: 100, size: 8192 }),
|
|
acpToolUpdate(2, { sessionUpdate: "usage_update", used: 300, size: 8192 }),
|
|
acpToolUpdate(3, { sessionUpdate: "usage_update", used: 600, size: 8192 }),
|
|
]);
|
|
|
|
const usageItems = transcript.filter((i) => i.acpSource === "usage_update");
|
|
assert.equal(usageItems.length, 1, "must coalesce to one item");
|
|
assert.equal(usageItems[0].text, "Tokens: 600/8192");
|
|
});
|
|
|
|
test("buildTranscript suppresses usage_update when used or size is missing", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, { sessionUpdate: "usage_update", used: 100 }), // size absent
|
|
acpToolUpdate(2, { sessionUpdate: "usage_update", size: 8192 }), // used absent
|
|
]);
|
|
assert.equal(transcript.length, 0);
|
|
});
|
|
|
|
test("buildTranscript renders available_commands_update as a lifecycle status line", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "available_commands_update",
|
|
availableCommands: [
|
|
{ name: "create_plan", description: "Create a plan" },
|
|
{ name: "research_codebase", description: "Research the codebase" },
|
|
],
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "status");
|
|
assert.equal(item.title, "Commands");
|
|
assert.equal(item.text, "Commands available: 2");
|
|
assert.equal(item.acpSource, "available_commands_update");
|
|
});
|
|
|
|
test("buildTranscript renders available_commands_update with zero commands", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "available_commands_update",
|
|
availableCommands: [],
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
assert.equal(transcript[0].text, "Commands available: 0");
|
|
});
|
|
|
|
test("buildTranscript renders config_option_update as a lifecycle status line", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "config_option_update",
|
|
configOptions: [
|
|
{ id: "model", name: "Model", type: "select", currentValue: "gpt-4o" },
|
|
{ id: "mode", name: "Mode", type: "select", currentValue: "auto" },
|
|
],
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "status");
|
|
assert.equal(item.title, "Config");
|
|
assert.equal(item.text, "Model = gpt-4o, Mode = auto");
|
|
assert.equal(item.acpSource, "config_option_update");
|
|
});
|
|
|
|
test("buildTranscript suppresses config_option_update when configOptions is empty", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, {
|
|
sessionUpdate: "config_option_update",
|
|
configOptions: [],
|
|
}),
|
|
]);
|
|
assert.equal(transcript.length, 0);
|
|
});
|
|
|
|
test("buildTranscript does not render keepalive (stays in else-dropped bucket)", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, { sessionUpdate: "keepalive" }),
|
|
]);
|
|
assert.equal(transcript.length, 0);
|
|
});
|
|
|
|
test("buildTranscript does not render unknown session/update types (firehose safety net)", () => {
|
|
const transcript = buildTranscript([
|
|
acpToolUpdate(1, { sessionUpdate: "some_future_type", value: 42 }),
|
|
]);
|
|
assert.equal(transcript.length, 0);
|
|
});
|
|
|
|
// --- system-prompt ordering ---
|
|
|
|
test("observer feed renders system-prompt before prompt-context in display order (first turn, realistic pool.rs sequence)", () => {
|
|
// Reproduces the real ordering bug: pool.rs emits turn_started BEFORE session/new,
|
|
// so turn_started creates the turn bucket first. Without the grouper holding the
|
|
// system-prompt as a standalone block, displayOrder becomes
|
|
// [turn(turn-1), single(system-prompt)] and System prompt renders after Prompt context.
|
|
// The fix: system-prompt items (acpSource "session/new") are held in
|
|
// pendingSystemPrompts[] and emitted as standalone blocks BEFORE the first turn.
|
|
const events = [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:00.100Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt:
|
|
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver Agent.",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
seq: 3,
|
|
timestamp: "2026-07-01T10:00:00.200Z",
|
|
kind: "session_resolved",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: { sessionId: "sess-1", isNewSession: true },
|
|
},
|
|
{
|
|
seq: 4,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: hello`,
|
|
},
|
|
{ type: "text", text: "[Thread context]\nPrior messages here." },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// Route through the display layer — this is the layer that contained the bug.
|
|
const rawItems = buildTranscript(events);
|
|
const displayItems = flattenDisplayBlocks(
|
|
buildTranscriptDisplayBlocks(rawItems),
|
|
);
|
|
const systemPromptIdx = displayItems.findIndex(
|
|
(i) => i.title === "System prompt",
|
|
);
|
|
const promptContextIdx = displayItems.findIndex(
|
|
(i) => i.title === "Prompt context",
|
|
);
|
|
assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
|
|
assert.ok(promptContextIdx !== -1, "expected a Prompt context item");
|
|
assert.ok(
|
|
systemPromptIdx < promptContextIdx,
|
|
`expected System prompt (idx ${systemPromptIdx}) before Prompt context (idx ${promptContextIdx}) in display order`,
|
|
);
|
|
// Also verify the fix input: system-prompt item must have turnId=null so the
|
|
// display grouper treats it as a standalone entry, not a turn-bucket item.
|
|
const systemPromptRawIdx = rawItems.findIndex(
|
|
(i) => i.title === "System prompt",
|
|
);
|
|
assert.equal(
|
|
rawItems[systemPromptRawIdx].turnId ?? null,
|
|
null,
|
|
"system-prompt item must have turnId=null to avoid turn-bucket grouping",
|
|
);
|
|
});
|
|
|
|
test("observer feed renders system-prompt before prompt-context in display order (multi-turn)", () => {
|
|
// On subsequent turns, session/new does not re-fire. The system-prompt standalone
|
|
// block emitted before turn-1 must not re-appear in turn-2's display output.
|
|
const events = [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:00.100Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt: "[Base]\nYou are helpful.\n\n[System]\nObserver.",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
seq: 3,
|
|
timestamp: "2026-07-01T10:00:00.200Z",
|
|
kind: "session_resolved",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: { sessionId: "sess-1", isNewSession: true },
|
|
},
|
|
{
|
|
seq: 4,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: turn 1`,
|
|
},
|
|
{ type: "text", text: "[Thread context]\nEmpty." },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
seq: 5,
|
|
timestamp: "2026-07-01T10:05:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-2",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
{
|
|
seq: 6,
|
|
timestamp: "2026-07-01T10:05:01.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-2",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 3,
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${"c".repeat(64)}\nFrom: x (hex: ${"d".repeat(64)})\nContent: turn 2`,
|
|
},
|
|
{ type: "text", text: "[Thread context]\nOne prior message." },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const rawItems = buildTranscript(events);
|
|
const displayItems = flattenDisplayBlocks(
|
|
buildTranscriptDisplayBlocks(rawItems),
|
|
);
|
|
const systemPromptIdx = displayItems.findIndex(
|
|
(i) => i.title === "System prompt",
|
|
);
|
|
// Both turns produce a Prompt context — grab the first one (turn-1).
|
|
const firstPromptContextIdx = displayItems.findIndex(
|
|
(i) => i.title === "Prompt context",
|
|
);
|
|
assert.ok(systemPromptIdx !== -1, "expected a System prompt item");
|
|
assert.ok(
|
|
firstPromptContextIdx !== -1,
|
|
"expected at least one Prompt context item",
|
|
);
|
|
assert.ok(
|
|
systemPromptIdx < firstPromptContextIdx,
|
|
`expected System prompt (idx ${systemPromptIdx}) before first Prompt context (idx ${firstPromptContextIdx}) in display order`,
|
|
);
|
|
const systemPromptRawIdx = rawItems.findIndex(
|
|
(i) => i.title === "System prompt",
|
|
);
|
|
assert.equal(
|
|
rawItems[systemPromptRawIdx].turnId ?? null,
|
|
null,
|
|
"system-prompt item must have turnId=null",
|
|
);
|
|
});
|
|
|
|
test("steer ingress bundles its prompt context into the steer prompt segment, not a standalone row", () => {
|
|
// The stray "Prompt context · 1 section" row was leaking from the goose
|
|
// session/steer path: it upserted metadata without an acpSource, so display
|
|
// grouping never consumed it into a prompt bundle. It must now ride behind
|
|
// the steer message bubble's checks-icon dialog like session/prompt context.
|
|
const events = [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 5,
|
|
method: "_goose/unstable/session/steer",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${"e".repeat(64)}\nFrom: x (hex: ${"f".repeat(64)})\nContent: steer me`,
|
|
},
|
|
{ type: "text", text: "[Thread context]\nPrior messages here." },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const rawItems = buildTranscript(events);
|
|
const steerMessage = rawItems.find((item) => item.type === "message");
|
|
const steerContext = rawItems.find((item) => item.type === "metadata");
|
|
assert.equal(steerMessage?.acpSource, "session/steer:user");
|
|
assert.equal(steerContext?.acpSource, "session/steer:context");
|
|
|
|
const [block] = buildTranscriptDisplayBlocks(rawItems);
|
|
assert.equal(block.kind, "turn");
|
|
const promptSegment = block.segments.find(
|
|
(segment) => segment.kind === "prompt",
|
|
);
|
|
assert.ok(promptSegment, "expected steer message to render a prompt bundle");
|
|
assert.equal(promptSegment.context?.id, steerContext.id);
|
|
assert.ok(
|
|
!block.segments.some(
|
|
(segment) => segment.kind === "item" && segment.item.type === "metadata",
|
|
),
|
|
"steer context must not leak as a standalone metadata row",
|
|
);
|
|
});
|
|
|
|
// --- session/prompt late delivery (live subscription timing race) ---
|
|
|
|
test("buildTranscript correctly renders prompt segment when session/prompt arrives after status lifecycle events", () => {
|
|
// Simulates the live-subscription timing race: status events (commands, mode,
|
|
// usage) arrive first because the desktop subscribed slightly after turn start,
|
|
// then session/prompt arrives later (e.g. via reconnect replay or archive
|
|
// backfill). buildTranscript is called in out-of-order sequence order but
|
|
// processTranscriptEvent handles insertion — the full rebuild path in
|
|
// appendAgentEvent (slow path for out-of-order) re-processes events sorted by
|
|
// timestamp+seq, so the prompt segment must appear.
|
|
const TURN = "turn-oot";
|
|
const SESS = "sess-oot";
|
|
const CH = "22222222-2222-2222-2222-222222222222";
|
|
const AUTHOR_HEX = "b".repeat(64);
|
|
const EVENT_HEX = "d".repeat(64);
|
|
|
|
const makeEvent = (seq, kind, timestamp, payload) => ({
|
|
seq,
|
|
kind,
|
|
timestamp,
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: SESS,
|
|
turnId: TURN,
|
|
payload,
|
|
});
|
|
|
|
// Status events arrive first (lower seq but same timestamp as prompt)
|
|
const commandsEvent = makeEvent(2, "acp_read", "2026-06-18T00:01:01Z", {
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: SESS,
|
|
update: {
|
|
sessionUpdate: "available_commands_update",
|
|
availableCommands: ["cmd1", "cmd2"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const modeEvent = makeEvent(3, "acp_read", "2026-06-18T00:01:01Z", {
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: SESS,
|
|
update: { sessionUpdate: "current_mode_update", currentModeId: "code" },
|
|
},
|
|
});
|
|
|
|
// session/prompt has the lowest seq — it was published first but arrived last
|
|
const promptEvent = makeEvent(1, "acp_write", "2026-06-18T00:01:00Z", {
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: SESS,
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${EVENT_HEX.toUpperCase()}\nFrom: Alice (hex: ${AUTHOR_HEX})\nContent: please help`,
|
|
},
|
|
{ type: "text", text: "[Context]\nScope: thread" },
|
|
],
|
|
},
|
|
});
|
|
|
|
// Deliver events out of order: status first, then prompt
|
|
const items = buildTranscript([commandsEvent, modeEvent, promptEvent]);
|
|
|
|
const userMsg = items.find((i) => i.type === "message" && i.role === "user");
|
|
assert.ok(
|
|
userMsg,
|
|
"user message item must be present even when session/prompt arrives after status events",
|
|
);
|
|
assert.equal(
|
|
userMsg.text,
|
|
"please help",
|
|
"user message text extracted from session/prompt Content: line",
|
|
);
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const turnBlock = blocks.find((b) => b.kind === "turn");
|
|
assert.ok(turnBlock, "expected a turn block");
|
|
const promptSegment = turnBlock.segments.find((s) => s.kind === "prompt");
|
|
assert.ok(
|
|
promptSegment,
|
|
"prompt segment must be present when session/prompt arrives out-of-order",
|
|
);
|
|
assert.equal(
|
|
promptSegment.user.text,
|
|
"please help",
|
|
"prompt segment carries the correct user text",
|
|
);
|
|
});
|
|
|
|
// --- session-boundary ordering: restart scenario end-to-end ─────────────────
|
|
|
|
test("buildTranscript restart sequence: both sessions retain their own system-prompt card", () => {
|
|
// Full two-session restart sequence routed through processTranscriptEvent.
|
|
// Each session/new event is keyed by (seq, timestamp) — the same dedup pair
|
|
// used by observerRelayStore — producing distinct system-prompt items for
|
|
// sess-1 and sess-2. Both must be present in the final transcript and placed
|
|
// in the correct run:
|
|
// sess-1: system-prompt → sess-1 activity (before boundary)
|
|
// sess-2: boundary → system-prompt → user-prompt → sess-2 activity
|
|
const CH = "33333333-3333-3333-3333-333333333333";
|
|
const AUTHOR_HEX = "c".repeat(64);
|
|
const USER_EVENT_HEX = "e".repeat(64);
|
|
|
|
const sess1Events = [
|
|
// sess-1 turn_started
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
// sess-1 session/new (first fire — pushes system-prompt to the stream)
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:00.100Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt:
|
|
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver.",
|
|
},
|
|
},
|
|
},
|
|
// sess-1 resolves
|
|
{
|
|
seq: 3,
|
|
timestamp: "2026-07-01T10:00:00.200Z",
|
|
kind: "session_resolved",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: { sessionId: "sess-1", isNewSession: true },
|
|
},
|
|
// sess-1 activity
|
|
{
|
|
seq: 4,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "sess-1",
|
|
update: {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call-1",
|
|
status: "completed",
|
|
title: "shell",
|
|
kind: "shell",
|
|
rawInput: { command: "echo hello" },
|
|
content: { type: "text", text: "hello" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const restartEvents = [
|
|
// Restart: turn_started with null sessionId
|
|
{
|
|
seq: 5,
|
|
timestamp: "2026-07-01T11:00:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-2",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
// sess-2 session/new: distinct (seq, timestamp) produces a separate item
|
|
// (system-prompt:${CH}:6:2026-07-01T11:00:00.100Z) that lands naturally
|
|
// at the stream tail and is re-anchored to run sess-2.
|
|
{
|
|
seq: 6,
|
|
timestamp: "2026-07-01T11:00:00.100Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-2",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt:
|
|
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver.",
|
|
},
|
|
},
|
|
},
|
|
// New session resolves
|
|
{
|
|
seq: 7,
|
|
timestamp: "2026-07-01T11:00:00.200Z",
|
|
kind: "session_resolved",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-2",
|
|
turnId: "turn-2",
|
|
payload: { sessionId: "sess-2", isNewSession: true },
|
|
},
|
|
// sess-2 user @mention prompt (production shape: a restart is triggered by
|
|
// a user message; this is what surfaced the original bug in the screenshot).
|
|
{
|
|
seq: 8,
|
|
timestamp: "2026-07-01T11:00:00.300Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-2",
|
|
turnId: "turn-2",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 3,
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-2",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${USER_EVENT_HEX.toUpperCase()}\nFrom: Will (hex: ${AUTHOR_HEX})\nContent: @Paul status check? I had to restart`,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
// sess-2 activity
|
|
{
|
|
seq: 9,
|
|
timestamp: "2026-07-01T11:00:01.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-2",
|
|
turnId: "turn-2",
|
|
payload: {
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "sess-2",
|
|
update: {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call-2",
|
|
status: "completed",
|
|
title: "shell",
|
|
kind: "shell",
|
|
rawInput: { command: "echo world" },
|
|
content: { type: "text", text: "world" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const items = buildTranscript([...sess1Events, ...restartEvents]);
|
|
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
|
|
|
|
// (a) Exactly one session-boundary block between the two sessions.
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
1,
|
|
"exactly one session-boundary block for a two-session restart",
|
|
);
|
|
|
|
const boundaryIdx = blocks.indexOf(boundaryBlocks[0]);
|
|
|
|
// (b) Exactly two system-prompt standalone blocks — one per session.
|
|
const systemPromptBlocks = blocks.filter(
|
|
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
|
|
);
|
|
assert.equal(
|
|
systemPromptBlocks.length,
|
|
2,
|
|
"must be exactly two system-prompt standalone blocks — one per session",
|
|
);
|
|
const sess1PromptIdx = blocks.indexOf(systemPromptBlocks[0]);
|
|
const sess2PromptIdx = blocks.indexOf(systemPromptBlocks[1]);
|
|
|
|
// sess-1 system-prompt appears BEFORE the boundary (in run sess-1).
|
|
assert.ok(
|
|
sess1PromptIdx < boundaryIdx,
|
|
`sess-1 system-prompt (idx ${sess1PromptIdx}) must precede boundary (idx ${boundaryIdx})`,
|
|
);
|
|
// sess-2 system-prompt appears AFTER the boundary (in run sess-2).
|
|
assert.ok(
|
|
boundaryIdx < sess2PromptIdx,
|
|
`boundary (idx ${boundaryIdx}) must precede sess-2 system-prompt (idx ${sess2PromptIdx})`,
|
|
);
|
|
|
|
// (c) sess-2 activity must appear AFTER both boundary AND sess-2 system-prompt.
|
|
// Required order: boundary → sess-2 system-prompt → sess-2 user-prompt/activity.
|
|
// Note: the system-prompt item retains sessionId=null (it was created from a
|
|
// null-session event); we locate it in the flat array by its item id, not sessionId.
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
const sess2SpItemId = systemPromptBlocks[1].item?.id;
|
|
const flatSess2PromptIdx = flat.findIndex((i) => i.id === sess2SpItemId);
|
|
const flatSess2ActivityIdx = flat.findIndex(
|
|
(i) => i.type === "tool" && i.sessionId === "sess-2",
|
|
);
|
|
assert.ok(
|
|
flatSess2PromptIdx !== -1,
|
|
"sess-2 system-prompt must appear in flattened output",
|
|
);
|
|
assert.ok(
|
|
flatSess2ActivityIdx !== -1,
|
|
"sess-2 tool activity must be present in flattened output",
|
|
);
|
|
assert.ok(
|
|
flatSess2PromptIdx < flatSess2ActivityIdx,
|
|
`sess-2 system-prompt (flat idx ${flatSess2PromptIdx}) must precede sess-2 activity (flat idx ${flatSess2ActivityIdx})`,
|
|
);
|
|
});
|
|
|
|
// --- same-seq different-timestamp: both system-prompt cards survive (archive collision) ──
|
|
|
|
test("buildTranscript same-seq different-timestamp session/new events both produce distinct system-prompt cards", () => {
|
|
// Archive rebuild scenario: two ObserverHandle processes both start at seq=1
|
|
// for the same channel. The (seq, timestamp) key pair must distinguish them
|
|
// so neither card is lost. If keyed by seq alone, the second would silently
|
|
// replace the first.
|
|
const CH = "55555555-5555-5555-5555-555555555555";
|
|
const events = [
|
|
// Process A: seq=1, timestamp T1
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-a",
|
|
turnId: "turn-a",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt: "[Base]\nProcess A.",
|
|
},
|
|
},
|
|
},
|
|
// Process B: seq=1 (same!), timestamp T2 (different)
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T11:00:00.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-b",
|
|
turnId: "turn-b",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt: "[Base]\nProcess B.",
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const rawItems = buildTranscript(events);
|
|
const systemPromptItems = rawItems.filter(
|
|
(i) => i.type === "metadata" && i.acpSource === "session/new",
|
|
);
|
|
|
|
assert.equal(
|
|
systemPromptItems.length,
|
|
2,
|
|
"two same-seq different-timestamp session/new events must produce two distinct system-prompt items — not one (key collision guard)",
|
|
);
|
|
|
|
const bodies = systemPromptItems.map((i) =>
|
|
(i.sections ?? []).map((s) => s.body).join("|"),
|
|
);
|
|
assert.ok(
|
|
bodies.some((b) => b.includes("Process A")),
|
|
"Process A system-prompt card must be present",
|
|
);
|
|
assert.ok(
|
|
bodies.some((b) => b.includes("Process B")),
|
|
"Process B system-prompt card must be present",
|
|
);
|
|
});
|
|
|
|
test("buildTranscript five-section system prompt card is standalone with all sections; CheckCheck context contains only Buzz/thread context", () => {
|
|
// Production scenario: team-pack agent harness emits
|
|
// [Base]/[System (with team delimiter)]/[Agent Memory — core]/[Channel Canvas]
|
|
// in systemPrompt. The display layer must:
|
|
// (a) Render it as a standalone single block (acpSource "session/new"),
|
|
// NOT inside any turn's prompt bundle.
|
|
// (b) The standalone item must carry all five sections in order:
|
|
// Base → System → Team Instructions → Core Memory → Channel Canvas.
|
|
// (c) The prompt segment's context (CheckCheck dialog) must contain only
|
|
// the session/prompt:context sections (Buzz event + Thread context),
|
|
// never the system-prompt sections.
|
|
const CH = "44444444-4444-4444-4444-444444444444";
|
|
const events = [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:00.100Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt: [
|
|
"[Base]",
|
|
"You are a helpful assistant.",
|
|
"",
|
|
"[System]",
|
|
"Custom persona.",
|
|
"",
|
|
"---",
|
|
"# Team Instructions",
|
|
"Always tag on handoff.",
|
|
"",
|
|
"[Agent Memory — core]",
|
|
"I am Duncan.",
|
|
"",
|
|
"[Channel Canvas]",
|
|
"Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
|
"Last modified: 2026-07-01T10:00:00Z",
|
|
"Fetch current content with: buzz canvas get --channel 44444444-4444-4444-4444-444444444444",
|
|
].join("\n"),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
seq: 3,
|
|
timestamp: "2026-07-01T10:00:00.200Z",
|
|
kind: "session_resolved",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-e2e",
|
|
turnId: "turn-1",
|
|
payload: { sessionId: "sess-e2e", isNewSession: true },
|
|
},
|
|
{
|
|
seq: 4,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-e2e",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-e2e",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: hello`,
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "[Thread context]\nPrior messages here.",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const rawItems = buildTranscript(events);
|
|
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
|
|
// (a) Exactly one standalone system-prompt single block.
|
|
const systemPromptBlocks = blocks.filter(
|
|
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
|
|
);
|
|
assert.equal(
|
|
systemPromptBlocks.length,
|
|
1,
|
|
"exactly one standalone system-prompt single block",
|
|
);
|
|
|
|
// (b) The standalone item carries all five sections in order.
|
|
const spItem = systemPromptBlocks[0].item;
|
|
assert.ok(spItem, "system-prompt block must have an item");
|
|
const titles = (spItem.sections ?? []).map((s) => s.title);
|
|
assert.deepEqual(
|
|
titles,
|
|
["Base", "System", "Team Instructions", "Core Memory", "Channel Canvas"],
|
|
"system-prompt standalone card must carry Base → System → Team Instructions → Core Memory → Channel Canvas in order",
|
|
);
|
|
|
|
// (c) The system-prompt item must NOT be inside any turn group.
|
|
// Verify by checking that no turn block contains it.
|
|
const systemPromptInTurnBlock = blocks.some(
|
|
(b) =>
|
|
b.kind === "turn" &&
|
|
b.segments.some((seg) =>
|
|
seg.kind === "prompt"
|
|
? seg.user.acpSource === "session/new" ||
|
|
seg.context?.acpSource === "session/new"
|
|
: seg.kind === "item"
|
|
? seg.item.acpSource === "session/new"
|
|
: false,
|
|
),
|
|
);
|
|
assert.ok(
|
|
!systemPromptInTurnBlock,
|
|
"system-prompt must not appear inside any turn block",
|
|
);
|
|
|
|
// (d) CheckCheck context (prompt segment's context field) must contain only
|
|
// the session/prompt:context item — Buzz/thread context only, no system-prompt sections.
|
|
const promptContextItem = flat.find(
|
|
(i) => i.acpSource === "session/prompt:context",
|
|
);
|
|
assert.ok(
|
|
promptContextItem,
|
|
"prompt context item (acpSource session/prompt:context) must be present",
|
|
);
|
|
const contextSectionTitles = (promptContextItem.sections ?? []).map(
|
|
(s) => s.title,
|
|
);
|
|
// Must have Buzz event and Thread context sections, NOT Base/System/Team Instructions/Core Memory/Channel Canvas.
|
|
assert.ok(
|
|
contextSectionTitles.some((t) => t.toLowerCase().includes("buzz")),
|
|
"prompt context must contain a Buzz event section",
|
|
);
|
|
assert.ok(
|
|
!contextSectionTitles.some(
|
|
(t) =>
|
|
t === "Base" ||
|
|
t === "System" ||
|
|
t === "Team Instructions" ||
|
|
t === "Core Memory" ||
|
|
t === "Channel Canvas",
|
|
),
|
|
"prompt context must NOT contain system-prompt sections (Base/System/Team Instructions/Core Memory/Channel Canvas)",
|
|
);
|
|
});
|
|
|
|
// --- claude-agent-acp _meta.systemPrompt.append transport ---
|
|
|
|
test("buildTranscript session/new via _meta.systemPrompt.append produces identical standalone card as bare systemPrompt field", () => {
|
|
// claude-agent-acp delivers the system prompt at _meta.systemPrompt.append
|
|
// instead of the bare systemPrompt field. The observer must extract it and
|
|
// build the identical standalone card (same five sections, same acpSource,
|
|
// same turnId: null, same placement before the first turn).
|
|
const CH = "55555555-5555-5555-5555-555555555555";
|
|
const SYSTEM_PROMPT = [
|
|
"[Base]",
|
|
"You are a helpful assistant.",
|
|
"",
|
|
"[System]",
|
|
"Custom persona.",
|
|
"",
|
|
"---",
|
|
"# Team Instructions",
|
|
"Always tag on handoff.",
|
|
"",
|
|
"[Agent Memory \u2014 core]",
|
|
"I am Duncan.",
|
|
"",
|
|
"[Channel Canvas]",
|
|
"Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
|
"Last modified: 2026-07-01T10:00:00Z",
|
|
"Fetch current content with: buzz canvas get --channel 55555555-5555-5555-5555-555555555555",
|
|
].join("\n");
|
|
|
|
const makeEvents = (params) => [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "turn_started",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: { source: "channel", triggeringEventIds: [] },
|
|
},
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:00.100Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: null,
|
|
turnId: "turn-1",
|
|
payload: { jsonrpc: "2.0", id: 1, method: "session/new", params },
|
|
},
|
|
{
|
|
seq: 3,
|
|
timestamp: "2026-07-01T10:00:00.200Z",
|
|
kind: "session_resolved",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-cc",
|
|
turnId: "turn-1",
|
|
payload: { sessionId: "sess-cc", isNewSession: true },
|
|
},
|
|
{
|
|
seq: 4,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-cc",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "session/prompt",
|
|
params: {
|
|
sessionId: "sess-cc",
|
|
prompt: [
|
|
{
|
|
type: "text",
|
|
text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: hello`,
|
|
},
|
|
{ type: "text", text: "[Thread context]\nPrior messages here." },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// Build transcript from the claude-agent-acp _meta transport.
|
|
const metaEvents = makeEvents({
|
|
_meta: { systemPrompt: { append: SYSTEM_PROMPT } },
|
|
});
|
|
const metaRaw = buildTranscript(metaEvents);
|
|
const metaBlocks = buildTranscriptDisplayBlocks(metaRaw);
|
|
const metaFlat = flattenDisplayBlocks(metaBlocks);
|
|
|
|
// Also build from the standard bare-field transport for comparison.
|
|
const fieldEvents = makeEvents({ systemPrompt: SYSTEM_PROMPT });
|
|
const fieldRaw = buildTranscript(fieldEvents);
|
|
const fieldBlocks = buildTranscriptDisplayBlocks(fieldRaw);
|
|
|
|
// (a) Both produce exactly one standalone system-prompt single block.
|
|
const metaSPBlocks = metaBlocks.filter(
|
|
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
|
|
);
|
|
const fieldSPBlocks = fieldBlocks.filter(
|
|
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
|
|
);
|
|
assert.equal(
|
|
metaSPBlocks.length,
|
|
1,
|
|
"_meta: exactly one standalone system-prompt block",
|
|
);
|
|
assert.equal(
|
|
fieldSPBlocks.length,
|
|
1,
|
|
"field: exactly one standalone system-prompt block",
|
|
);
|
|
|
|
// (b) Both carry the same five ordered sections.
|
|
const EXPECTED_TITLES = [
|
|
"Base",
|
|
"System",
|
|
"Team Instructions",
|
|
"Core Memory",
|
|
"Channel Canvas",
|
|
];
|
|
const metaTitles = (metaSPBlocks[0].item?.sections ?? []).map((s) => s.title);
|
|
const fieldTitles = (fieldSPBlocks[0].item?.sections ?? []).map(
|
|
(s) => s.title,
|
|
);
|
|
assert.deepEqual(
|
|
metaTitles,
|
|
EXPECTED_TITLES,
|
|
"_meta: five sections in order",
|
|
);
|
|
assert.deepEqual(
|
|
fieldTitles,
|
|
EXPECTED_TITLES,
|
|
"field: five sections in order",
|
|
);
|
|
|
|
// (c) System prompt appears before Prompt context in both display orders.
|
|
const metaSPIdx = metaFlat.findIndex((i) => i.title === "System prompt");
|
|
const metaPCIdx = metaFlat.findIndex((i) => i.title === "Prompt context");
|
|
assert.ok(metaSPIdx !== -1, "_meta: System prompt item present");
|
|
assert.ok(metaPCIdx !== -1, "_meta: Prompt context item present");
|
|
assert.ok(
|
|
metaSPIdx < metaPCIdx,
|
|
`_meta: System prompt (${metaSPIdx}) must precede Prompt context (${metaPCIdx})`,
|
|
);
|
|
|
|
// (d) The _meta item has turnId: null (standalone, not in a turn bucket).
|
|
const metaSPRawIdx = metaRaw.findIndex((i) => i.title === "System prompt");
|
|
assert.equal(
|
|
metaRaw[metaSPRawIdx]?.turnId ?? null,
|
|
null,
|
|
"_meta: system-prompt item must have turnId=null",
|
|
);
|
|
});
|
|
|
|
test("buildTranscript session/new bare systemPrompt field takes precedence over _meta.systemPrompt.append", () => {
|
|
// When both transports are present (non-standard but must not regress),
|
|
// the standard bare field must win — a reversed ?? would silently use the
|
|
// wrong text and the card body would differ from the wire source of truth.
|
|
const CH = "66666666-6666-6666-6666-666666666666";
|
|
const events = [
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "acp_write",
|
|
agentIndex: 0,
|
|
channelId: CH,
|
|
sessionId: "sess-both",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "session/new",
|
|
params: {
|
|
systemPrompt: "[Base]\nWinner.",
|
|
_meta: { systemPrompt: { append: "[Base]\nLoser." } },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const rawItems = buildTranscript(events);
|
|
const spItem = rawItems.find((i) => i.title === "System prompt");
|
|
assert.ok(spItem, "System prompt item must be present");
|
|
const bodies = (spItem.sections ?? []).map((s) => s.body).join("|");
|
|
assert.ok(
|
|
bodies.includes("Winner"),
|
|
"bare systemPrompt must win over _meta.systemPrompt.append",
|
|
);
|
|
assert.ok(
|
|
!bodies.includes("Loser"),
|
|
"_meta.systemPrompt.append must not appear when bare field is present",
|
|
);
|
|
});
|
|
|
|
// ── authorization envelope + nonce-keyed cards ────────────────────────────────
|
|
|
|
/** Build an acp_read permission event with a full authorization envelope. */
|
|
function makePermissionRequestWithAuth(
|
|
seq,
|
|
requestId,
|
|
nonce,
|
|
{ actionable = true, reason, turnId = "turn-1", channelId = "ch-1" } = {},
|
|
) {
|
|
return {
|
|
seq,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId,
|
|
sessionId: "session-1",
|
|
turnId,
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: requestId,
|
|
method: "session/request_permission",
|
|
params: {
|
|
title: "Confirm push",
|
|
toolCallId: "tool-1",
|
|
options: [
|
|
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
|
{ optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
|
],
|
|
},
|
|
},
|
|
authorization: { requestNonce: nonce, actionable, reason },
|
|
};
|
|
}
|
|
|
|
test("buildTranscript_nonce_keyed_card_is_actionable_with_options", () => {
|
|
// An acp_read with an authorization envelope should produce one card
|
|
// keyed by nonce, with actionable=true and the parsed options attached.
|
|
const transcript = buildTranscript([
|
|
makePermissionRequestWithAuth(1, "req-n1", "nonce-abc"),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.type, "lifecycle");
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.equal(item.requestNonce, "nonce-abc");
|
|
assert.equal(item.actionable, true);
|
|
assert.equal(item.channelId, "ch-1");
|
|
assert.ok(Array.isArray(item.options));
|
|
assert.equal(item.options.length, 2);
|
|
assert.equal(item.options[0].optionId, "allow_once");
|
|
// Card is keyed by nonce, not by turn.
|
|
assert.ok(
|
|
item.id.includes("nonce-abc"),
|
|
`expected nonce in id, got ${item.id}`,
|
|
);
|
|
});
|
|
|
|
test("buildTranscript_actionable_false_envelope_produces_read_only_card", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequestWithAuth(1, "req-n2", "nonce-readonly", {
|
|
actionable: false,
|
|
reason: "auto-rejected: reject policy",
|
|
}),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.actionable, false);
|
|
assert.equal(item.authorizationReason, "auto-rejected: reject policy");
|
|
});
|
|
|
|
test("buildTranscript_concurrent_requests_same_turn_produce_separate_cards", () => {
|
|
// Two permission requests in the same turn with different nonces must each
|
|
// get their own card — nonce is the unique key.
|
|
const transcript = buildTranscript([
|
|
makePermissionRequestWithAuth(1, "req-c1", "nonce-c1", {
|
|
turnId: "turn-1",
|
|
}),
|
|
makePermissionRequestWithAuth(2, "req-c2", "nonce-c2", {
|
|
turnId: "turn-1",
|
|
}),
|
|
]);
|
|
|
|
// Two distinct cards.
|
|
const cards = transcript.filter((i) => i.renderClass === "permission");
|
|
assert.equal(cards.length, 2, "expected two separate permission cards");
|
|
const nonces = cards.map((c) => c.requestNonce).sort();
|
|
assert.deepEqual(nonces, ["nonce-c1", "nonce-c2"]);
|
|
// Each card id is unique.
|
|
assert.notEqual(cards[0].id, cards[1].id);
|
|
});
|
|
|
|
test("buildTranscript_without_auth_envelope_falls_back_to_turn_keyed_card", () => {
|
|
// A permission request without an authorization envelope (legacy / reject
|
|
// policy path) still produces a card using the turn-based key.
|
|
const transcript = buildTranscript([
|
|
{
|
|
seq: 1,
|
|
timestamp: "2026-07-01T10:00:00.000Z",
|
|
kind: "acp_read",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-legacy",
|
|
payload: {
|
|
jsonrpc: "2.0",
|
|
id: "req-leg",
|
|
method: "session/request_permission",
|
|
params: {
|
|
title: "Confirm push",
|
|
toolCallId: "tool-1",
|
|
options: [
|
|
{ optionId: "allow_once", kind: "allow_once", name: "Allow" },
|
|
],
|
|
},
|
|
},
|
|
// No authorization field.
|
|
},
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.equal(item.requestNonce, undefined);
|
|
assert.equal(item.actionable, undefined);
|
|
// Fall-back key uses turn id.
|
|
assert.ok(
|
|
item.id.includes("turn-legacy"),
|
|
`expected turn id in fallback key, got ${item.id}`,
|
|
);
|
|
});
|
|
|
|
test("buildTranscript_uncertain_outcome_uses_pinned_copy", () => {
|
|
// The 'uncertain' terminal state must use the verbatim pinned copy, never
|
|
// "denied" or "failed closed".
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, "req-unc"),
|
|
makePermissionResponse(2, "req-unc", "uncertain"),
|
|
]);
|
|
|
|
assert.equal(transcript.length, 1);
|
|
const item = transcript[0];
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.match(
|
|
item.outcome ?? "",
|
|
/Approval outcome unknown.*agent process stopped/i,
|
|
"uncertain must use the pinned copy",
|
|
);
|
|
// Must not use 'denied' or 'failed closed'.
|
|
assert.doesNotMatch(item.outcome ?? "", /denied/i);
|
|
assert.doesNotMatch(item.outcome ?? "", /failed closed/i);
|
|
});
|
|
|
|
test("buildTranscript_timed_out_outcome_renders_correctly", () => {
|
|
const transcript = buildTranscript([
|
|
makePermissionRequest(1, "req-to"),
|
|
makePermissionResponse(2, "req-to", "timed_out"),
|
|
]);
|
|
|
|
const item = transcript[0];
|
|
assert.equal(item.renderClass, "permission");
|
|
assert.ok(item.outcome, "timed_out should produce an outcome string");
|
|
assert.doesNotMatch(item.outcome ?? "", /Approved/i);
|
|
});
|
|
|
|
test("buildTranscript_nonce_card_channelId_is_threaded_from_event", () => {
|
|
// The channelId on the card must come from the event, not a hard-coded value,
|
|
// so PermissionDecisionButtons can pass it to sendPermissionDecision.
|
|
const transcript = buildTranscript([
|
|
makePermissionRequestWithAuth(1, "req-ch", "nonce-ch", {
|
|
channelId: "specific-channel-id",
|
|
}),
|
|
]);
|
|
|
|
const item = transcript[0];
|
|
assert.equal(item.channelId, "specific-channel-id");
|
|
});
|
|
|
|
test("buildTranscript_control_result_non_sent_marks_card_delivery_failed", () => {
|
|
// A `control_result` with non-`sent` status must set deliveryFailed on the
|
|
// matching card so PermissionDecisionButtons can re-enable buttons for retry.
|
|
const nonce = "nonce-delivery-fail";
|
|
const events = [
|
|
// First: the permission request that creates the card.
|
|
makePermissionRequestWithAuth(1, "req-df", nonce),
|
|
// Second: a control_result with non-sent status.
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "control_result",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
type: "permission_decision",
|
|
status: "no_active_turn",
|
|
requestNonce: nonce,
|
|
optionId: "allow_once",
|
|
},
|
|
},
|
|
];
|
|
const transcript = buildTranscript(events);
|
|
|
|
const card = transcript.find(
|
|
(i) => i.renderClass === "permission" && i.requestNonce === nonce,
|
|
);
|
|
assert.ok(card, "permission card must exist");
|
|
assert.equal(
|
|
card.deliveryFailed,
|
|
1,
|
|
"deliveryFailed must be 1 after first non-sent control_result",
|
|
);
|
|
// Card must still be actionable so the user can retry.
|
|
assert.equal(
|
|
card.actionable,
|
|
true,
|
|
"card must remain actionable after delivery failure",
|
|
);
|
|
});
|
|
|
|
test("buildTranscript_control_result_second_failure_increments_delivery_failed", () => {
|
|
// A second non-`sent` control_result must increment deliveryFailed so the
|
|
// useEffect([deliveryFailed]) dependency in PermissionDecisionButtons
|
|
// re-fires and re-enables the buttons for a second retry attempt.
|
|
const nonce = "nonce-delivery-fail-2";
|
|
const events = [
|
|
makePermissionRequestWithAuth(1, "req-df2", nonce),
|
|
// First failure.
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "control_result",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
type: "permission_decision",
|
|
status: "no_active_turn",
|
|
requestNonce: nonce,
|
|
optionId: "allow_once",
|
|
},
|
|
},
|
|
// Second failure (user retried; harness still unavailable).
|
|
{
|
|
seq: 3,
|
|
timestamp: "2026-07-01T10:00:02.000Z",
|
|
kind: "control_result",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
type: "permission_decision",
|
|
status: "channel_closed",
|
|
requestNonce: nonce,
|
|
optionId: "allow_once",
|
|
},
|
|
},
|
|
];
|
|
const transcript = buildTranscript(events);
|
|
|
|
const card = transcript.find(
|
|
(i) => i.renderClass === "permission" && i.requestNonce === nonce,
|
|
);
|
|
assert.ok(card, "permission card must exist");
|
|
assert.equal(
|
|
card.deliveryFailed,
|
|
2,
|
|
"deliveryFailed must be 2 after two non-sent control_results — each failure must increment the token",
|
|
);
|
|
assert.equal(
|
|
card.actionable,
|
|
true,
|
|
"card must remain actionable after second delivery failure",
|
|
);
|
|
});
|
|
|
|
test("buildTranscript_control_result_sent_does_not_mark_delivery_failed", () => {
|
|
// A `control_result` with `sent` status must NOT set deliveryFailed — the
|
|
// click reached the harness successfully.
|
|
const nonce = "nonce-delivery-ok";
|
|
const events = [
|
|
makePermissionRequestWithAuth(1, "req-ok", nonce),
|
|
{
|
|
seq: 2,
|
|
timestamp: "2026-07-01T10:00:01.000Z",
|
|
kind: "control_result",
|
|
agentIndex: 0,
|
|
channelId: "ch-1",
|
|
sessionId: "session-1",
|
|
turnId: "turn-1",
|
|
payload: {
|
|
type: "permission_decision",
|
|
status: "sent",
|
|
requestNonce: nonce,
|
|
optionId: "allow_once",
|
|
},
|
|
},
|
|
];
|
|
const transcript = buildTranscript(events);
|
|
|
|
const card = transcript.find(
|
|
(i) => i.renderClass === "permission" && i.requestNonce === nonce,
|
|
);
|
|
assert.ok(card, "permission card must exist");
|
|
assert.equal(
|
|
card.deliveryFailed,
|
|
undefined,
|
|
"deliveryFailed must not be set on sent control_result",
|
|
);
|
|
});
|