Repository navigation
Ledger drops content blocks: keep-last dedup treats split response lines as snapshots #88
Copy link
Copy link
Closed
Labels
bugSomething isn't workingSomething isn't working
Description
Activity
Дополнение по второй сессии: тот же Claude Code 2.1.296, другая сессия (аудит 2026-10-11). Это проверка Claude — она не засчитывается.
значение запросов в главной цепочке 125 из них записаны несколькими строками 102 (до 7 строк на запрос) блоков на строку всегда 1 (308 строк) запросов с разным usage между строками 0 tool_useв сыром JSONL179 вызовов в ledger.rounds[].tools123 (потеряно 56, 31 %) - Скрипт из описания воспроизводит это без изменений.
- Те же 123 из 179 получаются на коммите мержа feat(cli): wait_loop finding, API-time metrics, longest-turn inventory #23 (
e4e9d97), то есть потеря есть как минимум с feat(cli): wait_loop finding, API-time metrics, longest-turn inventory #23. - В этой выборке
tool_useвсегда стоит в последней строке запроса. Поэтому ложных «тихих тиков» нет: критерий требует отсутствия вызовов, и wait-loop не задет.
Уточнение к «Приложение не затронуто»
Для итогов токенов это так. Но в рендерере есть то же правило «последняя строка запроса»:
classifyRoundsберёт вызовы раунда только из последней строки. Если повтор стоит не последним среди параллельных вызовов запроса, категория Loop в панели его не видит:повтор одной строкой (контроль) → Loop в панели: 600.04k повтор + параллельный Bash в том же запросе, следующей строкой → Loop в панели: НЕТКак проверить (один раз, ~1 мин, из корня репозитория)
cat > /tmp/repro-88-panel.mts <<'EOF' import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const R = process.cwd(); // запускать из корня репозитория const load = (p: string) => import(`${R}/${p}`); let t = Date.parse('2026-10-01T07:00:00Z'), n = 0; const at = (ms = 20_000) => new Date((t += ms)).toISOString(); const base = (x = {}) => ({ uuid: `u${++n}`, parentUuid: null, sessionId: 's', cwd: '/p', isSidechain: false, ...x }); const user = (content: unknown, x = {}, gap?: number) => ({ ...base(x), type: 'user', timestamp: at(gap), message: { role: 'user', content } }); const ai = (block: object, ctx: number, out: number, ids: { rid?: string; mid: string }, x = {}, gap?: number) => ({ ...base(x), type: 'assistant', timestamp: at(gap), ...(ids.rid ? { requestId: ids.rid } : {}), message: { id: ids.mid, role: 'assistant', model: 'claude-x', content: [block], usage: { input_tokens: 0, cache_read_input_tokens: ctx, cache_creation_input_tokens: 0, output_tokens: out } } }); const result = (id: string, gap?: number) => ({ ...base(), type: 'user', timestamp: at(gap), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }] } }); const text = (s: string) => ({ type: 'text', text: s }); const tool = (id: string, name: string, input: object) => ({ type: 'tool_use', id, name, input }); const write = (lines: object[]) => { const f = join(mkdtempSync(join(tmpdir(), 'repro-')), 's.jsonl'); writeFileSync(f, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); return f; }; // Read f, затем запрос q2 с повтором Read f. Вариант 1: q2 — одна строка. Вариант 2: q2 — две строки, Read f + параллельный Bash (последняя строка) const jsonl = await load('src/main/utils/jsonl.ts'); const { ChunkBuilder } = await load('src/main/services/analysis/ChunkBuilder.ts'); const { transformChunksToConversation } = await load('src/renderer/utils/groupTransformer.ts'); const { processSessionContextWithPhases } = await load('src/renderer/utils/contextTracker.ts'); const build = (parallel: boolean) => [ user('go'), ai(tool('r1', 'Read', { file_path: '/x/f.ts' }), 600_000, 40, { rid: 'q1', mid: 'm1' }), result('r1'), ai(tool('r2', 'Read', { file_path: '/x/f.ts' }), 600_000, 40, { rid: 'q2', mid: 'm2' }), ...(parallel ? [ai(tool('b2', 'Bash', { command: 'ls' }), 600_000, 40, { rid: 'q2', mid: 'm2' })] : []), result('r2'), ...(parallel ? [result('b2')] : []), ai(text('done'), 600_000, 40, { rid: 'q3', mid: 'm3' }), ]; for (const parallel of [false, true]) { const msgs = await jsonl.parseJsonlFile(write(build(parallel))); const conv = transformChunksToConversation(new ChunkBuilder().buildChunks(msgs, []), [], false); const loops = [...processSessionContextWithPhases(conv.items, '/p', {}, new Map(), new Map()).statsMap.values()] .flatMap((s: { newInjections: { category: string; estimatedTokens: number }[] }) => s.newInjections.filter((i) => i.category === 'loop')); console.log(`${parallel ? 'повтор + параллельный Bash в том же запросе, следующей строкой' : 'повтор одной строкой (контроль)'} → Loop в панели: ${loops.length ? `${loops[0].estimatedTokens / 1e3}k` : 'НЕТ'}`); } EOF pnpm exec tsx /tmp/repro-88-panel.mts
Правильно: Loop есть в обоих вариантах. Сейчас: во втором варианте
НЕТ.Склейку содержимого из п. 1 «Что сделать» стоит применить и к
classifyRounds, иначе в панели останется та же потеря. Сценарий «многострочный запрос с параллельнымиtool_use» есть в таблице теста паритета #103 (эпик #94).
Generated by Claude Code
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
Суть (гипотеза)
deduplicateByRequestId(src/main/utils/jsonl.ts:256) оставляет последнюю строку наrequestId, исходя из того, что строки одного запроса — это снимки стрима и последняя содержит всё (так написано в комментарии к функции и кmergeAssistantFragments). В проверенной сессии Claude Code пишет один блок ответа на строку (thinking, text, каждый tool_use — отдельной строкой) с одинаковымusage. Значит, строки не снимки, а части ответа, и keep-last выбрасывает все блоки, кроме последнего.Для подсчёта токенов это безвредно (usage во всех строках одинаков). Но CLI берёт из оставшейся строки и содержимое:
buildLedger→RoundRow.tools(src/cli/analyzeSession.ts:410): колонка tools в BY TURN / ROUNDS и текстcontext_spike;detectCycleFindings(:805) →cycle_motif/probe_no_progressвидят только последний вызов раунда;thinkingTokensOf— thinking-блок всегда в первой строке ответа, поэтому выпадает всегда (связано с thinking_heavy measures returned thinking text, not billed thinking tokens #83).Приложение не затронуто:
calculateMetricsиспользует дедупликацию только для usage.Наблюдение (одна сессия, Claude Code 2.1.296)
ledger.rounds[].toolsПример: запрос с 6 параллельными
sub_issue_writeзаписан 7 строками (text + 6 tool_use); в ledger раунд показывает 1 вызов.Как воспроизвести
Сравнить «last line only» с
pnpm analyze:session <file> --json | jq '[.ledger.rounds[].tools | length] | add'.Что сделать
requestIdсклеивать содержимое (какmergeAssistantFragmentsуже делает для строк безrequestId), usage брать из последней строки.tool_use.id/ идентичности, чтобы не удвоить.deduplicateByRequestIdиmergeAssistantFragments.Перепроверка (каждый пункт — дважды, независимо)
ledger.rounds[].toolsтеряет вызовы по сравнению с сырым JSONL — проверка №1Acceptance
tool_useв сыром JSONL главной цепочки (тест на фикстуре из нескольких строк на запрос)thinkingTokensOf(или он переведён на usage, см. thinking_heavy measures returned thinking text, not billed thinking tokens #83)pnpm typecheck && pnpm testзелёные