From 94f831f7d172ebb88e6929eb69a1f7f3c97ba488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 20 May 2026 11:00:50 +0300 Subject: [PATCH] fix(observer): uuid-dedup in parseLines (C-1 root fix for quirk #101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: Claude Code's transcript JSONL file accumulates duplicated context- rebuild snapshots — the same entry 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. Live evidence: episodes-2026-05.jsonl lines 14/15/16 of the same session showed session_turn 139 → 140 → 91 (backwards in time). Probe on transcript 553717ec: 22400 entries, only 6074 unique uuid (68% dup rate); real user prompts 264 total vs 92 unique-uuid. Fix: parseLines now tracks a `seenUuid` Set and skips entries whose uuid has already been encountered (keep-first). Entries without `uuid` (synthetic test fixtures) pass through unchanged. All downstream functions (findTurnStart, extractEnvironment, extractTaskSize, etc.) operate on the deduped entries array, so the fix is single-point and total. Tests: new `parseTranscript — uuid-dedup` describe block covers (1) duplicated-uuid prompts collapse → session_turn counts once, (2) distinct-uuid entries preserved (no over-dedup), (3) no-uuid entries pass through (synthetic-fixture safety), (4) duplicated-uuid assistant turns → tool_calls / files_touched counted once. 110/110 parser tests green (was 106). Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/observer-transcript-parser.mjs | 16 +++- tools/observer-transcript-parser.test.mjs | 102 ++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) 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