mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
215f521880
commit
67a2d047eb
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user