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:
Will Pfleger
2026-07-11 13:40:16 -04:00
committed by GitHub
parent 3b9b371f87
commit 84ec63aa24
10 changed files with 1306 additions and 251 deletions

View File

@@ -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",

View File

@@ -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 (

View File

@@ -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)",
);
});

View File

@@ -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,

View File

@@ -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`,
);
});

View File

@@ -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);
}

View File

@@ -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" },
]);
});

View File

@@ -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;
}

View 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`,
});
});
});

View File

@@ -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`,