Repository navigation
Inline subagent prompt ends the turn for the hook and bell: budget spend under-counted #98
Description
Part of #94.
⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.
Что не так
Когда разговор сабагента записан прямо в основной файл (строки с isSidechain: true), его стартовый промпт — это user-строка. Так устроены фикстура test/fixtures/turn-accounting/session.jsonl и тест из #69 «a sidechain user line opens no turn». Граница хода у хука (isTurnBoundary) sidechain не исключает, поэтому хук и колокол на этом промпте обнуляют ход. Панель, где ChunkBuilder отбрасывает sidechain-строки, считает ход целиком.
| перечитано за ход | |
|---|---|
хук (analyzeTurn) |
6M |
| колокол (бакет хода) | 6M |
| панель Re-read | 12M |
При бюджете 10M хук не запрещает вызовы, колокол молчит, а панель показывает 12M. Обещания, которые это нарушает:
- feat(boundary): canonical turn-boundary predicate shared by hook and app #44: «canonical turn-boundary predicate shared by hook and app»;
- feat(chat): hook-parity re-read pill on turn cost badges #41 и feat(ui): sidebar session highlight + Re-read section in Visible Context panel #48: Re-read = число хука;
- fix(turn-budget): one turn, one number — hook, bell and panel parity #68: «sidechain exclusion everywhere» — но исключены только assistant-строки.
Не проверено: пишет ли текущий Claude Code сабагентов в основной файл, а не в отдельные файлы. Фикстура проекта и тест #69 исходят из того, что пишет.
Как проверить (один раз, ~1 мин)
Из корня репозитория:
cat > /tmp/repro-sidechain.mts <<'EOF'
import { mkdtempSync, readFileSync, 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; };
// ход с промптом сабагента прямо в основном файле (isSidechain: true), бюджет хода 10M
const BUDGET = 10_000_000;
const lines = [
user('fix the parser'),
ai(tool('t1', 'Task', { description: 'find bug', prompt: 'find the parser bug' }), 6_000_000, 30, { rid: 'm1', mid: 'mm1' }),
user('find the parser bug', { isSidechain: true }),
ai(text('found it'), 1_000_000, 30, { rid: 's1', mid: 'ms1' }, { isSidechain: true }),
result('t1'),
ai(text('fixed'), 6_000_000, 30, { rid: 'm2', mid: 'mm2' }),
];
const f = write(lines);
const { analyzeTurn } = await load('scripts/turn-accounting.mjs');
const jsonl = await load('src/main/utils/jsonl.ts');
const { TurnBudgetDetector } = await load('src/main/utils/loopDetection.ts');
const { ChunkBuilder } = await load('src/main/services/analysis/ChunkBuilder.ts');
const { transformChunksToConversation } = await load('src/renderer/utils/groupTransformer.ts');
const { sumTurnReread } = await load('src/renderer/utils/contextTracker.ts');
const msgs = await jsonl.parseJsonlFile(f);
const det = new TurnBudgetDetector();
const alert = msgs.some((m: object) => det.feed('f', [m], BUDGET) !== null);
const groups = transformChunksToConversation(new ChunkBuilder().buildChunks(msgs, []), [], false).items.filter((i: { type: string }) => i.type === 'ai');
const row = {
'хук (analyzeTurn)': analyzeTurn(readFileSync(f, 'utf8').trim().split('\n').reverse()).spent,
'колокол (бакет хода)': det.currentTurnTotal('f'),
'панель Re-read': sumTurnReread(groups[0].group.responses).tokens,
};
console.table(row);
console.log(`хук запретит вызовы: ${row['хук (analyzeTurn)'] >= BUDGET}; колокол позвонит: ${alert}; панель показывает ${row['панель Re-read'] / 1e6}M при бюджете ${BUDGET / 1e6}M`);
console.log(row['хук (analyzeTurn)'] !== row['панель Re-read'] ? 'ВОСПРОИЗВЕДЕНО: промпт сабагента обнуляет ход у хука и колокола' : 'OK');
EOF
pnpm exec tsx /tmp/repro-sidechain.mtsПравильно: три одинаковых числа (12M), хук запрещает вызовы, колокол звонит. Сейчас: 6M / 6M / 12M, хук запретит вызовы: false; колокол позвонит: false, строка ВОСПРОИЗВЕДЕНО.
Где в коде (гипотеза)
scripts/turn-accounting.mjs:208—isTurnBoundary=isUserChunkLine || isCompactSummary, аisUserChunkLineне смотрит наisSidechain.- Колокол:
src/main/utils/loopDetection.ts:342обнуляет бакет поisTurnBoundary. Номер хода при этом считается без sidechain (строка 337) — то есть номер верный, а сумма обнулена. - Счётчик ходов в сайдбаре и чипы sidechain исключают (
jsonl.ts:545).
Что сделать
- Исключить sidechain-строки из границы хода в каноне (
isTurnBoundary), как это уже сделано для счётчика ходов и чипов. - Убедиться, что существующий parity-тест остаётся зелёным.
Acceptance
- Тест: промпт сабагента в основном файле — хук, колокол и панель дают одно перечитывание за ход
- Строка добавлена в тест паритета (см. эпик Epic: one number on every surface — cross-surface parity (audit follow-ups) #94)
- Проверено человеком: воспроизводится / не воспроизводится
Activity
fix(turn): sidechain prompt must not consume a turn number (#98)
fix(turn): inline subagent prompt no longer splits the hook turn (#98)
fix(turn): sidechain prompt must not consume a turn number (#98)
fix(turn): inline subagent prompt no longer splits the hook turn (#98) (
fix(cli): count teammate relays as transcript turns in CLI numbering (#…
fix(cli): count teammate relays as transcript turns in CLI numbering (#…
fix(cli): count teammate relays as transcript turns in CLI numbering (#…
fix(cli): count teammate relays as transcript turns in CLI numbering (#…
test(parity): one edge-case fixture through every consumer (#103) (#132)