Skip to content

CLI turn numbers differ from chat chips: relays not counted, sidechain prompts counted #99

Description

@axisrow

Part of #94.

⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.

Что не так

Чат (чипы «Turn N»), сайдбар и колокол нумеруют ходы транскрипта так: user-сообщения плюс релеи тиммейтов, без промптов сабагентов (#58). CLI (pnpm analyze:session --turn N, pnpm turn-spend:stats --audit, pnpm session:sums) нумерует иначе — через isTurnNumberLine и buildLedger:

  • релеи не считаются;
  • промпт сабагента в основном файле считается ходом.
чат: номер последнего хода сайдбар: ходов analyze:session: номер последнего хода analyze:session: ходов
team-сессия с релеем 3 3 2 2
сессия с промптом сабагента 1 1 2 2

На практике «turn 46» из колокола или чата в team-сессии нельзя открыть через pnpm analyze:session --turn 46 — это будет другой ход. #69 обещал «one numbering everywhere». Расхождение признано в комментарии turnSpendStats.ts:127 («NOT the bell's turnNumber»), но не исправлено. В turn-accounting.mjs:214 и .d.mts:72 это по-прежнему называется «app parity».

Как проверить (один раз, ~1 мин)

Из корня репозитория:

cat > /tmp/repro-turns.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; };
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 cli = await load('src/cli/analyzeSession.ts');
const lastChip = async (msgs: object[]) => transformChunksToConversation(new ChunkBuilder().buildChunks(msgs, []), [], false).items
  .filter((i: { type: string }) => i.type === 'ai').map((i: { group: { turnIndex: number } }) => i.group.turnIndex + 1).at(-1);
const rows: Record<string, object> = {};
// 1) team-сессия: user → ответ → релей тиммейта → ответ → user → ответ
const relay = (s: string) => user(`<teammate-message teammate_id="lead">${s}</teammate-message>`, { isMeta: false });
const f1 = write([
  user('lead task'), ai(text('a1'), 100_000, 50, { rid: 'q1', mid: 'm1' }),
  relay('status?'), ai(text('a2'), 100_000, 50, { rid: 'q2', mid: 'm2' }),
  user('next'), ai(text('a3'), 100_000, 50, { rid: 'q3', mid: 'm3' }),
]);
// 2) промпт сабагента прямо в основном файле (isSidechain: true)
const f2 = write([
  user('fix the parser'), ai(tool('t1', 'Task', { prompt: 'find the bug' }), 100_000, 30, { rid: 'p1', mid: 'n1' }),
  user('find the bug', { isSidechain: true }), ai(text('found'), 100_000, 30, { rid: 's1', mid: 'ns1' }, { isSidechain: true }),
  result('t1'), ai(text('fixed'), 100_000, 30, { rid: 'p2', mid: 'n2' }),
]);
for (const [name, f] of [['team-сессия с релеем', f1], ['сессия с промптом сабагента', f2]] as const) {
  const msgs = await jsonl.parseJsonlFile(f);
  const ledger = cli.buildLedger(msgs);
  rows[name] = {
    'чат: номер последнего хода': await lastChip(msgs),
    'сайдбар: ходов': (await jsonl.analyzeSessionFileMetadata(f)).turnCount,
    'analyze:session: номер последнего хода': ledger.rounds.at(-1).turnIndex,
    'analyze:session: ходов': ledger.turns.length,
  };
}
console.table(rows);
const bad = Object.entries(rows).filter(([, r]) => (r as Record<string, number>)['чат: номер последнего хода'] !== (r as Record<string, number>)['analyze:session: номер последнего хода']);
console.log(bad.length ? `ВОСПРОИЗВЕДЕНО: --turn N в CLI указывает не на тот ход, что «Turn N» в чате (${bad.map(([k]) => k).join('; ')})` : 'OK');
EOF
pnpm exec tsx /tmp/repro-turns.mts

Правильно: в каждой строке номер последнего хода в чате и в analyze:session совпадает. Сейчас: 3 против 2 и 1 против 2, строка ВОСПРОИЗВЕДЕНО.

Где в коде (гипотеза)

  • scripts/turn-accounting.mjs:216 — isTurnNumberLine: только user-строки, без исключения sidechain.
  • src/cli/analyzeSession.ts:433 — buildLedger открывает ход на isParsedUserChunkMessage без проверки isSidechain; deep-dive --turn — строка 1122.
  • Чипы и сайдбар: isTranscriptTurnLine и !isSidechain (jsonl.ts:545).

Что сделать

  1. Одна нумерация для CLI и приложения: ходы транскрипта без sidechain.
  2. Если CLI должна сохранить нумерацию хука, печатать оба номера, например turn 2 (в чате: Turn 3).

Acceptance


  • Проверено человеком: воспроизводится / не воспроизводится

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