Skip to content

Ledger drops content blocks: keep-last dedup treats split response lines as snapshots #88

Description

@axisrow

⚠️ Выводы не проверены человеком — доверия к ним нет. Ишью заведено по аудиту, который сделал Claude Code. Автор репозитория просил считать эти выводы непроверенными: прежде чем чинить, дважды воспроизведите каждый пункт по шагам ниже — на своих сессиях, а не по цифрам отсюда — и отметьте результат в разделе «Перепроверка». Claude перепроверил их один раз (код + сырой JSONL одной сессии Claude Code 2.1.296); это не засчитывается.

Суть (гипотеза)

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)

значение
запросов в главной цепочке 72
из них записаны несколькими строками 55
блоков на строку всегда 1 (160 строк)
запросов с разным usage между строками 0
tool_use блоков в сыром JSONL 96
вызовов в ledger.rounds[].tools 64 (потеряна ~⅓)
thinking-блоков 48, ни один не попал в оставленные строки

Пример: запрос с 6 параллельными sub_issue_write записан 7 строками (text + 6 tool_use); в ledger раунд показывает 1 вызов.

Как воспроизвести

# python3 repro.py ~/.claude/projects/<dir>/<session>.jsonl
import json, sys, collections
by = collections.defaultdict(list)
for line in open(sys.argv[1]):
    e = json.loads(line)
    if e.get("type") == "assistant" and e.get("requestId") and not e.get("isSidechain"):
        by[e["requestId"]].append(e)
blocks = lambda e: e["message"].get("content", [])
print("requests", len(by), "multi-line", sum(len(v) > 1 for v in by.values()))
print("tool_use all lines", sum(b.get("type") == "tool_use" for v in by.values() for e in v for b in blocks(e)))
print("tool_use last line only", sum(b.get("type") == "tool_use" for v in by.values() for b in blocks(v[-1])))
print("usage identical across lines", sum(len({json.dumps(e["message"].get("usage"), sort_keys=True) for e in v}) == 1 for v in by.values()))

Сравнить «last line only» с pnpm analyze:session <file> --json | jq '[.ledger.rounds[].tools | length] | add'.

Что сделать

  1. Для строк с requestId склеивать содержимое (как mergeAssistantFragments уже делает для строк без requestId), usage брать из последней строки.
  2. Учесть старые версии Claude Code, где строки могли быть настоящими снимками с накапливающимся содержимым: дедуплицировать блоки по tool_use.id / идентичности, чтобы не удвоить.
  3. Обновить комментарии к deduplicateByRequestId и mergeAssistantFragments.

Перепроверка (каждый пункт — дважды, независимо)

  • Claude Code пишет один блок на строку с одинаковым usage — проверка №1
  • то же — проверка №2 (другая сессия / другая версия Claude Code)
  • ledger.rounds[].tools теряет вызовы по сравнению с сырым JSONL — проверка №1
  • то же — проверка №2
  • Найдены (или исключены) старые сессии, где строки — накапливающиеся снимки

Acceptance

  • Число вызовов в ledger = числу tool_use в сыром JSONL главной цепочки (тест на фикстуре из нескольких строк на запрос)
  • Thinking-блоки доходят до thinkingTokensOf (или он переведён на usage, см. thinking_heavy measures returned thinking text, not billed thinking tokens #83)
  • Тест на старый формат снимков не удваивает блоки
  • pnpm typecheck && pnpm test зелёные

Activity

  1. axisrow commented on Oct 11, 2026

    @axisrow
    OwnerAuthor

    Дополнение по второй сессии: тот же Claude Code 2.1.296, другая сессия (аудит 2026-10-11). Это проверка Claude — она не засчитывается.

    значение
    запросов в главной цепочке 125
    из них записаны несколькими строками 102 (до 7 строк на запрос)
    блоков на строку всегда 1 (308 строк)
    запросов с разным usage между строками 0
    tool_use в сыром JSONL 179
    вызовов в ledger.rounds[].tools 123 (потеряно 56, 31 %)

    Уточнение к «Приложение не затронуто»

    Для итогов токенов это так. Но в рендерере есть то же правило «последняя строка запроса»: 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions