diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index b4f4d875..6b03b08d 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -23,15 +23,29 @@ function parseLines(text) { const entries = []; let broken = 0; let total = 0; + // quirk #101 root fix: Claude Code's transcript file accumulates duplicated + // context-rebuild snapshots — the same entry is re-printed with the SAME + // `uuid`. Without dedup, session_turn / task_size / events double-count and + // session_turn becomes non-monotonic across episodes parsed at different + // file-growth states. Keep the first occurrence per uuid; entries without a + // uuid (synthetic test fixtures) pass through unchanged. + const seenUuid = new Set(); for (const line of String(text || '').split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; total += 1; + let e; try { - entries.push(JSON.parse(trimmed)); + e = JSON.parse(trimmed); } catch { broken += 1; // broken line — counted for parse_gap, never thrown + continue; } + if (e && e.uuid) { + if (seenUuid.has(e.uuid)) continue; + seenUuid.add(e.uuid); + } + entries.push(e); } return { entries, broken, total }; } diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 05097223..ccd0cfaa 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -670,6 +670,108 @@ describe('parseTranscript — AskUserQuestion in-turn choice', () => { }); }); +describe('parseTranscript — uuid-dedup for duplicated snapshots (quirk #101)', () => { + // Claude Code's transcript file accumulates duplicated context-rebuild + // snapshots; the same entry is re-printed with the SAME `uuid`. Without + // dedup, session_turn / task_size / events double-count and become + // non-monotonic across episodes parsed at different file-growth states. + // Root fix: dedup by uuid in parseLines. + + it('collapses duplicated-uuid user prompts (session_turn counts once)', () => { + const dup = 'aaaa-bbbb-cccc'; + const lines = [ + JSON.stringify({ + uuid: dup, + type: 'user', + message: { role: 'user', content: 'hi' }, + timestamp: '2026-05-19T10:00:00Z', + sessionId: 's1', + }), + JSON.stringify({ + uuid: dup, + type: 'user', + message: { role: 'user', content: 'hi' }, + timestamp: '2026-05-19T10:00:00Z', + sessionId: 's1', + }), + ].join('\n'); + const ep = parseTranscript(lines, 'fallback'); + expect(ep.environment.session_turn).toBe(1); + }); + + it('preserves distinct-uuid entries (no over-dedup)', () => { + const lines = [ + JSON.stringify({ + uuid: 'one', + type: 'user', + message: { role: 'user', content: 'first' }, + timestamp: '2026-05-19T09:00:00Z', + sessionId: 's1', + }), + JSON.stringify({ + uuid: 'two', + type: 'user', + message: { role: 'user', content: 'second' }, + timestamp: '2026-05-19T10:00:00Z', + sessionId: 's1', + }), + ].join('\n'); + const ep = parseTranscript(lines, 'fallback'); + expect(ep.environment.session_turn).toBe(2); + }); + + it('entries without uuid pass through unchanged (synthetic fixtures unaffected)', () => { + // Existing tests build entries without `uuid` — must still work. + const t = jsonl([ + userPrompt('one', '2026-05-19T09:00:00Z'), + userPrompt('two', '2026-05-19T10:00:00Z'), + ]); + expect(parseTranscript(t).environment.session_turn).toBe(2); + }); + + it('collapses duplicated-uuid assistant turns (task_size counted once)', () => { + const lines = [ + JSON.stringify({ + uuid: 'u-prompt', + type: 'user', + message: { role: 'user', content: 'go' }, + timestamp: '2026-05-19T10:00:00Z', + sessionId: 's1', + }), + JSON.stringify({ + uuid: 'u-asst', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }, + { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } }, + ], + }, + timestamp: '2026-05-19T10:01:00Z', + sessionId: 's1', + }), + // EXACT duplicate of the assistant entry — snapshot rebuild artifact + JSON.stringify({ + uuid: 'u-asst', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.js' } }, + { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/x.js' } }, + ], + }, + timestamp: '2026-05-19T10:01:00Z', + sessionId: 's1', + }), + ].join('\n'); + const ep = parseTranscript(lines, 'fallback'); + expect(ep.task_size.tool_calls).toBe(2); + expect(ep.task_size.files_touched).toBe(1); + }); +}); + describe('extractEnvironment — parallel_session narrowed to tool_result evidence', () => { // Collision detection scans ONLY tool_result content (real command output). // Prose mentions in user prompts / assistant text — including analysis text