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
parent 215f521880
commit 67a2d047eb
5 changed files with 613 additions and 14 deletions

View File

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