From 8b68e1531e70ebf5bce520ab72e7c9ed0f17b138 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 00:12:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(turn-budget):=20one=20turn,=20one=20num?= =?UTF-8?q?ber=20=E2=80=94=20hook,=20bell=20and=20panel=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live audit of session 67105434 showed both numbers were correct for different turns; the notification never named its turn. Canon gains isMainChainAssistantLine (hook/CLI/panel exclude sidechain+synthetic by one definition), the incident carries turnNumber+turnStartTs so the bell reads 'Turn budget · turn 8 (07:08) · 15.1M / 15.0M', the panel's Re-read rows show the turn start time, and parity is pinned by a batch-feed test plus a new --audit CLI (parity OK on 19+278+82 live turns). Co-Authored-By: Claude Code --- scripts/turn-accounting.d.mts | 7 +- scripts/turn-accounting.mjs | 13 +- scripts/turn-budget-hook.d.mts | 3 + scripts/turn-budget-hook.mjs | 2 + src/cli/turnSpendStats.ts | 115 +++++++++++++++++- .../services/infrastructure/FileWatcher.ts | 11 +- src/main/utils/loopDetection.ts | 22 ++++ .../components/RereadSection.tsx | 8 ++ src/renderer/types/contextInjection.ts | 2 + src/renderer/utils/contextTracker.ts | 5 + test/fixtures/turn-accounting/session.jsonl | 2 +- .../infrastructure/FileWatcher.test.ts | 6 +- test/main/utils/jsonl.test.ts | 7 +- test/main/utils/loopDetection.test.ts | 2 + test/shared/turnAccounting.parity.test.ts | 51 ++++++-- 15 files changed, 234 insertions(+), 22 deletions(-) diff --git a/scripts/turn-accounting.d.mts b/scripts/turn-accounting.d.mts index 45c7a2bf..98e97aa2 100644 --- a/scripts/turn-accounting.d.mts +++ b/scripts/turn-accounting.d.mts @@ -18,9 +18,10 @@ export interface AccountableMessage { messageId?: string; isMeta?: boolean; isCompactSummary?: boolean; + isSidechain?: boolean; content?: unknown; usage?: UsageLike; - message?: { id?: string; usage?: UsageLike; content?: unknown }; + message?: { id?: string; model?: string; usage?: UsageLike; content?: unknown }; } /** requestId ?? messageId ?? message.id — one billed request per key. */ @@ -67,6 +68,10 @@ export declare function isTranscriptTurnLine(m: AccountableMessage): boolean; /** Turn boundary: user-initiated message or compaction marker. */ export declare function isTurnBoundary(m: AccountableMessage): boolean; +/** Main-chain assistant line — the only lines billed to the turn's re-read + * (sidechain/synthetic excluded; detector and panel see the same set). */ +export declare function isMainChainAssistantLine(m: AccountableMessage): boolean; + /** Sum input-side spend of the current turn, scanning lines newest-first. */ export declare function analyzeTurn(linesNewestFirst: Iterable): { spent: number; diff --git a/scripts/turn-accounting.mjs b/scripts/turn-accounting.mjs index 2d73755f..068cbb32 100644 --- a/scripts/turn-accounting.mjs +++ b/scripts/turn-accounting.mjs @@ -206,6 +206,17 @@ export function isTurnBoundary(m) { return isUserChunkLine(m) || m.isCompactSummary === true; } +/** Main-chain assistant line — the only lines whose usage is billed to the + * turn's re-read. Sidechain (subagent) rounds run in their own context and + * synthetic lines carry no real request; the detector (loopDetection.ts) + * and the panel's aiGroups never see them, so the hook must not either — + * one definition, no drift. */ +export function isMainChainAssistantLine(m) { + return ( + m.type === 'assistant' && m.isSidechain !== true && m.message?.model !== '' + ); +} + /** Sum input-side tokens of the current turn, scanning lines newest-first. */ export function analyzeTurn(linesNewestFirst) { let spent = 0; @@ -225,7 +236,7 @@ export function analyzeTurn(linesNewestFirst) { boundaryFound = true; break; } - if (m.type === 'assistant' && m.message?.usage) { + if (isMainChainAssistantLine(m) && m.message?.usage) { const key = billedRequestKey(m); if (key) { if (billed.has(key)) continue; diff --git a/scripts/turn-budget-hook.d.mts b/scripts/turn-budget-hook.d.mts index d5201f80..763f1fdb 100644 --- a/scripts/turn-budget-hook.d.mts +++ b/scripts/turn-budget-hook.d.mts @@ -47,6 +47,9 @@ export declare function isRealUserLine(m: AccountableMessage): boolean; /** Turn boundary: real user message or compaction marker. */ export declare function isTurnBoundary(m: AccountableMessage): boolean; +/** Main-chain assistant line — billed to the turn's re-read. */ +export declare function isMainChainAssistantLine(m: AccountableMessage): boolean; + /** Sum input-side spend of the current turn, scanning lines newest-first. */ export declare function analyzeTurn(linesNewestFirst: Iterable): { spent: number; diff --git a/scripts/turn-budget-hook.mjs b/scripts/turn-budget-hook.mjs index 0a5f9195..31ed50e0 100644 --- a/scripts/turn-budget-hook.mjs +++ b/scripts/turn-budget-hook.mjs @@ -48,6 +48,7 @@ import { billedTotalTokens, firstAssistantTotalTokens, inputSideTokens, + isMainChainAssistantLine, isRealUserLine, isTurnBoundary, lastAssistantTotalTokens, @@ -59,6 +60,7 @@ export { billedTotalTokens, firstAssistantTotalTokens, inputSideTokens, + isMainChainAssistantLine, isRealUserLine, isTurnBoundary, lastAssistantTotalTokens, diff --git a/src/cli/turnSpendStats.ts b/src/cli/turnSpendStats.ts index fc588818..4a869150 100644 --- a/src/cli/turnSpendStats.ts +++ b/src/cli/turnSpendStats.ts @@ -16,12 +16,14 @@ import * as readline from 'readline'; // same turn-boundary predicate the hook enforces — one definition, no drift import { + analyzeTurn, billedRequestKey, inputSideTokens, + isMainChainAssistantLine, isTurnBoundary, } from '../../scripts/turn-budget-hook.mjs'; -import { wantsHelp } from './args'; +import { takeFlagValue, wantsHelp } from './args'; export interface TurnSpend { file: string; @@ -67,7 +69,7 @@ export function feedLine( return; } - if (msg.type === 'assistant' && inner.usage && state.current) { + if (isMainChainAssistantLine(msg) && inner.usage && state.current) { const side = inputSideTokens(inner.usage); const key = billedRequestKey(msg); if (key) { @@ -102,10 +104,117 @@ export function percentile(sorted: number[], q: number): number { // CLI // ============================================================================= +/** --audit : the three accountings side by side, per turn. + * Every number shown to a human (hook notification, detector bell, panel + * Re-read) must come out equal here — divergence is a bug, this prints it. */ +async function auditFile(filePath: string): Promise { + const { sumTurnReread } = await import('../renderer/utils/contextTracker'); + const { TurnBudgetDetector } = await import('../main/utils/loopDetection'); + + const lines = fs + .readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.trim() !== ''); + // ParsedMessage shape for the detector: usage/model/toolCalls at the top + const flat = lines.map((l) => { + const m = JSON.parse(l) as Record; + const inner = m.message as { usage?: unknown; model?: string } | undefined; + return { ...m, usage: m.usage ?? inner?.usage, model: inner?.model, toolCalls: [] }; + }); + + // turn slices (1-based numbering, same as the detector's turnNumber): + // turn 1 opens at the FIRST boundary; metadata before it is not a turn + const slices: string[][] = []; + let cur: string[] = []; + let sawBoundary = false; + for (const line of lines) { + let m: Record = {}; + try { + m = JSON.parse(line) as Record; + } catch { + continue; + } + if (isTurnBoundary(m)) { + if (sawBoundary && cur.length) slices.push(cur); + cur = []; + sawBoundary = true; + } + if (sawBoundary) cur.push(line); + } + if (cur.length) slices.push(cur); + + // detector path (the live bell): line-by-line feed, exactly what FileWatcher + // streams; snapshot each turn's running total right before its boundary resets + // the bucket. Batch-feed equivalence is pinned by the parity test. + const det = new TurnBudgetDetector(); + const detTotals: number[] = []; + for (const m of flat) { + const before = det.currentTurnTotal('audit'); + if (isTurnBoundary(m as never)) detTotals.push(before); + det.feed('audit', [m as never], Number.MAX_SAFE_INTEGER); + } + detTotals.push(det.currentTurnTotal('audit')); + + const fmtK = (n: number): string => + n >= 1e6 ? (n / 1e6).toFixed(2) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(0) + 'k' : String(n); + console.log('turn start(ts) analyzeTurn detector panelReread rq'); + let mismatches = 0; + slices.forEach((slice, i) => { + const hook = analyzeTurn([...slice].reverse()).spent; + // detTotals[0] is the pre-first-boundary prefix; turn i (1-based) = [i+1] + const bell = detTotals[i + 1] ?? 0; + const panel = sumTurnReread( + slice + .map((l) => JSON.parse(l) as Record) + .filter((m) => m.type === 'assistant') + .map((m) => ({ + ...m, + usage: m.usage ?? (m.message as { usage?: unknown } | undefined)?.usage, + model: (m.message as { model?: string } | undefined)?.model, + toolCalls: [], + })) as never[] + ); + const agree = hook === bell && hook === panel.tokens; + if (!agree) mismatches++; + const startTs = (() => { + try { + return (JSON.parse(slice[0]) as { timestamp?: string }).timestamp ?? ''; + } catch { + return ''; + } + })(); + console.log( + String(i + 1).padStart(4), + startTs.padEnd(24), + fmtK(hook).padStart(11), + fmtK(bell).padStart(10), + fmtK(panel.tokens).padStart(12), + String(panel.requests).padStart(4), + agree ? '' : ' <-- MISMATCH' + ); + }); + console.log( + mismatches === 0 + ? `parity OK (${slices.length} turns)` + : `parity BROKEN: ${mismatches}/${slices.length} turns disagree` + ); +} + async function main(): Promise { const argv = process.argv.slice(2); if (wantsHelp(argv)) { - console.log('Usage: pnpm turn-spend:stats [--p N]'); + console.log('Usage: pnpm turn-spend:stats [--p N] | --audit '); + return; + } + const aIdx = argv.indexOf('--audit'); + if (aIdx !== -1) { + const { value } = takeFlagValue(argv, aIdx); + if (!value) { + console.error('--audit requires a .jsonl path'); + process.exitCode = 1; + return; + } + await auditFile(path.resolve(value)); return; } const pIdx = argv.indexOf('--p'); diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 919cef9e..26b93ea0 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -103,11 +103,20 @@ function detectorIncidentToError(args: { }; if (kind === 'turn_budget') { const budget = incident as TurnBudgetIncident; + // turn number + wall-clock start — the keys to find the crossing turn in + // the chat and the Visible Context panel (Re-read section shows both) + const turnLabel = + budget.turnNumber > 0 + ? `turn ${budget.turnNumber}` + + (budget.turnStartTs + ? ` (${new Date(budget.turnStartTs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})` + : '') + : 'current turn'; return createDetectedError({ ...common, source: 'turn_budget', message: - `Turn budget · ${formatTokensCompact(budget.spent)} / ` + + `Turn budget · ${turnLabel} · ${formatTokensCompact(budget.spent)} / ` + `${formatTokensCompact(budget.budget)} — turn re-read crossed the ` + 'limit; tool calls are being denied', triggerName: 'Turn budget', diff --git a/src/main/utils/loopDetection.ts b/src/main/utils/loopDetection.ts index 03c0099b..7f5ed4dc 100644 --- a/src/main/utils/loopDetection.ts +++ b/src/main/utils/loopDetection.ts @@ -253,6 +253,11 @@ interface FileBudgetState { /** this turn already fired its crossing notification */ notified: boolean; lastToolUseId: string; + /** 1-based ordinal of the current turn (user/compact boundaries seen) */ + turnNumber: number; + /** timestamp of the boundary that opened the current turn + * (raw JSONL carries ISO strings, parsed transcripts carry Dates) */ + turnStartTs?: string | Date; cwd?: string; } @@ -262,6 +267,7 @@ const freshBudgetState = (): FileBudgetState => ({ total: 0, notified: false, lastToolUseId: '', + turnNumber: 0, }); export interface TurnBudgetIncident { @@ -269,6 +275,11 @@ export interface TurnBudgetIncident { spent: number; /** configured per-turn budget (the hook's currency) */ budget: number; + /** 1-based ordinal of the turn that crossed (0 = file opened mid-turn) */ + turnNumber: number; + /** when the crossing turn started — the key to find it in the chat + * (raw JSONL carries ISO strings, parsed transcripts carry Dates) */ + turnStartTs?: string | Date; toolUseId: string; cwd?: string; batchIndex: number; @@ -296,6 +307,12 @@ export class TurnBudgetDetector { this.perFile.clear(); } + /** Current turn's running input-side total — the parity-test seam: the + * offline accounting (analyzeTurn) must equal this after any feed shape. */ + currentTurnTotal(filePath: string): number { + return this.perFile.get(filePath)?.total ?? 0; + } + /** Same contract as LoopDetector.feed; budget = notifications.turnBudget.maxInputTokensPerTurn. */ feed(filePath: string, messages: ParsedMessage[], budget: number): TurnBudgetIncident | null { let state = this.perFile.get(filePath) ?? freshBudgetState(); @@ -307,7 +324,10 @@ export class TurnBudgetDetector { const msg = messages[i]; // a new user turn (or compaction) starts a fresh budget bucket if (isTurnBoundary(msg)) { + const prevTurnNumber = state.turnNumber; state = freshBudgetState(); + state.turnNumber = prevTurnNumber + 1; + state.turnStartTs = msg.timestamp; this.perFile.set(filePath, state); } // main-chain assistant lines only — same accounting as the hook @@ -336,6 +356,8 @@ export class TurnBudgetDetector { incident = { spent: state.total, budget, + turnNumber: state.turnNumber, + turnStartTs: state.turnStartTs, toolUseId: state.lastToolUseId, cwd: state.cwd, batchIndex: i, diff --git a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx index e49903ad..5f6d8646 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx @@ -54,6 +54,14 @@ export const RereadSection = ({ Turn {injection.turnIndex + 1} + {injection.turnStartTs && ( + + {new Date(injection.turnStartTs).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} {injection.requests} rq diff --git a/src/renderer/types/contextInjection.ts b/src/renderer/types/contextInjection.ts index 81972dec..a0796a83 100644 --- a/src/renderer/types/contextInjection.ts +++ b/src/renderer/types/contextInjection.ts @@ -307,6 +307,8 @@ export interface RereadInjection { estimatedTokens: number; /** Billed requests in the turn (requestId-deduped) */ requests: number; + /** When the turn started — the key to match a turn-budget notification */ + turnStartTs?: string; } // ============================================================================= diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index 908ee423..a5d992c1 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -515,6 +515,10 @@ export function sumTurnReread(responses: ParsedMessage[]): { tokens: number; req let requests = 0; for (const msg of responses ?? []) { if (msg.type !== 'assistant') continue; + // main-chain only — same accounting as the hook's isMainChainAssistantLine + // (aiGroup.responses never carries sidechain/synthetic lines; this is + // parity insurance, not behavior) + if (msg.isSidechain === true || msg.model === '') continue; const key = billedRequestKey(msg); if (!key) { // a line without any request id is its own request @@ -1096,6 +1100,7 @@ function computeContextStats(params: ComputeContextStatsParams): ComputeContextS aiGroupId: turnGroupId, estimatedTokens: turnReread.tokens, requests: turnReread.requests, + turnStartTs: aiGroup.startTime ? new Date(aiGroup.startTime).toISOString() : undefined, } satisfies RereadInjection); } diff --git a/test/fixtures/turn-accounting/session.jsonl b/test/fixtures/turn-accounting/session.jsonl index 735f696c..1afcd8f3 100644 --- a/test/fixtures/turn-accounting/session.jsonl +++ b/test/fixtures/turn-accounting/session.jsonl @@ -1,4 +1,4 @@ -{"type":"user","message":{"role":"user","content":"turn 1 prompt"}} +{"type":"user","timestamp":"2026-10-08T03:00:00.000Z","message":{"role":"user","content":"turn 1 prompt"}} {"type":"assistant","requestId":"req_a","message":{"id":"msg_a1","content":[{"type":"text","text":"frag 1"}],"usage":{"input_tokens":1000,"cache_read_input_tokens":100000,"cache_creation_input_tokens":0,"output_tokens":10}}} {"type":"assistant","requestId":"req_a","message":{"id":"msg_a2","content":[{"type":"text","text":"frag 2"}],"usage":{"input_tokens":1000,"cache_read_input_tokens":100000,"cache_creation_input_tokens":0,"output_tokens":20}}} {"type":"assistant","requestId":"req_b","message":{"id":"msg_b1","content":[],"usage":{"input_tokens":2000,"cache_read_input_tokens":200000,"cache_creation_input_tokens":0,"output_tokens":500}}} diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 2639c462..93ed2aff 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -926,7 +926,7 @@ describe('FileWatcher', () => { const budgetError = vi.mocked(notificationManager.addError).mock.calls[0][0]; expect(budgetError.source).toBe('turn_budget'); expect(budgetError.triggerName).toBe('Turn budget'); - expect(budgetError.message).toContain('Turn budget · 12.0M / 10.0M'); + expect(budgetError.message).toContain('Turn budget · current turn · 12.0M / 10.0M'); expect(budgetError.toolUseId).toBe('t2'); expect(budgetError.sessionId).toBe('session-1'); @@ -1136,7 +1136,7 @@ describe('FileWatcher', () => { const [error, opts] = vi.mocked(notificationManager.addError).mock.calls[0]; expect(error.source).toBe('turn_budget'); expect(error.triggerName).toBe('Turn budget'); - expect(error.message).toContain('Turn budget · 12.0M / 10.0M'); + expect(error.message).toMatch(/Turn budget · turn 1 \(\d{2}:\d{2}\) · 12\.0M \/ 10\.0M/); expect(opts).toEqual({ silent: true }); watcher.stop(); @@ -1223,7 +1223,7 @@ describe('FileWatcher', () => { expect(notificationManager.addError).toHaveBeenCalledTimes(1); const [error] = vi.mocked(notificationManager.addError).mock.calls[0]; - expect(error.message).toContain('Turn budget · 13.0M / 10.0M'); + expect(error.message).toMatch(/Turn budget · turn 1 \(\d{2}:\d{2}\) · 13\.0M \/ 10\.0M/); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index f24a6f11..3b0a3998 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -456,8 +456,8 @@ describe('jsonl', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-relay-')); try { const filePath = path.join(tempDir, 'session.jsonl'); - const relay = - 'hello\nWhy is this tag in the log?'; + // a complete relay block — issue #59 canon: not user text + const relay = 'hello'; const lines = [ JSON.stringify({ type: 'user', @@ -485,8 +485,7 @@ describe('jsonl', () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-relay2-')); try { const filePath = path.join(tempDir, 'session.jsonl'); - const relay = - 'hello\nWhy is this tag in the log?'; + const relay = 'hello'; const lines = [ JSON.stringify({ type: 'user', diff --git a/test/main/utils/loopDetection.test.ts b/test/main/utils/loopDetection.test.ts index 76e54f40..4d839afb 100644 --- a/test/main/utils/loopDetection.test.ts +++ b/test/main/utils/loopDetection.test.ts @@ -381,6 +381,8 @@ describe('TurnBudgetDetector', () => { expect(incident).toEqual({ spent: 12_000_000, budget, + turnNumber: 0, + turnStartTs: undefined, toolUseId: 't-c', cwd: undefined, batchIndex: 0, diff --git a/test/shared/turnAccounting.parity.test.ts b/test/shared/turnAccounting.parity.test.ts index 3378f76a..49c074d0 100644 --- a/test/shared/turnAccounting.parity.test.ts +++ b/test/shared/turnAccounting.parity.test.ts @@ -9,8 +9,8 @@ * * The fixture pins: GLM stream fragments (same requestId, full usage each), * Claude stream snapshots (same requestId, growing output), a keyless - * assistant line (own request), a sidechain round (counted — no sidechain - * filter in this accounting), a ghost line WITHOUT usage (skipped), junk + * assistant line (own request), a sidechain round (excluded — main-chain + * only accounting everywhere), a ghost line WITHOUT usage (skipped), junk * usage fields (ignored), and all the non-boundary user-line shapes * (teammate, interrupt, isMeta tool_result) plus the current boundary canon * ( IS a boundary). @@ -21,6 +21,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { analyzeTurn } from '../../scripts/turn-budget-hook.mjs'; +import { TurnBudgetDetector } from '../../src/main/utils/loopDetection'; import { feedLine } from '../../src/cli/turnSpendStats'; import { sumTurnReread } from '../../src/renderer/utils/contextTracker'; @@ -51,7 +52,13 @@ function flatten( usage?: Record; } ): Record { - return { ...m, usage: m.usage ?? m.message?.usage }; + // ParsedMessage shape: usage/model/toolCalls flattened to the top level + return { + ...m, + usage: m.usage ?? m.message?.usage, + model: (m.message as { model?: string } | undefined)?.model, + toolCalls: [], + }; } describe('turn accounting — golden parity across consumers', () => { @@ -73,10 +80,11 @@ describe('turn accounting — golden parity across consumers', () => { const spends = state.spends as { inputSide: number; rounds: number }[]; // canonical boundary: is system output, NOT a turn - // boundary — turn 1 now spans fixture lines 2..10 (311.8k + 4.4k) + // boundary — turn 1 now spans fixture lines 2..10 (306.3k main + 4.4k; + // the sidechain round's 5.5k is excluded, main-chain only) expect(spends).toHaveLength(3); - expect(spends.map((s) => s.inputSide)).toEqual([316_200, 10_798, 102_320]); - expect(spends.map((s) => s.rounds)).toEqual([5, 2, 2]); + expect(spends.map((s) => s.inputSide)).toEqual([310_700, 10_798, 102_320]); + expect(spends.map((s) => s.rounds)).toEqual([4, 2, 2]); }); it('sumTurnReread (renderer) matches the hook on the same requests', () => { @@ -86,7 +94,34 @@ describe('turn accounting — golden parity across consumers', () => { .map((l) => flatten(JSON.parse(l))) .filter((m) => m.type === 'assistant'); const reread = sumTurnReread(turn1Responses as never[]); - expect(reread.tokens).toBe(311_800); - expect(reread.requests).toBe(4); + expect(reread.tokens).toBe(306_300); + expect(reread.requests).toBe(3); + }); + + it('TurnBudgetDetector.feed agrees with analyzeTurn at every batch boundary', () => { + // live FileWatcher feeds new messages in arbitrary batches — the running + // total must equal the offline accounting after every prefix, batches of + // 1..5 exercise both single lines and multi-fragment requests + const msgs = lines.map((l) => flatten(JSON.parse(l))) as never[]; + for (let end = 1; end <= msgs.length; end++) { + for (const batch of [1, 3, 5]) { + const det = new TurnBudgetDetector(); + for (let i = 0; i < end; i += batch) { + det.feed('f', msgs.slice(i, Math.min(i + batch, end)), Number.MAX_SAFE_INTEGER); + } + const expected = analyzeTurn(lines.slice(0, end).reverse()).spent; + expect(det.currentTurnTotal('f')).toBe(expected); + } + } + }); + + it('the detector labels its incident with the turn number and start time', () => { + const msgs = lines.map((l) => flatten(JSON.parse(l))) as never[]; + const det = new TurnBudgetDetector(); + // budget small enough to cross on the first turn's first request + const incident = det.feed('f', msgs, 1); + expect(incident).not.toBeNull(); + expect(incident?.turnNumber).toBe(1); + expect(incident?.turnStartTs).toBe(msgs[0].timestamp); }); }); From f6953c28f5f26051611d19756be6f81a6b8f04bf Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 00:46:20 +0800 Subject: [PATCH 2/5] fix(panel): keep the Re-read row time outside the truncate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top rows (the biggest turns — the ones worth matching against a bell notification) were exactly the ones whose turn start time got truncated away. Co-Authored-By: Claude Code --- .../components/RereadSection.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx index 5f6d8646..ca471fc3 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx @@ -54,15 +54,15 @@ export const RereadSection = ({ Turn {injection.turnIndex + 1} - {injection.turnStartTs && ( - - {new Date(injection.turnStartTs).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - )} + {injection.turnStartTs && ( + + {new Date(injection.turnStartTs).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} {injection.requests} rq From 2df46e275697adf6ca85ca07a25bdd01af607446 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 01:32:09 +0800 Subject: [PATCH 3/5] fix(panel): Re-read spans all phases; bell marks crossing spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red→green: a session with a compaction boundary lost its pre-compact reread rows on 'Current' (the 15M turns of session 67105434 were invisible — phase snapshot only), and the bell's 15.1M read as a mismatch against the panel's final 15.46M. Now phaseInfo.rereadAll keeps every reread injection session-wide (tagged phaseNumber, rendered with a ph badge), ChatHistory merges it into the panel input, and the notification says '15.1M at crossing / 15.0M' so the edge-triggered snapshot is not mistaken for the turn's final sum. Tests 955/955; CLI audit parity OK (19 turns). Co-Authored-By: Claude Code --- eslint.config.js | 1 + .../services/infrastructure/FileWatcher.ts | 2 +- src/renderer/components/chat/ChatHistory.tsx | 7 ++- .../components/RereadSection.tsx | 8 ++++ src/renderer/types/contextInjection.ts | 6 +++ src/renderer/utils/contextTracker.ts | 22 ++++++++- .../infrastructure/FileWatcher.test.ts | 15 ++++-- test/renderer/utils/contextTracker.test.ts | 46 +++++++++++++++++++ 8 files changed, 100 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index a858905d..c2afce83 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -556,6 +556,7 @@ export default defineConfig([ 'billedTotalTokens', 'firstAssistantTotalTokens', 'inputSideTokens', + 'isMainChainAssistantLine', 'isRealUserLine', 'isTeammateRelayLine', 'isTranscriptTurnLine', diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 26b93ea0..7ac58541 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -116,7 +116,7 @@ function detectorIncidentToError(args: { ...common, source: 'turn_budget', message: - `Turn budget · ${turnLabel} · ${formatTokensCompact(budget.spent)} / ` + + `Turn budget · ${turnLabel} · ${formatTokensCompact(budget.spent)} at crossing / ` + `${formatTokensCompact(budget.budget)} — turn re-read crossed the ` + 'limit; tool calls are being denied', triggerName: 'Turn budget', diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 255f4d25..b4afa16e 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -153,7 +153,12 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { } const stats = sessionContextStats.get(targetAiGroupId); - const injections = stats?.accumulatedInjections ?? []; + // reread is session-wide spend (phaseInfo.rereadAll), not phase content — + // a bell notification's turn must be visible without switching phases + const injections = [ + ...(stats?.accumulatedInjections ?? []).filter((inj) => inj.category !== 'reread'), + ...(sessionPhaseInfo?.rereadAll ?? []), + ]; // Get total tokens from the target AI group let totalTokens: number | undefined; diff --git a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx index ca471fc3..1c682540 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx @@ -55,6 +55,14 @@ export const RereadSection = ({ Turn {injection.turnIndex + 1} + {injection.phaseNumber !== undefined && ( + + ph{injection.phaseNumber} + + )} {injection.turnStartTs && ( {new Date(injection.turnStartTs).toLocaleTimeString([], { diff --git a/src/renderer/types/contextInjection.ts b/src/renderer/types/contextInjection.ts index a0796a83..aa236945 100644 --- a/src/renderer/types/contextInjection.ts +++ b/src/renderer/types/contextInjection.ts @@ -309,6 +309,8 @@ export interface RereadInjection { requests: number; /** When the turn started — the key to match a turn-budget notification */ turnStartTs?: string; + /** Phase the turn belongs to (session-wide reread ledger spans phases) */ + phaseNumber?: number; } // ============================================================================= @@ -468,4 +470,8 @@ export interface ContextPhaseInfo { compactionCount: number; aiGroupPhaseMap: Map; // aiGroupId → phaseNumber compactionTokenDeltas: Map; // compactGroupId → delta + /** Every reread injection of the session, all phases — spend is not phase + * content; the panel's Re-read section renders this regardless of the + * selected phase so a bell notification's turn is always findable. */ + rereadAll: RereadInjection[]; } diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index a5d992c1..d4f9827d 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -1372,7 +1372,11 @@ export function processSessionContextWithPhases( claudeMdTokenData?: Record, mentionedFileTokenData?: Map, directoryTokenData?: Record -): { statsMap: Map; phaseInfo: ContextPhaseInfo } { +): { + statsMap: Map; + phaseInfo: ContextPhaseInfo; + rereadAll: RereadInjection[]; +} { const statsMap = new Map(); let accumulatedInjections: ContextInjection[] = []; let previousPaths = new Set(); @@ -1385,6 +1389,11 @@ export function processSessionContextWithPhases( const phases: ContextPhase[] = []; const aiGroupPhaseMap = new Map(); const compactionTokenDeltas = new Map(); + // Session-level reread ledger — spend, not phase content. The panel's + // Re-read section must show every turn of the session regardless of the + // selected phase: a bell notification's turn has to be findable without + // phase switching. Not reset on compaction. + const rereadAll: RereadInjection[] = []; // Track phase boundaries let currentPhaseFirstAIGroupId: string | null = null; @@ -1482,6 +1491,14 @@ export function processSessionContextWithPhases( // Tag with phase number stats.phaseNumber = currentPhaseNumber; + // reread is spend, not content: it bypasses the phase reset above and + // lives in the session-wide ledger (phaseInfo.rereadAll) + for (const inj of stats.newInjections) { + if (inj.category === 'reread') { + rereadAll.push({ ...inj, phaseNumber: currentPhaseNumber }); + } + } + // Build compaction token delta for this phase's first AI group if (isFirstAiGroup && currentPhaseCompactGroupId && lastAIGroupBeforeCompact) { const preTokens = getLastAssistantTotalTokens(lastAIGroupBeforeCompact); @@ -1548,9 +1565,10 @@ export function processSessionContextWithPhases( compactionCount: currentPhaseNumber - 1, aiGroupPhaseMap, compactionTokenDeltas, + rereadAll, }; - return { statsMap, phaseInfo }; + return { statsMap, phaseInfo, rereadAll }; } // ============================================================================= diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 93ed2aff..3393c1e2 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -926,7 +926,12 @@ describe('FileWatcher', () => { const budgetError = vi.mocked(notificationManager.addError).mock.calls[0][0]; expect(budgetError.source).toBe('turn_budget'); expect(budgetError.triggerName).toBe('Turn budget'); - expect(budgetError.message).toContain('Turn budget · current turn · 12.0M / 10.0M'); + expect(budgetError.message).toContain( + 'Turn budget · current turn · 12.0M at crossing / 10.0M' + ); + // the bell reports the spend at the crossing, not the turn's final sum — + // the wording must say so or it reads as a mismatch against the panel + expect(budgetError.message).toContain('at crossing'); expect(budgetError.toolUseId).toBe('t2'); expect(budgetError.sessionId).toBe('session-1'); @@ -1136,7 +1141,9 @@ describe('FileWatcher', () => { const [error, opts] = vi.mocked(notificationManager.addError).mock.calls[0]; expect(error.source).toBe('turn_budget'); expect(error.triggerName).toBe('Turn budget'); - expect(error.message).toMatch(/Turn budget · turn 1 \(\d{2}:\d{2}\) · 12\.0M \/ 10\.0M/); + expect(error.message).toMatch( + /Turn budget · turn 1 \(\d{2}:\d{2}\) · 12\.0M at crossing \/ 10\.0M/ + ); expect(opts).toEqual({ silent: true }); watcher.stop(); @@ -1223,7 +1230,9 @@ describe('FileWatcher', () => { expect(notificationManager.addError).toHaveBeenCalledTimes(1); const [error] = vi.mocked(notificationManager.addError).mock.calls[0]; - expect(error.message).toMatch(/Turn budget · turn 1 \(\d{2}:\d{2}\) · 13\.0M \/ 10\.0M/); + expect(error.message).toMatch( + /Turn budget · turn 1 \(\d{2}:\d{2}\) · 13\.0M at crossing \/ 10\.0M/ + ); watcher.stop(); fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/test/renderer/utils/contextTracker.test.ts b/test/renderer/utils/contextTracker.test.ts index 58ebed5c..d537c45f 100644 --- a/test/renderer/utils/contextTracker.test.ts +++ b/test/renderer/utils/contextTracker.test.ts @@ -411,6 +411,52 @@ describe('contextTracker compaction reset', () => { }); }); +describe('contextTracker reread spans all phases', () => { + function compactItem(): ChatItem { + return { + type: 'compact', + group: { id: 'compact-0' } as unknown as never, + } as unknown as ChatItem; + } + + it('keeps pre-compaction reread injections in rereadAll with their phaseNumber', () => { + const frag = (rid: string): ParsedMessage => { + const m = assistantMsg({ input: 1000, cacheRead: 133_000, output: 100 }); + (m as unknown as { requestId?: string }).requestId = rid; + return m; + }; + const items = [ + userGroup(), + // phase 1: multi-request turn → reread injection + aiGroup( + 'ai-0', + 0, + [], + [ + frag('req_a'), + frag('req_a'), + assistantMsg({ input: 2000, cacheRead: 60_000, output: 2_000 }), // working round + ] + ), + compactItem(), + userGroup(), + // phase 2 (current): single-request turn, no reread + aiGroup('ai-1', 0, [], [assistantMsg({ input: 1000, cacheRead: 5_000, output: 500 })]), + ]; + + const { statsMap, rereadAll } = processSessionContextWithPhases(items, '/proj'); + + // the phase snapshot shown on "Current" carries no reread from phase 1 + const current = statsMap.get('ai-1')!; + expect(current.accumulatedInjections.some((inj) => inj.category === 'reread')).toBe(false); + // but the session-level reread list keeps it, tagged with its phase + expect(rereadAll).toHaveLength(1); + expect(rereadAll[0].category).toBe('reread'); + expect(rereadAll[0].estimatedTokens).toBe(196_000); + expect(rereadAll[0].phaseNumber).toBe(1); + }); +}); + describe('findLastTrackedAiGroupId', () => { it('returns the last AI group that has stats (not just the last AI group)', () => { const items = [userGroup(), aiGroup('a', 0, [], []), userGroup(), aiGroup('b', 1, [], [])]; From d92e99965f3c798500487e681a99b153382e7938 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 01:57:39 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(ci):=20pin=20bell=20turn=20time=20to=20?= =?UTF-8?q?24h=20=E2=80=94=20locale-independent=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toLocaleTimeString without hour12 renders "05:03 PM" under en-US (CI) but "17:03" under ru, so the startup catch-up message broke the /turn N (\d{2}:\d{2})/ assertions only in CI. hour12: false makes the bell message deterministic in every locale. Co-Authored-By: Claude Code --- src/main/services/infrastructure/FileWatcher.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 7ac58541..f1b2446e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -109,7 +109,11 @@ function detectorIncidentToError(args: { budget.turnNumber > 0 ? `turn ${budget.turnNumber}` + (budget.turnStartTs - ? ` (${new Date(budget.turnStartTs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})` + ? ` (${new Date(budget.turnStartTs).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + })})` : '') : 'current turn'; return createDetectedError({ From 7feaa7b2755303a1b98c4a2aa6ddd993531ca6d1 Mon Sep 17 00:00:00 2001 From: axisrow Date: Fri, 9 Oct 2026 02:40:20 +0800 Subject: [PATCH 5/5] fix(review): bell turn numbering matches the chat chips; panel time pinned to 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on PR #68: 1. loopDetection.ts numbered incidents with isTurnBoundary (user+compact), but the chat Turn N chips count transcript turns (user+relay) minus sidechain and compact summaries — a compaction before the crossing inflated the bell number, a relay deflated it. Number with the chip canon (isTranscriptTurnLine && !isSidechain && !isCompactSummary); the spend bucket still resets on the hook boundary. Regression tests cover both directions. 2. RereadSection rendered row time without hour12:false while d92e999 pinned the bell time to 24h — in a 12h locale the panel showed 7:08 PM against the bell 07:08. Same options now. Co-Authored-By: Claude Code --- src/main/utils/loopDetection.ts | 28 +++++++++++---- .../components/RereadSection.tsx | 1 + test/main/utils/loopDetection.test.ts | 36 +++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/main/utils/loopDetection.ts b/src/main/utils/loopDetection.ts index 7f5ed4dc..88b33f90 100644 --- a/src/main/utils/loopDetection.ts +++ b/src/main/utils/loopDetection.ts @@ -12,7 +12,12 @@ import { type ParsedMessage } from '@main/types'; import { billedRequestKey } from '@main/utils/jsonl'; import { isStalledRound } from '@shared/constants/loopPolicy'; -import { billedTotalTokens, inputSideTokens, isTurnBoundary } from '@shared/turnAccounting'; +import { + billedTotalTokens, + inputSideTokens, + isTranscriptTurnLine, + isTurnBoundary, +} from '@shared/turnAccounting'; import { bashStem, normalizeCallKey } from '@shared/utils/callKey'; export interface LoopIncident { @@ -253,7 +258,7 @@ interface FileBudgetState { /** this turn already fired its crossing notification */ notified: boolean; lastToolUseId: string; - /** 1-based ordinal of the current turn (user/compact boundaries seen) */ + /** 1-based ordinal of the current turn (transcript turns: user/relay) */ turnNumber: number; /** timestamp of the boundary that opened the current turn * (raw JSONL carries ISO strings, parsed transcripts carry Dates) */ @@ -322,12 +327,23 @@ export class TurnBudgetDetector { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; + // Numbering follows the chat's Turn N chips (jsonl turnCount canon: + // transcript turns — user OR teammate relay — minus sidechain and + // compact summaries, which masquerade as user lines once parsed), so + // the bell number matches what the chat shows: a compaction must not + // inflate it, a relay must not deflate it. The SPEND bucket still + // resets on the hook boundary (user or compact — pre-compact spend + // must not count). + if (isTranscriptTurnLine(msg) && !msg.isSidechain && !msg.isCompactSummary) { + state.turnNumber += 1; + state.turnStartTs = msg.timestamp; + } // a new user turn (or compaction) starts a fresh budget bucket if (isTurnBoundary(msg)) { - const prevTurnNumber = state.turnNumber; - state = freshBudgetState(); - state.turnNumber = prevTurnNumber + 1; - state.turnStartTs = msg.timestamp; + const numbered = freshBudgetState(); + numbered.turnNumber = state.turnNumber; + numbered.turnStartTs = state.turnStartTs; + state = numbered; this.perFile.set(filePath, state); } // main-chain assistant lines only — same accounting as the hook diff --git a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx index 1c682540..fc035728 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/RereadSection.tsx @@ -68,6 +68,7 @@ export const RereadSection = ({ {new Date(injection.turnStartTs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', + hour12: false, })} )} diff --git a/test/main/utils/loopDetection.test.ts b/test/main/utils/loopDetection.test.ts index 4d839afb..6282291d 100644 --- a/test/main/utils/loopDetection.test.ts +++ b/test/main/utils/loopDetection.test.ts @@ -417,4 +417,40 @@ describe('TurnBudgetDetector', () => { const incident = det.feed('s', [big('r', 50_000)], 100_000); expect(incident?.spent).toBe(110_000); }); + + it('a teammate relay numbers like the chat chip but keeps the bucket flowing', () => { + const det = new TurnBudgetDetector(); + const relay = { + type: 'user', + isMeta: false, + content: 'yo', + timestamp: '2026-01-01T10:05:00Z', + } as unknown as ParsedMessage; + // relay opens transcript turn 2 (a chat chip) without resetting spend: + // 6M before it + 6M after it crosses in the same bucket + const batch = [userTurn, big('a', 6_000_000), relay, big('b', 6_000_000)]; + const incident = det.feed('s', batch, budget); + expect(incident?.turnNumber).toBe(2); + expect(incident?.turnStartTs).toBe('2026-01-01T10:05:00Z'); + expect(incident?.spent).toBe(12_000_000); + }); + + it('a compaction resets the bucket but not the chat-facing number', () => { + const det = new TurnBudgetDetector(); + const compact = { + type: 'user', + isCompactSummary: true, + isMeta: false, + content: 'summary of the conversation so far', + } as unknown as ParsedMessage; + expect(det.feed('s', [userTurn, big('a', 9_500_000)], budget)).toBeNull(); + // compact drops the 9.5M and must NOT advance the number — the chat + // renders it as CompactBoundary, not a Turn chip + expect(det.feed('s', [compact, big('b', 1_000_000)], budget)).toBeNull(); + const incident = det.feed('s', [big('c', 9_500_000)], budget); + expect(incident?.turnNumber).toBe(1); + // crossing spends the post-compact bucket only (b's 1M + c's 9.5M) — + // the pre-compact 9.5M is gone + expect(incident?.spent).toBe(10_500_000); + }); });