mirror of
https://github.com/block/buzz.git
synced 2026-11-09 19:02:00 +00: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:
@@ -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})`,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user