Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions scripts/smoke/verify-parser-turn-boundary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Verify the parser treats *only typed user prompts* (bare-string content) as
// turn boundaries — and does NOT split on mid-turn user-text injections
// (skill content, command-message envelopes, system reminders, interrupts).
//
// Each case below is a tiny synthesized transcript. The assertion is that
// the parser produces the expected number of turns and that the LAST turn
// contains the assistant's synthesis text.

import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { writeFileSync, openSync, closeSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';

const __dirname = dirname(fileURLToPath(import.meta.url));
const parserPath = join(__dirname, '..', '..', 'dist', 'parser.js');
const { parseSessionFd } = await import(parserPath);

const tmp = mkdtempSync(join(tmpdir(), 'parser-boundary-'));

function write(name, lines) {
const path = join(tmp, name);
writeFileSync(path, lines.map(JSON.stringify).join('\n') + '\n');
return path;
}
function parse(path) {
const fd = openSync(path, 'r');
try { return parseSessionFd(fd); }
finally { closeSync(fd); }
}

// Building blocks
const userPrompt = (text, t) => ({
type: 'user', message: { role: 'user', content: text }, timestamp: t,
});
const userTextInjection = (text, t) => ({
type: 'user', message: { role: 'user', content: [{ type: 'text', text }] }, timestamp: t,
});
const userToolResult = (toolUseId, t) => ({
type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'ok' }] }, timestamp: t,
});
const assistantText = (text, model, t) => ({
type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'text', text }] }, timestamp: t,
});
const assistantToolUse = (name, id, model, t) => ({
type: 'assistant', message: { role: 'assistant', model, content: [{ type: 'tool_use', name, id, input: {} }] }, timestamp: t,
});

const cases = [];

cases.push({
name: 'baseline: one typed prompt, one assistant text → 1 turn',
lines: [
userPrompt('hi', '00'),
assistantText('hello', 'm', '01'),
],
expectTurns: 1,
expectLastSynthesis: 'hello',
});

cases.push({
name: 'two typed prompts (welcome + real) → 2 turns',
lines: [
userPrompt('hi', '00'),
assistantText('hello', 'm', '01'),
userPrompt('do X', '02'),
assistantText('result', 'm', '03'),
],
expectTurns: 2,
expectLastSynthesis: 'result',
});

cases.push({
name: 'skill-content user-text mid-turn does NOT split',
lines: [
userPrompt('/skill arg', '00'),
userTextInjection('Base directory for this skill: /path/skill\n# skill body content\n...', '00.5'),
assistantText('I will do the thing', 'm', '01'),
assistantToolUse('Bash', 't1', 'm', '02'),
userToolResult('t1', '03'),
assistantText('FINAL synthesis', 'm', '04'),
],
expectTurns: 1,
expectLastSynthesis: 'FINAL synthesis',
});

cases.push({
name: 'user-interrupt mid-Agent does NOT split (the 0.6% case)',
lines: [
userPrompt('do the explore', '00'),
assistantText('Dispatching subagent.', 'm', '01'),
assistantToolUse('Agent', 'tA', 'm', '02'),
userTextInjection('[Request interrupted by user for tool use]', '03'),
assistantText('Recovered. Here is the answer.', 'm', '04'),
],
expectTurns: 1,
expectLastSynthesis: 'Recovered. Here is the answer.',
});

cases.push({
name: 'command-message envelope user-text does NOT split',
lines: [
userPrompt('/talk-to-tim ask Tim something', '00'),
userTextInjection('<command-message>talk-to-tim</command-message>\n<command-args>ask Tim something</command-args>', '00.5'),
assistantText('Spawning Tim now.', 'm', '01'),
assistantToolUse('Agent', 'tA', 'm', '02'),
userToolResult('tA', '03'),
assistantText("Here is Tim's response.", 'm', '04'),
],
expectTurns: 1,
expectLastSynthesis: "Here is Tim's response.",
});

cases.push({
name: 'multiple injections in same turn still 1 turn',
lines: [
userPrompt('go', '00'),
userTextInjection('skill body', '00.5'),
assistantText('working', 'm', '01'),
userTextInjection('system reminder ABC', '01.5'),
assistantToolUse('Read', 'tR', 'm', '02'),
userToolResult('tR', '03'),
userTextInjection('another reminder', '03.5'),
assistantText('done', 'm', '04'),
],
expectTurns: 1,
expectLastSynthesis: 'done',
});

cases.push({
name: 'subagent transcript pre-context line still gives 2 turns',
// Mimics what `prompt_suggestion` / `compact` built-ins produce:
// line 0: assistant (parent's prior message carried in as pre-context)
// line 1: user (typed prompt — bare string)
// line 2+: subagent's own assistant work
// Should remain 2 turns (pre-context + subagent work), matching prior behavior.
lines: [
assistantText('parent prior message', 'm-parent', '00'),
userPrompt('subagent task prompt', '01'),
assistantText('subagent reply', 'm-sub', '02'),
],
expectTurns: 2,
expectLastSynthesis: 'subagent reply',
});

let pass = 0, fail = 0;
for (const c of cases) {
const path = write(`${c.name.replace(/[^a-z0-9]+/gi, '-')}.jsonl`, c.lines);
const parsed = parse(path);
const turns = parsed?.turns ?? [];
const lastTurn = turns[turns.length - 1];
const lastSynth = lastTurn?.textBlocks().slice(-1)[0] ?? '';

const turnsOk = turns.length === c.expectTurns;
const synthOk = !c.expectLastSynthesis || lastSynth === c.expectLastSynthesis;
if (turnsOk && synthOk) {
console.log(`PASS ${c.name}`);
pass++;
} else {
console.log(`FAIL ${c.name}`);
console.log(` expected turns=${c.expectTurns}, got ${turns.length}`);
console.log(` expected last synth=${JSON.stringify(c.expectLastSynthesis)}, got ${JSON.stringify(lastSynth)}`);
fail++;
}
}

rmSync(tmp, { recursive: true, force: true });
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
19 changes: 15 additions & 4 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,22 @@ function buildSession(lines: unknown[]): ParsedSession {
currentAssistantLines.push({ line: entry, prevTimestamp });
} else if (role === 'user') {
const rawContent = message?.['content'];
const content = Array.isArray(rawContent) ? rawContent as Array<Record<string, unknown>> : [];

// A user message with text content marks the end of the previous turn.
const hasText = typeof rawContent === 'string' || content.some(block => block['type'] === 'text');
if (hasText && currentAssistantLines.length > 0) {
// Only a typed user prompt — a bare string `content` — marks a turn
// boundary. Array-form `content` covers two non-boundary shapes:
// (a) `[{type: 'tool_result', ...}]` — tool replies, never a turn end.
// (b) `[{type: 'text', text: '...'}]` — mid-turn user-text injections
// (skill content, command-message envelopes, system reminders,
// `"[Request interrupted by user for tool use]"`). These are
// written by the harness during a turn, not typed by the user,
// so treating them as turn boundaries splits one Claude Code
// turn into multiple parser turns and (when the split lands
// between a tool dispatch and its synthesis) drops the
// synthesis chat span at `handleStop`'s `turns[-1]`.
// The bare-string case is the one Claude Code uses for actual user
// prompts (`message.content: '/talk-to-tim ...'`).
const isTypedUserPrompt = typeof rawContent === 'string';
if (isTypedUserPrompt && currentAssistantLines.length > 0) {
turns.push(buildTurn(currentAssistantLines));
currentAssistantLines = [];
}
Expand Down
Loading