summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 15:26:40 +0900
committerAdam Malczewski <[email protected]>2026-06-28 15:26:40 +0900
commit22a8dbf96dda066dc5027694b1fe512df9f3fc11 (patch)
treeac6a534d2f88b99e6d93adeba54948cca276fcfb /src
parented3425cb8ed57862f954505831be38a2588729bd (diff)
parent7300c233ab2ce3e3d20fef9b6b051e36a509bcdc (diff)
downloaddispatch-web-22a8dbf96dda066dc5027694b1fe512df9f3fc11.tar.gz
dispatch-web-22a8dbf96dda066dc5027694b1fe512df9f3fc11.zip
Merge branch 'feature/cache-display-fix' into predev
Diffstat (limited to 'src')
-rw-r--r--src/app/store.test.ts16
-rw-r--r--src/core/metrics/place.test.ts97
-rw-r--r--src/core/metrics/place.ts75
3 files changed, 140 insertions, 48 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 2cf473c..e1a11e0 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -553,13 +553,17 @@ describe("createAppStore", () => {
event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
});
- await new Promise((r) => setTimeout(r, 50));
-
- expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
-
- await new Promise((r) => setTimeout(r, 50));
+ // `turn-sealed` triggers an async `syncTail` (cache.sinceSeq → historySync
+ // → cache.commit → applyHistory). Poll for the side-effect rather than
+ // guessing a fixed delay — under suite load a fixed `setTimeout` raced the
+ // fetch chain and flaked here.
+ await vi.waitFor(() => {
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+ });
- expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ await vi.waitFor(() => {
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ });
store.dispose();
});
diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts
index c05ba3b..9c925a3 100644
--- a/src/core/metrics/place.test.ts
+++ b/src/core/metrics/place.test.ts
@@ -354,6 +354,81 @@ describe("interleaveTurnMetrics", () => {
expectGroupAt(rows, 7, g6);
});
+ it("trimmed leading turns: a mixed tool+text transcript tail-aligns text-only turns to their OWN (newest) entries, not stale trimmed ones", () => {
+ // A long conversation where the chat limit unloaded the oldest turn (t1).
+ // Metrics still hold all three turns; the loaded transcript is turns 2-3.
+ // Turn 2 is a tool turn (matched by stepId); turn 3 is text-only (no
+ // 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 g5 = assistantGroup(4, "tool-reply");
+ const g6 = userGroup(5, "q3");
+ const g7 = assistantGroup(6, "text-reply");
+ const step1 = makeStep("s1", 11, 1); // t1 (trimmed)
+ const step2 = makeStep("s2", 22, 2); // t2 (loaded, tool)
+ const step3 = makeStep("s3", 33, 3); // t3 (loaded, text-only — unanchored)
+ const entries = [
+ makeEntry("t1", 11, 1, [step1]),
+ makeEntry("t2", 22, 2, [step2]),
+ makeEntry("t3", 33, 3, [step3]),
+ ];
+ const rows = interleaveTurnMetrics([g3, g4, g5, g6, g7], entries);
+
+ const tmRows = rows.filter(
+ (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics",
+ );
+ // Two loaded turns → two turn-metrics rows. The trimmed t1 does NOT render.
+ expect(tmRows).toHaveLength(2);
+ // CRITICAL: the text-only turn (segment 1) got t3 (its own newest entry),
+ // not t1 (the stale trimmed one). A misaligned head-align would show t1.
+ expect(tmRows[1]?.turn.turnId).toBe("t3");
+ expect(tmRows[0]?.turn.turnId).toBe("t2");
+ // And t1 never appears as a rendered row.
+ expect(tmRows.some((r) => r.turn.turnId === "t1")).toBe(false);
+ });
+
+ it("trimmed turn still counts toward the cumulative 'chat total' on the first visible turn", () => {
+ // t1 is trimmed (no segment) but finalized; t2 is the loaded visible turn.
+ // t2's "Chat Total" cumulative must INCLUDE t1's usage (the whole chat),
+ // even though t1 renders no row of its own.
+ const g1 = userGroup(2, "q2");
+ const g2 = assistantGroup(3, "a2");
+ const entries = [
+ {
+ turnId: "t1",
+ steps: [],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 },
+ steps: [],
+ },
+ },
+ {
+ turnId: "t2",
+ steps: [],
+ total: {
+ turnId: "t2",
+ usage: { inputTokens: 2000, outputTokens: 20, cacheReadTokens: 1600 },
+ steps: [],
+ },
+ },
+ ];
+ const rows = interleaveTurnMetrics([g1, g2], entries);
+ const tmRows = rows.filter(
+ (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics",
+ );
+ // Only the loaded turn renders a row; the trimmed t1 does not.
+ expect(tmRows).toHaveLength(1);
+ expect(tmRows[0]?.turn.turnId).toBe("t2");
+ // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100.
+ expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000);
+ expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100);
+ // Retention baseline is the prior finalized turn (t1, even though trimmed).
+ expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000);
+ expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500);
+ });
+
it("in-flight turn (no durationMs) still produces turn row", () => {
const g1 = userGroup(1, "q1");
const g2 = toolCallGroup(2, "s1", "c1");
@@ -391,7 +466,7 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 4, "t1");
});
- it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => {
+ 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 step1 = makeStep("s1", 100, 50);
@@ -401,13 +476,19 @@ describe("interleaveTurnMetrics", () => {
[makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])],
);
- // Unmatched entry (t2) emits a standalone turn-metrics row at the top.
- expect(rows).toHaveLength(5);
- expectTurnMetricsAt(rows, 0, "t2");
- expectGroupAt(rows, 1, g1);
- expectGroupAt(rows, 2, g2);
- expectStepMetricsAt(rows, 3, "s1", 0);
- expectTurnMetricsAt(rows, 4, "t1");
+ // t2's content was unloaded by the chat limit (no segment for it); its
+ // metrics must NOT render a standalone row piled at the top. Only the
+ // loaded turn's content + its matched metrics appear. (t2 still counts
+ // toward the cumulative "chat total" — see the cache-total tests.)
+ expect(rows).toHaveLength(4);
+ expectGroupAt(rows, 0, g1);
+ expectGroupAt(rows, 1, g2);
+ expectStepMetricsAt(rows, 2, "s1", 0);
+ expectTurnMetricsAt(rows, 3, "t1");
+ // No standalone turn-metrics row for t2 anywhere.
+ const tmRows = rows.filter((r) => r.kind === "turn-metrics");
+ expect(tmRows).toHaveLength(1);
+ expect((tmRows[0] as { readonly turn: TurnMetrics }).turn.turnId).toBe("t1");
});
it("turn with no steps emits only turn-metrics (no step-metrics)", () => {
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index b165fd0..7122b09 100644
--- a/src/core/metrics/place.ts
+++ b/src/core/metrics/place.ts
@@ -27,10 +27,11 @@ function addUsage(a: Usage, b: Usage): Usage {
* Splits groups into per-turn segments: a new segment begins at each `single`
* group with `group.chunk.role === "user"`. Segments are matched to entries
* by `stepId` presence when possible (robust against chat-limit trimming: when
- * a turn's user message is trimmed, head-alignment would be off by one, but
+ * a turn's user message is trimmed, positional alignment would be off, but
* stepId matching still finds the right entry). Segments with no stepId-bearing
- * groups (text-only turns) fall back to sequential matching against unused
- * entries.
+ * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the
+ * loaded transcript is always a SUFFIX of the full turn history (the chat limit
+ * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`.
*
* Within a segment that has a matched entry, each completed step's metrics
* are placed INLINE right after the last group bearing that step's `stepId`.
@@ -44,9 +45,13 @@ function addUsage(a: Usage, b: Usage): Usage {
* is finalized via `done` or durable data). A still-generating turn emits no
* turn-total row.
*
- * Cumulative usage is computed across finalized turns in entry-array order
- * (turn order), so the per-turn "chat total" cache rate is correct regardless
- * of which turns were trimmed.
+ * Fully trimmed turns (entries whose content was unloaded by the chat limit and
+ * which match no segment) are NOT rendered as standalone rows — that previously
+ * piled a wall of stale cache badges at the top of a long, trimmed transcript.
+ * Their usage still counts toward the per-turn "chat total" cumulative (computed
+ * across ALL finalized turns in entry-array order), so the running cache rate
+ * stays correct regardless of which turns were trimmed; paging earlier history
+ * back in ("Show earlier messages") re-matches them and re-renders their rows.
*/
export function interleaveTurnMetrics(
groups: readonly RenderGroup[],
@@ -84,8 +89,9 @@ export function interleaveTurnMetrics(
const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId)));
// Match segments to entries. Pass 1: match by stepId overlap (handles
- // trimming where head-alignment would be wrong). Pass 2: sequential fallback
- // for unmatched segments (text-only turns with no stepId-bearing groups).
+ // trimming where positional alignment alone could be ambiguous). Pass 2:
+ // positional tail-alignment fallback for unmatched segments (text-only turns
+ // with no stepId-bearing groups).
const usedEntries = new Set<number>();
const segmentEntry = new Map<number, TurnMetricsEntry>();
const segmentEntryIndex = new Map<number, number>();
@@ -127,19 +133,36 @@ export function interleaveTurnMetrics(
}
}
- // Pass 2: sequential fallback for unmatched segments.
- // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT:
- // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the
- // newest and trims the oldest), so match the LAST T entries to the T
- // segments. This prevents misaligning oldest (trimmed) entries to newest
- // segments — which would show "turn 1" on turn 20's content.
- const pass1Matches = segmentEntry.size;
- if (pass1Matches === 0 && K >= T) {
+ // Pass 2: positional fallback for segments pass 1 left unmatched
+ // (text-only turns with no stepId-bearing groups to anchor on).
+ //
+ // The loaded transcript is always a SUFFIX of the full turn history —
+ // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so
+ // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT
+ // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the
+ // metrics hold at least as many turns as there are loaded segments
+ // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content
+ // was unloaded) and must be skipped, never matched to a newer segment.
+ //
+ // This MUST run even when pass 1 matched SOME segments (tool turns). The
+ // earlier code only tail-aligned when pass 1 matched NONE, falling back to
+ // HEAD-alignment otherwise — which, with leading trimmed entries, matched a
+ // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the
+ // "new steps show no / wrong cache" failure). Tail-aligning by position is
+ // safe alongside pass 1: stepIds are unique per turn, so pass 1 already
+ // grabbed each tool turn's positionally-correct entry, leaving the right
+ // entry free for each text-only turn.
+ //
+ // Only when `K < T` (fewer entries than segments — some loaded turns have no
+ // metrics yet, e.g. a metrics sync still pending or a freshly loaded
+ // transcript) do we head-align, assigning the first K entries to the first K
+ // unmatched segments (the turns that DO have metrics sit at the front).
+ if (K >= T) {
// Tail-align: skip the first K-T entries (trimmed turns).
for (let seg = 0; seg < T; seg++) {
if (segmentEntry.has(seg)) continue;
const entryIdx = K - T + seg;
- if (entryIdx < K && !usedEntries.has(entryIdx)) {
+ if (entryIdx >= 0 && entryIdx < K && !usedEntries.has(entryIdx)) {
usedEntries.add(entryIdx);
const e = entries[entryIdx];
if (e !== undefined) {
@@ -149,7 +172,7 @@ export function interleaveTurnMetrics(
}
}
} else {
- // Head-align fallback for remaining unmatched segments.
+ // Head-align fallback (K < T): first K entries to first K unmatched segments.
let nextUnused = 0;
for (let seg = 0; seg < T; seg++) {
if (segmentEntry.has(seg)) continue;
@@ -186,22 +209,6 @@ export function interleaveTurnMetrics(
const firstUserIdx = segmentStarts[0] ?? 0;
- // Emit turn-metrics rows for entries that weren't matched to any segment
- // (fully trimmed turns — their content was unloaded by the chat limit, but
- // their aggregate metrics still show so the user knows what was trimmed).
- for (let i = 0; i < entries.length; i++) {
- if (usedEntries.has(i)) continue;
- const e = entries[i];
- if (e === undefined || e.total === null) continue;
- rows.push({
- kind: "turn-metrics",
- turn: e.total,
- turnNumber: i + 1,
- cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage,
- prevTurnUsage: prevUsageByEntry[i] ?? null,
- });
- }
-
for (let i = 0; i < firstUserIdx; i++) {
const g = groups[i];
if (g !== undefined) {