summaryrefslogtreecommitdiffhomepage
path: root/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'src/core')
-rw-r--r--src/core/chunks/groups.test.ts234
-rw-r--r--src/core/chunks/groups.ts124
-rw-r--r--src/core/chunks/index.ts2
-rw-r--r--src/core/metrics/place.test.ts98
-rw-r--r--src/core/metrics/place.ts6
-rw-r--r--src/core/metrics/placement-scenarios.test.ts16
6 files changed, 185 insertions, 295 deletions
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
index db632a3..cc2aa40 100644
--- a/src/core/chunks/groups.test.ts
+++ b/src/core/chunks/groups.test.ts
@@ -3,16 +3,16 @@ import { describe, expect, it } from "vitest";
import { groupRenderedChunks } from "./groups";
import type { RenderedChunk } from "./types";
-const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({
+const text = (seq: number, role: Role, t: string): RenderedChunk => ({
seq,
role,
chunk: { type: "text", text: t },
- provisional,
+ provisional: false,
});
-
const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
seq,
role: "assistant",
+ provisional,
chunk: {
type: "tool-call",
toolCallId: id,
@@ -20,12 +20,11 @@ const call = (seq: number, id: string, stepId?: string, provisional = false): Re
input: { id },
...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
},
- provisional,
});
-
const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
seq,
role: "tool",
+ provisional,
chunk: {
type: "tool-result",
toolCallId: id,
@@ -34,165 +33,120 @@ const result = (seq: number, id: string, stepId?: string, provisional = false):
isError: false,
...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
},
- provisional,
});
+function toolGroups(rows: readonly RenderedChunk[]) {
+ return groupRenderedChunks(rows).map((g) => {
+ if (g.kind !== "tool") throw new Error("expected a tool group");
+ return g;
+ });
+}
+
describe("groupRenderedChunks", () => {
it("returns no groups for an empty stream", () => {
expect(groupRenderedChunks([])).toEqual([]);
});
-
- it("passes non-tool chunks through as single groups, in order", () => {
- const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]);
- expect(groups).toHaveLength(2);
- expect(groups.every((g) => g.kind === "single")).toBe(true);
- });
-
- it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => {
- const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]);
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.kind)).toEqual(["single", "single"]);
+ it("passes non-tool chunks through unchanged and in order", () => {
+ const rows = [text(1, "user", "hi"), text(2, "assistant", "hello")];
+ expect(groupRenderedChunks(rows)).toEqual(rows.map((chunk) => ({ kind: "single", chunk })));
});
-
- it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => {
- const groups = groupRenderedChunks([
- call(1, "a"),
- call(2, "b"),
- result(3, "a"),
- result(4, "b"),
+ it("pairs a single call and result into one group", () => {
+ const c = call(1, "a", "s1");
+ const r = result(2, "a", "s1");
+ expect(toolGroups([c, r])).toEqual([
+ {
+ kind: "tool",
+ entry: { toolCallId: "a", toolName: "tool-a", call: c.chunk, result: r.chunk },
+ stepId: "s1",
+ provisional: false,
+ },
]);
- expect(groups).toHaveLength(4);
- expect(groups.every((g) => g.kind === "single")).toBe(true);
});
-
- it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => {
- const groups = groupRenderedChunks([
+ it("renders parallel calls independently in call order, with out-of-order results", () => {
+ const groups = toolGroups([
call(1, "a", "s1"),
call(2, "b", "s1"),
- result(3, "a", "s1"),
- result(4, "b", "s1"),
+ result(3, "b", "s1"),
+ result(4, "a", "s1"),
]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.stepId).toBe("s1");
- expect(g.entries).toHaveLength(2);
- expect(g.entries[0]?.call.toolCallId).toBe("a");
- expect(g.entries[0]?.result?.content).toBe("result-a");
- expect(g.entries[1]?.call.toolCallId).toBe("b");
- expect(g.entries[1]?.result?.content).toBe("result-b");
+ expect(groups.map((g) => g.entry.toolCallId)).toEqual(["a", "b"]);
+ expect(groups.map((g) => g.entry.result?.content)).toEqual(["result-a", "result-b"]);
});
-
- it("positions the batch at the first call and keeps surrounding chunks in order", () => {
+ it("pairs legacy chunks without stepId and mismatching stepIds by toolCallId", () => {
+ const groups = toolGroups([
+ call(1, "a"),
+ result(2, "a"),
+ call(3, "b", "s1"),
+ result(4, "b", "s2"),
+ ]);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.entry.result).not.toBeNull();
+ expect(groups[1]?.stepId).toBe("s1");
+ });
+ it("keeps surrounding text at its position and absorbs results", () => {
const groups = groupRenderedChunks([
text(1, "assistant", "before"),
call(2, "a", "s1"),
- call(3, "b", "s1"),
- result(4, "a", "s1"),
- result(5, "b", "s1"),
- text(6, "assistant", "after"),
+ text(3, "assistant", "between"),
+ call(4, "b", "s1"),
+ result(5, "a"),
+ result(6, "b"),
+ text(7, "assistant", "after"),
]);
- expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool", "single", "tool", "single"]);
});
-
- it("marks the batch provisional when any of its calls/results is provisional", () => {
- const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.provisional).toBe(true);
- expect(g.entries).toHaveLength(2);
- expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet)
+ it("keeps pending calls and orphan results", () => {
+ const groups = toolGroups([call(1, "pending"), result(2, "orphan", "s2", true)]);
+ expect(groups[0]?.entry.result).toBeNull();
+ expect(groups[1]?.entry.call).toBeNull();
+ expect(groups[1]?.entry.toolName).toBe("tool-orphan");
+ expect(groups[1]?.provisional).toBe(true);
+ expect(groups[1]?.stepId).toBe("s2");
});
-
- it("batches one step while leaving a different single-call step ungrouped", () => {
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "c", "s2"),
- result(4, "a", "s1"),
- result(5, "b", "s1"),
- result(6, "c", "s2"),
- ]);
- expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]);
- const batch = groups[0];
- if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(batch.entries).toHaveLength(2);
- // the s2 single call + its result remain as separate single groups
- const singles = groups.slice(1);
- expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call");
- expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result");
+ it.each([
+ [true, false],
+ [false, true],
+ [false, false],
+ ])("combines call/result provisional flags (%s, %s)", (cp, rp) => {
+ expect(toolGroups([call(1, "a", "s1", cp), result(2, "a", "s1", rp)])[0]?.provisional).toBe(
+ cp || rp,
+ );
});
-
- // --- Duplicate toolCallId hardening (fix-toolcall-duplicate-keys) ---
- // A provider double-emission (e.g. openrouter/stealth/ox-alpha) can persist the
- // SAME toolCallId twice within one step. The renderer must NEVER crash on that
- // (Svelte `each_key_duplicate` aborts the reactive flush — the tab appears dead).
-
- it("dedupes duplicate toolCallIds within a batch", () => {
- // The PII_REDACTED_CONVERSATION_SHORT_01 pattern: every id emitted twice, in order (seq 1,2 then 3,4).
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "a", "s1"),
- call(4, "b", "s1"),
+ it("prefers call provenance, falling back to result provenance", () => {
+ const groups = toolGroups([
+ { ...call(1, "a", "s1"), turnId: "t1" },
+ { ...result(2, "a", "s2"), turnId: "t2" },
+ call(3, "b"),
+ { ...result(4, "b", "s3"), turnId: "t3" },
]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.entries).toHaveLength(2);
- expect(g.entries.map((e) => e.call.toolCallId)).toEqual(["a", "b"]);
- });
-
- it("dedupes duplicate toolCallIds and pairs each unique call with its result", () => {
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "a", "s1"),
- call(4, "b", "s1"),
- result(5, "a", "s1"),
- result(6, "b", "s1"),
- result(7, "a", "s1"),
- result(8, "b", "s1"),
+ expect(groups.map((g) => [g.stepId, g.turnId])).toEqual([
+ ["s1", "t1"],
+ ["s3", "t3"],
]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.entries).toHaveLength(2);
- expect(g.entries[0]?.call.toolCallId).toBe("a");
- expect(g.entries[0]?.result?.content).toBe("result-a");
- expect(g.entries[1]?.call.toolCallId).toBe("b");
- expect(g.entries[1]?.result?.content).toBe("result-b");
});
-
- it("keeps the first occurrence of a duplicate toolCallId and skips the second", () => {
- const firstA = call(1, "a", "s1"); // first occurrence of a
- const firstB = call(2, "b", "s1"); // first occurrence of b
- const groups = groupRenderedChunks([
- firstA,
- firstB,
- call(3, "a", "s1"), // duplicate of a — skipped
- call(4, "b", "s1"), // duplicate of b — skipped
+ it("dedupes calls/results across steps, first occurrence wins", () => {
+ const first = call(1, "a", "s1", true);
+ const firstResult = result(4, "a", "s1");
+ const groups = toolGroups([
+ first,
+ call(2, "a", "s2"),
+ call(3, "b"),
+ firstResult,
+ result(5, "a", "s2"),
+ result(6, "b"),
+ result(7, "b"),
]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.entries).toHaveLength(2);
- // The FIRST occurrence (by stream order) is the one kept — referential
- // equality against the seq-1 / seq-2 chunks (the entry stores the inner
- // chunk; the outer `seq` lives on the RenderedChunk wrapper).
- expect(g.entries[0]?.call).toBe(firstA.chunk);
- expect(g.entries[1]?.call).toBe(firstB.chunk);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.entry.call).toBe(first.chunk);
+ expect(groups[0]?.entry.result).toBe(firstResult.chunk);
+ expect(groups[0]?.provisional).toBe(true);
});
-
- it("keeps the first (provisional) occurrence of a duplicate and marks the batch provisional", () => {
- // One provisional + one committed chunk with the SAME id: the first
- // (provisional) occurrence wins, and the batch stays provisional.
- const prov = call(1, "a", "s1", true);
- const groups = groupRenderedChunks([prov, call(2, "a", "s1")]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.provisional).toBe(true);
- expect(g.entries).toHaveLength(1);
- expect(g.entries[0]?.call).toBe(prov.chunk);
- expect(g.entries[0]?.result).toBeNull();
+ it("dedupes orphan results too", () => {
+ expect(toolGroups([result(1, "a"), result(2, "a")])).toHaveLength(1);
+ });
+ it("positions a card at its call even when its result came first", () => {
+ const c = call(3, "a");
+ const groups = groupRenderedChunks([result(1, "a"), text(2, "assistant", "before call"), c]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool"]);
});
});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
index 9df0fa5..c8493cc 100644
--- a/src/core/chunks/groups.ts
+++ b/src/core/chunks/groups.ts
@@ -1,111 +1,65 @@
import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire";
import type { RenderedChunk } from "./types";
-/**
- * One tool call within a batch, paired with its result (matched by `toolCallId`).
- * `result` is null while the call is still pending (no result chunk yet).
- */
-export interface ToolBatchEntry {
- readonly call: ToolCallChunk;
+/** One tool call and its response. Null call = unloaded history; null result = pending. */
+export interface ToolEntry {
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly call: ToolCallChunk | null;
readonly result: ToolResultChunk | null;
}
-/**
- * A render group: either a single rendered chunk (rendered as today) or a batch
- * of tool calls the model emitted together in one step (shared `stepId`), to be
- * rendered as one grouped unit.
- */
export type RenderGroup =
| { readonly kind: "single"; readonly chunk: RenderedChunk }
| {
- readonly kind: "tool-batch";
- readonly stepId: string;
- readonly entries: readonly ToolBatchEntry[];
+ readonly kind: "tool";
+ readonly entry: ToolEntry;
readonly provisional: boolean;
- /** The producing turn (from the batch's first call chunk), when known. */
+ readonly stepId?: string;
readonly turnId?: string;
};
/**
- * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId`
- * (the backend's authoritative batch key) where the step has 2+ calls become one
- * `tool-batch` group, positioned at the first call and pairing each call with its
- * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on
- * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0
- * replay rows) and every non-tool chunk render as `single` groups, in order.
- *
- * Pure: input → output, no DOM, no Svelte.
+ * One independent group per call, at the call's position, absorbing its result
+ * by toolCallId (including legacy chunks without stepId). Orphan results stay
+ * visible at their own position. First occurrence wins for duplicate ids, so
+ * the stable t<toolCallId> render keys are always unique, even on corrupt replay.
+ * Pure: input → output; no DOM, effects, or Svelte.
*/
export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] {
- // 1. Steps that batched 2+ tool calls.
- const callsPerStep = new Map<string, number>();
- for (const rc of rendered) {
- if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) {
- callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1);
- }
- }
- const batchSteps = new Set<string>();
- for (const [stepId, count] of callsPerStep) {
- if (count >= 2) batchSteps.add(stepId);
- }
-
- // 2. toolCallIds belonging to a batch (so their results are absorbed), and a
- // lookup of result chunks by toolCallId for pairing.
- const batchCallIds = new Set<string>();
- const resultByCallId = new Map<string, ToolResultChunk>();
+ const calls = new Map<string, RenderedChunk>();
+ const results = new Map<string, RenderedChunk>();
for (const rc of rendered) {
- const chunk = rc.chunk;
- if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
- batchCallIds.add(chunk.toolCallId);
- } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) {
- resultByCallId.set(chunk.toolCallId, chunk);
- }
+ const c = rc.chunk;
+ if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc);
+ if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc);
}
- // 3. Emit groups in stream order; each batch lands at its first call.
const groups: RenderGroup[] = [];
- const emittedSteps = new Set<string>();
+ const emitted = new Set<string>();
for (const rc of rendered) {
- const chunk = rc.chunk;
-
- if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
- const stepId = chunk.stepId;
- if (emittedSteps.has(stepId)) continue;
- emittedSteps.add(stepId);
-
- const entries: ToolBatchEntry[] = [];
- // Dedupe by toolCallId within the batch: a provider double-emission (or any
- // corrupted transcript) can carry the SAME call id on multiple tool-call
- // chunks in one step. The first occurrence (stream order) wins; later ones
- // are skipped so the keyed `{#each}` in the UI never sees duplicate keys
- // (Svelte `each_key_duplicate` aborts the reactive flush — tab appears dead).
- const seenCallIds = new Set<string>();
- let provisional = false;
- let turnId: string | undefined;
- for (const inner of rendered) {
- if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) {
- if (seenCallIds.has(inner.chunk.toolCallId)) continue;
- seenCallIds.add(inner.chunk.toolCallId);
- const result = resultByCallId.get(inner.chunk.toolCallId) ?? null;
- entries.push({ call: inner.chunk, result });
- if (inner.provisional) provisional = true;
- turnId ??= inner.turnId;
- }
- }
- groups.push(
- turnId !== undefined
- ? { kind: "tool-batch", stepId, entries, provisional, turnId }
- : { kind: "tool-batch", stepId, entries, provisional },
- );
+ const c = rc.chunk;
+ if (c.type !== "tool-call" && c.type !== "tool-result") {
+ groups.push({ kind: "single", chunk: rc });
continue;
}
-
- if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
- continue; // absorbed into its batch
- }
-
- groups.push({ kind: "single", chunk: rc });
+ if (emitted.has(c.toolCallId)) continue;
+ // A matching call owns the card's position, even if the result came first.
+ if (c.type === "tool-result" && calls.has(c.toolCallId)) continue;
+ emitted.add(c.toolCallId);
+ const callRow = calls.get(c.toolCallId);
+ const resultRow = results.get(c.toolCallId);
+ const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null;
+ const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null;
+ const stepId = call?.stepId ?? result?.stepId;
+ const turnId = callRow?.turnId ?? resultRow?.turnId;
+ groups.push({
+ kind: "tool",
+ entry: { toolCallId: c.toolCallId, toolName: call?.toolName ?? c.toolName, call, result },
+ provisional: Boolean(callRow?.provisional || resultRow?.provisional),
+ ...(stepId !== undefined ? { stepId } : {}),
+ ...(turnId !== undefined ? { turnId } : {}),
+ });
}
-
return groups;
}
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index bdd6ce4..3822a79 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -1,4 +1,4 @@
-export type { RenderGroup, ToolBatchEntry } from "./groups";
+export type { RenderGroup, ToolEntry } from "./groups";
export { groupRenderedChunks } from "./groups";
export { resolveImageUrl } from "./image-url";
export {
diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts
index 393d23c..7695e21 100644
--- a/src/core/metrics/place.test.ts
+++ b/src/core/metrics/place.test.ts
@@ -28,31 +28,36 @@ function assistantGroup(seq: number, text: string): RenderGroup {
};
}
-function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup {
+function toolCallGroup(stepId: string, toolCallId: string): RenderGroup {
return {
- kind: "single",
- chunk: {
- seq,
- role: "assistant",
- chunk: {
+ kind: "tool",
+ stepId,
+ provisional: false,
+ entry: {
+ toolCallId,
+ toolName: "test",
+ result: null,
+ call: {
type: "tool-call",
toolCallId,
toolName: "test",
input: {},
stepId: stepId as StepId,
},
- provisional: false,
},
};
}
-function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup {
+function toolResultGroup(stepId: string, toolCallId: string): RenderGroup {
return {
- kind: "single",
- chunk: {
- seq,
- role: "tool",
- chunk: {
+ kind: "tool",
+ stepId,
+ provisional: false,
+ entry: {
+ toolCallId,
+ toolName: "test",
+ call: null,
+ result: {
type: "tool-result",
toolCallId,
toolName: "test",
@@ -60,29 +65,10 @@ function toolResultGroup(seq: number, stepId: string, toolCallId: string): Rende
isError: false,
stepId: stepId as StepId,
},
- provisional: false,
},
};
}
-function toolBatchGroup(stepId: string, toolCallIds: string[]): RenderGroup {
- return {
- kind: "tool-batch",
- stepId,
- entries: toolCallIds.map((id) => ({
- call: {
- type: "tool-call" as const,
- toolCallId: id,
- toolName: "test",
- input: {},
- stepId: stepId as StepId,
- },
- result: null,
- })),
- provisional: false,
- };
-}
-
function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics {
return {
stepId: stepId as StepId,
@@ -169,9 +155,9 @@ describe("interleaveTurnMetrics", () => {
it("head-aligned: segment i gets entries[i]", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
- const g4 = toolCallGroup(4, "s2", "c2");
+ const g4 = toolCallGroup("s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -192,7 +178,7 @@ describe("interleaveTurnMetrics", () => {
it("a trailing segment with no entry (in-flight turn) renders no metrics", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const step = makeStep("s1", 100, 50);
@@ -220,26 +206,28 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 2, "t1");
});
- it("tool step anchors inline after its tool-batch group", () => {
+ it("tool step anchors inline after its LAST independent tool card", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("t#0", ["c1", "c2"]);
+ const g2 = toolCallGroup("t#0", "c1");
+ const parallel = toolCallGroup("t#0", "c2");
const g3 = assistantGroup(3, "a1");
const step0 = makeStep("t#0", 100, 50);
const step1 = makeStep("t#1", 200, 80);
const turn = makeEntry("t1", 300, 130, [step0, step1]);
- const rows = interleaveTurnMetrics([g1, g2, g3], [turn]);
+ const rows = interleaveTurnMetrics([g1, g2, parallel, g3], [turn]);
- expect(rows).toHaveLength(5);
+ expect(rows).toHaveLength(6);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
- expectStepMetricsAt(rows, 2, "t#0", 0);
- expectGroupAt(rows, 3, g3);
- expectTurnMetricsAt(rows, 4, "t1");
+ expectGroupAt(rows, 2, parallel);
+ expectStepMetricsAt(rows, 3, "t#0", 0);
+ expectGroupAt(rows, 4, g3);
+ expectTurnMetricsAt(rows, 5, "t1");
});
it("single tool-call group anchors its step", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(3, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
@@ -255,7 +243,7 @@ describe("interleaveTurnMetrics", () => {
it("single tool-result group anchors its step", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolResultGroup(2, "s1", "c1");
+ const g2 = toolResultGroup("s1", "c1");
const g3 = assistantGroup(3, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
@@ -271,9 +259,9 @@ describe("interleaveTurnMetrics", () => {
it("multi-step: each tool step inline, unanchored text step skipped", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("t#0", ["c1"]);
+ const g2 = toolCallGroup("t#0", "c1");
const g3 = assistantGroup(2, "thinking");
- const g4 = toolBatchGroup("t#1", ["c2", "c3"]);
+ const g4 = toolCallGroup("t#1", "c2");
const g5 = assistantGroup(3, "a1");
const step0 = makeStep("t#0", 100, 50);
const step1 = makeStep("t#1", 200, 80);
@@ -294,10 +282,10 @@ describe("interleaveTurnMetrics", () => {
it("multiple turns head-aligned with inline steps", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const g4 = userGroup(3, "q2");
- const g5 = toolCallGroup(4, "s2", "c2");
+ const g5 = toolCallGroup("s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -332,7 +320,7 @@ describe("interleaveTurnMetrics", () => {
it("fewer metrics than segments: trailing segments are bare", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const g5 = userGroup(5, "q3");
@@ -361,7 +349,7 @@ describe("interleaveTurnMetrics", () => {
// stepId groups) — the failure case. The text-only turn MUST get its OWN
// entry (t3), NOT the trimmed t1's stale metrics.
const g3 = userGroup(3, "q2");
- const g4 = toolBatchGroup("s2", ["c2"]);
+ const g4 = toolCallGroup("s2", "c2");
const g5 = assistantGroup(4, "tool-reply");
const g6 = userGroup(5, "q3");
const g7 = assistantGroup(6, "text-reply");
@@ -436,7 +424,7 @@ describe("interleaveTurnMetrics", () => {
it("in-flight turn (no durationMs) still produces turn row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const step = makeStep("s1", 100, 50);
const turn: TurnMetricsEntry = {
turnId: "t1",
@@ -459,7 +447,7 @@ describe("interleaveTurnMetrics", () => {
it("leading non-turn groups emit as plain group rows", () => {
const g0 = assistantGroup(1, "system msg");
const g1 = userGroup(2, "q1");
- const g2 = toolCallGroup(3, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const step = makeStep("s1", 100, 50);
const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]);
@@ -473,7 +461,7 @@ describe("interleaveTurnMetrics", () => {
it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -509,7 +497,7 @@ describe("interleaveTurnMetrics", () => {
it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const step1 = makeStep("s1", 100, 50);
const entry = makeProgressiveEntry("t1", [step1]);
@@ -524,7 +512,7 @@ describe("interleaveTurnMetrics", () => {
it("entry with total emits step rows + a turn-metrics row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const step1 = makeStep("s1", 100, 50);
const entry = makeEntry("t1", 100, 50, [step1]);
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index 9f8b109..d4c343a 100644
--- a/src/core/metrics/place.ts
+++ b/src/core/metrics/place.ts
@@ -3,13 +3,11 @@ import type { RenderGroup } from "../chunks";
import type { MetricsRow, StepMetrics, TurnMetricsEntry } from "./types";
function groupStepId(g: RenderGroup): string | undefined {
- if (g.kind === "tool-batch") return g.stepId;
- const c = g.chunk.chunk;
- return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined;
+ return g.kind === "tool" ? g.stepId : undefined;
}
function groupTurnId(g: RenderGroup): string | undefined {
- return g.kind === "tool-batch" ? g.turnId : g.chunk.turnId;
+ return g.kind === "tool" ? g.turnId : g.chunk.turnId;
}
function isUser(g: RenderGroup): boolean {
diff --git a/src/core/metrics/placement-scenarios.test.ts b/src/core/metrics/placement-scenarios.test.ts
index 16499f7..ce3ed5b 100644
--- a/src/core/metrics/placement-scenarios.test.ts
+++ b/src/core/metrics/placement-scenarios.test.ts
@@ -36,9 +36,9 @@ function layout(rows: readonly MetricsRow[]): string[] {
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}:<img>`);
- else if (c.type === "tool-call") out.push(`call:${c.toolCallId}`);
- else if (c.type === "tool-result") out.push(`result:${c.toolCallId}`);
else if (c.type === "error") out.push(`error:${c.message}`);
+ } else {
+ out.push(`tool:${r.group.entry.toolCallId}`);
}
}
return out;
@@ -245,8 +245,7 @@ for (const legacy of [false, true]) {
expect(sim.layout()).toEqual([
...before,
"user:q5",
- "call:k1",
- "result:k1",
+ "tool:k1",
"[step t5#0]",
"assistant:a5",
"[turn t5]",
@@ -291,8 +290,7 @@ for (const legacy of [false, true]) {
"assistant:a1",
"[turn t1]",
"user:q2",
- "call:k1",
- "result:k1",
+ "tool:k1",
"[step t2#0]",
"user:steer!",
]);
@@ -304,12 +302,10 @@ for (const legacy of [false, true]) {
"assistant:a1",
"[turn t1]",
"user:q2",
- "call:k1",
- "result:k1",
+ "tool:k1",
"[step t2#0]",
"user:steer!",
- "call:k2",
- "result:k2",
+ "tool:k2",
"[step t2#1]",
"assistant:a2",
"[turn t2]",