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

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