mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
fix(desktop): separate system prompt from turn context (#1754)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -36,6 +36,7 @@ export default defineConfig({
|
||||
"**/profile-active-turn.spec.ts",
|
||||
"**/config-bridge-screenshots.spec.ts",
|
||||
"**/observer-feed-screenshots.spec.ts",
|
||||
"**/core-memory-screenshots.spec.ts",
|
||||
"**/activity-scope-label-screenshots.spec.ts",
|
||||
"**/local-archive-screenshots.spec.ts",
|
||||
"**/agent-readiness-screenshots.spec.ts",
|
||||
|
||||
@@ -449,7 +449,6 @@ function TranscriptTurnSegmentView({
|
||||
context={segment.context}
|
||||
profiles={profiles}
|
||||
setup={segment.setup}
|
||||
systemPrompt={segment.systemPrompt}
|
||||
user={segment.user}
|
||||
/>
|
||||
);
|
||||
@@ -653,13 +652,11 @@ function TurnPromptBlock({
|
||||
context,
|
||||
profiles,
|
||||
setup,
|
||||
systemPrompt,
|
||||
user,
|
||||
}: {
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
profiles?: UserProfileLookup;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
user: Extract<TranscriptItem, { type: "message" }>;
|
||||
}) {
|
||||
return (
|
||||
@@ -677,7 +674,6 @@ function TurnPromptBlock({
|
||||
item={user}
|
||||
profiles={profiles}
|
||||
setup={setup}
|
||||
systemPrompt={systemPrompt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -688,18 +684,16 @@ function PromptUserMessage({
|
||||
item,
|
||||
profiles,
|
||||
setup = [],
|
||||
systemPrompt = null,
|
||||
}: {
|
||||
context?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
item: Extract<TranscriptItem, { type: "message" }>;
|
||||
profiles?: UserProfileLookup;
|
||||
setup?: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
systemPrompt?: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
}) {
|
||||
const [contextOpen, setContextOpen] = React.useState(false);
|
||||
const contextSections = React.useMemo(
|
||||
() => [...(systemPrompt?.sections ?? []), ...(context?.sections ?? [])],
|
||||
[context, systemPrompt],
|
||||
() => [...(context?.sections ?? [])],
|
||||
[context],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -950,11 +950,11 @@ test("buildTranscript does not render unknown session/update types (firehose saf
|
||||
|
||||
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 injection mechanism,
|
||||
// displayOrder becomes [turn(turn-1), single(system-prompt)] and System prompt
|
||||
// renders after the entire turn block — after Prompt context.
|
||||
// The fix: system-prompt items (acpSource "session/new") are held and injected
|
||||
// into the prompt segment of the first turn that follows them in stream order.
|
||||
// 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,
|
||||
@@ -1050,8 +1050,8 @@ test("observer feed renders system-prompt before prompt-context in display order
|
||||
});
|
||||
|
||||
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 item
|
||||
// injected into turn-1 must not re-appear in turn-2's prompt segment.
|
||||
// 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,
|
||||
@@ -1326,23 +1326,14 @@ test("buildTranscript correctly renders prompt segment when session/prompt arriv
|
||||
|
||||
// --- session-boundary ordering: restart scenario end-to-end ─────────────────
|
||||
|
||||
test("buildTranscript restart sequence: system-prompt renders after session-boundary, not before", () => {
|
||||
test("buildTranscript restart sequence: both sessions retain their own system-prompt card", () => {
|
||||
// Full two-session restart sequence routed through processTranscriptEvent.
|
||||
// This is the production scenario that the grouping-only fix missed:
|
||||
// upsertMetadata's replaceItem kept the system-prompt item at its first-session
|
||||
// array position (before any sess-1 activity), so splitIntoSessionRuns saw it
|
||||
// first (currentRun === null) and placed it in run sess-1 — above the boundary.
|
||||
//
|
||||
// Fix requires BOTH:
|
||||
// 1. Normalizer: reposition system-prompt to the stream tail on re-fire
|
||||
// (removeItem + pushItem with new timestamp) so it arrives at the restart
|
||||
// event position in stream order.
|
||||
// 2. Grouping: splitIntoSessionRuns pending-buffer re-anchors the
|
||||
// stale-stamped tail item into the new session run's head.
|
||||
//
|
||||
// Final display order must be: boundary < system-prompt < user-prompt < sess-2 activity.
|
||||
// The session/prompt:user event mirrors production (a restart is triggered by
|
||||
// a user @mention; the screenshot that surfaced the bug shows exactly this).
|
||||
// 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);
|
||||
@@ -1428,8 +1419,9 @@ test("buildTranscript restart sequence: system-prompt renders after session-boun
|
||||
turnId: "turn-2",
|
||||
payload: { source: "channel", triggeringEventIds: [] },
|
||||
},
|
||||
// session/new re-fires for the same channel (restart signal):
|
||||
// upsertMetadata must REPOSITION to tail, not replace in-place.
|
||||
// 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",
|
||||
@@ -1524,64 +1516,284 @@ test("buildTranscript restart sequence: system-prompt renders after session-boun
|
||||
|
||||
const boundaryIdx = blocks.indexOf(boundaryBlocks[0]);
|
||||
|
||||
// (b) The system-prompt must appear AFTER the boundary — not before it.
|
||||
// Production path: system-prompt rides in the prompt bundle of the turn that
|
||||
// carries the user @mention (acpSource "session/new" in the prompt segment).
|
||||
const systemPromptBlockIdx = blocks.findIndex(
|
||||
(b) =>
|
||||
(b.kind === "single" && b.item?.acpSource === "session/new") ||
|
||||
(b.kind === "turn" &&
|
||||
b.segments.some(
|
||||
(seg) =>
|
||||
seg.kind === "prompt" &&
|
||||
seg.systemPrompt?.acpSource === "session/new",
|
||||
)),
|
||||
// (b) Exactly two system-prompt standalone blocks — one per session.
|
||||
const systemPromptBlocks = blocks.filter(
|
||||
(b) => b.kind === "single" && b.item?.acpSource === "session/new",
|
||||
);
|
||||
assert.ok(
|
||||
systemPromptBlockIdx !== -1,
|
||||
"system-prompt item must be present in the output",
|
||||
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(
|
||||
boundaryIdx < systemPromptBlockIdx,
|
||||
`boundary (idx ${boundaryIdx}) must precede system-prompt (idx ${systemPromptBlockIdx})`,
|
||||
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 system-prompt.
|
||||
// This pins the full required order: boundary → system-prompt → activity.
|
||||
// Because session/prompt:user is present, the system-prompt rides inside the
|
||||
// prompt bundle of the same turn as the tool activity — they share the same
|
||||
// block. Use flat item order to assert system-prompt precedes activity.
|
||||
// (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 sess2ActivityItem = flat.find(
|
||||
(i) => i.type === "tool" && i.sessionId === "sess-2",
|
||||
);
|
||||
assert.ok(
|
||||
sess2ActivityItem,
|
||||
"sess-2 tool activity must be present in flattened output",
|
||||
);
|
||||
const sess2BlockIdx = blocks.findIndex((b) =>
|
||||
flattenDisplayBlocks([b]).some(
|
||||
(i) => i.type === "tool" && i.sessionId === "sess-2",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
boundaryIdx < sess2BlockIdx,
|
||||
`boundary (idx ${boundaryIdx}) must precede sess-2 activity (idx ${sess2BlockIdx})`,
|
||||
);
|
||||
// system-prompt and tool activity may be in the same turn block (prompt bundle
|
||||
// + activity segment). Assert ordering via flat item indices.
|
||||
const flatSystemPromptIdx = flat.findIndex(
|
||||
(i) => i.acpSource === "session/new",
|
||||
);
|
||||
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(
|
||||
flatSystemPromptIdx !== -1,
|
||||
"system-prompt must appear in flattened output",
|
||||
flatSess2PromptIdx !== -1,
|
||||
"sess-2 system-prompt must appear in flattened output",
|
||||
);
|
||||
assert.ok(
|
||||
flatSystemPromptIdx < flatSess2ActivityIdx,
|
||||
`system-prompt (flat idx ${flatSystemPromptIdx}) must precede sess-2 activity (flat idx ${flatSess2ActivityIdx})`,
|
||||
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 four-section system prompt card is standalone with all sections; CheckCheck context contains only Buzz/thread context", () => {
|
||||
// Production scenario: harness emits [Base]/[System]/[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 four sections in order:
|
||||
// Base → System → 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.",
|
||||
"",
|
||||
"[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 four 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", "Core Memory", "Channel Canvas"],
|
||||
"system-prompt standalone card must carry Base → System → 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/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 === "Core Memory" ||
|
||||
t === "Channel Canvas",
|
||||
),
|
||||
"prompt context must NOT contain system-prompt sections (Base/System/Core Memory/Channel Canvas)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,19 +115,6 @@ function replaceItem(d: TranscriptDraft, id: string, updated: TranscriptItem) {
|
||||
d.itemsById.set(id, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from the draft by id. Used when an item needs to be
|
||||
* repositioned — remove it from its current slot then push it to the tail.
|
||||
*/
|
||||
function removeItem(d: TranscriptDraft, id: string) {
|
||||
ensureMutable(d);
|
||||
const idx = d.items.findIndex((it) => it.id === id);
|
||||
if (idx !== -1) {
|
||||
d.items.splice(idx, 1);
|
||||
}
|
||||
d.itemsById.delete(id);
|
||||
}
|
||||
|
||||
function pushItem(d: TranscriptDraft, item: TranscriptItem) {
|
||||
ensureMutable(d);
|
||||
d.items.push(item);
|
||||
@@ -579,28 +566,6 @@ function upsertMetadata(
|
||||
) {
|
||||
const existing = d.itemsById.get(id);
|
||||
if (existing?.type === "metadata") {
|
||||
// A re-fire of a system-prompt item (acpSource "session/new") is a
|
||||
// session-restart signal. The existing item sits at the position of the
|
||||
// FIRST session's event, not the restart event — keeping it there would
|
||||
// leave it before the new-session boundary divider in the display feed.
|
||||
// Reposition it to the stream TAIL with the new event's timestamp so the
|
||||
// display grouper can forward it to the correct (new) session run.
|
||||
// All other metadata items (prompt-context, steer-context) replace in-place
|
||||
// as before — they don't carry cross-session boundary semantics.
|
||||
if (acpSource === "session/new") {
|
||||
removeItem(d, id);
|
||||
sealOpenMessages(d);
|
||||
pushItem(d, {
|
||||
...existing,
|
||||
sections,
|
||||
timestamp,
|
||||
channelId: ctx.channelId,
|
||||
turnId: ctx.turnId ?? existing.turnId,
|
||||
sessionId: ctx.sessionId ?? existing.sessionId,
|
||||
acpSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
replaceItem(d, id, {
|
||||
...existing,
|
||||
sections,
|
||||
@@ -905,11 +870,13 @@ export function processTranscriptEvent(
|
||||
}
|
||||
} else if (event.kind === "acp_write" && method === "session/new") {
|
||||
// The base + persona prompts ride session/new's systemPrompt, framed by
|
||||
// the harness as [Base]/[System]. Surface them as one "System prompt" item
|
||||
// keyed per channel-session — session/new fires once per channel-session,
|
||||
// so a re-created session correctly replaces the prior item.
|
||||
// turnId: null keeps it out of turn buckets; acpSource "session/new" lets
|
||||
// the display grouper inject it before prompt-context in the prompt segment.
|
||||
// the harness as [Base]/[System]/[Agent Memory — core]/[Channel Canvas].
|
||||
// Each session/new event is keyed by (seq, timestamp) — the same dedup
|
||||
// pair used by observerRelayStore — so distinct sessions each retain
|
||||
// their own system-prompt card even across archive rebuilds where two
|
||||
// processes may emit the same seq. turnId: null keeps it out of turn
|
||||
// buckets; acpSource "session/new" lets the display grouper place it
|
||||
// as a standalone card before the session's first turn.
|
||||
const params = asRecord(payload.params);
|
||||
const systemPrompt = asString(params.systemPrompt);
|
||||
if (systemPrompt) {
|
||||
@@ -917,7 +884,7 @@ export function processTranscriptEvent(
|
||||
if (sections.length > 0) {
|
||||
upsertMetadata(
|
||||
d,
|
||||
`system-prompt:${ch}`,
|
||||
`system-prompt:${ch}:${event.seq}:${event.timestamp}`,
|
||||
"System prompt",
|
||||
sections,
|
||||
event.timestamp,
|
||||
|
||||
@@ -580,7 +580,6 @@ test("buildTranscriptDisplayBlocks bundles steer message with steer context behi
|
||||
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.equal(steerSegment.systemPrompt, null);
|
||||
assert.deepEqual(steerSegment.setup, []);
|
||||
// No standalone "Prompt context" metadata row leaks into the feed.
|
||||
assert.ok(
|
||||
@@ -934,20 +933,20 @@ function firstTurnSequence() {
|
||||
];
|
||||
}
|
||||
|
||||
test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt", () => {
|
||||
// Regression for: pre-resolution null-session items (turn_started + session/new)
|
||||
// were assigned to a synthetic "unknown" run, causing the system prompt to emit
|
||||
// as a standalone "System prompt" row instead of staying in the prompt bundle.
|
||||
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) No standalone "System prompt" single block.
|
||||
// (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,
|
||||
0,
|
||||
"system prompt must not appear as a standalone single block",
|
||||
1,
|
||||
"system prompt must appear as exactly one standalone single block",
|
||||
);
|
||||
|
||||
// (b) No session-boundary blocks — this is a single-session transcript.
|
||||
@@ -958,7 +957,7 @@ test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt",
|
||||
"must be zero session-boundary blocks for a first-turn single-session sequence",
|
||||
);
|
||||
|
||||
// (c) System prompt is inside the turn group (consumed by the prompt bundle).
|
||||
// (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);
|
||||
@@ -966,8 +965,20 @@ test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt",
|
||||
(item) => item.acpSource === "session/new",
|
||||
);
|
||||
assert.ok(
|
||||
systemPromptInTurn,
|
||||
"system prompt item must be present inside a turn block (prompt bundle)",
|
||||
!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})`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1134,14 +1145,7 @@ test("buildTranscriptDisplayBlocks_restartScenario_systemPromptAfterBoundary", (
|
||||
// (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") ||
|
||||
(b.kind === "turn" &&
|
||||
b.segments.some(
|
||||
(seg) =>
|
||||
seg.kind === "prompt" &&
|
||||
seg.systemPrompt?.acpSource === "session/new",
|
||||
)),
|
||||
(b) => b.kind === "single" && b.item.acpSource === "session/new",
|
||||
);
|
||||
assert.ok(
|
||||
systemPromptBlockIndex !== -1,
|
||||
@@ -1227,7 +1231,7 @@ test("buildTranscriptDisplayBlocks_firstEverSession_systemPromptInSingleRun", ()
|
||||
"no session-boundary block for a first-ever single session",
|
||||
);
|
||||
|
||||
// System prompt must appear somewhere in the output (in the turn bundle).
|
||||
// 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"),
|
||||
@@ -1406,3 +1410,302 @@ test("buildTranscriptDisplayBlocks_sessionBoundary_keyStableAcrossPrepend", () =
|
||||
"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`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ export type TranscriptTurnSegment =
|
||||
| {
|
||||
kind: "prompt";
|
||||
user: Extract<TranscriptItem, { type: "message" }>;
|
||||
systemPrompt: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
context: Extract<TranscriptItem, { type: "metadata" }> | null;
|
||||
setup: Extract<TranscriptItem, { type: "lifecycle" }>[];
|
||||
};
|
||||
@@ -139,13 +138,7 @@ type TurnBucket = {
|
||||
items: TranscriptItem[];
|
||||
};
|
||||
|
||||
function classifyTurnItems(
|
||||
items: TranscriptItem[],
|
||||
externalSystemPrompt: Extract<
|
||||
TranscriptItem,
|
||||
{ type: "metadata" }
|
||||
> | null = null,
|
||||
): TranscriptTurnSegment[] {
|
||||
function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
|
||||
const userPrompt = items.find(isUserPrompt) ?? null;
|
||||
const setupLifecycle = items.filter(isSetupLifecycle);
|
||||
const promptContext = items.find(isPromptContext) ?? null;
|
||||
@@ -168,7 +161,6 @@ function classifyTurnItems(
|
||||
return {
|
||||
kind: "prompt",
|
||||
user: item,
|
||||
systemPrompt: null,
|
||||
context: pendingSteerContexts.shift() ?? null,
|
||||
setup: [],
|
||||
};
|
||||
@@ -190,7 +182,6 @@ function classifyTurnItems(
|
||||
{
|
||||
kind: "prompt",
|
||||
user: userPrompt,
|
||||
systemPrompt: externalSystemPrompt,
|
||||
context: promptContext,
|
||||
setup: setupLifecycle,
|
||||
},
|
||||
@@ -406,8 +397,7 @@ function getRenderClass(item: TranscriptItem) {
|
||||
* Pre-resolution items arrive with `sessionId: null` before any session has
|
||||
* been assigned. We defer those leading null-session items and **prepend them
|
||||
* to the first run that has a non-null sessionId**, so they stay in the same
|
||||
* session run as the turn they belong to and the `pendingSystemPrompt` slot in
|
||||
* `buildBlocksForRun` can consume them correctly.
|
||||
* session run as the turn they belong to.
|
||||
*
|
||||
* **Restart / session-boundary ordering**: on a restart the normalizer stamps
|
||||
* `session/new` with `latestSessionId` (the OLD session's id) because the new
|
||||
@@ -415,13 +405,14 @@ function getRenderClass(item: TranscriptItem) {
|
||||
* (stale), not null. Under the plain grouping rule it lands in the prior run,
|
||||
* causing System Prompt to render ABOVE the session-boundary divider.
|
||||
*
|
||||
* Fix: a `session/new` marker (`isSystemPrompt`) is a session-START signal.
|
||||
* Fix: `session/new` markers (`isSystemPrompt`) are session-START signals.
|
||||
* When one arrives and a prior run already exists, park it (and any null-session
|
||||
* items that follow) in `pendingNewRunBuffer`. When the next distinct non-null
|
||||
* sessionId resolves, flush the buffer to the HEAD of that new run — placing
|
||||
* System Prompt after the boundary. If no new session ever resolves after the
|
||||
* marker (stream ends mid-restart), flush back into the current run so nothing
|
||||
* is dropped.
|
||||
* items that follow) in `pendingNewRunBuffer`. Multiple markers may arrive
|
||||
* before the new session resolves (rapid restart loop) — all are accumulated
|
||||
* in order. When the next distinct non-null sessionId resolves, flush the
|
||||
* buffer to the HEAD of that new run — placing all System Prompt cards after
|
||||
* the boundary. If no new session ever resolves after the marker(s) (stream
|
||||
* ends mid-restart), flush back into the current run so nothing is dropped.
|
||||
*
|
||||
* Mid-stream null-session items (after at least one session has resolved, and
|
||||
* no pending-new-run buffer is open) are attributed to the most recently seen
|
||||
@@ -439,7 +430,7 @@ function splitIntoSessionRuns(
|
||||
let currentRun: { sessionId: string; items: TranscriptItem[] } | null = null;
|
||||
// Buffer for items that arrive before any session has resolved.
|
||||
const preSessionBuffer: TranscriptItem[] = [];
|
||||
// Buffer for a session/new marker (and any null-session items trailing it)
|
||||
// Buffer for session/new marker(s) (and any null-session items trailing them)
|
||||
// that must be re-anchored to the NEXT resolved session (restart scenario).
|
||||
let pendingNewRunBuffer: TranscriptItem[] | null = null;
|
||||
|
||||
@@ -462,8 +453,15 @@ function splitIntoSessionRuns(
|
||||
|
||||
// A session/new marker after a prior run is a restart signal: park it in
|
||||
// the pending buffer so it re-anchors to the next distinct session run.
|
||||
// Multiple session/new markers may arrive before the new session resolves
|
||||
// (e.g. a rapid restart loop) — accumulate all of them rather than
|
||||
// reinitializing, so no marker is silently dropped.
|
||||
if (isSystemPrompt(item) && currentRun !== null) {
|
||||
pendingNewRunBuffer = [item];
|
||||
if (pendingNewRunBuffer !== null) {
|
||||
pendingNewRunBuffer.push(item);
|
||||
} else {
|
||||
pendingNewRunBuffer = [item];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -512,10 +510,11 @@ function splitIntoSessionRuns(
|
||||
* Raw observer order is preserved in the source items; this only reorders
|
||||
* within a turn for user-facing narrative flow.
|
||||
*
|
||||
* System-prompt items (acpSource "session/new") are per-channel singles with
|
||||
* turnId=null. They are injected into the prompt segment of the first turn
|
||||
* that follows them in stream order — placing System prompt between the user
|
||||
* message bubble and the Prompt context sections in the rendered output.
|
||||
* System-prompt items (acpSource "session/new") are keyed per source-session
|
||||
* event identity (channel + seq + timestamp) so each session retains its own
|
||||
* card. They render as standalone single blocks at the head of their session
|
||||
* run — before the run's first turn — and never enter the prompt segment or
|
||||
* the CheckCheck (Prompt context) dialog.
|
||||
*
|
||||
* When items span multiple sessions (archived history + live session), a
|
||||
* `session-boundary` block is injected between consecutive session runs.
|
||||
@@ -611,25 +610,50 @@ function buildBlocksForRun(
|
||||
{ kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string }
|
||||
> = [];
|
||||
|
||||
// System-prompt items (turnId=null, acpSource "session/new") accumulate here
|
||||
// until consumed by the first turn that follows them in stream order.
|
||||
let pendingSystemPrompt: Extract<
|
||||
TranscriptItem,
|
||||
{ type: "metadata" }
|
||||
> | null = null;
|
||||
// Strategy: accumulate null-session items into `openBatch` while no turn has
|
||||
// been seen. The batch opens on the first system-prompt item. When the first
|
||||
// turn-bound item is encountered (whether it creates a new bucket or reuses
|
||||
// one already started earlier — e.g. turn_started → session/new →
|
||||
// session_resolved, all on the same turn ID), the open batch is bound to that
|
||||
// turn ID in `pendingForTurn` and sealed (openBatch = null) so subsequent
|
||||
// null-session items fall through to inline emission and are NOT hoisted to
|
||||
// the session head. Append to any existing batch for that turn rather than
|
||||
// overwrite so a repeated seal on the same turn cannot lose an earlier batch.
|
||||
// End-of-stream fallback: if no turn ever arrives, emit the open batch as
|
||||
// standalone singles so the cards remain visible.
|
||||
let openBatch: TranscriptItem[] | null = null;
|
||||
// Maps a turn ID to the items that must flush before that turn in the render pass.
|
||||
const pendingForTurn = new Map<string, TranscriptItem[]>();
|
||||
|
||||
for (const item of items) {
|
||||
const turnId = item.turnId;
|
||||
if (!turnId) {
|
||||
if (isSystemPrompt(item)) {
|
||||
// Hold system-prompt for injection into the next turn's prompt segment.
|
||||
pendingSystemPrompt = item;
|
||||
if (openBatch !== null) {
|
||||
// Pre-turn buffering is active — accumulate in wire order.
|
||||
openBatch.push(item);
|
||||
} else if (isSystemPrompt(item)) {
|
||||
// First system-prompt seen and no turn yet — open the batch.
|
||||
openBatch = [item];
|
||||
} else {
|
||||
// No pending batch, or batch already sealed; emit inline.
|
||||
displayOrder.push({ kind: "single", item });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seal the open batch on the FIRST turn-bound item encountered while
|
||||
// buffering is active — new bucket or existing (e.g. session_resolved
|
||||
// reusing the turn_started bucket).
|
||||
if (openBatch !== null) {
|
||||
const existing = pendingForTurn.get(turnId);
|
||||
if (existing) {
|
||||
existing.push(...openBatch);
|
||||
} else {
|
||||
pendingForTurn.set(turnId, openBatch);
|
||||
}
|
||||
openBatch = null;
|
||||
}
|
||||
|
||||
let bucket = turnBuckets.get(turnId);
|
||||
if (!bucket) {
|
||||
bucket = { turnId, items: [] };
|
||||
@@ -639,9 +663,6 @@ function buildBlocksForRun(
|
||||
bucket.items.push(item);
|
||||
}
|
||||
|
||||
// Track per-turn injected system-prompt so multi-turn streams don't re-inject.
|
||||
const consumedSystemPrompts = new Set<string>();
|
||||
|
||||
for (const entry of displayOrder) {
|
||||
if (entry.kind === "single") {
|
||||
blocks.push({ kind: "single", item: entry.item });
|
||||
@@ -653,32 +674,16 @@ function buildBlocksForRun(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inject system-prompt into the first turn that has a user-prompt item
|
||||
// (it surfaces in the prompt bundle before user/context/activity).
|
||||
// If the turn has no user prompt, emit the system-prompt as a standalone
|
||||
// block BEFORE the turn so it always leads the session regardless of
|
||||
// whether a user prompt is present.
|
||||
let systemPromptForTurn: Extract<
|
||||
TranscriptItem,
|
||||
{ type: "metadata" }
|
||||
> | null = null;
|
||||
if (
|
||||
pendingSystemPrompt &&
|
||||
!consumedSystemPrompts.has(pendingSystemPrompt.id)
|
||||
) {
|
||||
if (bucket.items.some(isUserPrompt)) {
|
||||
// Consume into the prompt bundle.
|
||||
systemPromptForTurn = pendingSystemPrompt;
|
||||
consumedSystemPrompts.add(pendingSystemPrompt.id);
|
||||
} else {
|
||||
// No user prompt in this turn — emit standalone before the turn block
|
||||
// so the system prompt leads the session (boundary → prompt → activity).
|
||||
blocks.push({ kind: "single", item: pendingSystemPrompt });
|
||||
consumedSystemPrompts.add(pendingSystemPrompt.id);
|
||||
// Flush any items bound to this turn (system-prompt card(s) plus any
|
||||
// interleaved null-session frames) as standalone blocks before the turn.
|
||||
const beforeTurn = pendingForTurn.get(entry.turnId);
|
||||
if (beforeTurn) {
|
||||
for (const pending of beforeTurn) {
|
||||
blocks.push({ kind: "single", item: pending });
|
||||
}
|
||||
}
|
||||
|
||||
const segments = classifyTurnItems(bucket.items, systemPromptForTurn);
|
||||
const segments = classifyTurnItems(bucket.items);
|
||||
if (segments.length > 0) {
|
||||
blocks.push({
|
||||
kind: "turn",
|
||||
@@ -688,14 +693,13 @@ function buildBlocksForRun(
|
||||
}
|
||||
}
|
||||
|
||||
// If system-prompt was never consumed (session/new arrived without any
|
||||
// subsequent turn — stream still incomplete or mid-restart), emit it as a
|
||||
// standalone single so it remains visible and is not silently dropped.
|
||||
if (
|
||||
pendingSystemPrompt &&
|
||||
!consumedSystemPrompts.has(pendingSystemPrompt.id)
|
||||
) {
|
||||
blocks.push({ kind: "single", item: pendingSystemPrompt });
|
||||
// End-of-stream fallback: session/new arrived but no turn followed yet
|
||||
// (incomplete stream or mid-restart). Emit as standalone singles so the
|
||||
// cards remain visible and are not silently dropped.
|
||||
if (openBatch !== null) {
|
||||
for (const pending of openBatch) {
|
||||
blocks.push({ kind: "single", item: pending });
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
@@ -724,9 +728,6 @@ export function flattenDisplayBlocks(
|
||||
} else if (segment.kind === "prompt") {
|
||||
result.push(segment.user);
|
||||
result.push(...segment.setup);
|
||||
if (segment.systemPrompt) {
|
||||
result.push(segment.systemPrompt);
|
||||
}
|
||||
if (segment.context) {
|
||||
result.push(segment.context);
|
||||
}
|
||||
|
||||
@@ -237,3 +237,255 @@ test("parseSystemPromptSections returns no sections for empty input", () => {
|
||||
assert.deepEqual(parseSystemPromptSections(""), []);
|
||||
assert.deepEqual(parseSystemPromptSections(" "), []);
|
||||
});
|
||||
|
||||
// ── [Agent Memory — core] section tests ──────────────────────────────────────
|
||||
|
||||
test("parseSystemPromptSections extracts core as its own section after Base+System", () => {
|
||||
const framed =
|
||||
"[Base]\nbase text\n\n[System]\npersona text\n\n[Agent Memory — core]\nmy memories";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text" },
|
||||
{ title: "System", body: "persona text" },
|
||||
{ title: "Core Memory", body: "my memories" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections extracts core as its own section after Base only", () => {
|
||||
const framed = "[Base]\nbase text\n\n[Agent Memory — core]\nmy memories";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text" },
|
||||
{ title: "Core Memory", body: "my memories" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections extracts core as its own section after System only", () => {
|
||||
const framed = "[System]\npersona text\n\n[Agent Memory — core]\nmy memories";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "System", body: "persona text" },
|
||||
{ title: "Core Memory", body: "my memories" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections returns only a core section when no Base/System present", () => {
|
||||
const framed = "[Agent Memory — core]\nmy memories";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [{ title: "Core Memory", body: "my memories" }]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections keeps an embedded core-like line literal when a real appended core follows", () => {
|
||||
// The persona body contains a line that looks like the header. The actual
|
||||
// appended core block comes last — only the LAST boundary should split.
|
||||
const framed = [
|
||||
"[Base]",
|
||||
"base text",
|
||||
"",
|
||||
"[System]",
|
||||
"persona preamble",
|
||||
"[Agent Memory — core]",
|
||||
"this is NOT the core section — it is inside the persona body",
|
||||
"",
|
||||
"[Agent Memory — core]",
|
||||
"this IS the appended core",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text" },
|
||||
{
|
||||
title: "System",
|
||||
body: "persona preamble\n[Agent Memory — core]\nthis is NOT the core section — it is inside the persona body",
|
||||
},
|
||||
{
|
||||
title: "Core Memory",
|
||||
body: "this IS the appended core",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections keeps exact core header literal when only a single newline precedes it (no-core persona)", () => {
|
||||
// A no-core [System] persona that contains the exact header text on its own
|
||||
// line (preceded by only a single \n, not the double-newline appended
|
||||
// separator) must NOT be extracted as a Core Memory section.
|
||||
const framed = [
|
||||
"[System]",
|
||||
"persona preamble",
|
||||
"[Agent Memory — core]",
|
||||
"this is persona text, not a real core block",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "System",
|
||||
body: "persona preamble\n[Agent Memory — core]\nthis is persona text, not a real core block",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => {
|
||||
// The real Buzz harness emits [Workspace] content before [Base]. The parser
|
||||
// folds [Workspace] into the Base section (existing unchanged behavior);
|
||||
// core is extracted as a distinct "Core Memory" section last.
|
||||
const framed = [
|
||||
"[Workspace]",
|
||||
"You are operating inside the Buzz platform.",
|
||||
"",
|
||||
"[Base]",
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"[System]",
|
||||
"Custom persona instructions.",
|
||||
"",
|
||||
"[Agent Memory — core]",
|
||||
"I am Duncan.",
|
||||
"## Lessons Learned",
|
||||
"Always tag on handoff.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "Base",
|
||||
body: "[Workspace]\nYou are operating inside the Buzz platform.\n\n[Base]\nYou are an assistant.",
|
||||
},
|
||||
{ title: "System", body: "Custom persona instructions." },
|
||||
{
|
||||
title: "Core Memory",
|
||||
body: "I am Duncan.\n## Lessons Learned\nAlways tag on handoff.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Channel Canvas extraction ─────────────────────────────────────────────────
|
||||
|
||||
test("parseSystemPromptSections pins the full Base+System+Core+Canvas harness shape", () => {
|
||||
// with_canvas() appends "\n\n[Channel Canvas]\n{metadata}" after the core block.
|
||||
// render_canvas_section() emits the revision event ID, last-modified timestamp,
|
||||
// and fetch command — never the canvas body. All four sections are extracted in order.
|
||||
const framed = [
|
||||
"[Base]",
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"[System]",
|
||||
"Persona instructions.",
|
||||
"",
|
||||
"[Agent Memory — core]",
|
||||
"I am Duncan.",
|
||||
"",
|
||||
"[Channel Canvas]",
|
||||
"Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||
"Last modified: 2026-07-11T10:00:00Z",
|
||||
"Fetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "You are an assistant." },
|
||||
{ title: "System", body: "Persona instructions." },
|
||||
{ title: "Core Memory", body: "I am Duncan." },
|
||||
{
|
||||
title: "Channel Canvas",
|
||||
body: "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections extracts canvas when no Base/System/Core present (canvas-only)", () => {
|
||||
const framed = ["[Channel Canvas]", "Canvas only."].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Channel Canvas", body: "Canvas only." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections extracts Core+Canvas with no Base/System", () => {
|
||||
const framed = [
|
||||
"[Agent Memory — core]",
|
||||
"Core content.",
|
||||
"",
|
||||
"[Channel Canvas]",
|
||||
"Canvas content.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Core Memory", body: "Core content." },
|
||||
{ title: "Channel Canvas", body: "Canvas content." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections extracts Base+Canvas when no System or Core", () => {
|
||||
const framed = [
|
||||
"[Base]",
|
||||
"Base content.",
|
||||
"",
|
||||
"[Channel Canvas]",
|
||||
"Canvas content.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "Base content." },
|
||||
{ title: "Channel Canvas", body: "Canvas content." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections omits canvas section when no canvas present (no regression)", () => {
|
||||
// Existing Base+System+Core shape must still produce exactly three sections.
|
||||
const framed = [
|
||||
"[Base]",
|
||||
"Base.",
|
||||
"",
|
||||
"[System]",
|
||||
"System.",
|
||||
"",
|
||||
"[Agent Memory — core]",
|
||||
"Core.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "Base." },
|
||||
{ title: "System", body: "System." },
|
||||
{ title: "Core Memory", body: "Core." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections keeps embedded canvas-like line literal when only a single newline precedes it (no-canvas persona)", () => {
|
||||
// A [Channel Canvas] header inside a persona body preceded by a single \n
|
||||
// (not the double-newline appended separator) must NOT be extracted as a
|
||||
// Canvas section — same last-occurrence guard as the Core extraction.
|
||||
const framed = [
|
||||
"[System]",
|
||||
"persona preamble",
|
||||
"[Channel Canvas]",
|
||||
"this is persona text, not a real canvas block",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "System",
|
||||
body: "persona preamble\n[Channel Canvas]\nthis is persona text, not a real canvas block",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections keeps an embedded canvas-like line literal when a real appended canvas follows", () => {
|
||||
// If the persona body contains "[Channel Canvas]\n..." with only a single
|
||||
// preceding newline AND a real appended canvas (double-newline boundary)
|
||||
// follows, the LAST-occurrence guard picks the appended one as the real
|
||||
// boundary, leaving the embedded one in the System body.
|
||||
const framed = [
|
||||
"[System]",
|
||||
"persona preamble",
|
||||
"[Channel Canvas]",
|
||||
"this is inside the persona body — NOT the real canvas",
|
||||
"",
|
||||
"[Channel Canvas]",
|
||||
"this IS the appended canvas",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "System",
|
||||
body: "persona preamble\n[Channel Canvas]\nthis is inside the persona body — NOT the real canvas",
|
||||
},
|
||||
{ title: "Channel Canvas", body: "this IS the appended canvas" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -56,42 +56,94 @@ export function parsePromptText(text: string): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the framed `session/new` `systemPrompt` into its `Base`/`System`
|
||||
* sub-sections deterministically.
|
||||
* Split the framed `session/new` `systemPrompt` into its `Base`/`System`/
|
||||
* `Core Memory`/`Channel Canvas` sub-sections deterministically.
|
||||
*
|
||||
* The harness frames the value as `[Base]\n{base}\n\n[System]\n{persona}`, with
|
||||
* either prompt omitted when absent: base-only is `[Base]\n{base}`, persona-only
|
||||
* is `[System]\n{persona}`. We partition on the FIRST `\n[System]\n` boundary and
|
||||
* read each labeled body literally. Unlike the generic `parsePromptSections`,
|
||||
* embedded `[...]` lines inside a body never start a new section — so a persona
|
||||
* containing a bracketed line, or a mid-string-elided header on an oversize
|
||||
* prompt, can never drop a label or inflate the section count.
|
||||
* The harness composes the value in order:
|
||||
* `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
|
||||
* with any section omitted when absent. Extraction runs in reverse producer
|
||||
* order so that each `lastIndexOf` search operates on the full input and
|
||||
* each extraction boundary is unambiguous.
|
||||
*
|
||||
* Three extraction passes before Base/System parsing:
|
||||
*
|
||||
* 1. **Canvas** (`[Channel Canvas]`): appended last by `with_canvas()`.
|
||||
* - Start-of-string: canvas-only input.
|
||||
* - Appended frame (`\n\n[Channel Canvas]\n`): blank-line separator used by
|
||||
* `with_canvas()`; LAST occurrence guards against an embedded header in a
|
||||
* persona body (single preceding newline only).
|
||||
*
|
||||
* 2. **Core** (`[Agent Memory — core]`): appended before canvas by `with_core()`.
|
||||
* Same two cases, same last-occurrence guard.
|
||||
*
|
||||
* 3. **Base/System**: remainder after canvas and core extraction.
|
||||
* Split on the first `\n[System]\n` boundary; no embedded `[...]` line
|
||||
* inside a body can start a new section.
|
||||
*/
|
||||
export function parseSystemPromptSections(
|
||||
systemPrompt: string,
|
||||
): PromptSection[] {
|
||||
const sections: PromptSection[] = [];
|
||||
|
||||
// Persona-only frame: no [Base], starts directly with [System].
|
||||
if (systemPrompt.startsWith("[System]\n")) {
|
||||
const body = systemPrompt.slice("[System]\n".length).trim();
|
||||
if (body) sections.push({ title: "System", body });
|
||||
return sections;
|
||||
// ── 1. Extract [Channel Canvas] ───────────────────────────────────────────
|
||||
const CANVAS_HEADER = "[Channel Canvas]";
|
||||
const CANVAS_MARKER_INLINE = `\n\n${CANVAS_HEADER}\n`;
|
||||
let canvasBody: string | null = null;
|
||||
let remainder = systemPrompt;
|
||||
|
||||
if (remainder.startsWith(`${CANVAS_HEADER}\n`)) {
|
||||
canvasBody = remainder.slice(`${CANVAS_HEADER}\n`.length).trim();
|
||||
remainder = "";
|
||||
} else {
|
||||
const lastCanvas = remainder.lastIndexOf(CANVAS_MARKER_INLINE);
|
||||
if (lastCanvas !== -1) {
|
||||
canvasBody = remainder
|
||||
.slice(lastCanvas + CANVAS_MARKER_INLINE.length)
|
||||
.trim();
|
||||
remainder = remainder.slice(0, lastCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise the head (up to the first [System] boundary, or the whole string)
|
||||
// is the [Base] body.
|
||||
const marker = "\n[System]\n";
|
||||
const at = systemPrompt.indexOf(marker);
|
||||
const head = at === -1 ? systemPrompt : systemPrompt.slice(0, at);
|
||||
const baseBody = head.replace(/^\[Base]\n/, "").trim();
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
// ── 2. Extract [Agent Memory — core] ──────────────────────────────────────
|
||||
const CORE_HEADER = "[Agent Memory — core]";
|
||||
const CORE_MARKER_INLINE = `\n\n${CORE_HEADER}\n`;
|
||||
let coreBody: string | null = null;
|
||||
|
||||
if (at !== -1) {
|
||||
const systemBody = systemPrompt.slice(at + marker.length).trim();
|
||||
sections.push({ title: "System", body: systemBody });
|
||||
if (remainder.startsWith(`${CORE_HEADER}\n`)) {
|
||||
coreBody = remainder.slice(`${CORE_HEADER}\n`.length).trim();
|
||||
remainder = "";
|
||||
} else {
|
||||
const lastCore = remainder.lastIndexOf(CORE_MARKER_INLINE);
|
||||
if (lastCore !== -1) {
|
||||
coreBody = remainder.slice(lastCore + CORE_MARKER_INLINE.length).trim();
|
||||
remainder = remainder.slice(0, lastCore);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Parse Base/System from the remaining prefix ────────────────────────
|
||||
const baseAndSystem = remainder;
|
||||
if (baseAndSystem) {
|
||||
if (baseAndSystem.startsWith("[System]\n")) {
|
||||
const body = baseAndSystem.slice("[System]\n".length).trim();
|
||||
if (body) sections.push({ title: "System", body });
|
||||
} else {
|
||||
const marker = "\n[System]\n";
|
||||
const at = baseAndSystem.indexOf(marker);
|
||||
const head = at === -1 ? baseAndSystem : baseAndSystem.slice(0, at);
|
||||
const baseBody = head.replace(/^\[Base]\n/, "").trim();
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
|
||||
if (at !== -1) {
|
||||
const systemBody = baseAndSystem.slice(at + marker.length).trim();
|
||||
if (systemBody) sections.push({ title: "System", body: systemBody });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Append core and canvas sections in producer order ──────────────────
|
||||
if (coreBody) sections.push({ title: "Core Memory", body: coreBody });
|
||||
if (canvasBody) sections.push({ title: "Channel Canvas", body: canvasBody });
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
|
||||
214
desktop/tests/e2e/core-memory-screenshots.spec.ts
Normal file
214
desktop/tests/e2e/core-memory-screenshots.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/core-memory";
|
||||
|
||||
const OBSERVER_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey;
|
||||
const CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301";
|
||||
const NOW = new Date("2025-06-15T12:00:00Z").toISOString();
|
||||
|
||||
const MANAGED_AGENTS = [
|
||||
{
|
||||
pubkey: OBSERVER_AGENT_PUBKEY,
|
||||
name: "Observer Agent",
|
||||
status: "running" as const,
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
];
|
||||
|
||||
const SYSTEM_PROMPT_WITH_CORE =
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n" +
|
||||
"[System]\nYou are Observer Agent. You coordinate multi-agent workflows.\n\n" +
|
||||
"[Agent Memory — core]\n" +
|
||||
"I am Duncan — full-stack executor on the Buzz team.\n\n" +
|
||||
"## Lessons Learned\n\n" +
|
||||
"### Tag teammates on handoff — ALWAYS (CRITICAL)\n" +
|
||||
"After completing a task, @mention the next person in the workflow.\n\n" +
|
||||
"### Source claims require a fresh fetch (CRITICAL)\n" +
|
||||
"Never quote repo source off a stale clone.";
|
||||
|
||||
async function waitForSeedHook(page: import("@playwright/test").Page) {
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function",
|
||||
null,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function openObserverFeedPanel(
|
||||
page: import("@playwright/test").Page,
|
||||
agentPubkey: string,
|
||||
) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForSeedHook(page);
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
const messageRow = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ has: page.getByText("Observer Agent", { exact: false }) });
|
||||
await expect(messageRow.first()).toBeVisible({ timeout: 8_000 });
|
||||
await messageRow.first().getByRole("button").first().click();
|
||||
const profilePanel = page.getByTestId("user-profile-panel");
|
||||
await expect(profilePanel).toBeVisible({ timeout: 10_000 });
|
||||
const activityBtn = page.getByTestId(
|
||||
`user-profile-view-activity-${agentPubkey}`,
|
||||
);
|
||||
await expect(activityBtn).toBeVisible({ timeout: 5_000 });
|
||||
await activityBtn.click();
|
||||
const feedPanel = page.getByTestId("agent-session-thread-panel");
|
||||
await expect(feedPanel).toBeVisible({ timeout: 10_000 });
|
||||
return feedPanel;
|
||||
}
|
||||
|
||||
async function seedObserverEvents(
|
||||
page: import("@playwright/test").Page,
|
||||
agentPubkey: string,
|
||||
events: Array<{
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
kind: string;
|
||||
agentIndex: number | null;
|
||||
channelId: string | null;
|
||||
sessionId: string | null;
|
||||
turnId: string | null;
|
||||
payload: unknown;
|
||||
}>,
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ pubkey, evts }) => {
|
||||
window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({
|
||||
agentPubkey: pubkey,
|
||||
events: evts,
|
||||
});
|
||||
},
|
||||
{ pubkey: agentPubkey, evts: events },
|
||||
);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
async function settleAnimations(panel: import("@playwright/test").Locator) {
|
||||
await panel.evaluate((el) =>
|
||||
Promise.all(
|
||||
el
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((a) => {
|
||||
const timing = a.effect?.getTiming();
|
||||
return timing?.iterations !== Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.map((a) => a.finished),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("core memory section screenshots", () => {
|
||||
test.use({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (err) => {
|
||||
console.error("PAGE ERROR:", err.message);
|
||||
});
|
||||
});
|
||||
|
||||
test("01-core-memory-collapsed", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: null,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { systemPrompt: SYSTEM_PROMPT_WITH_CORE },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("System prompt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Open the outer details element so the sections are visible.
|
||||
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
|
||||
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
|
||||
for (const details of el.querySelectorAll("details")) {
|
||||
details.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for Core Memory section label to appear (collapsed by default).
|
||||
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
|
||||
// Crop to the transcript-metadata-item to show the sections tightly.
|
||||
const metadataItem = feedPanel.getByTestId("transcript-metadata-item");
|
||||
await metadataItem.screenshot({
|
||||
path: `${SHOTS}/01-core-memory-collapsed.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("02-core-memory-expanded", async ({ page }) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
const feedPanel = await openObserverFeedPanel(page, OBSERVER_AGENT_PUBKEY);
|
||||
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: NOW,
|
||||
kind: "acp_write",
|
||||
agentIndex: 0,
|
||||
channelId: CHANNEL_ID,
|
||||
sessionId: "session-001",
|
||||
turnId: null,
|
||||
payload: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { systemPrompt: SYSTEM_PROMPT_WITH_CORE },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(feedPanel.getByText("System prompt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
|
||||
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
|
||||
for (const details of el.querySelectorAll("details")) {
|
||||
details.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Expand all section accordions including Core Memory.
|
||||
const sectionButtons = feedPanel
|
||||
.getByTestId("transcript-metadata-item")
|
||||
.getByTestId("transcript-prompt-context-sections")
|
||||
.getByRole("button");
|
||||
const allButtons = await sectionButtons.all();
|
||||
expect(allButtons.length).toBeGreaterThan(0);
|
||||
for (const btn of allButtons) {
|
||||
await btn.click();
|
||||
}
|
||||
|
||||
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settleAnimations(feedPanel);
|
||||
|
||||
const metadataItem = feedPanel.getByTestId("transcript-metadata-item");
|
||||
await metadataItem.screenshot({
|
||||
path: `${SHOTS}/02-core-memory-expanded.png`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -418,7 +418,7 @@ test.describe("observer feed screenshots", () => {
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt:
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -428,6 +428,24 @@ test.describe("observer feed screenshots", () => {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// All four section headings must be present in the card — expand it first.
|
||||
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
|
||||
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
|
||||
for (const details of el.querySelectorAll("details")) {
|
||||
details.open = true;
|
||||
}
|
||||
});
|
||||
await expect(feedPanel.getByText("Base")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(feedPanel.getByText("System", { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(feedPanel.getByText("Channel Canvas")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Open the outer ActivityRow <details> to reveal the section content,
|
||||
// then click each section accordion button to expand it (mirrors shot 05 —
|
||||
// system-prompt sections now render as React button accordions, not native
|
||||
@@ -640,7 +658,7 @@ test.describe("observer feed screenshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("11 — first-turn bundle: user bubble + Checks ingress dialog", async ({
|
||||
test("11 — first-turn bundle: standalone system-prompt card + Checks ingress (per-turn context only)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { managedAgents: MANAGED_AGENTS });
|
||||
@@ -648,9 +666,10 @@ test.describe("observer feed screenshots", () => {
|
||||
|
||||
// Full realistic pool.rs first-turn wire sequence:
|
||||
// turn_started → session/new → session_resolved → session/prompt
|
||||
// Verifies the prompt bundle keeps context out of the feed: the system
|
||||
// prompt and prompt context both live in the dialog opened via the
|
||||
// CheckCheck footer ingress, with system-prompt sections listed first.
|
||||
// Verifies the consolidated presentation: session/new.systemPrompt always
|
||||
// renders as a standalone top-level "System prompt" card (never injected into
|
||||
// the CheckCheck bundle). The CheckCheck dialog contains only per-turn context
|
||||
// (Buzz event / Thread context) — no Base/System/Core Memory/Channel Canvas sections.
|
||||
await seedObserverEvents(page, OBSERVER_AGENT_PUBKEY, [
|
||||
{
|
||||
seq: 1,
|
||||
@@ -676,7 +695,7 @@ test.describe("observer feed screenshots", () => {
|
||||
method: "session/new",
|
||||
params: {
|
||||
systemPrompt:
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.",
|
||||
"[Base]\nYou are a helpful AI assistant running in Buzz.\n\n[System]\nYou are Observer Agent. You coordinate multi-agent workflows in the #agents channel.\n\n[Agent Memory — core]\nI am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.\n\n[Channel Canvas]\nCanvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -722,22 +741,62 @@ test.describe("observer feed screenshots", () => {
|
||||
await expect(feedPanel.getByTestId("transcript-prompt-bundle")).toBeVisible(
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
// Neither the system prompt nor the prompt context renders in the feed.
|
||||
await expect(feedPanel.getByText("System prompt")).toHaveCount(0);
|
||||
|
||||
// Scroll to the top of the feed to bring the standalone "System prompt"
|
||||
// card (which precedes the user bubble) into view.
|
||||
await feedPanel.evaluate((el) => el.scrollTo({ top: 0 }));
|
||||
|
||||
// Standalone "System prompt" card is visible as a top-level feed row —
|
||||
// it must remain present even after the first prompt arrives.
|
||||
await expect(feedPanel.getByText("System prompt")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// The standalone card shows "4 sections" collapsed — expand it to reveal
|
||||
// the section headings, then assert all four are present.
|
||||
await feedPanel.getByTestId("transcript-metadata-item").evaluate((el) => {
|
||||
if (el.tagName === "DETAILS") (el as HTMLDetailsElement).open = true;
|
||||
for (const details of el.querySelectorAll("details")) {
|
||||
details.open = true;
|
||||
}
|
||||
});
|
||||
await expect(feedPanel.getByText("Base")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(feedPanel.getByText("System", { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(feedPanel.getByText("Core Memory")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(feedPanel.getByText("Channel Canvas")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Per-turn prompt context (Buzz event / Thread context) does NOT appear
|
||||
// as a standalone feed row — it lives behind the CheckCheck toggle.
|
||||
await expect(feedPanel.getByText("Prompt context")).toHaveCount(0);
|
||||
|
||||
// Open the dialog: system-prompt sections (Base/System) come before the
|
||||
// prompt-context sections (Buzz event/Thread context).
|
||||
// Open the CheckCheck dialog: it contains ONLY per-turn context sections
|
||||
// (Buzz event, Thread context). Base/System/Core Memory/Channel Canvas must NOT appear.
|
||||
await feedPanel.getByTestId("transcript-prompt-context-toggle").click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
||||
const sectionTitles = await dialog
|
||||
const sectionArticles = dialog
|
||||
.getByTestId("transcript-prompt-context-sections")
|
||||
.locator("article")
|
||||
.allInnerTexts();
|
||||
expect(sectionTitles.length).toBe(4);
|
||||
expect(sectionTitles[0]).toContain("Base");
|
||||
expect(sectionTitles[1]).toContain("System");
|
||||
.locator("article");
|
||||
const sectionTitles = await sectionArticles.allInnerTexts();
|
||||
// Only per-turn context sections (Buzz event + Thread context) — no system-prompt sections.
|
||||
expect(sectionTitles.length).toBe(2);
|
||||
expect(sectionTitles[0]).toContain("Buzz event");
|
||||
expect(sectionTitles[1]).toContain("Thread context");
|
||||
// Collect all article heading text and assert none of the four
|
||||
// system-prompt section labels appear — including exact "System" which
|
||||
// would be ambiguous via substring search on the full dialog text.
|
||||
const forbidden = ["Base", "System", "Core Memory", "Channel Canvas"];
|
||||
for (const title of sectionTitles) {
|
||||
for (const label of forbidden) {
|
||||
expect(title).not.toContain(label);
|
||||
}
|
||||
}
|
||||
await settleAnimations(dialog);
|
||||
await dialog.screenshot({
|
||||
path: `${SHOTS}/11-first-turn-ordering.png`,
|
||||
|
||||
Reference in New Issue
Block a user