mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
1876 lines
64 KiB
JavaScript
1876 lines
64 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildTranscriptDisplayBlocks,
|
|
flattenDisplayBlocks,
|
|
formatTurnSetupLabel,
|
|
getDisplayBlockKey,
|
|
} from "./agentSessionTranscriptGrouping.ts";
|
|
import { isObserverEventAfter } from "../observerRelayStore.ts";
|
|
|
|
const baseTimestamp = "2026-06-14T22:20:23.000Z";
|
|
|
|
function lifecycle(id, title, acpSource, turnId, text = "") {
|
|
return {
|
|
id,
|
|
type: "lifecycle",
|
|
title,
|
|
text,
|
|
timestamp: baseTimestamp,
|
|
acpSource,
|
|
turnId,
|
|
sessionId: "sess-1",
|
|
channelId: "channel-1",
|
|
};
|
|
}
|
|
|
|
function userPrompt(id, text, turnId) {
|
|
return {
|
|
id,
|
|
type: "message",
|
|
role: "user",
|
|
title: "Buzz event",
|
|
text,
|
|
timestamp: baseTimestamp,
|
|
acpSource: "session/prompt:user",
|
|
turnId,
|
|
sessionId: "sess-1",
|
|
channelId: "channel-1",
|
|
};
|
|
}
|
|
|
|
function promptContext(id, turnId) {
|
|
return {
|
|
id,
|
|
type: "metadata",
|
|
title: "Prompt context",
|
|
sections: [{ title: "Channel", body: "general" }],
|
|
timestamp: baseTimestamp,
|
|
acpSource: "session/prompt:context",
|
|
turnId,
|
|
sessionId: "sess-1",
|
|
channelId: "channel-1",
|
|
};
|
|
}
|
|
|
|
function assistantMessage(id, text, turnId) {
|
|
return {
|
|
id,
|
|
type: "message",
|
|
role: "assistant",
|
|
title: "Assistant",
|
|
text,
|
|
timestamp: "2026-06-14T22:20:47.000Z",
|
|
acpSource: "agent_message_chunk",
|
|
turnId,
|
|
sessionId: "sess-1",
|
|
channelId: "channel-1",
|
|
};
|
|
}
|
|
|
|
function toolCall(id, turnId) {
|
|
return {
|
|
id,
|
|
type: "tool",
|
|
title: "Shell",
|
|
toolName: "buzz-dev-mcp__shell",
|
|
buzzToolName: null,
|
|
status: "completed",
|
|
args: {},
|
|
result: "ok",
|
|
isError: false,
|
|
timestamp: "2026-06-14T22:20:47.000Z",
|
|
startedAt: "2026-06-14T22:20:47.000Z",
|
|
completedAt: "2026-06-14T22:20:47.400Z",
|
|
acpSource: "tool_call_update",
|
|
turnId,
|
|
sessionId: "sess-1",
|
|
channelId: "channel-1",
|
|
};
|
|
}
|
|
|
|
test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => {
|
|
const rawItems = [
|
|
lifecycle(
|
|
"turn",
|
|
"Turn started",
|
|
"turn_started",
|
|
"turn-1",
|
|
"Triggered by 1 event.",
|
|
),
|
|
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
|
userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"),
|
|
promptContext("context", "turn-1"),
|
|
assistantMessage("assistant", "Thinking out loud.", "turn-1"),
|
|
toolCall("tool", "turn-1"),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
|
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
|
|
|
|
assert.deepEqual(displayOrder, [
|
|
"prompt",
|
|
"turn",
|
|
"session",
|
|
"context",
|
|
"assistant",
|
|
"tool",
|
|
]);
|
|
|
|
const turnBlock = blocks[0];
|
|
assert.equal(turnBlock?.kind, "turn");
|
|
assert.equal(turnBlock.segments[0]?.kind, "prompt");
|
|
const promptSegment = turnBlock.segments[0];
|
|
assert.equal(promptSegment.user.id, "prompt");
|
|
assert.equal(promptSegment.context?.id, "context");
|
|
assert.equal(promptSegment.setup.length, 2);
|
|
assert.equal(turnBlock.segments[1]?.kind, "item");
|
|
assert.equal(turnBlock.segments[2]?.kind, "item");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => {
|
|
const rawItems = [
|
|
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
|
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
|
userPrompt("prompt", "hello", "turn-1"),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
|
assert.equal(blocks.length, 1);
|
|
assert.equal(blocks[0]?.kind, "turn");
|
|
|
|
const turnBlock = blocks[0];
|
|
assert.equal(turnBlock.segments.length, 1);
|
|
assert.equal(turnBlock.segments[0]?.kind, "prompt");
|
|
assert.equal(
|
|
formatTurnSetupLabel(turnBlock.segments[0].setup),
|
|
"Turn started · Session ready",
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks hides setup and context when prompt is missing", () => {
|
|
const rawItems = [
|
|
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
|
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
|
promptContext("context", "turn-1"),
|
|
toolCall("tool", "turn-1"),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
|
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
|
|
|
|
assert.deepEqual(displayOrder, ["tool"]);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks drops setup-and-context-only turns", () => {
|
|
const rawItems = [
|
|
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
|
lifecycle("session", "Session ready", "session_resolved", "turn-1"),
|
|
promptContext("context", "turn-1"),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
|
|
|
assert.deepEqual(blocks, []);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => {
|
|
const rawItems = [
|
|
lifecycle("turn", "Turn started", "turn_started", "turn-1"),
|
|
userPrompt("prompt", "hello", "turn-1"),
|
|
lifecycle(
|
|
"error",
|
|
"Turn error",
|
|
"turn_error",
|
|
"turn-1",
|
|
"timeout: agent hung",
|
|
),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(rawItems);
|
|
const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id);
|
|
|
|
assert.deepEqual(displayOrder, ["prompt", "turn", "error"]);
|
|
assert.equal(blocks[0]?.segments[0]?.kind, "prompt");
|
|
assert.equal(blocks[0]?.segments[1]?.kind, "item");
|
|
assert.equal(blocks[0]?.segments[1]?.item.id, "error");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks passes through items without turnId", () => {
|
|
const orphan = {
|
|
id: "orphan",
|
|
type: "lifecycle",
|
|
title: "Wire parse error",
|
|
text: "bad json",
|
|
timestamp: baseTimestamp,
|
|
acpSource: "acp_parse_error",
|
|
channelId: "channel-1",
|
|
};
|
|
|
|
const blocks = buildTranscriptDisplayBlocks([orphan]);
|
|
assert.equal(blocks.length, 1);
|
|
assert.equal(blocks[0]?.kind, "single");
|
|
assert.equal(blocks[0]?.item.id, "orphan");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", () => {
|
|
const items = [1, 2, 3].map((index) => ({
|
|
id: `tool:${index}`,
|
|
type: "tool",
|
|
renderClass: "file-read",
|
|
descriptor: {
|
|
renderClass: "file-read",
|
|
label: "Read file",
|
|
preview: `file-${index}.ts`,
|
|
groupKey: "read_file",
|
|
},
|
|
title: "read_file",
|
|
toolName: "read_file",
|
|
buzzToolName: null,
|
|
status: "completed",
|
|
args: { path: `file-${index}.ts` },
|
|
result: "",
|
|
isError: false,
|
|
timestamp: "2026-06-18T00:00:00Z",
|
|
startedAt: "2026-06-18T00:00:00Z",
|
|
completedAt: "2026-06-18T00:00:01Z",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
}));
|
|
|
|
const [block] = buildTranscriptDisplayBlocks(items);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.equal(block.segments.length, 1);
|
|
assert.equal(block.segments[0].kind, "summary");
|
|
assert.equal(block.segments[0].summary.label, "Read 3 files");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () => {
|
|
const items = [1, 2].map((index) => ({
|
|
id: `edit:${index}`,
|
|
type: "tool",
|
|
renderClass: "file-edit",
|
|
descriptor: {
|
|
renderClass: "file-edit",
|
|
label: "Edited file",
|
|
preview: `src/file-${index}.ts`,
|
|
groupKey: "file-edit:str_replace",
|
|
},
|
|
title: "str_replace",
|
|
toolName: "str_replace",
|
|
buzzToolName: null,
|
|
status: "completed",
|
|
args: { path: `src/file-${index}.ts` },
|
|
result: "",
|
|
isError: false,
|
|
timestamp: "2026-06-18T00:00:00Z",
|
|
startedAt: "2026-06-18T00:00:00Z",
|
|
completedAt: "2026-06-18T00:00:01Z",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
}));
|
|
|
|
const [block] = buildTranscriptDisplayBlocks(items);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.equal(block.segments.length, 1);
|
|
assert.equal(block.segments[0].kind, "summary");
|
|
assert.equal(block.segments[0].summary.label, "Edited 2 files");
|
|
assert.equal(block.segments[0].summary.renderClass, "file-edit");
|
|
assert.deepEqual(
|
|
block.segments[0].summary.items.map((item) => item.id),
|
|
["edit:1", "edit:2"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks groups mixed consecutive eligible tool runs", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
mkTool("read-2", "Read file", "file-read", "read_file"),
|
|
mkTool("read-3", "Read file", "file-read", "read_file"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.equal(block.segments.length, 1);
|
|
assert.equal(block.segments[0].kind, "summary");
|
|
assert.equal(block.segments[0].summary.variant, "mixed");
|
|
assert.equal(block.segments[0].summary.label, "Ran 4 tool calls");
|
|
assert.deepEqual(
|
|
block.segments[0].summary.items.map((item) => item.id),
|
|
["read-1", "shell-1", "read-2", "read-3"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks groups tool bursts at threshold 2", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.equal(block.segments.length, 1);
|
|
assert.equal(block.segments[0].kind, "summary");
|
|
assert.equal(block.segments[0].summary.variant, "mixed");
|
|
assert.equal(block.segments[0].summary.label, "Ran 2 tool calls");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks keeps a lone eligible tool row expanded", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["item"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks nests same-kind summaries inside tool bursts", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("read-2", "Read file", "file-read", "read_file"),
|
|
mkTool("read-3", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
mkTool("skill-1", "Read skill", "skill-read", "skill:load"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.equal(block.segments.length, 1);
|
|
assert.equal(block.segments[0].kind, "summary");
|
|
assert.equal(block.segments[0].summary.variant, "mixed");
|
|
assert.equal(block.segments[0].summary.label, "Ran 5 tool calls");
|
|
// Mixed summaries are the only visible burst summary; nested same-kind
|
|
// summaries flatten back to leaf rows to avoid redundant rows such as
|
|
// "Ran 16 tool calls" → "Ran 12 commands".
|
|
assert.deepEqual(
|
|
block.segments[0].summary.segments.map((child) =>
|
|
child.kind === "item" ? child.item.id : child.summary.label,
|
|
),
|
|
["read-1", "read-2", "read-3", "shell-1", "skill-1"],
|
|
);
|
|
// Flat leaf items preserve original order.
|
|
assert.deepEqual(
|
|
block.segments[0].summary.items.map((item) => item.id),
|
|
["read-1", "read-2", "read-3", "shell-1", "skill-1"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks collapses alternating search/read bursts into one summary", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("read-2", "Read file", "file-read", "read_file"),
|
|
mkTool("read-3", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-2", "Ran command", "shell", "shell:command"),
|
|
mkTool("read-4", "Read file", "file-read", "read_file"),
|
|
mkTool("read-5", "Read file", "file-read", "read_file"),
|
|
mkTool("read-6", "Read file", "file-read", "read_file"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.equal(block.segments.length, 1);
|
|
assert.equal(block.segments[0].kind, "summary");
|
|
assert.equal(block.segments[0].summary.variant, "mixed");
|
|
assert.equal(block.segments[0].summary.label, "Ran 8 tool calls");
|
|
assert.deepEqual(
|
|
block.segments[0].summary.segments.map((child) =>
|
|
child.kind === "summary" ? child.summary.label : child.item.id,
|
|
),
|
|
[
|
|
"shell-1",
|
|
"read-1",
|
|
"read-2",
|
|
"read-3",
|
|
"shell-2",
|
|
"read-4",
|
|
"read-5",
|
|
"read-6",
|
|
],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks keeps messages out of mixed tool runs", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
assistantMessage("assistant", "Here is what I found.", "turn-1"),
|
|
mkTool("read-2", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-2", "Ran command", "shell", "shell:command"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["summary", "item", "summary"],
|
|
);
|
|
assert.equal(block.segments[1].item.id, "assistant");
|
|
assert.deepEqual(
|
|
block.segments[0].summary.items.map((item) => item.id),
|
|
["read-1", "shell-1"],
|
|
);
|
|
assert.deepEqual(
|
|
block.segments[2].summary.items.map((item) => item.id),
|
|
["read-2", "shell-2"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks breaks failed tools out of mixed tool runs", () => {
|
|
const failed = {
|
|
...mkTool("shell-fail", "Ran command failed", "error", "shell:command"),
|
|
isError: true,
|
|
};
|
|
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
mkTool("skill-1", "Read skill", "skill-read", "skill:load"),
|
|
failed,
|
|
mkTool("read-2", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-2", "Ran command", "shell", "shell:command"),
|
|
mkTool("image-1", "Viewed image", "image", "view_image"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["summary", "item", "summary"],
|
|
);
|
|
assert.equal(block.segments[0].summary.variant, "mixed");
|
|
assert.equal(block.segments[0].summary.label, "Ran 3 tool calls");
|
|
assert.equal(block.segments[1].item.id, "shell-fail");
|
|
assert.equal(block.segments[2].summary.variant, "mixed");
|
|
assert.deepEqual(
|
|
block.segments[2].summary.items.map((item) => item.id),
|
|
["read-2", "shell-2", "image-1"],
|
|
);
|
|
});
|
|
|
|
test("flattenDisplayBlocks preserves child order through mixed summaries", () => {
|
|
const blocks = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("shell-1", "Ran command", "shell", "shell:command"),
|
|
mkTool("edit-1", "Edited file", "file-edit", "file-edit:str_replace"),
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
flattenDisplayBlocks(blocks).map((item) => item.id),
|
|
["read-1", "shell-1", "edit-1"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks never same-kind groups failed tools", () => {
|
|
const mkFailed = (id) => ({
|
|
...mkTool(id, "Ran command failed", "error", "shell:command"),
|
|
isError: true,
|
|
});
|
|
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkFailed("fail-1"),
|
|
mkFailed("fail-2"),
|
|
mkFailed("fail-3"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["item", "item", "item"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks never same-kind groups status tool rows", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("status-1", "Context compacted", "status", "status:post-compact"),
|
|
mkTool("status-2", "Context compacted", "status", "status:post-compact"),
|
|
mkTool("status-3", "Context compacted", "status", "status:post-compact"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["item", "item", "item"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks never same-kind groups suppressed tool rows", () => {
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("stop-1", "Checked todos", "suppressed", "suppressed:stop-hook"),
|
|
mkTool("stop-2", "Checked todos", "suppressed", "suppressed:stop-hook"),
|
|
mkTool("stop-3", "Checked todos", "suppressed", "suppressed:stop-hook"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["item", "item", "item"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks breaks same-kind runs on an ineligible row", () => {
|
|
const failed = {
|
|
...mkTool("fail-1", "Read file failed", "error", "read_file"),
|
|
isError: true,
|
|
};
|
|
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
mkTool("read-1", "Read file", "file-read", "read_file"),
|
|
mkTool("read-2", "Read file", "file-read", "read_file"),
|
|
failed,
|
|
mkTool("read-3", "Read file", "file-read", "read_file"),
|
|
mkTool("read-4", "Read file", "file-read", "read_file"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["summary", "item", "summary"],
|
|
);
|
|
assert.equal(block.segments[1].item.id, "fail-1");
|
|
assert.deepEqual(
|
|
block.segments[0].summary.items.map((item) => item.id),
|
|
["read-1", "read-2"],
|
|
);
|
|
assert.deepEqual(
|
|
block.segments[2].summary.items.map((item) => item.id),
|
|
["read-3", "read-4"],
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks bundles steer message with steer context behind the prompt segment", () => {
|
|
const steerMessage = {
|
|
id: "steer:chan-1:turn-1",
|
|
type: "message",
|
|
role: "user",
|
|
title: "Buzz event",
|
|
text: "@Bart new steer instruction",
|
|
timestamp: baseTimestamp,
|
|
acpSource: "session/steer:user",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
};
|
|
const steerContext = {
|
|
id: "steer-context:chan-1:turn-1",
|
|
type: "metadata",
|
|
title: "Prompt context",
|
|
sections: [{ title: "Thread history", body: "prior messages" }],
|
|
timestamp: baseTimestamp,
|
|
acpSource: "session/steer:context",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
};
|
|
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
assistantMessage("assistant", "Working on it.", "turn-1"),
|
|
steerMessage,
|
|
steerContext,
|
|
toolCall("tool", "turn-1"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
assert.deepEqual(
|
|
block.segments.map((segment) => segment.kind),
|
|
["item", "prompt", "item"],
|
|
);
|
|
const steerSegment = block.segments[1];
|
|
assert.equal(steerSegment.user.id, "steer:chan-1:turn-1");
|
|
assert.equal(steerSegment.context?.id, "steer-context:chan-1:turn-1");
|
|
assert.deepEqual(steerSegment.setup, []);
|
|
// No standalone "Prompt context" metadata row leaks into the feed.
|
|
assert.ok(
|
|
!block.segments.some(
|
|
(segment) => segment.kind === "item" && segment.item.type === "metadata",
|
|
),
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks keeps orphan steer context visible when no steer message exists", () => {
|
|
const steerContext = {
|
|
id: "steer-context:chan-1:turn-1",
|
|
type: "metadata",
|
|
title: "Prompt context",
|
|
sections: [{ title: "Thread history", body: "prior messages" }],
|
|
timestamp: baseTimestamp,
|
|
acpSource: "session/steer:context",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
};
|
|
|
|
const [block] = buildTranscriptDisplayBlocks([
|
|
steerContext,
|
|
toolCall("tool", "turn-1"),
|
|
]);
|
|
|
|
assert.equal(block.kind, "turn");
|
|
const flattened = flattenDisplayBlocks([block]).map((item) => item.id);
|
|
assert.ok(flattened.includes("steer-context:chan-1:turn-1"));
|
|
});
|
|
|
|
function mkTool(id, label, renderClass = "generic", groupKey = label) {
|
|
return {
|
|
id,
|
|
type: "tool",
|
|
renderClass,
|
|
descriptor: {
|
|
renderClass,
|
|
label,
|
|
preview: id,
|
|
source: "harness",
|
|
groupKey,
|
|
},
|
|
title: label,
|
|
toolName: label,
|
|
buzzToolName: null,
|
|
status: "completed",
|
|
args: {},
|
|
result: "",
|
|
isError: false,
|
|
timestamp: "2026-06-18T00:00:00Z",
|
|
startedAt: "2026-06-18T00:00:00Z",
|
|
completedAt: "2026-06-18T00:00:01Z",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
};
|
|
}
|
|
|
|
// ── Session-run splitting and session-boundary blocks ──────────────────────────
|
|
|
|
/**
|
|
* Build a minimal tool-call item stamped with a specific session.
|
|
*/
|
|
function sessionItem(id, sessionId, ts = "2026-07-08T00:00:00.000Z") {
|
|
return {
|
|
id,
|
|
type: "tool",
|
|
renderClass: "generic",
|
|
descriptor: {
|
|
renderClass: "generic",
|
|
label: id,
|
|
preview: id,
|
|
source: "harness",
|
|
groupKey: id,
|
|
},
|
|
title: id,
|
|
toolName: id,
|
|
buzzToolName: null,
|
|
status: "completed",
|
|
args: {},
|
|
result: "",
|
|
isError: false,
|
|
timestamp: ts,
|
|
startedAt: ts,
|
|
completedAt: ts,
|
|
turnId: `turn-${id}`,
|
|
sessionId,
|
|
channelId: "chan-1",
|
|
};
|
|
}
|
|
|
|
// ── Single session — no boundary injected ──────────────────────────────────────
|
|
|
|
test("buildTranscriptDisplayBlocks_singleSession_noBoundaryBlock", () => {
|
|
const items = [sessionItem("a", "sess-1"), sessionItem("b", "sess-1")];
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
0,
|
|
"no session-boundary blocks for a single session",
|
|
);
|
|
});
|
|
|
|
// ── Two sessions — one boundary between them ───────────────────────────────────
|
|
|
|
test("buildTranscriptDisplayBlocks_twoSessions_oneBoundaryBetween", () => {
|
|
// items ordered oldest-first: sess-1 then sess-2
|
|
const items = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
];
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
1,
|
|
"exactly one boundary for two sessions",
|
|
);
|
|
// The boundary is inserted BEFORE the newer run (sess-2).
|
|
const boundaryIndex = blocks.indexOf(boundaryBlocks[0]);
|
|
const prevBlock = blocks[boundaryIndex - 1];
|
|
const nextBlock = blocks[boundaryIndex + 1];
|
|
// Previous content block belongs to sess-1 items, next to sess-2.
|
|
const flatPrev = flattenDisplayBlocks([prevBlock]).map((i) => i.id);
|
|
const flatNext = flattenDisplayBlocks([nextBlock]).map((i) => i.id);
|
|
assert.ok(flatPrev.includes("a"), "content before boundary is sess-1");
|
|
assert.ok(flatNext.includes("b"), "content after boundary is sess-2");
|
|
});
|
|
|
|
// ── Newest session labeled correctly relative to latestLiveSessionId ──────────
|
|
|
|
test("buildTranscriptDisplayBlocks_newestMatchesLive_labelStateCurrent", () => {
|
|
const items = [
|
|
sessionItem("old", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("new", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
];
|
|
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
|
|
const boundary = blocks.find((b) => b.kind === "session-boundary");
|
|
assert.ok(boundary, "boundary present");
|
|
assert.equal(
|
|
boundary.labelState,
|
|
"current",
|
|
"labelState=current when newest session matches live id",
|
|
);
|
|
assert.equal(boundary.sessionId, "sess-2", "boundary sessionId is sess-2");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks_newestNoLive_labelStateMostRecent", () => {
|
|
const items = [
|
|
sessionItem("old", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("new", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
];
|
|
// latestLiveSessionId is null → newest session is "most-recent"
|
|
const blocksNoLive = buildTranscriptDisplayBlocks(items, null);
|
|
const boundaryNoLive = blocksNoLive.find(
|
|
(b) => b.kind === "session-boundary",
|
|
);
|
|
assert.equal(
|
|
boundaryNoLive.labelState,
|
|
"most-recent",
|
|
"labelState=most-recent when no live id (archived-only view)",
|
|
);
|
|
|
|
// latestLiveSessionId is a DIFFERENT session → newest is still "most-recent"
|
|
const blocksDiffLive = buildTranscriptDisplayBlocks(items, "sess-other");
|
|
const boundaryDiff = blocksDiffLive.find(
|
|
(b) => b.kind === "session-boundary",
|
|
);
|
|
assert.equal(
|
|
boundaryDiff.labelState,
|
|
"most-recent",
|
|
"labelState=most-recent when live id differs from newest visible",
|
|
);
|
|
});
|
|
|
|
// ── Three sessions — two boundaries ────────────────────────────────────────────
|
|
|
|
test("buildTranscriptDisplayBlocks_threeSessions_twoBoundaries", () => {
|
|
const items = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
sessionItem("c", "sess-3", "2026-07-08T00:00:03.000Z"),
|
|
];
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(boundaryBlocks.length, 2, "two boundaries for three sessions");
|
|
// With no live id: newest (sess-3) boundary = "most-recent"; older = "earlier".
|
|
const newestBoundary = boundaryBlocks[boundaryBlocks.length - 1];
|
|
const olderBoundary = boundaryBlocks[0];
|
|
assert.equal(
|
|
newestBoundary.labelState,
|
|
"most-recent",
|
|
"newest boundary is most-recent when latestLiveSessionId is null",
|
|
);
|
|
assert.equal(
|
|
olderBoundary.labelState,
|
|
"earlier",
|
|
"older boundary is earlier when latestLiveSessionId is null",
|
|
);
|
|
});
|
|
|
|
// ── Null sessionId items stay in the current run ───────────────────────────────
|
|
|
|
test("buildTranscriptDisplayBlocks_nullSessionId_staysInCurrentRun", () => {
|
|
// An item with null sessionId should not start a new run.
|
|
const items = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
// null sessionId — stays in sess-1 run
|
|
{ ...sessionItem("b", null, "2026-07-08T00:00:02.000Z"), sessionId: null },
|
|
sessionItem("c", "sess-1", "2026-07-08T00:00:03.000Z"),
|
|
];
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
0,
|
|
"no boundary when null-sessionId items are present within a single session",
|
|
);
|
|
});
|
|
|
|
// ── flattenDisplayBlocks skips session-boundary blocks ────────────────────────
|
|
|
|
test("flattenDisplayBlocks_skipsSessionBoundaryBlocks", () => {
|
|
const items = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
];
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
assert.ok(
|
|
blocks.some((b) => b.kind === "session-boundary"),
|
|
"test setup: boundary must be present",
|
|
);
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
const ids = flat.map((i) => i.id);
|
|
assert.ok(ids.includes("a"), "item a is in flattened output");
|
|
assert.ok(ids.includes("b"), "item b is in flattened output");
|
|
assert.equal(
|
|
flat.filter((i) => i.kind === "session-boundary").length,
|
|
0,
|
|
"session-boundary items are excluded from flatten",
|
|
);
|
|
});
|
|
|
|
// ── isObserverEventAfter — latest-live ordering ──────────────────────────────
|
|
|
|
test("isObserverEventAfter returns true when candidate has later timestamp", () => {
|
|
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 5 };
|
|
const candidate = { timestamp: "2026-07-08T00:00:02.000Z", seq: 1 };
|
|
assert.ok(isObserverEventAfter(candidate, stored));
|
|
});
|
|
|
|
test("isObserverEventAfter returns false when candidate has earlier timestamp", () => {
|
|
const stored = { timestamp: "2026-07-08T00:00:02.000Z", seq: 5 };
|
|
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 10 };
|
|
assert.ok(!isObserverEventAfter(candidate, stored));
|
|
});
|
|
|
|
test("isObserverEventAfter returns true for same timestamp, higher seq — session B advances over session A", () => {
|
|
// This is the tiebreak case: timestamp equal, seq tiebreak must mirror
|
|
// compareObserverEvents so latest-live never drifts from transcript order.
|
|
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
|
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 7 };
|
|
assert.ok(isObserverEventAfter(candidate, stored));
|
|
});
|
|
|
|
test("isObserverEventAfter returns false for same timestamp, same seq", () => {
|
|
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
|
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
|
assert.ok(!isObserverEventAfter(candidate, stored));
|
|
});
|
|
|
|
test("isObserverEventAfter returns false for same timestamp, lower seq", () => {
|
|
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 7 };
|
|
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
|
assert.ok(!isObserverEventAfter(candidate, stored));
|
|
});
|
|
|
|
// ── Pre-resolution null-session deferral (regression for first-turn bundle) ───
|
|
|
|
/**
|
|
* Builds the exact wire sequence the harness emits on the first turn:
|
|
* turn_started(null) → session/new(null) → session_resolved(sess) → session/prompt(sess)
|
|
*
|
|
* After processTranscriptEvent the TranscriptItems produced are:
|
|
* - lifecycle turn_started: sessionId=null, turnId="turn-001"
|
|
* - metadata session/new (system prompt): sessionId=null, turnId=null
|
|
* - lifecycle session_resolved: sessionId="session-001", turnId="turn-001"
|
|
* - message session/prompt:user: sessionId="session-001", turnId="turn-001"
|
|
*/
|
|
function firstTurnSequence() {
|
|
const ts = "2026-07-08T10:00:00.000Z";
|
|
return [
|
|
// turn_started: sessionId null, turnId present
|
|
{
|
|
id: "turn-started",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Turn started",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "turn_started",
|
|
turnId: "turn-001",
|
|
sessionId: null,
|
|
channelId: "chan-1",
|
|
},
|
|
// session/new system prompt: sessionId null, turnId null (processTranscriptEvent forces turnId null)
|
|
{
|
|
id: "system-prompt:chan-1",
|
|
type: "metadata",
|
|
renderClass: "raw-rail",
|
|
title: "System prompt",
|
|
sections: [
|
|
{ title: "Base", body: "You are a helpful AI assistant." },
|
|
{ title: "System", body: "You are Observer Agent." },
|
|
],
|
|
timestamp: ts,
|
|
acpSource: "session/new",
|
|
turnId: null,
|
|
sessionId: null,
|
|
channelId: "chan-1",
|
|
},
|
|
// session_resolved: first item with a non-null sessionId
|
|
{
|
|
id: "session-resolved",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Session ready",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "session_resolved",
|
|
turnId: "turn-001",
|
|
sessionId: "session-001",
|
|
channelId: "chan-1",
|
|
},
|
|
// session/prompt:user — the user message bubble
|
|
{
|
|
id: "user-prompt",
|
|
type: "message",
|
|
role: "user",
|
|
title: "Buzz event",
|
|
text: "@Observer Agent help me debug this",
|
|
timestamp: ts,
|
|
acpSource: "session/prompt:user",
|
|
turnId: "turn-001",
|
|
sessionId: "session-001",
|
|
channelId: "chan-1",
|
|
},
|
|
];
|
|
}
|
|
|
|
test("buildTranscriptDisplayBlocks_firstTurnSequence_standaloneSystemPrompt", () => {
|
|
// session/new is session-scoped, not turn-scoped. After the consolidation,
|
|
// the grouper emits it as a standalone "System prompt" single block that
|
|
// appears BEFORE the first user-prompt turn — never inside the prompt bundle.
|
|
const blocks = buildTranscriptDisplayBlocks(firstTurnSequence());
|
|
|
|
// (a) Exactly one standalone "System prompt" single block.
|
|
const systemPromptSingles = blocks.filter(
|
|
(b) => b.kind === "single" && b.item.acpSource === "session/new",
|
|
);
|
|
assert.equal(
|
|
systemPromptSingles.length,
|
|
1,
|
|
"system prompt must appear as exactly one standalone single block",
|
|
);
|
|
|
|
// (b) No session-boundary blocks — this is a single-session transcript.
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
0,
|
|
"must be zero session-boundary blocks for a first-turn single-session sequence",
|
|
);
|
|
|
|
// (c) System prompt is NOT inside any turn group (not in prompt bundle).
|
|
const turnBlocks = blocks.filter((b) => b.kind === "turn");
|
|
assert.ok(turnBlocks.length > 0, "at least one turn block must exist");
|
|
const allTurnItems = flattenDisplayBlocks(turnBlocks);
|
|
const systemPromptInTurn = allTurnItems.some(
|
|
(item) => item.acpSource === "session/new",
|
|
);
|
|
assert.ok(
|
|
!systemPromptInTurn,
|
|
"system prompt item must NOT be present inside a turn block",
|
|
);
|
|
|
|
// (d) Standalone system-prompt block appears BEFORE the first turn block.
|
|
const systemPromptIdx = blocks.indexOf(systemPromptSingles[0]);
|
|
const firstTurnIdx = blocks.findIndex((b) => b.kind === "turn");
|
|
assert.ok(
|
|
firstTurnIdx !== -1,
|
|
"there must be at least one turn block after the system prompt",
|
|
);
|
|
assert.ok(
|
|
systemPromptIdx < firstTurnIdx,
|
|
`system-prompt single (${systemPromptIdx}) must precede first turn block (${firstTurnIdx})`,
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks_genuineSecondSession_boundaryPreserved", () => {
|
|
// Regression guard: the deferral fix must not over-collapse two genuinely
|
|
// distinct sessions. A second session_resolved with a different sessionId
|
|
// still gets its own run and a session-boundary block.
|
|
const ts2 = "2026-07-08T11:00:00.000Z";
|
|
const items = [
|
|
// First session (may have pre-resolution preamble)
|
|
...firstTurnSequence(),
|
|
// Second session — starts fresh with its own turn_started (no pre-null preamble here)
|
|
{
|
|
id: "turn-started-2",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Turn started",
|
|
text: "",
|
|
timestamp: ts2,
|
|
acpSource: "turn_started",
|
|
turnId: "turn-002",
|
|
sessionId: "session-002",
|
|
channelId: "chan-1",
|
|
},
|
|
{
|
|
id: "user-prompt-2",
|
|
type: "message",
|
|
role: "user",
|
|
title: "Buzz event",
|
|
text: "second session message",
|
|
timestamp: ts2,
|
|
acpSource: "session/prompt:user",
|
|
turnId: "turn-002",
|
|
sessionId: "session-002",
|
|
channelId: "chan-1",
|
|
},
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
1,
|
|
"exactly one session-boundary block between two distinct sessions",
|
|
);
|
|
assert.equal(
|
|
boundaryBlocks[0].sessionId,
|
|
"session-002",
|
|
"boundary is labeled with the newer session id",
|
|
);
|
|
});
|
|
|
|
// ── Duplicate-key guard: non-contiguous runs of the same sessionId ────────────
|
|
|
|
test("buildTranscriptDisplayBlocks_nonContiguousRunsSameSession_distinctBoundaryKeys", () => {
|
|
// Scenario: sess-B frames, then sess-A frames, then sess-C frames, then
|
|
// sess-A frames re-resolve (e.g. same agent session re-observed in a new
|
|
// live subscription). splitIntoSessionRuns treats each session-id change as
|
|
// a new run, so sess-A appears twice — once at runIndex=1 and once at
|
|
// runIndex=3 — yielding two session-boundary blocks both with
|
|
// sessionId="sess-A".
|
|
//
|
|
// The React key for a session-boundary is
|
|
// `session-boundary:${sessionId}:${runIndex}`
|
|
// Without the runIndex tiebreaker the two sess-A boundaries share the same
|
|
// key, causing React to silently duplicate or omit children. This test
|
|
// asserts the runIndex tiebreaker keeps the keys distinct.
|
|
const items = [
|
|
sessionItem("b-item", "sess-B", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("a-item", "sess-A", "2026-07-08T00:00:02.000Z"),
|
|
sessionItem("c-item", "sess-C", "2026-07-08T00:00:03.000Z"),
|
|
sessionItem("a-item-2", "sess-A", "2026-07-08T00:00:04.000Z"),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items, "sess-A");
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
|
|
// Four distinct session runs → three boundaries (before sess-A, before
|
|
// sess-C, and before the re-occurring sess-A).
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
3,
|
|
"three boundaries for four runs (sess-B, sess-A, sess-C, sess-A)",
|
|
);
|
|
|
|
// Two boundaries share sessionId="sess-A" (the collision case pre-fix).
|
|
const sessABoundaries = boundaryBlocks.filter(
|
|
(b) => b.sessionId === "sess-A",
|
|
);
|
|
assert.equal(
|
|
sessABoundaries.length,
|
|
2,
|
|
"two boundary blocks both carry sessionId=sess-A (the collision the fix guards)",
|
|
);
|
|
|
|
// The runIndex values must be distinct across ALL boundary blocks so that
|
|
// `session-boundary:${sessionId}:${runIndex}` keys are unique.
|
|
const runIndexSet = new Set(boundaryBlocks.map((b) => b.runIndex));
|
|
assert.equal(
|
|
runIndexSet.size,
|
|
boundaryBlocks.length,
|
|
"every session-boundary block has a distinct runIndex",
|
|
);
|
|
|
|
// The React keys derived from each boundary must also be distinct.
|
|
const keys = boundaryBlocks.map(
|
|
(b) => `session-boundary:${b.sessionId}:${b.runIndex}`,
|
|
);
|
|
const keySet = new Set(keys);
|
|
assert.equal(
|
|
keySet.size,
|
|
keys.length,
|
|
"all React keys derived from session-boundary blocks are unique",
|
|
);
|
|
});
|
|
|
|
// ── session/new run-anchor: restart scenario ──────────────────────────────────
|
|
|
|
/**
|
|
* Build a system-prompt metadata item as the normalizer produces it on restart:
|
|
* stale-stamped with the previous session's id (latestSessionId at emit time).
|
|
*/
|
|
function systemPromptItem(id, staleSessionId, ts = "2026-07-08T12:00:00.000Z") {
|
|
return {
|
|
id,
|
|
type: "metadata",
|
|
renderClass: "raw-rail",
|
|
title: "System prompt",
|
|
sections: [{ title: "Base", body: "You are a helpful assistant." }],
|
|
timestamp: ts,
|
|
acpSource: "session/new",
|
|
turnId: null,
|
|
sessionId: staleSessionId,
|
|
channelId: "chan-1",
|
|
};
|
|
}
|
|
|
|
test("buildTranscriptDisplayBlocks_restartScenario_systemPromptAfterBoundary", () => {
|
|
// Restart wire sequence: toolA(sess-1) → session/new(sess-1 stale) → toolB(sess-2)
|
|
// The session/new item is stale-stamped with sess-1 (the OLD session id).
|
|
// After the fix it must sort into the sess-2 run — AFTER the session-boundary
|
|
// block, not before it.
|
|
const ts1 = "2026-07-08T12:00:00.000Z";
|
|
const ts2 = "2026-07-08T12:01:00.000Z";
|
|
const items = [
|
|
// Prior session activity
|
|
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
|
|
// session/new stale-stamped with OLD session id (the bug scenario)
|
|
systemPromptItem("system-prompt", "sess-1", ts2),
|
|
// New session activity
|
|
{ ...sessionItem("toolB", "sess-2", ts2), turnId: "turn-2" },
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
|
|
|
|
// (a) Exactly one session-boundary block must exist.
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
1,
|
|
"exactly one session-boundary block for two sessions",
|
|
);
|
|
|
|
// (b) The session/new item must appear AFTER the boundary, not before it.
|
|
const boundaryIndex = blocks.indexOf(boundaryBlocks[0]);
|
|
const systemPromptBlockIndex = blocks.findIndex(
|
|
(b) => b.kind === "single" && b.item.acpSource === "session/new",
|
|
);
|
|
assert.ok(
|
|
systemPromptBlockIndex !== -1,
|
|
"system-prompt item must be present in the output",
|
|
);
|
|
assert.ok(
|
|
boundaryIndex < systemPromptBlockIndex,
|
|
`boundary (index ${boundaryIndex}) must come before system-prompt (index ${systemPromptBlockIndex})`,
|
|
);
|
|
|
|
// (c) toolA must appear before the boundary; toolB after.
|
|
const flatAll = flattenDisplayBlocks(blocks);
|
|
const ids = flatAll.map((i) => i.id);
|
|
assert.ok(ids.includes("toolA"), "toolA present in flattened output");
|
|
assert.ok(ids.includes("toolB"), "toolB present in flattened output");
|
|
const toolAIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "toolA"),
|
|
);
|
|
const toolBIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "toolB"),
|
|
);
|
|
assert.ok(
|
|
toolAIdx < boundaryIndex,
|
|
"toolA block must be before the boundary",
|
|
);
|
|
assert.ok(toolBIdx > boundaryIndex, "toolB block must be after the boundary");
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks_firstEverSession_systemPromptInSingleRun", () => {
|
|
// First-ever session: session/new arrives with sessionId null before any
|
|
// session resolves — the preSessionBuffer path handles it, no boundary emitted.
|
|
// This test guards against regressing the first-session behavior.
|
|
const ts = "2026-07-08T10:00:00.000Z";
|
|
const items = [
|
|
// session/new with null sessionId (first ever, no stale-stamp)
|
|
{
|
|
id: "system-prompt",
|
|
type: "metadata",
|
|
renderClass: "raw-rail",
|
|
title: "System prompt",
|
|
sections: [{ title: "Base", body: "You are a helpful assistant." }],
|
|
timestamp: ts,
|
|
acpSource: "session/new",
|
|
turnId: null,
|
|
sessionId: null,
|
|
channelId: "chan-1",
|
|
},
|
|
// session_resolved gives the first non-null sessionId
|
|
{
|
|
id: "session-resolved",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Session ready",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "session_resolved",
|
|
turnId: "turn-001",
|
|
sessionId: "session-001",
|
|
channelId: "chan-1",
|
|
},
|
|
// User prompt follows
|
|
{
|
|
id: "user-prompt",
|
|
type: "message",
|
|
role: "user",
|
|
title: "Buzz event",
|
|
text: "@Agent hello",
|
|
timestamp: ts,
|
|
acpSource: "session/prompt:user",
|
|
turnId: "turn-001",
|
|
sessionId: "session-001",
|
|
channelId: "chan-1",
|
|
},
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items, "session-001");
|
|
|
|
// No boundary — this is a single-session transcript.
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
0,
|
|
"no session-boundary block for a first-ever single session",
|
|
);
|
|
|
|
// System prompt must appear somewhere in the output (as a standalone single).
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
assert.ok(
|
|
flat.some((i) => i.acpSource === "session/new"),
|
|
"system-prompt item must be present in the single-session output",
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks_sessionNewNoFollowingSession_notDropped", () => {
|
|
// session/new arrives after a resolved session but the stream ends before a
|
|
// new session resolves (e.g. agent shutdown mid-restart). The item must not
|
|
// be silently dropped — it should fall back into the current run.
|
|
const ts1 = "2026-07-08T12:00:00.000Z";
|
|
const ts2 = "2026-07-08T12:01:00.000Z";
|
|
const items = [
|
|
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
|
|
// session/new stale-stamped; stream ends here — no new session resolves.
|
|
systemPromptItem("system-prompt", "sess-1", ts2),
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
|
|
// No boundary — still a single session (no new session resolved).
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
0,
|
|
"no boundary when session/new has no following new session",
|
|
);
|
|
|
|
// system-prompt must not be dropped.
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
assert.ok(
|
|
flat.some((i) => i.id === "system-prompt"),
|
|
"system-prompt must not be dropped when no new session follows",
|
|
);
|
|
// toolA must also be present.
|
|
assert.ok(
|
|
flat.some((i) => i.id === "toolA"),
|
|
"toolA must be present when session/new has no following new session",
|
|
);
|
|
});
|
|
|
|
// --- no-user-prompt ordering: system-prompt leads turns in the same run ─────
|
|
|
|
test("splitIntoSessionRuns: system-prompt renders before turn blocks when no user-prompt follows", () => {
|
|
// Covers buildBlocksForRun's no-user-prompt branch: when session/new is
|
|
// followed by a tool turn but no session/prompt:user, the system-prompt must
|
|
// appear as a standalone block BEFORE the turn (boundary → prompt → activity),
|
|
// not appended after all turns (the old behaviour).
|
|
const ts1 = "2026-07-08T12:00:00.000Z";
|
|
const ts2 = "2026-07-08T12:01:00.000Z";
|
|
const ts3 = "2026-07-08T12:02:00.000Z";
|
|
const items = [
|
|
// A preceding tool item establishes run sess-1 so a boundary is emitted.
|
|
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
|
|
// system/new repositioned to tail (stale sess-1 stamp) — represents the
|
|
// restart marker after the normalizer's reposition-on-refire fix.
|
|
systemPromptItem("system-prompt", "sess-1", ts2),
|
|
// New run's tool item (no user prompt in turn-2).
|
|
{ ...sessionItem("toolB", "sess-2", ts3), turnId: "turn-2" },
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
|
|
// One boundary between the two sessions.
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(boundaryBlocks.length, 1, "exactly one boundary");
|
|
|
|
const boundaryIdx = blocks.indexOf(boundaryBlocks[0]);
|
|
|
|
const systemPromptIdx = blocks.findIndex(
|
|
(b) => b.kind === "single" && b.item?.id === "system-prompt",
|
|
);
|
|
assert.ok(systemPromptIdx !== -1, "system-prompt block must be present");
|
|
|
|
const toolBIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "toolB"),
|
|
);
|
|
assert.ok(toolBIdx !== -1, "toolB block must be present");
|
|
|
|
// Required order: boundary → system-prompt → toolB activity.
|
|
assert.ok(
|
|
boundaryIdx < systemPromptIdx,
|
|
`boundary (${boundaryIdx}) must precede system-prompt (${systemPromptIdx})`,
|
|
);
|
|
assert.ok(
|
|
systemPromptIdx < toolBIdx,
|
|
`system-prompt (${systemPromptIdx}) must precede toolB activity (${toolBIdx})`,
|
|
);
|
|
|
|
// toolA (sess-1) must appear before the boundary.
|
|
const toolAIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "toolA"),
|
|
);
|
|
assert.ok(toolAIdx !== -1, "toolA block must be present");
|
|
assert.ok(
|
|
toolAIdx < boundaryIdx,
|
|
`toolA (${toolAIdx}) must be before boundary (${boundaryIdx})`,
|
|
);
|
|
});
|
|
|
|
// ── Session-boundary firstItemId key stability (regression) ───────────────────
|
|
//
|
|
// Previously getDisplayBlockKey keyed boundaries as
|
|
// `session-boundary:${sessionId}:${runIndex}`. `runIndex` is the run's
|
|
// position in the ordered array — it SHIFTS when older sessions are prepended
|
|
// (archive page load), causing React to remount unchanged boundaries and churn
|
|
// the virtual list.
|
|
//
|
|
// The fix replaces `runIndex` with `firstItemId` — the id of the first
|
|
// TranscriptItem in the run — which is invariant across prepend.
|
|
|
|
test("buildTranscriptDisplayBlocks_sessionBoundary_emitsFirstItemId", () => {
|
|
// Two sessions: sess-1 (older) then sess-2 (newer).
|
|
const items = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
sessionItem("c", "sess-2", "2026-07-08T00:00:03.000Z"),
|
|
];
|
|
const blocks = buildTranscriptDisplayBlocks(items, null);
|
|
const boundary = blocks.find((b) => b.kind === "session-boundary");
|
|
assert.ok(boundary, "boundary must be present between two sessions");
|
|
assert.equal(
|
|
boundary.sessionId,
|
|
"sess-2",
|
|
"boundary labels the newer session",
|
|
);
|
|
// firstItemId must equal the id of the first item in sess-2's run.
|
|
assert.ok(boundary.firstItemId, "boundary must carry firstItemId");
|
|
// "b" is the first item in sess-2's run (from sessionItem("b", "sess-2")).
|
|
assert.equal(
|
|
boundary.firstItemId,
|
|
"b",
|
|
"firstItemId must equal the id of the first item in the following session run",
|
|
);
|
|
});
|
|
|
|
test("buildTranscriptDisplayBlocks_sessionBoundary_keyStableAcrossPrepend", () => {
|
|
// Before: sess-1 then sess-2.
|
|
const before = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:02.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:03.000Z"),
|
|
];
|
|
const blocksBefore = buildTranscriptDisplayBlocks(before, null);
|
|
const boundaryBefore = blocksBefore.find(
|
|
(b) => b.kind === "session-boundary" && b.sessionId === "sess-2",
|
|
);
|
|
assert.ok(boundaryBefore, "must have sess-2 boundary before prepend");
|
|
const keyBefore = `session-boundary:${boundaryBefore.sessionId}:${boundaryBefore.firstItemId}`;
|
|
|
|
// After: prepend an older sess-0 before sess-1. Now [sess-0, sess-1, sess-2].
|
|
const after = [
|
|
sessionItem("z", "sess-0", "2026-07-08T00:00:01.000Z"), // oldest — prepended
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:02.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:03.000Z"),
|
|
];
|
|
const blocksAfter = buildTranscriptDisplayBlocks(after, null);
|
|
const boundaryAfter = blocksAfter.find(
|
|
(b) => b.kind === "session-boundary" && b.sessionId === "sess-2",
|
|
);
|
|
assert.ok(boundaryAfter, "must still have sess-2 boundary after prepend");
|
|
const keyAfter = `session-boundary:${boundaryAfter.sessionId}:${boundaryAfter.firstItemId}`;
|
|
|
|
// firstItemId-based key must be identical before and after prepend.
|
|
assert.equal(
|
|
keyBefore,
|
|
keyAfter,
|
|
"session-boundary React key must not change when an older session is prepended",
|
|
);
|
|
|
|
// Sanity check: runIndex DID shift (from 1 to 2) confirming the old key would have changed.
|
|
assert.equal(boundaryBefore.runIndex, 1, "runIndex before prepend must be 1");
|
|
assert.equal(
|
|
boundaryAfter.runIndex,
|
|
2,
|
|
"runIndex after prepend must be 2, confirming it is unstable",
|
|
);
|
|
});
|
|
|
|
// ── Multiple pre-resolution session/new markers — both survive ────────────────
|
|
|
|
test("buildTranscriptDisplayBlocks_twoPreResolutionSessionNewMarkers_bothSurviveBeforeTurn", () => {
|
|
// Restart loop: two session/new items arrive after sess-1 resolves but before
|
|
// sess-2 resolves (e.g. a rapid double-restart). A null-session frame arrives
|
|
// between them (models a lifecycle event with no session yet). All three must
|
|
// appear in the sess-2 run — neither marker nor the interleaved null-session
|
|
// frame may be silently dropped. This is the documented
|
|
// "marker(s) plus trailing null-session items" contract.
|
|
//
|
|
// Wire order:
|
|
// toolA(sess-1) → sp-a(sess-1 stale) → null-frame(null) → sp-b(sess-1 stale) → toolB(sess-2)
|
|
const ts1 = "2026-07-08T12:00:00.000Z";
|
|
const ts2 = "2026-07-08T12:01:00.000Z";
|
|
const ts3 = "2026-07-08T12:01:15.000Z";
|
|
const ts4 = "2026-07-08T12:01:30.000Z";
|
|
const ts5 = "2026-07-08T12:02:00.000Z";
|
|
|
|
// A null-session lifecycle frame (models turn_started or similar arriving
|
|
// between two session/new firings during a rapid double-restart).
|
|
const nullFrame = {
|
|
id: "null-frame",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Session starting",
|
|
text: "",
|
|
timestamp: ts3,
|
|
acpSource: "turn_started",
|
|
turnId: null,
|
|
sessionId: null,
|
|
channelId: "chan-1",
|
|
};
|
|
|
|
const items = [
|
|
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
|
|
systemPromptItem("sp-a", "sess-1", ts2),
|
|
nullFrame,
|
|
systemPromptItem("sp-b", "sess-1", ts4),
|
|
{ ...sessionItem("toolB", "sess-2", ts5), turnId: "turn-2" },
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
|
|
|
|
// Exactly one boundary between the two sessions.
|
|
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
|
assert.equal(
|
|
boundaryBlocks.length,
|
|
1,
|
|
"exactly one session-boundary for two sessions",
|
|
);
|
|
const boundaryIdx = blocks.indexOf(boundaryBlocks[0]);
|
|
|
|
// Both system-prompt blocks must be present.
|
|
const systemPromptBlocks = blocks.filter(
|
|
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
|
|
);
|
|
assert.equal(
|
|
systemPromptBlocks.length,
|
|
2,
|
|
"both session/new markers must produce standalone single blocks",
|
|
);
|
|
|
|
const spAIdx = blocks.findIndex(
|
|
(b) => b.kind === "single" && b.item?.id === "sp-a",
|
|
);
|
|
const spBIdx = blocks.findIndex(
|
|
(b) => b.kind === "single" && b.item?.id === "sp-b",
|
|
);
|
|
const toolBIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "toolB"),
|
|
);
|
|
|
|
// Both system-prompt blocks appear AFTER the boundary.
|
|
assert.ok(
|
|
boundaryIdx < spAIdx,
|
|
`boundary (${boundaryIdx}) must precede sp-a (${spAIdx})`,
|
|
);
|
|
assert.ok(
|
|
boundaryIdx < spBIdx,
|
|
`boundary (${boundaryIdx}) must precede sp-b (${spBIdx})`,
|
|
);
|
|
|
|
// Both system-prompt blocks appear BEFORE toolB's turn block.
|
|
assert.ok(
|
|
spAIdx < toolBIdx,
|
|
`sp-a (${spAIdx}) must precede toolB (${toolBIdx})`,
|
|
);
|
|
assert.ok(
|
|
spBIdx < toolBIdx,
|
|
`sp-b (${spBIdx}) must precede toolB (${toolBIdx})`,
|
|
);
|
|
|
|
// sp-a must appear before sp-b (wire order preserved).
|
|
assert.ok(
|
|
spAIdx < spBIdx,
|
|
`sp-a (${spAIdx}) must precede sp-b (${spBIdx}) — wire order must be preserved`,
|
|
);
|
|
|
|
// The interleaved null-session frame must survive and appear in the sess-2 run
|
|
// (after the boundary). This proves the "marker plus trailing null-session items"
|
|
// contract — the null frame lands in the pending buffer between sp-a and sp-b
|
|
// and must not be lost when the second marker arrives.
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
const nullFrameInFlat = flat.some((i) => i.id === "null-frame");
|
|
assert.ok(
|
|
nullFrameInFlat,
|
|
"interleaved null-session frame must survive in the flattened output",
|
|
);
|
|
const nullFrameBlockIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "null-frame"),
|
|
);
|
|
assert.ok(
|
|
boundaryIdx < nullFrameBlockIdx,
|
|
`null-session frame (${nullFrameBlockIdx}) must appear after boundary (${boundaryIdx}) — it belongs to the new session run`,
|
|
);
|
|
// Wire order: sp-a → null-frame → sp-b (all flush in insertion order).
|
|
const flatSpAIdx = flat.findIndex((i) => i.id === "sp-a");
|
|
const flatNullIdx = flat.findIndex((i) => i.id === "null-frame");
|
|
const flatSpBIdx = flat.findIndex((i) => i.id === "sp-b");
|
|
assert.ok(
|
|
flatSpAIdx < flatNullIdx,
|
|
`sp-a (${flatSpAIdx}) must precede null-frame (${flatNullIdx}) in flat output`,
|
|
);
|
|
assert.ok(
|
|
flatNullIdx < flatSpBIdx,
|
|
`null-frame (${flatNullIdx}) must precede sp-b (${flatSpBIdx}) in flat output`,
|
|
);
|
|
|
|
// toolA (sess-1) must appear before the boundary.
|
|
const toolAIdx = blocks.findIndex((b) =>
|
|
flattenDisplayBlocks([b]).some((i) => i.id === "toolA"),
|
|
);
|
|
assert.ok(
|
|
toolAIdx < boundaryIdx,
|
|
`toolA (${toolAIdx}) must be before boundary (${boundaryIdx})`,
|
|
);
|
|
});
|
|
|
|
// ── Regression suite for pendingForTurn / openBatch sealing ─────────────────
|
|
|
|
// Production wire order: turn_started(t1) → session/new → session_resolved(t1).
|
|
// The session/new arrives AFTER the bucket for turn-1 already exists.
|
|
// The system-prompt must still be hoisted before turn-1.
|
|
test("buildTranscriptDisplayBlocks_productionWireOrder_systemPromptBeforeTurn1", () => {
|
|
const ts = "2026-07-08T12:00:00.000Z";
|
|
const items = [
|
|
// turn_started creates the bucket BEFORE session/new fires.
|
|
{
|
|
id: "turn-started-t1",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Turn started",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "turn_started",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
},
|
|
// session/new fires AFTER the bucket exists (stale-stamped session).
|
|
systemPromptItem("sp", "sess-0", ts),
|
|
// session_resolved reuses the existing turn-1 bucket.
|
|
{
|
|
id: "session-resolved-t1",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Session resolved",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "session_resolved",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
},
|
|
// A real activity item so the turn block has segments and appears in output.
|
|
{
|
|
id: "assistant-t1",
|
|
type: "message",
|
|
role: "assistant",
|
|
title: "Assistant",
|
|
text: "Hello",
|
|
timestamp: ts,
|
|
acpSource: "agent_message_chunk",
|
|
turnId: "turn-1",
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
},
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
|
|
// sp must appear as a standalone single block.
|
|
const spBlockIdx = blocks.findIndex(
|
|
(b) => b.kind === "single" && b.item?.id === "sp",
|
|
);
|
|
// turn-1 must appear as a turn block.
|
|
const turnBlockIdx = blocks.findIndex(
|
|
(b) => b.kind === "turn" && b.turnId === "turn-1",
|
|
);
|
|
|
|
assert.ok(spBlockIdx !== -1, "sp block must be present");
|
|
assert.ok(turnBlockIdx !== -1, "turn-1 block must be present");
|
|
|
|
// System-prompt block must appear before the turn-1 block.
|
|
assert.ok(
|
|
spBlockIdx < turnBlockIdx,
|
|
`sp block (${spBlockIdx}) must precede turn-1 block (${turnBlockIdx}) — system-prompt must flush before its anchor turn even when bucket pre-existed`,
|
|
);
|
|
});
|
|
|
|
// Later null-session items (after turn-1) must NOT be hoisted before turn-1.
|
|
// Sequence: sp(null) → turn-1 item → later-null-frame(null) → turn-2 item
|
|
test("buildTranscriptDisplayBlocks_laterNullFrameAfterTurn1_notHoistedBeforeTurn1", () => {
|
|
const ts = "2026-07-08T12:00:00.000Z";
|
|
const items = [
|
|
systemPromptItem("sp", "sess-1", ts),
|
|
{ ...sessionItem("toolA", "sess-1", ts), turnId: "turn-1" },
|
|
{
|
|
id: "later-null-frame",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Session resolved",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "session_resolved",
|
|
turnId: null,
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
},
|
|
{ ...sessionItem("toolB", "sess-1", ts), turnId: "turn-2" },
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
|
|
const spIdx = flat.findIndex((i) => i.id === "sp");
|
|
const toolAIdx = flat.findIndex((i) => i.id === "toolA");
|
|
const laterNullIdx = flat.findIndex((i) => i.id === "later-null-frame");
|
|
const toolBIdx = flat.findIndex((i) => i.id === "toolB");
|
|
|
|
assert.ok(spIdx !== -1, "sp must be present");
|
|
assert.ok(toolAIdx !== -1, "toolA must be present");
|
|
assert.ok(laterNullIdx !== -1, "later-null-frame must be present");
|
|
assert.ok(toolBIdx !== -1, "toolB must be present");
|
|
|
|
assert.ok(spIdx < toolAIdx, `sp (${spIdx}) must precede toolA (${toolAIdx})`);
|
|
assert.ok(
|
|
toolAIdx < laterNullIdx,
|
|
`toolA (${toolAIdx}) must precede later-null-frame (${laterNullIdx}) — post-turn null items must NOT be hoisted`,
|
|
);
|
|
assert.ok(
|
|
laterNullIdx < toolBIdx,
|
|
`later-null-frame (${laterNullIdx}) must precede toolB (${toolBIdx})`,
|
|
);
|
|
});
|
|
|
|
// A leading non-system-prompt null-session item before any session/new must be
|
|
// emitted inline and must not prevent subsequent system-prompt hoisting. The
|
|
// buffer-open decision must not depend on displayOrder.length === 0.
|
|
test("buildTranscriptDisplayBlocks_leadingSingleBeforeSessionNew_emittedInline", () => {
|
|
const ts = "2026-07-08T12:00:00.000Z";
|
|
const items = [
|
|
{
|
|
id: "early-lifecycle",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Agent connected",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "agent_connected",
|
|
turnId: null,
|
|
sessionId: "sess-1",
|
|
channelId: "chan-1",
|
|
},
|
|
systemPromptItem("sp", "sess-1", ts),
|
|
{ ...sessionItem("toolA", "sess-1", ts), turnId: "turn-1" },
|
|
];
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items);
|
|
const flat = flattenDisplayBlocks(blocks);
|
|
|
|
const earlyIdx = flat.findIndex((i) => i.id === "early-lifecycle");
|
|
const spIdx = flat.findIndex((i) => i.id === "sp");
|
|
const toolAIdx = flat.findIndex((i) => i.id === "toolA");
|
|
|
|
assert.ok(earlyIdx !== -1, "early-lifecycle must be present");
|
|
assert.ok(spIdx !== -1, "sp must be present");
|
|
assert.ok(toolAIdx !== -1, "toolA must be present");
|
|
|
|
assert.ok(
|
|
earlyIdx < toolAIdx,
|
|
`early-lifecycle (${earlyIdx}) must precede toolA (${toolAIdx})`,
|
|
);
|
|
assert.ok(
|
|
spIdx < toolAIdx,
|
|
`sp (${spIdx}) must precede toolA (${toolAIdx}) — system-prompt must be hoisted even after a leading item`,
|
|
);
|
|
});
|
|
|
|
// ── getDisplayBlockKey ──────────────────────────────────────────────────────────
|
|
|
|
test("getDisplayBlockKey_single_returnsItemId", () => {
|
|
const block = { kind: "single", item: { id: "item-42" } };
|
|
assert.equal(getDisplayBlockKey(block), "item-42");
|
|
});
|
|
|
|
test("getDisplayBlockKey_turn_returnsPrefixedTurnId", () => {
|
|
const block = { kind: "turn", turnId: "t-99", segments: [] };
|
|
assert.equal(getDisplayBlockKey(block), "turn:t-99");
|
|
});
|
|
|
|
test("getDisplayBlockKey_sessionBoundary_usesFirstItemIdNotRunIndex", () => {
|
|
const block = {
|
|
kind: "session-boundary",
|
|
sessionId: "sess-2",
|
|
sessionStartTimestamp: "2026-07-08T00:00:00.000Z",
|
|
labelState: "most-recent",
|
|
runIndex: 3,
|
|
firstItemId: "first-item-abc",
|
|
};
|
|
assert.equal(
|
|
getDisplayBlockKey(block),
|
|
"session-boundary:sess-2:first-item-abc",
|
|
"key must use firstItemId, not runIndex",
|
|
);
|
|
});
|
|
|
|
test("getDisplayBlockKey_independentOfLatestLiveSessionId", () => {
|
|
// Build blocks for the same items with different latestLiveSessionId values.
|
|
// Keys must be identical — only labelState differs.
|
|
const items = [
|
|
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
|
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
|
];
|
|
|
|
const blocksLive = buildTranscriptDisplayBlocks(items, "sess-2");
|
|
const blocksNoLive = buildTranscriptDisplayBlocks(items, null);
|
|
const blocksDiffLive = buildTranscriptDisplayBlocks(items, "sess-other");
|
|
|
|
const keysLive = blocksLive.map(getDisplayBlockKey);
|
|
const keysNoLive = blocksNoLive.map(getDisplayBlockKey);
|
|
const keysDiffLive = blocksDiffLive.map(getDisplayBlockKey);
|
|
|
|
assert.deepEqual(
|
|
keysLive,
|
|
keysNoLive,
|
|
"keys must not vary with latestLiveSessionId=null",
|
|
);
|
|
assert.deepEqual(
|
|
keysLive,
|
|
keysDiffLive,
|
|
"keys must not vary with latestLiveSessionId=other",
|
|
);
|
|
|
|
// Sanity check: labelState DID change (proves the test setup matters).
|
|
const boundaryLive = blocksLive.find((b) => b.kind === "session-boundary");
|
|
const boundaryNoLive = blocksNoLive.find(
|
|
(b) => b.kind === "session-boundary",
|
|
);
|
|
assert.notEqual(
|
|
boundaryLive.labelState,
|
|
boundaryNoLive.labelState,
|
|
"labelState must differ (test setup sanity check)",
|
|
);
|
|
});
|
|
|
|
test("getDisplayBlockKey_parity_matchesBuildTranscriptDisplayBlocksOutput", () => {
|
|
// End-to-end: derive keys from buildTranscriptDisplayBlocks and assert they
|
|
// match the expected key for each block kind (the load-bearing invariant that
|
|
// outer-panel-derived ids match inner DOM data-message-id).
|
|
const items = [
|
|
sessionItem("orphan", "sess-1", "2026-07-08T00:00:00.000Z"),
|
|
// Multi-session to get a boundary block.
|
|
sessionItem("tool-a", "sess-2", "2026-07-08T00:00:01.000Z"),
|
|
];
|
|
|
|
// Give orphan no turnId so it becomes a "single" block.
|
|
items[0].turnId = undefined;
|
|
|
|
const blocks = buildTranscriptDisplayBlocks(items, null);
|
|
const keys = blocks.map(getDisplayBlockKey);
|
|
|
|
// Expect: [single:orphan, boundary:sess-2:tool-a, turn:turn-tool-a]
|
|
assert.ok(keys.includes("orphan"), "single block key = item.id");
|
|
assert.ok(
|
|
keys.some((k) => k.startsWith("session-boundary:sess-2:")),
|
|
"boundary block key uses session-boundary: prefix",
|
|
);
|
|
assert.ok(
|
|
keys.some((k) => k.startsWith("turn:")),
|
|
"turn block key uses turn: prefix",
|
|
);
|
|
|
|
// No duplicates.
|
|
assert.equal(new Set(keys).size, keys.length, "all keys must be unique");
|
|
});
|
|
|
|
// ── Key-parity invariant: transient [turn, single] → [single, turn] reorder ──
|
|
|
|
test("getDisplayBlockKey_firstTurnReorder_keysStableAcrossReorder", () => {
|
|
// Partial sequence: turn_started → session/new (two items, before session_resolved).
|
|
// At this point the key set is some combination of single + turn keys.
|
|
const ts = "2026-07-08T10:00:00.000Z";
|
|
const partialItems = [
|
|
{
|
|
id: "turn-started",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Turn started",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "turn_started",
|
|
turnId: "turn-001",
|
|
sessionId: null,
|
|
channelId: "chan-1",
|
|
},
|
|
{
|
|
id: "system-prompt:chan-1",
|
|
type: "metadata",
|
|
renderClass: "raw-rail",
|
|
title: "System prompt",
|
|
sections: [{ title: "Base", body: "You are a helpful AI assistant." }],
|
|
timestamp: ts,
|
|
acpSource: "session/new",
|
|
turnId: null,
|
|
sessionId: null,
|
|
channelId: "chan-1",
|
|
},
|
|
];
|
|
|
|
const partialBlocks = buildTranscriptDisplayBlocks(partialItems);
|
|
const partialKeys = new Set(partialBlocks.map(getDisplayBlockKey));
|
|
|
|
// Full sequence: add session_resolved (same turnId) — this seals the open
|
|
// batch and may reorder [turn, single] → [single, turn].
|
|
const fullItems = [
|
|
...partialItems,
|
|
{
|
|
id: "session-resolved",
|
|
type: "lifecycle",
|
|
renderClass: "lifecycle",
|
|
title: "Session ready",
|
|
text: "",
|
|
timestamp: ts,
|
|
acpSource: "session_resolved",
|
|
turnId: "turn-001",
|
|
sessionId: "session-001",
|
|
channelId: "chan-1",
|
|
},
|
|
];
|
|
|
|
const fullBlocks = buildTranscriptDisplayBlocks(fullItems);
|
|
const fullKeys = new Set(fullBlocks.map(getDisplayBlockKey));
|
|
|
|
// The KEY IDENTITIES must be identical — only the ORDER may differ.
|
|
assert.deepEqual(
|
|
partialKeys,
|
|
fullKeys,
|
|
"block key identities must be identical before and after session_resolved seals the open batch (order may differ)",
|
|
);
|
|
});
|