fix(observer): order system prompt below current-session divider on restart (#1734)

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-10 17:44:12 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 215f521880
commit 67a2d047eb
5 changed files with 613 additions and 14 deletions
+4 -1
View File
@@ -287,8 +287,11 @@ const overrides = new Map([
// (current_mode_update, usage_update, available_commands_update,
// config_option_update) + replaceLifecycleItem helper for usage coalescing +
// system-prompt ordering fix (turnId: null for per-channel items).
// +35: session/new reposition-on-refire fix — removeItem helper +
// upsertMetadata restart branch (remove+sealOpenMessages+push instead of
// replaceItem in-place) so system-prompt anchor moves to stream tail.
// Load-bearing feature growth; queued to split in next transcript refactor.
["src/features/agents/ui/agentSessionTranscript.ts", 1167],
["src/features/agents/ui/agentSessionTranscript.ts", 1202],
// catalog module; agent_models.rs retains the thin wrapper (~50 lines).
// File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess
// fallback. Queued to split into dedicated discovery modules.
@@ -1323,3 +1323,265 @@ test("buildTranscript correctly renders prompt segment when session/prompt arriv
"prompt segment carries the correct user text",
);
});
// --- session-boundary ordering: restart scenario end-to-end ─────────────────
test("buildTranscript restart sequence: system-prompt renders after session-boundary, not before", () => {
// 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).
const CH = "33333333-3333-3333-3333-333333333333";
const AUTHOR_HEX = "c".repeat(64);
const USER_EVENT_HEX = "e".repeat(64);
const sess1Events = [
// sess-1 turn_started
{
seq: 1,
timestamp: "2026-07-01T10:00:00.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: CH,
sessionId: null,
turnId: "turn-1",
payload: { source: "channel", triggeringEventIds: [] },
},
// sess-1 session/new (first fire — pushes system-prompt to the stream)
{
seq: 2,
timestamp: "2026-07-01T10:00:00.100Z",
kind: "acp_write",
agentIndex: 0,
channelId: CH,
sessionId: null,
turnId: "turn-1",
payload: {
jsonrpc: "2.0",
id: 1,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver.",
},
},
},
// sess-1 resolves
{
seq: 3,
timestamp: "2026-07-01T10:00:00.200Z",
kind: "session_resolved",
agentIndex: 0,
channelId: CH,
sessionId: "sess-1",
turnId: "turn-1",
payload: { sessionId: "sess-1", isNewSession: true },
},
// sess-1 activity
{
seq: 4,
timestamp: "2026-07-01T10:00:01.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: CH,
sessionId: "sess-1",
turnId: "turn-1",
payload: {
method: "session/update",
params: {
sessionId: "sess-1",
update: {
sessionUpdate: "tool_call",
toolCallId: "call-1",
status: "completed",
title: "shell",
kind: "shell",
rawInput: { command: "echo hello" },
content: { type: "text", text: "hello" },
},
},
},
},
];
const restartEvents = [
// Restart: turn_started with null sessionId
{
seq: 5,
timestamp: "2026-07-01T11:00:00.000Z",
kind: "turn_started",
agentIndex: 0,
channelId: CH,
sessionId: null,
turnId: "turn-2",
payload: { source: "channel", triggeringEventIds: [] },
},
// session/new re-fires for the same channel (restart signal):
// upsertMetadata must REPOSITION to tail, not replace in-place.
{
seq: 6,
timestamp: "2026-07-01T11:00:00.100Z",
kind: "acp_write",
agentIndex: 0,
channelId: CH,
sessionId: null,
turnId: "turn-2",
payload: {
jsonrpc: "2.0",
id: 2,
method: "session/new",
params: {
systemPrompt:
"[Base]\nYou are a helpful assistant.\n\n[System]\nObserver.",
},
},
},
// New session resolves
{
seq: 7,
timestamp: "2026-07-01T11:00:00.200Z",
kind: "session_resolved",
agentIndex: 0,
channelId: CH,
sessionId: "sess-2",
turnId: "turn-2",
payload: { sessionId: "sess-2", isNewSession: true },
},
// sess-2 user @mention prompt (production shape: a restart is triggered by
// a user message; this is what surfaced the original bug in the screenshot).
{
seq: 8,
timestamp: "2026-07-01T11:00:00.300Z",
kind: "acp_write",
agentIndex: 0,
channelId: CH,
sessionId: "sess-2",
turnId: "turn-2",
payload: {
jsonrpc: "2.0",
id: 3,
method: "session/prompt",
params: {
sessionId: "sess-2",
prompt: [
{
type: "text",
text: `[Buzz event: @mention]\nEvent ID: ${USER_EVENT_HEX.toUpperCase()}\nFrom: Will (hex: ${AUTHOR_HEX})\nContent: @Paul status check? I had to restart`,
},
],
},
},
},
// sess-2 activity
{
seq: 9,
timestamp: "2026-07-01T11:00:01.000Z",
kind: "acp_read",
agentIndex: 0,
channelId: CH,
sessionId: "sess-2",
turnId: "turn-2",
payload: {
method: "session/update",
params: {
sessionId: "sess-2",
update: {
sessionUpdate: "tool_call",
toolCallId: "call-2",
status: "completed",
title: "shell",
kind: "shell",
rawInput: { command: "echo world" },
content: { type: "text", text: "world" },
},
},
},
},
];
const items = buildTranscript([...sess1Events, ...restartEvents]);
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
// (a) Exactly one session-boundary block between the two sessions.
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
1,
"exactly one session-boundary block for a two-session restart",
);
const boundaryIdx = blocks.indexOf(boundaryBlocks[0]);
// (b) 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",
)),
);
assert.ok(
systemPromptBlockIdx !== -1,
"system-prompt item must be present in the output",
);
assert.ok(
boundaryIdx < systemPromptBlockIdx,
`boundary (idx ${boundaryIdx}) must precede system-prompt (idx ${systemPromptBlockIdx})`,
);
// (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.
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 flatSess2ActivityIdx = flat.findIndex(
(i) => i.type === "tool" && i.sessionId === "sess-2",
);
assert.ok(
flatSystemPromptIdx !== -1,
"system-prompt must appear in flattened output",
);
assert.ok(
flatSystemPromptIdx < flatSess2ActivityIdx,
`system-prompt (flat idx ${flatSystemPromptIdx}) must precede sess-2 activity (flat idx ${flatSess2ActivityIdx})`,
);
});
@@ -115,6 +115,19 @@ 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);
@@ -566,6 +579,28 @@ 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,
@@ -1083,3 +1083,248 @@ test("buildTranscriptDisplayBlocks_nonContiguousRunsSameSession_distinctBoundary
"all React keys derived from session-boundary blocks are unique",
);
});
// ── session/new run-anchor: restart scenario ──────────────────────────────────
/**
* Build a system-prompt metadata item as the normalizer produces it on restart:
* stale-stamped with the previous session's id (latestSessionId at emit time).
*/
function systemPromptItem(id, staleSessionId, ts = "2026-07-08T12:00:00.000Z") {
return {
id,
type: "metadata",
renderClass: "raw-rail",
title: "System prompt",
sections: [{ title: "Base", body: "You are a helpful assistant." }],
timestamp: ts,
acpSource: "session/new",
turnId: null,
sessionId: staleSessionId,
channelId: "chan-1",
};
}
test("buildTranscriptDisplayBlocks_restartScenario_systemPromptAfterBoundary", () => {
// Restart wire sequence: toolA(sess-1) → session/new(sess-1 stale) → toolB(sess-2)
// The session/new item is stale-stamped with sess-1 (the OLD session id).
// After the fix it must sort into the sess-2 run — AFTER the session-boundary
// block, not before it.
const ts1 = "2026-07-08T12:00:00.000Z";
const ts2 = "2026-07-08T12:01:00.000Z";
const items = [
// Prior session activity
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
// session/new stale-stamped with OLD session id (the bug scenario)
systemPromptItem("system-prompt", "sess-1", ts2),
// New session activity
{ ...sessionItem("toolB", "sess-2", ts2), turnId: "turn-2" },
];
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
// (a) Exactly one session-boundary block must exist.
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
1,
"exactly one session-boundary block for two sessions",
);
// (b) The session/new item must appear AFTER the boundary, not before it.
const boundaryIndex = blocks.indexOf(boundaryBlocks[0]);
const systemPromptBlockIndex = blocks.findIndex(
(b) =>
(b.kind === "single" && b.item.acpSource === "session/new") ||
(b.kind === "turn" &&
b.segments.some(
(seg) =>
seg.kind === "prompt" &&
seg.systemPrompt?.acpSource === "session/new",
)),
);
assert.ok(
systemPromptBlockIndex !== -1,
"system-prompt item must be present in the output",
);
assert.ok(
boundaryIndex < systemPromptBlockIndex,
`boundary (index ${boundaryIndex}) must come before system-prompt (index ${systemPromptBlockIndex})`,
);
// (c) toolA must appear before the boundary; toolB after.
const flatAll = flattenDisplayBlocks(blocks);
const ids = flatAll.map((i) => i.id);
assert.ok(ids.includes("toolA"), "toolA present in flattened output");
assert.ok(ids.includes("toolB"), "toolB present in flattened output");
const toolAIdx = blocks.findIndex((b) =>
flattenDisplayBlocks([b]).some((i) => i.id === "toolA"),
);
const toolBIdx = blocks.findIndex((b) =>
flattenDisplayBlocks([b]).some((i) => i.id === "toolB"),
);
assert.ok(
toolAIdx < boundaryIndex,
"toolA block must be before the boundary",
);
assert.ok(toolBIdx > boundaryIndex, "toolB block must be after the boundary");
});
test("buildTranscriptDisplayBlocks_firstEverSession_systemPromptInSingleRun", () => {
// First-ever session: session/new arrives with sessionId null before any
// session resolves — the preSessionBuffer path handles it, no boundary emitted.
// This test guards against regressing the first-session behavior.
const ts = "2026-07-08T10:00:00.000Z";
const items = [
// session/new with null sessionId (first ever, no stale-stamp)
{
id: "system-prompt",
type: "metadata",
renderClass: "raw-rail",
title: "System prompt",
sections: [{ title: "Base", body: "You are a helpful assistant." }],
timestamp: ts,
acpSource: "session/new",
turnId: null,
sessionId: null,
channelId: "chan-1",
},
// session_resolved gives the first non-null sessionId
{
id: "session-resolved",
type: "lifecycle",
renderClass: "lifecycle",
title: "Session ready",
text: "",
timestamp: ts,
acpSource: "session_resolved",
turnId: "turn-001",
sessionId: "session-001",
channelId: "chan-1",
},
// User prompt follows
{
id: "user-prompt",
type: "message",
role: "user",
title: "Buzz event",
text: "@Agent hello",
timestamp: ts,
acpSource: "session/prompt:user",
turnId: "turn-001",
sessionId: "session-001",
channelId: "chan-1",
},
];
const blocks = buildTranscriptDisplayBlocks(items, "session-001");
// No boundary — this is a single-session transcript.
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
0,
"no session-boundary block for a first-ever single session",
);
// System prompt must appear somewhere in the output (in the turn bundle).
const flat = flattenDisplayBlocks(blocks);
assert.ok(
flat.some((i) => i.acpSource === "session/new"),
"system-prompt item must be present in the single-session output",
);
});
test("buildTranscriptDisplayBlocks_sessionNewNoFollowingSession_notDropped", () => {
// session/new arrives after a resolved session but the stream ends before a
// new session resolves (e.g. agent shutdown mid-restart). The item must not
// be silently dropped — it should fall back into the current run.
const ts1 = "2026-07-08T12:00:00.000Z";
const ts2 = "2026-07-08T12:01:00.000Z";
const items = [
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
// session/new stale-stamped; stream ends here — no new session resolves.
systemPromptItem("system-prompt", "sess-1", ts2),
];
const blocks = buildTranscriptDisplayBlocks(items);
// No boundary — still a single session (no new session resolved).
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(
boundaryBlocks.length,
0,
"no boundary when session/new has no following new session",
);
// system-prompt must not be dropped.
const flat = flattenDisplayBlocks(blocks);
assert.ok(
flat.some((i) => i.id === "system-prompt"),
"system-prompt must not be dropped when no new session follows",
);
// toolA must also be present.
assert.ok(
flat.some((i) => i.id === "toolA"),
"toolA must be present when session/new has no following new session",
);
});
// --- no-user-prompt ordering: system-prompt leads turns in the same run ─────
test("splitIntoSessionRuns: system-prompt renders before turn blocks when no user-prompt follows", () => {
// Covers buildBlocksForRun's no-user-prompt branch: when session/new is
// followed by a tool turn but no session/prompt:user, the system-prompt must
// appear as a standalone block BEFORE the turn (boundary → prompt → activity),
// not appended after all turns (the old behaviour).
const ts1 = "2026-07-08T12:00:00.000Z";
const ts2 = "2026-07-08T12:01:00.000Z";
const ts3 = "2026-07-08T12:02:00.000Z";
const items = [
// A preceding tool item establishes run sess-1 so a boundary is emitted.
{ ...sessionItem("toolA", "sess-1", ts1), turnId: "turn-1" },
// system/new repositioned to tail (stale sess-1 stamp) — represents the
// restart marker after the normalizer's reposition-on-refire fix.
systemPromptItem("system-prompt", "sess-1", ts2),
// New run's tool item (no user prompt in turn-2).
{ ...sessionItem("toolB", "sess-2", ts3), turnId: "turn-2" },
];
const blocks = buildTranscriptDisplayBlocks(items);
// One boundary between the two sessions.
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
assert.equal(boundaryBlocks.length, 1, "exactly one boundary");
const boundaryIdx = blocks.indexOf(boundaryBlocks[0]);
const systemPromptIdx = blocks.findIndex(
(b) => b.kind === "single" && b.item?.id === "system-prompt",
);
assert.ok(systemPromptIdx !== -1, "system-prompt block must be present");
const toolBIdx = blocks.findIndex((b) =>
flattenDisplayBlocks([b]).some((i) => i.id === "toolB"),
);
assert.ok(toolBIdx !== -1, "toolB block must be present");
// Required order: boundary → system-prompt → toolB activity.
assert.ok(
boundaryIdx < systemPromptIdx,
`boundary (${boundaryIdx}) must precede system-prompt (${systemPromptIdx})`,
);
assert.ok(
systemPromptIdx < toolBIdx,
`system-prompt (${systemPromptIdx}) must precede toolB activity (${toolBIdx})`,
);
// toolA (sess-1) must appear before the boundary.
const toolAIdx = blocks.findIndex((b) =>
flattenDisplayBlocks([b]).some((i) => i.id === "toolA"),
);
assert.ok(toolAIdx !== -1, "toolA block must be present");
assert.ok(
toolAIdx < boundaryIdx,
`toolA (${toolAIdx}) must be before boundary (${boundaryIdx})`,
);
});
@@ -394,9 +394,23 @@ function getRenderClass(item: TranscriptItem) {
* session run as the turn they belong to and the `pendingSystemPrompt` slot in
* `buildBlocksForRun` can consume them correctly.
*
* Mid-stream null-session items (after at least one session has resolved) are
* attributed to the most recently seen session run — same as before, this
* handles gap frames that arrive after resolution.
* **Restart / session-boundary ordering**: on a restart the normalizer stamps
* `session/new` with `latestSessionId` (the OLD session's id) because the new
* session hasn't resolved yet. So the item arrives with `sessionId = "sess-1"`
* (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.
* 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.
*
* 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
* session run — handling gap frames that arrive after resolution.
*
* Only if the entire stream is null-session (no session ever resolves) do the
* deferred items form a single fallback run keyed `"unknown"`.
@@ -410,20 +424,34 @@ 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)
// that must be re-anchored to the NEXT resolved session (restart scenario).
let pendingNewRunBuffer: TranscriptItem[] | null = null;
for (const item of items) {
if (item.sessionId === null || item.sessionId === undefined) {
if (currentRun === null) {
// No session resolved yet — defer into the pre-session buffer.
preSessionBuffer.push(item);
} else if (pendingNewRunBuffer !== null) {
// Pending buffer already open — park trailing null-session items here.
pendingNewRunBuffer.push(item);
} else {
// Session already resolved — attribute to current run.
// Ordinary mid-stream null-session item — attribute to current run.
currentRun.items.push(item);
}
continue;
}
// item.sessionId is non-null from here.
// 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.
if (isSystemPrompt(item) && currentRun !== null) {
pendingNewRunBuffer = [item];
continue;
}
if (!currentRun || item.sessionId !== currentRun.sessionId) {
const newRun: { sessionId: string; items: TranscriptItem[] } = {
sessionId: item.sessionId,
@@ -433,13 +461,29 @@ function splitIntoSessionRuns(
// First resolved session: prepend buffered pre-resolution items.
newRun.items.push(...preSessionBuffer);
preSessionBuffer.length = 0;
} else if (pendingNewRunBuffer !== null) {
// New distinct session after a session/new marker: the buffered items
// open this run (placed before its first regular item).
newRun.items.push(...pendingNewRunBuffer);
pendingNewRunBuffer = null;
}
currentRun = newRun;
runs.push(currentRun);
} else if (pendingNewRunBuffer !== null) {
// Same sessionId resolved again — the session/new didn't precede a new
// session. Flush the buffer into the current run so nothing is dropped.
currentRun.items.push(...pendingNewRunBuffer);
pendingNewRunBuffer = null;
}
currentRun.items.push(item);
}
// Stream ended with an open pending buffer (session/new seen, no new session
// resolved after it) — flush into the current run so nothing is dropped.
if (pendingNewRunBuffer !== null && currentRun !== null) {
currentRun.items.push(...pendingNewRunBuffer);
}
// Entire stream was null-session (no session ever resolved): emit as one run.
if (currentRun === null && preSessionBuffer.length > 0) {
runs.push({ sessionId: "unknown", items: preSessionBuffer });
@@ -588,19 +632,29 @@ function buildBlocksForRun(
continue;
}
// Inject system-prompt into the first turn that has a user-prompt item.
// On subsequent turns, system-prompt stays null (session/new doesn't re-fire).
// 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) &&
bucket.items.some(isUserPrompt)
!consumedSystemPrompts.has(pendingSystemPrompt.id)
) {
systemPromptForTurn = pendingSystemPrompt;
consumedSystemPrompts.add(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);
}
}
const segments = classifyTurnItems(bucket.items, systemPromptForTurn);
@@ -613,9 +667,9 @@ function buildBlocksForRun(
}
}
// If system-prompt was never consumed (no session/prompt followed — e.g.
// session/new arrived without a subsequent turn, or the stream is still
// incomplete), emit it as a standalone single so it remains visible.
// 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)