// End-to-end placement scenarios: real transcript + metrics reducers fed real // AgentEvents, rendered through groupRenderedChunks → interleaveTurnMetrics. // The invariant under test: at EVERY point of a turn (not just after it ends), // each turn's metrics sit under that turn — never under a neighbour. import type { AgentEvent, StepId, StoredChunk, TurnMetrics, Usage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { appendUserMessage, applyHistory, foldEvent, groupRenderedChunks, initialState, selectChunks, type TranscriptState, trimTranscript, } from "../chunks"; import { interleaveTurnMetrics } from "./place"; import { applyDurableMetrics, foldMetricsEvent, initialMetricsState, selectOrderedTurnMetrics, } from "./reducer"; import type { MetricsRow, MetricsState } from "./types"; const C = "c1"; const u = (i: number, o: number): Usage => ({ inputTokens: i, outputTokens: o }); /** Ordered list of "what follows each user text bubble / which turn rows appear where". */ function layout(rows: readonly MetricsRow[]): string[] { const out: string[] = []; for (const r of rows) { if (r.kind === "turn-metrics") out.push(`[turn ${r.turn.turnId}]`); else if (r.kind === "step-metrics") out.push(`[step ${r.step.stepId}]`); else if (r.group.kind === "single") { const c = r.group.chunk.chunk; if (c.type === "text") out.push(`${r.group.chunk.role}:${c.text}`); else if (c.type === "image") out.push(`${r.group.chunk.role}:`); else if (c.type === "error") out.push(`error:${c.message}`); } else { out.push(`tool:${r.group.entry.toolCallId}`); } } return out; } class Sim { t: TranscriptState = initialState(); m: MetricsState = initialMetricsState(); seq = 0; /** Durable server-side log (what loadSince returns). */ log: StoredChunk[] = []; durable: TurnMetrics[] = []; constructor(private readonly legacy = false) {} private ev(e: AgentEvent): void { this.t = foldEvent(this.t, e); this.m = foldMetricsEvent(this.m, e); } private persist(role: StoredChunk["role"], chunk: StoredChunk["chunk"], turnId: string): void { this.seq++; this.log.push( this.legacy ? { seq: this.seq, role, chunk } : { seq: this.seq, role, chunk, turnId }, ); } /** The sender types + sends (optimistic echo), optionally with an image. */ send(text: string, image = false): void { this.t = appendUserMessage( this.t, text, image ? [{ url: "data:image/png;base64,AA", mimeType: "image/png" }] : undefined, ); } start(turnId: string, text: string, image = false): void { this.ev({ type: "turn-start", conversationId: C, turnId } as AgentEvent); this.ev({ type: "user-message", conversationId: C, turnId, text } as AgentEvent); this.persist("user", { type: "text", text }, turnId); if (image) this.persist("user", { type: "image", url: "/images/c1/x.png" }, turnId); } text(turnId: string, text: string): void { this.ev({ type: "text-delta", conversationId: C, turnId, delta: text } as AgentEvent); } toolStep(turnId: string, step: number, id: string): void { const stepId = `${turnId}#${step}` as StepId; this.ev({ type: "tool-call", conversationId: C, turnId, stepId, toolCallId: id, toolName: "t", input: {}, } as AgentEvent); this.completeStep(turnId, step); this.ev({ type: "tool-result", conversationId: C, turnId, stepId, toolCallId: id, toolName: "t", content: "ok", isError: false, } as AgentEvent); this.persist( "assistant", { type: "tool-call", toolCallId: id, toolName: "t", input: {}, stepId }, turnId, ); this.persist( "tool", { type: "tool-result", toolCallId: id, toolName: "t", content: "ok", isError: false, stepId }, turnId, ); } completeStep(turnId: string, step: number): void { const stepId = `${turnId}#${step}` as StepId; this.ev({ type: "usage", conversationId: C, turnId, stepId, usage: u(100, 10) } as AgentEvent); this.ev({ type: "step-complete", conversationId: C, turnId, stepId, ttftMs: 100, decodeMs: 100, genTotalMs: 200, } as AgentEvent); } steer(turnId: string, text: string): void { this.ev({ type: "steering", conversationId: C, turnId, text } as AgentEvent); this.persist("user", { type: "text", text }, turnId); } /** Finish a turn: final text step, done, seal, then history + metrics sync. */ finish(turnId: string, text: string, finalStep: number): void { this.text(turnId, text); this.completeStep(turnId, finalStep); this.ev({ type: "done", conversationId: C, turnId, reason: "stop", durationMs: 500, usage: u(100, 10), } as AgentEvent); this.persist("assistant", { type: "text", text }, turnId); this.ev({ type: "turn-sealed", conversationId: C, turnId } as AgentEvent); this.sync(turnId, finalStep); } /** A turn that errors (never seals); backend persists its partial metrics. */ fail(turnId: string, message: string, steps: number): void { this.ev({ type: "error", conversationId: C, turnId, message } as AgentEvent); this.persist("assistant", { type: "error", message }, turnId); this.t = applyHistory(this.t, this.log); if (steps > 0) this.syncMetricsFor(turnId, steps - 1); } private syncMetricsFor(turnId: string, lastStep: number): void { const steps = []; for (let i = 0; i <= lastStep; i++) { steps.push({ stepId: `${turnId}#${i}` as StepId, usage: u(100, 10), decodeMs: 100 }); } this.durable.push({ turnId, usage: u(100 * steps.length, 10 * steps.length), steps }); this.m = applyDurableMetrics(this.m, this.durable); } private sync(turnId: string, lastStep: number): void { this.t = applyHistory(this.t, this.log); this.syncMetricsFor(turnId, lastStep); } trim(limit: number): void { this.t = trimTranscript(this.t, limit); } layout(): string[] { const groups = groupRenderedChunks(selectChunks(this.t)); return layout(interleaveTurnMetrics(groups, selectOrderedTurnMetrics(this.m))); } } /** Build a history of N text-only turns (t1..tN), each "qK" → "aK". */ function textHistory(sim: Sim, n: number): void { for (let k = 1; k <= n; k++) { sim.send(`q${k}`); sim.start(`t${k}`, `q${k}`); sim.finish(`t${k}`, `a${k}`, 0); } } for (const legacy of [false, true]) { // `legacy`: the persisted rows carry NO turnId (history written before the // backend stamped it). Live chunks always carry it. describe(`placement stays exact through a turn (${legacy ? "legacy rows" : "turnId rows"})`, () => { it("plain text turns", () => { const sim = new Sim(legacy); textHistory(sim, 3); expect(sim.layout()).toEqual([ "user:q1", "assistant:a1", "[turn t1]", "user:q2", "assistant:a2", "[turn t2]", "user:q3", "assistant:a3", "[turn t3]", ]); }); it("never shifts while a new turn is sent and streams — even after older turns were unloaded", () => { const sim = new Sim(legacy); textHistory(sim, 4); sim.trim(5); // 8 chunks > 5 → unloads whole quarters: q1 a1 q2 a2 const before = sim.layout(); expect(before).toEqual([ "user:q3", "assistant:a3", "[turn t3]", "user:q4", "assistant:a4", "[turn t4]", ]); const expectStable = () => expect(sim.layout().slice(0, before.length)).toEqual(before); sim.send("q5"); expectStable(); // just sent (optimistic echo, no turn yet) sim.start("t5", "q5"); expectStable(); // turn started sim.toolStep("t5", 0, "k1"); expectStable(); // tool step completed (metrics entry exists, not done) sim.text("t5", "partial…"); expectStable(); // streaming text sim.finish("t5", "a5", 1); expect(sim.layout()).toEqual([ ...before, "user:q5", "tool:k1", "[step t5#0]", "assistant:a5", "[turn t5]", ]); }); it("image attachments never shift other turns' metrics", () => { const sim = new Sim(legacy); sim.send("q1"); sim.start("t1", "q1"); sim.finish("t1", "a1", 0); sim.send("q2", true); sim.start("t2", "q2", true); sim.finish("t2", "a2", 0); sim.send("q3"); sim.start("t3", "q3"); sim.finish("t3", "a3", 0); expect(sim.layout()).toEqual([ "user:q1", "assistant:a1", "[turn t1]", "user:q2", "user:", "assistant:a2", "[turn t2]", "user:q3", "assistant:a3", "[turn t3]", ]); }); it("a mid-turn steering message keeps the turn total at the END of the turn", () => { const sim = new Sim(legacy); textHistory(sim, 1); sim.send("q2"); sim.start("t2", "q2"); sim.toolStep("t2", 0, "k1"); sim.steer("t2", "steer!"); // live, mid-turn: t1 untouched, steering bubble shows no totals expect(sim.layout()).toEqual([ "user:q1", "assistant:a1", "[turn t1]", "user:q2", "tool:k1", "[step t2#0]", "user:steer!", ]); sim.toolStep("t2", 1, "k2"); sim.finish("t2", "a2", 2); textHistoryFrom(sim, 3); expect(sim.layout()).toEqual([ "user:q1", "assistant:a1", "[turn t1]", "user:q2", "tool:k1", "[step t2#0]", "user:steer!", "tool:k2", "[step t2#1]", "assistant:a2", "[turn t2]", "user:q3", "assistant:a3", "[turn t3]", ]); }); it("an errored turn gets its own metrics and never shifts the others", () => { const sim = new Sim(legacy); textHistory(sim, 1); sim.send("q2"); sim.start("t2", "q2"); sim.text("t2", "partial"); sim.completeStep("t2", 0); sim.fail("t2", "boom", 1); textHistoryFrom(sim, 3); const l = sim.layout(); expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1); expect(l.indexOf("[turn t2]")).toBeGreaterThan(l.indexOf("error:boom")); expect(l.indexOf("[turn t2]")).toBeLessThan(l.indexOf("user:q3")); expect(l.indexOf("[turn t3]")).toBe(l.indexOf("assistant:a3") + 1); }); it("an errored turn with NO metrics never steals another turn's", () => { const sim = new Sim(legacy); textHistory(sim, 1); sim.send("q2"); sim.start("t2", "q2"); sim.fail("t2", "boom", 0); // failed before any step → no metrics row textHistoryFrom(sim, 3); expect(sim.layout()).toEqual([ "user:q1", "assistant:a1", "[turn t1]", "user:q2", "error:boom", "user:q3", "assistant:a3", "[turn t3]", ]); }); it("a turn cut in half by the chat limit still shows its total under its visible tail", () => { const sim = new Sim(legacy); sim.send("q1"); sim.start("t1", "q1"); sim.toolStep("t1", 0, "k1"); sim.toolStep("t1", 1, "k2"); sim.finish("t1", "a1", 2); textHistoryFrom(sim, 2); // log: q1 call result call result a1 | q2 a2 → keep newest 6 sim.trim(7); const l = sim.layout(); expect(l).not.toContain("user:q1"); expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1); expect(l.indexOf("[turn t2]")).toBe(l.indexOf("assistant:a2") + 1); }); }); } /** Continue a text history from turn `from` (one more turn). */ function textHistoryFrom(sim: Sim, from: number): void { sim.send(`q${from}`); sim.start(`t${from}`, `q${from}`); sim.finish(`t${from}`, `a${from}`, 0); }