summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 11:37:37 +0900
committerAdam Malczewski <[email protected]>2026-06-22 11:37:37 +0900
commit82802a14bc5921c6b62756c3a1a8953c087b5b0d (patch)
tree413824bc99b95243d911db95feeb24ae4173f79b
parentd6f60f4a81d9714176ef3d8cc60938c01c55b7d6 (diff)
downloaddispatch-web-82802a14bc5921c6b62756c3a1a8953c087b5b0d.tar.gz
dispatch-web-82802a14bc5921c6b62756c3a1a8953c087b5b0d.zip
fix(metrics): skip unanchored step-metrics — no more empty bubbles at tail
Step-metrics are only shown when anchored to their tool content (inline after the tool-call/result group). Steps whose chunks were trimmed (or text-only steps with no tool chunks) are now SKIPPED instead of piling up at the segment tail as empty 'step N · X tok' bubbles. The turn-total metrics row still shows the aggregate (tokens, duration, cache rate), so the conversation-level summary is preserved. Updated place.test.ts + ui.test.ts to anchor steps with tool-call groups where step-metrics are expected.
-rw-r--r--src/core/metrics/place.test.ts50
-rw-r--r--src/core/metrics/place.ts15
-rw-r--r--src/features/chat/ui.test.ts36
3 files changed, 64 insertions, 37 deletions
diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts
index 0b9c0ec..d91f1ab 100644
--- a/src/core/metrics/place.test.ts
+++ b/src/core/metrics/place.test.ts
@@ -169,9 +169,9 @@ describe("interleaveTurnMetrics", () => {
it("head-aligned: segment i gets entries[i]", () => {
const g1 = userGroup(1, "q1");
- const g2 = assistantGroup(2, "a1");
+ const g2 = toolCallGroup(2, "s1", "c1");
const g3 = userGroup(3, "q2");
- const g4 = assistantGroup(4, "a2");
+ const g4 = toolCallGroup(4, "s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -192,7 +192,7 @@ describe("interleaveTurnMetrics", () => {
it("a trailing segment with no entry (in-flight turn) renders no metrics", () => {
const g1 = userGroup(1, "q1");
- const g2 = assistantGroup(2, "a1");
+ const g2 = toolCallGroup(2, "s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const step = makeStep("s1", 100, 50);
@@ -207,18 +207,17 @@ describe("interleaveTurnMetrics", () => {
expectGroupAt(rows, 5, g4);
});
- it("single text-only turn: step row + turn-metrics both at tail", () => {
+ it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => {
const g1 = userGroup(1, "q1");
const g2 = assistantGroup(2, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
const rows = interleaveTurnMetrics([g1, g2], [turn]);
- expect(rows).toHaveLength(4);
+ expect(rows).toHaveLength(3);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
- expectStepMetricsAt(rows, 2, "s1", 0);
- expectTurnMetricsAt(rows, 3, "t1");
+ expectTurnMetricsAt(rows, 2, "t1");
});
it("tool step anchors inline after its tool-batch group", () => {
@@ -230,13 +229,12 @@ describe("interleaveTurnMetrics", () => {
const turn = makeEntry("t1", 300, 130, [step0, step1]);
const rows = interleaveTurnMetrics([g1, g2, g3], [turn]);
- expect(rows).toHaveLength(6);
+ expect(rows).toHaveLength(5);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
expectStepMetricsAt(rows, 2, "t#0", 0);
expectGroupAt(rows, 3, g3);
- expectStepMetricsAt(rows, 4, "t#1", 1);
- expectTurnMetricsAt(rows, 5, "t1");
+ expectTurnMetricsAt(rows, 4, "t1");
});
it("single tool-call group anchors its step", () => {
@@ -271,7 +269,7 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 4, "t1");
});
- it("multi-step: each tool step inline, final step + total at tail", () => {
+ it("multi-step: each tool step inline, unanchored text step skipped", () => {
const g1 = userGroup(1, "q1");
const g2 = toolBatchGroup("t#0", ["c1"]);
const g3 = assistantGroup(2, "thinking");
@@ -283,7 +281,7 @@ describe("interleaveTurnMetrics", () => {
const turn = makeEntry("t1", 350, 150, [step0, step1, step2]);
const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]);
- expect(rows).toHaveLength(9);
+ expect(rows).toHaveLength(8);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
expectStepMetricsAt(rows, 2, "t#0", 0);
@@ -291,8 +289,7 @@ describe("interleaveTurnMetrics", () => {
expectGroupAt(rows, 4, g4);
expectStepMetricsAt(rows, 5, "t#1", 1);
expectGroupAt(rows, 6, g5);
- expectStepMetricsAt(rows, 7, "t#2", 2);
- expectTurnMetricsAt(rows, 8, "t1");
+ expectTurnMetricsAt(rows, 7, "t1");
});
it("multiple turns head-aligned with inline steps", () => {
@@ -300,7 +297,7 @@ describe("interleaveTurnMetrics", () => {
const g2 = toolBatchGroup("s1", ["c1"]);
const g3 = assistantGroup(2, "a1");
const g4 = userGroup(3, "q2");
- const g5 = assistantGroup(4, "a2");
+ const g5 = toolCallGroup(4, "s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -320,23 +317,22 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 8, "t2");
});
- it("unanchored step (stepId not in groups) falls back to tail before turn-metrics", () => {
+ it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => {
const g1 = userGroup(1, "q1");
const g2 = assistantGroup(2, "a1");
const step0 = makeStep("orphan", 100, 50);
const turn = makeEntry("t1", 100, 50, [step0]);
const rows = interleaveTurnMetrics([g1, g2], [turn]);
- expect(rows).toHaveLength(4);
+ expect(rows).toHaveLength(3);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
- expectStepMetricsAt(rows, 2, "orphan", 0);
- expectTurnMetricsAt(rows, 3, "t1");
+ expectTurnMetricsAt(rows, 2, "t1");
});
it("fewer metrics than segments: trailing segments are bare", () => {
const g1 = userGroup(1, "q1");
- const g2 = assistantGroup(2, "a1");
+ const g2 = toolCallGroup(2, "s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const g5 = userGroup(5, "q3");
@@ -358,9 +354,9 @@ describe("interleaveTurnMetrics", () => {
expectGroupAt(rows, 7, g6);
});
- it("in-flight turn (no durationMs) still produces step + turn rows", () => {
+ it("in-flight turn (no durationMs) still produces turn row", () => {
const g1 = userGroup(1, "q1");
- const g2 = assistantGroup(2, "a1");
+ const g2 = toolCallGroup(2, "s1", "c1");
const step = makeStep("s1", 100, 50);
const turn: TurnMetricsEntry = {
turnId: "t1",
@@ -383,7 +379,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 = assistantGroup(3, "a1");
+ const g2 = toolCallGroup(3, "s1", "c1");
const step = makeStep("s1", 100, 50);
const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]);
@@ -397,7 +393,7 @@ describe("interleaveTurnMetrics", () => {
it("more metrics than segments: only T entries placed (extra ignored)", () => {
const g1 = userGroup(1, "q1");
- const g2 = assistantGroup(2, "a1");
+ const g2 = toolCallGroup(2, "s1", "c1");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -452,7 +448,7 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 4, "t1");
});
- it("progressive multi-step: unanchored steps at tail, no turn-metrics", () => {
+ it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => {
const g1 = userGroup(1, "q1");
const g2 = assistantGroup(2, "a1");
const step0 = makeStep("s1", 100, 50);
@@ -460,11 +456,9 @@ describe("interleaveTurnMetrics", () => {
const entry = makeProgressiveEntry("t1", [step0, step1]);
const rows = interleaveTurnMetrics([g1, g2], [entry]);
- expect(rows).toHaveLength(4);
+ expect(rows).toHaveLength(2);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
- expectStepMetricsAt(rows, 2, "s1", 0);
- expectStepMetricsAt(rows, 3, "s2", 1);
});
});
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index 2402e3e..cb16b30 100644
--- a/src/core/metrics/place.ts
+++ b/src/core/metrics/place.ts
@@ -190,10 +190,11 @@ export function interleaveTurnMetrics(
}
}
- // Classify each step as anchored or unanchored.
+ // Classify each step as anchored or unanchored. Unanchored steps
+ // (content trimmed, or text-only steps with no tool chunks) are SKIPPED —
+ // step-metrics are only shown inline next to the content they describe.
const anchored: Map<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> =
new Map();
- const unanchored: { stepIndex: number; step: (typeof entry.steps)[number] }[] = [];
for (let i = 0; i < entry.steps.length; i++) {
const step = entry.steps[i];
@@ -206,9 +207,8 @@ export function interleaveTurnMetrics(
anchored.set(anchorGroupIdx, arr);
}
arr.push({ stepIndex: i, step });
- } else {
- unanchored.push({ stepIndex: i, step });
}
+ // Unanchored steps (no matching group) are skipped — no tail bubbles.
}
// Emit groups; after each anchored group, emit its step-metrics rows.
@@ -226,11 +226,8 @@ export function interleaveTurnMetrics(
}
}
- // Segment tail: unanchored steps, then turn-metrics (only when total is present).
- unanchored.sort((a, b) => a.stepIndex - b.stepIndex);
- for (const { step, stepIndex } of unanchored) {
- rows.push({ kind: "step-metrics", step, index: stepIndex });
- }
+ // Turn-metrics row (only when the turn is finalized). Unanchored steps
+ // are skipped — no tail bubbles.
if (entry.total !== null) {
rows.push({
kind: "turn-metrics",
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index e541015..94db0ae 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -329,6 +329,18 @@ describe("ChatView", () => {
chunk: { type: "text", text: "Hello!" },
provisional: false,
},
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
];
const turnMetrics: TurnMetricsEntry[] = [
@@ -502,6 +514,18 @@ describe("ChatView", () => {
chunk: { type: "text", text: "Response" },
provisional: false,
},
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
];
const turnMetrics: TurnMetricsEntry[] = [
@@ -547,6 +571,18 @@ describe("ChatView", () => {
chunk: { type: "text", text: "Hello!" },
provisional: false,
},
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
];
const turnMetrics: TurnMetricsEntry[] = [