mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
fix(desktop): scope observer feed by channel and add session-boundary dividers (#1634)
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:
@@ -6,6 +6,7 @@ import {
|
||||
flattenDisplayBlocks,
|
||||
formatTurnSetupLabel,
|
||||
} from "./agentSessionTranscriptGrouping.ts";
|
||||
import { isObserverEventAfter } from "../observerRelayStore.ts";
|
||||
|
||||
const baseTimestamp = "2026-06-14T22:20:23.000Z";
|
||||
|
||||
@@ -639,3 +640,446 @@ function mkTool(id, label, renderClass = "generic", groupKey = label) {
|
||||
channelId: "chan-1",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Session-run splitting and session-boundary blocks ──────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal tool-call item stamped with a specific session.
|
||||
*/
|
||||
function sessionItem(id, sessionId, ts = "2026-07-08T00:00:00.000Z") {
|
||||
return {
|
||||
id,
|
||||
type: "tool",
|
||||
renderClass: "generic",
|
||||
descriptor: {
|
||||
renderClass: "generic",
|
||||
label: id,
|
||||
preview: id,
|
||||
source: "harness",
|
||||
groupKey: id,
|
||||
},
|
||||
title: id,
|
||||
toolName: id,
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: {},
|
||||
result: "",
|
||||
isError: false,
|
||||
timestamp: ts,
|
||||
startedAt: ts,
|
||||
completedAt: ts,
|
||||
turnId: `turn-${id}`,
|
||||
sessionId,
|
||||
channelId: "chan-1",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Single session — no boundary injected ──────────────────────────────────────
|
||||
|
||||
test("buildTranscriptDisplayBlocks_singleSession_noBoundaryBlock", () => {
|
||||
const items = [sessionItem("a", "sess-1"), sessionItem("b", "sess-1")];
|
||||
const blocks = buildTranscriptDisplayBlocks(items);
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
assert.equal(
|
||||
boundaryBlocks.length,
|
||||
0,
|
||||
"no session-boundary blocks for a single session",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Two sessions — one boundary between them ───────────────────────────────────
|
||||
|
||||
test("buildTranscriptDisplayBlocks_twoSessions_oneBoundaryBetween", () => {
|
||||
// items ordered oldest-first: sess-1 then sess-2
|
||||
const items = [
|
||||
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
||||
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
||||
];
|
||||
const blocks = buildTranscriptDisplayBlocks(items);
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
assert.equal(
|
||||
boundaryBlocks.length,
|
||||
1,
|
||||
"exactly one boundary for two sessions",
|
||||
);
|
||||
// The boundary is inserted BEFORE the newer run (sess-2).
|
||||
const boundaryIndex = blocks.indexOf(boundaryBlocks[0]);
|
||||
const prevBlock = blocks[boundaryIndex - 1];
|
||||
const nextBlock = blocks[boundaryIndex + 1];
|
||||
// Previous content block belongs to sess-1 items, next to sess-2.
|
||||
const flatPrev = flattenDisplayBlocks([prevBlock]).map((i) => i.id);
|
||||
const flatNext = flattenDisplayBlocks([nextBlock]).map((i) => i.id);
|
||||
assert.ok(flatPrev.includes("a"), "content before boundary is sess-1");
|
||||
assert.ok(flatNext.includes("b"), "content after boundary is sess-2");
|
||||
});
|
||||
|
||||
// ── Newest session labeled correctly relative to latestLiveSessionId ──────────
|
||||
|
||||
test("buildTranscriptDisplayBlocks_newestMatchesLive_labelStateCurrent", () => {
|
||||
const items = [
|
||||
sessionItem("old", "sess-1", "2026-07-08T00:00:01.000Z"),
|
||||
sessionItem("new", "sess-2", "2026-07-08T00:00:02.000Z"),
|
||||
];
|
||||
const blocks = buildTranscriptDisplayBlocks(items, "sess-2");
|
||||
const boundary = blocks.find((b) => b.kind === "session-boundary");
|
||||
assert.ok(boundary, "boundary present");
|
||||
assert.equal(
|
||||
boundary.labelState,
|
||||
"current",
|
||||
"labelState=current when newest session matches live id",
|
||||
);
|
||||
assert.equal(boundary.sessionId, "sess-2", "boundary sessionId is sess-2");
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks_newestNoLive_labelStateMostRecent", () => {
|
||||
const items = [
|
||||
sessionItem("old", "sess-1", "2026-07-08T00:00:01.000Z"),
|
||||
sessionItem("new", "sess-2", "2026-07-08T00:00:02.000Z"),
|
||||
];
|
||||
// latestLiveSessionId is null → newest session is "most-recent"
|
||||
const blocksNoLive = buildTranscriptDisplayBlocks(items, null);
|
||||
const boundaryNoLive = blocksNoLive.find(
|
||||
(b) => b.kind === "session-boundary",
|
||||
);
|
||||
assert.equal(
|
||||
boundaryNoLive.labelState,
|
||||
"most-recent",
|
||||
"labelState=most-recent when no live id (archived-only view)",
|
||||
);
|
||||
|
||||
// latestLiveSessionId is a DIFFERENT session → newest is still "most-recent"
|
||||
const blocksDiffLive = buildTranscriptDisplayBlocks(items, "sess-other");
|
||||
const boundaryDiff = blocksDiffLive.find(
|
||||
(b) => b.kind === "session-boundary",
|
||||
);
|
||||
assert.equal(
|
||||
boundaryDiff.labelState,
|
||||
"most-recent",
|
||||
"labelState=most-recent when live id differs from newest visible",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Three sessions — two boundaries ────────────────────────────────────────────
|
||||
|
||||
test("buildTranscriptDisplayBlocks_threeSessions_twoBoundaries", () => {
|
||||
const items = [
|
||||
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
||||
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
||||
sessionItem("c", "sess-3", "2026-07-08T00:00:03.000Z"),
|
||||
];
|
||||
const blocks = buildTranscriptDisplayBlocks(items);
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
assert.equal(boundaryBlocks.length, 2, "two boundaries for three sessions");
|
||||
// With no live id: newest (sess-3) boundary = "most-recent"; older = "earlier".
|
||||
const newestBoundary = boundaryBlocks[boundaryBlocks.length - 1];
|
||||
const olderBoundary = boundaryBlocks[0];
|
||||
assert.equal(
|
||||
newestBoundary.labelState,
|
||||
"most-recent",
|
||||
"newest boundary is most-recent when latestLiveSessionId is null",
|
||||
);
|
||||
assert.equal(
|
||||
olderBoundary.labelState,
|
||||
"earlier",
|
||||
"older boundary is earlier when latestLiveSessionId is null",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Null sessionId items stay in the current run ───────────────────────────────
|
||||
|
||||
test("buildTranscriptDisplayBlocks_nullSessionId_staysInCurrentRun", () => {
|
||||
// An item with null sessionId should not start a new run.
|
||||
const items = [
|
||||
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
||||
// null sessionId — stays in sess-1 run
|
||||
{ ...sessionItem("b", null, "2026-07-08T00:00:02.000Z"), sessionId: null },
|
||||
sessionItem("c", "sess-1", "2026-07-08T00:00:03.000Z"),
|
||||
];
|
||||
const blocks = buildTranscriptDisplayBlocks(items);
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
assert.equal(
|
||||
boundaryBlocks.length,
|
||||
0,
|
||||
"no boundary when null-sessionId items are present within a single session",
|
||||
);
|
||||
});
|
||||
|
||||
// ── flattenDisplayBlocks skips session-boundary blocks ────────────────────────
|
||||
|
||||
test("flattenDisplayBlocks_skipsSessionBoundaryBlocks", () => {
|
||||
const items = [
|
||||
sessionItem("a", "sess-1", "2026-07-08T00:00:01.000Z"),
|
||||
sessionItem("b", "sess-2", "2026-07-08T00:00:02.000Z"),
|
||||
];
|
||||
const blocks = buildTranscriptDisplayBlocks(items);
|
||||
assert.ok(
|
||||
blocks.some((b) => b.kind === "session-boundary"),
|
||||
"test setup: boundary must be present",
|
||||
);
|
||||
const flat = flattenDisplayBlocks(blocks);
|
||||
const ids = flat.map((i) => i.id);
|
||||
assert.ok(ids.includes("a"), "item a is in flattened output");
|
||||
assert.ok(ids.includes("b"), "item b is in flattened output");
|
||||
assert.equal(
|
||||
flat.filter((i) => i.kind === "session-boundary").length,
|
||||
0,
|
||||
"session-boundary items are excluded from flatten",
|
||||
);
|
||||
});
|
||||
|
||||
// ── isObserverEventAfter — latest-live ordering ──────────────────────────────
|
||||
|
||||
test("isObserverEventAfter returns true when candidate has later timestamp", () => {
|
||||
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 5 };
|
||||
const candidate = { timestamp: "2026-07-08T00:00:02.000Z", seq: 1 };
|
||||
assert.ok(isObserverEventAfter(candidate, stored));
|
||||
});
|
||||
|
||||
test("isObserverEventAfter returns false when candidate has earlier timestamp", () => {
|
||||
const stored = { timestamp: "2026-07-08T00:00:02.000Z", seq: 5 };
|
||||
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 10 };
|
||||
assert.ok(!isObserverEventAfter(candidate, stored));
|
||||
});
|
||||
|
||||
test("isObserverEventAfter returns true for same timestamp, higher seq — session B advances over session A", () => {
|
||||
// This is the tiebreak case: timestamp equal, seq tiebreak must mirror
|
||||
// compareObserverEvents so latest-live never drifts from transcript order.
|
||||
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
||||
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 7 };
|
||||
assert.ok(isObserverEventAfter(candidate, stored));
|
||||
});
|
||||
|
||||
test("isObserverEventAfter returns false for same timestamp, same seq", () => {
|
||||
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
||||
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
||||
assert.ok(!isObserverEventAfter(candidate, stored));
|
||||
});
|
||||
|
||||
test("isObserverEventAfter returns false for same timestamp, lower seq", () => {
|
||||
const stored = { timestamp: "2026-07-08T00:00:01.000Z", seq: 7 };
|
||||
const candidate = { timestamp: "2026-07-08T00:00:01.000Z", seq: 3 };
|
||||
assert.ok(!isObserverEventAfter(candidate, stored));
|
||||
});
|
||||
|
||||
// ── Pre-resolution null-session deferral (regression for first-turn bundle) ───
|
||||
|
||||
/**
|
||||
* Builds the exact wire sequence the harness emits on the first turn:
|
||||
* turn_started(null) → session/new(null) → session_resolved(sess) → session/prompt(sess)
|
||||
*
|
||||
* After processTranscriptEvent the TranscriptItems produced are:
|
||||
* - lifecycle turn_started: sessionId=null, turnId="turn-001"
|
||||
* - metadata session/new (system prompt): sessionId=null, turnId=null
|
||||
* - lifecycle session_resolved: sessionId="session-001", turnId="turn-001"
|
||||
* - message session/prompt:user: sessionId="session-001", turnId="turn-001"
|
||||
*/
|
||||
function firstTurnSequence() {
|
||||
const ts = "2026-07-08T10:00:00.000Z";
|
||||
return [
|
||||
// turn_started: sessionId null, turnId present
|
||||
{
|
||||
id: "turn-started",
|
||||
type: "lifecycle",
|
||||
renderClass: "lifecycle",
|
||||
title: "Turn started",
|
||||
text: "",
|
||||
timestamp: ts,
|
||||
acpSource: "turn_started",
|
||||
turnId: "turn-001",
|
||||
sessionId: null,
|
||||
channelId: "chan-1",
|
||||
},
|
||||
// session/new system prompt: sessionId null, turnId null (processTranscriptEvent forces turnId null)
|
||||
{
|
||||
id: "system-prompt:chan-1",
|
||||
type: "metadata",
|
||||
renderClass: "raw-rail",
|
||||
title: "System prompt",
|
||||
sections: [
|
||||
{ title: "Base", body: "You are a helpful AI assistant." },
|
||||
{ title: "System", body: "You are Observer Agent." },
|
||||
],
|
||||
timestamp: ts,
|
||||
acpSource: "session/new",
|
||||
turnId: null,
|
||||
sessionId: null,
|
||||
channelId: "chan-1",
|
||||
},
|
||||
// session_resolved: first item with a 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",
|
||||
},
|
||||
// session/prompt:user — the user message bubble
|
||||
{
|
||||
id: "user-prompt",
|
||||
type: "message",
|
||||
role: "user",
|
||||
title: "Buzz event",
|
||||
text: "@Observer Agent help me debug this",
|
||||
timestamp: ts,
|
||||
acpSource: "session/prompt:user",
|
||||
turnId: "turn-001",
|
||||
sessionId: "session-001",
|
||||
channelId: "chan-1",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test("buildTranscriptDisplayBlocks_firstTurnSequence_noStandaloneSystemPrompt", () => {
|
||||
// Regression for: pre-resolution null-session items (turn_started + session/new)
|
||||
// were assigned to a synthetic "unknown" run, causing the system prompt to emit
|
||||
// as a standalone "System prompt" row instead of staying in the prompt bundle.
|
||||
const blocks = buildTranscriptDisplayBlocks(firstTurnSequence());
|
||||
|
||||
// (a) No standalone "System prompt" single block.
|
||||
const systemPromptSingles = blocks.filter(
|
||||
(b) => b.kind === "single" && b.item.acpSource === "session/new",
|
||||
);
|
||||
assert.equal(
|
||||
systemPromptSingles.length,
|
||||
0,
|
||||
"system prompt must not appear as a standalone single block",
|
||||
);
|
||||
|
||||
// (b) No session-boundary blocks — this is a single-session transcript.
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
assert.equal(
|
||||
boundaryBlocks.length,
|
||||
0,
|
||||
"must be zero session-boundary blocks for a first-turn single-session sequence",
|
||||
);
|
||||
|
||||
// (c) System prompt is inside the turn group (consumed by the prompt bundle).
|
||||
const turnBlocks = blocks.filter((b) => b.kind === "turn");
|
||||
assert.ok(turnBlocks.length > 0, "at least one turn block must exist");
|
||||
const allTurnItems = flattenDisplayBlocks(turnBlocks);
|
||||
const systemPromptInTurn = allTurnItems.some(
|
||||
(item) => item.acpSource === "session/new",
|
||||
);
|
||||
assert.ok(
|
||||
systemPromptInTurn,
|
||||
"system prompt item must be present inside a turn block (prompt bundle)",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildTranscriptDisplayBlocks_genuineSecondSession_boundaryPreserved", () => {
|
||||
// Regression guard: the deferral fix must not over-collapse two genuinely
|
||||
// distinct sessions. A second session_resolved with a different sessionId
|
||||
// still gets its own run and a session-boundary block.
|
||||
const ts2 = "2026-07-08T11:00:00.000Z";
|
||||
const items = [
|
||||
// First session (may have pre-resolution preamble)
|
||||
...firstTurnSequence(),
|
||||
// Second session — starts fresh with its own turn_started (no pre-null preamble here)
|
||||
{
|
||||
id: "turn-started-2",
|
||||
type: "lifecycle",
|
||||
renderClass: "lifecycle",
|
||||
title: "Turn started",
|
||||
text: "",
|
||||
timestamp: ts2,
|
||||
acpSource: "turn_started",
|
||||
turnId: "turn-002",
|
||||
sessionId: "session-002",
|
||||
channelId: "chan-1",
|
||||
},
|
||||
{
|
||||
id: "user-prompt-2",
|
||||
type: "message",
|
||||
role: "user",
|
||||
title: "Buzz event",
|
||||
text: "second session message",
|
||||
timestamp: ts2,
|
||||
acpSource: "session/prompt:user",
|
||||
turnId: "turn-002",
|
||||
sessionId: "session-002",
|
||||
channelId: "chan-1",
|
||||
},
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(items);
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
assert.equal(
|
||||
boundaryBlocks.length,
|
||||
1,
|
||||
"exactly one session-boundary block between two distinct sessions",
|
||||
);
|
||||
assert.equal(
|
||||
boundaryBlocks[0].sessionId,
|
||||
"session-002",
|
||||
"boundary is labeled with the newer session id",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Duplicate-key guard: non-contiguous runs of the same sessionId ────────────
|
||||
|
||||
test("buildTranscriptDisplayBlocks_nonContiguousRunsSameSession_distinctBoundaryKeys", () => {
|
||||
// Scenario: sess-B frames, then sess-A frames, then sess-C frames, then
|
||||
// sess-A frames re-resolve (e.g. same agent session re-observed in a new
|
||||
// live subscription). splitIntoSessionRuns treats each session-id change as
|
||||
// a new run, so sess-A appears twice — once at runIndex=1 and once at
|
||||
// runIndex=3 — yielding two session-boundary blocks both with
|
||||
// sessionId="sess-A".
|
||||
//
|
||||
// The React key for a session-boundary is
|
||||
// `session-boundary:${sessionId}:${runIndex}`
|
||||
// Without the runIndex tiebreaker the two sess-A boundaries share the same
|
||||
// key, causing React to silently duplicate or omit children. This test
|
||||
// asserts the runIndex tiebreaker keeps the keys distinct.
|
||||
const items = [
|
||||
sessionItem("b-item", "sess-B", "2026-07-08T00:00:01.000Z"),
|
||||
sessionItem("a-item", "sess-A", "2026-07-08T00:00:02.000Z"),
|
||||
sessionItem("c-item", "sess-C", "2026-07-08T00:00:03.000Z"),
|
||||
sessionItem("a-item-2", "sess-A", "2026-07-08T00:00:04.000Z"),
|
||||
];
|
||||
|
||||
const blocks = buildTranscriptDisplayBlocks(items, "sess-A");
|
||||
const boundaryBlocks = blocks.filter((b) => b.kind === "session-boundary");
|
||||
|
||||
// Four distinct session runs → three boundaries (before sess-A, before
|
||||
// sess-C, and before the re-occurring sess-A).
|
||||
assert.equal(
|
||||
boundaryBlocks.length,
|
||||
3,
|
||||
"three boundaries for four runs (sess-B, sess-A, sess-C, sess-A)",
|
||||
);
|
||||
|
||||
// Two boundaries share sessionId="sess-A" (the collision case pre-fix).
|
||||
const sessABoundaries = boundaryBlocks.filter(
|
||||
(b) => b.sessionId === "sess-A",
|
||||
);
|
||||
assert.equal(
|
||||
sessABoundaries.length,
|
||||
2,
|
||||
"two boundary blocks both carry sessionId=sess-A (the collision the fix guards)",
|
||||
);
|
||||
|
||||
// The runIndex values must be distinct across ALL boundary blocks so that
|
||||
// `session-boundary:${sessionId}:${runIndex}` keys are unique.
|
||||
const runIndexSet = new Set(boundaryBlocks.map((b) => b.runIndex));
|
||||
assert.equal(
|
||||
runIndexSet.size,
|
||||
boundaryBlocks.length,
|
||||
"every session-boundary block has a distinct runIndex",
|
||||
);
|
||||
|
||||
// The React keys derived from each boundary must also be distinct.
|
||||
const keys = boundaryBlocks.map(
|
||||
(b) => `session-boundary:${b.sessionId}:${b.runIndex}`,
|
||||
);
|
||||
const keySet = new Set(keys);
|
||||
assert.equal(
|
||||
keySet.size,
|
||||
keys.length,
|
||||
"all React keys derived from session-boundary blocks are unique",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user