diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 0b8fefb9..028f7fe9 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -430,6 +430,16 @@ export function extractTokenUsage(turn) { web_search_requests: web_search, web_fetch_requests: web_fetch, iterations, + // v4.3 LLM-agent cost fields — always zero at parse time; + // populated retroactively by controller scripts / reviewer response. + classifier_input_tokens: 0, + classifier_output_tokens: 0, + self_assessment_input_tokens: 0, + self_assessment_output_tokens: 0, + reviewer_input_tokens: 0, + reviewer_output_tokens: 0, + reviewer_subagent_usd: 0, + reviewer_direct_fallback_usd: 0, }; } @@ -814,12 +824,18 @@ export function parseTranscript(transcriptText, fallbackSessionId = null, option return { schema_version: 4, - schema_minor: 0, + schema_minor: 3, task_id: sessionId, task_ref: sessionId, timestamps: { started_at, ended_at }, path_type: usedSuperpowers ? 'regulated' : 'improvised', outcome: 'unknown', + // v4.3: reviewed outcome — always null at write time, filled by /brain-retro reviewer. + outcome_reviewed: null, + outcome_reviewed_source: null, + // v4.3: embedding of first user prompt — null at parse time (sync parser cannot + // await model load); populated asynchronously by the Stop-hook after parseTranscript. + prompt_embedding_base64: null, prompt_signal: classifyPromptSignal(prompt), decision_provenance, environment: { ..._envBase, classifier_model: _classifierModel }, diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 314c311b..51ba7359 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -233,7 +233,7 @@ describe('parseTranscript', () => { expect(ep.events).toEqual([]); expect(ep.outcome).toBe('unknown'); expect(ep.schema_version).toBe(4); - expect(ep.schema_minor).toBe(0); + expect(ep.schema_minor).toBe(3); }); it('produces a complete 7-field primary_rationale', () => { @@ -466,7 +466,7 @@ describe('parseTranscript — v4 episode (Phase 2 Task 15 bump)', () => { ]); const ep = parseTranscript(t); expect(ep.schema_version).toBe(4); - expect(ep.schema_minor).toBe(0); + expect(ep.schema_minor).toBe(3); expect('classifier_output' in ep).toBe(true); expect('degraded_mode' in ep).toBe(true); expect('classifier_model' in ep.environment).toBe(true); @@ -961,6 +961,10 @@ describe('extractTokenUsage (Task 2)', () => { expect(extractTokenUsage(turn)).toEqual({ input_tokens: 18, output_tokens: 8, cache_read_input_tokens: 180, cache_creation_input_tokens: 70, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, + classifier_input_tokens: 0, classifier_output_tokens: 0, + self_assessment_input_tokens: 0, self_assessment_output_tokens: 0, + reviewer_input_tokens: 0, reviewer_output_tokens: 0, + reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0, }); }); it('captures server_tool_use bonus fields (web_search/web_fetch)', () => { @@ -985,17 +989,23 @@ describe('extractTokenUsage (Task 2)', () => { expect(extractTokenUsage(turn)).toEqual({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, + classifier_input_tokens: 0, classifier_output_tokens: 0, + self_assessment_input_tokens: 0, self_assessment_output_tokens: 0, + reviewer_input_tokens: 0, reviewer_output_tokens: 0, + reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0, }); }); it('handles empty/null turn safely', () => { - expect(extractTokenUsage([])).toEqual({ + const zeroShape = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, - }); - expect(extractTokenUsage(null)).toEqual({ - input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, - }); + classifier_input_tokens: 0, classifier_output_tokens: 0, + self_assessment_input_tokens: 0, self_assessment_output_tokens: 0, + reviewer_input_tokens: 0, reviewer_output_tokens: 0, + reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0, + }; + expect(extractTokenUsage([])).toEqual(zeroShape); + expect(extractTokenUsage(null)).toEqual(zeroShape); }); it('safely skips entries where usage is a non-object primitive (defensive guard)', () => { const turn = [ @@ -1030,6 +1040,10 @@ describe('parseTranscript — task_cost integration (Task 2)', () => { expect(result.task_cost).toEqual({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, web_search_requests: 0, web_fetch_requests: 0, iterations: 0, + classifier_input_tokens: 0, classifier_output_tokens: 0, + self_assessment_input_tokens: 0, self_assessment_output_tokens: 0, + reviewer_input_tokens: 0, reviewer_output_tokens: 0, + reviewer_subagent_usd: 0, reviewer_direct_fallback_usd: 0, }); }); }); @@ -1640,7 +1654,7 @@ describe('parseTranscript v3 fields', () => { it('emits schema_version: 4', () => { const ep = parseTranscript(transcriptDirectFeature(), 'sess-1'); expect(ep.schema_version).toBe(4); - expect(ep.schema_minor).toBe(0); + expect(ep.schema_minor).toBe(3); }); it('sets recommended_node for direct feature-classified episode', () => { @@ -1720,3 +1734,82 @@ describe('parseTranscript — router-state enrichment (Task 3)', () => { } }); }); + +// ─── Phase 3 deferred #2: parser write-block v4.3 ──────────────────────────── + +describe('parseTranscript — schema v4.3 write-block fields (phase 3 deferred #2)', () => { + function simpleTranscript(prompt = 'add a feature', ts = '2026-05-25T10:00:00Z', sid = 's-v43') { + return [ + JSON.stringify({ type: 'user', message: { role: 'user', content: prompt }, timestamp: ts, sessionId: sid }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, timestamp: ts, sessionId: sid }), + ].join('\n'); + } + + it('emits schema_minor 3', () => { + const ep = parseTranscript(simpleTranscript()); + expect(ep.schema_minor).toBe(3); + }); + + it('emits outcome_reviewed: null', () => { + const ep = parseTranscript(simpleTranscript()); + expect('outcome_reviewed' in ep).toBe(true); + expect(ep.outcome_reviewed).toBeNull(); + }); + + it('emits outcome_reviewed_source: null', () => { + const ep = parseTranscript(simpleTranscript()); + expect('outcome_reviewed_source' in ep).toBe(true); + expect(ep.outcome_reviewed_source).toBeNull(); + }); + + it('emits prompt_embedding_base64 as null when embedding model unavailable', () => { + // parser is synchronous; embedding is null by design (filled async by stop-hook) + const ep = parseTranscript(simpleTranscript()); + expect('prompt_embedding_base64' in ep).toBe(true); + expect(ep.prompt_embedding_base64).toBeNull(); + }); + + it('does not throw when transcript has unusual content', () => { + // robustness guard: parser must never throw regardless of transcript shape + expect(() => parseTranscript(simpleTranscript('', '2026-05-25T10:00:00Z'))).not.toThrow(); + expect(() => parseTranscript('')).not.toThrow(); + expect(() => parseTranscript('{ broken json\nnot valid')).not.toThrow(); + }); + + it('task_cost has 8 new zero-default LLM-cost fields', () => { + const ep = parseTranscript(simpleTranscript()); + const cost = ep.task_cost; + expect(typeof cost.classifier_input_tokens).toBe('number'); + expect(typeof cost.classifier_output_tokens).toBe('number'); + expect(typeof cost.self_assessment_input_tokens).toBe('number'); + expect(typeof cost.self_assessment_output_tokens).toBe('number'); + expect(typeof cost.reviewer_input_tokens).toBe('number'); + expect(typeof cost.reviewer_output_tokens).toBe('number'); + expect(typeof cost.reviewer_subagent_usd).toBe('number'); + expect(typeof cost.reviewer_direct_fallback_usd).toBe('number'); + // all default to 0 + expect(cost.classifier_input_tokens).toBe(0); + expect(cost.classifier_output_tokens).toBe(0); + expect(cost.self_assessment_input_tokens).toBe(0); + expect(cost.self_assessment_output_tokens).toBe(0); + expect(cost.reviewer_input_tokens).toBe(0); + expect(cost.reviewer_output_tokens).toBe(0); + expect(cost.reviewer_subagent_usd).toBe(0); + expect(cost.reviewer_direct_fallback_usd).toBe(0); + }); + + it('task_cost retains all existing fields alongside new ones', () => { + const lines = [ + JSON.stringify({ type: 'user', message: { role: 'user', content: 'do it' } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 500, cache_creation_input_tokens: 50 } } }), + ].join('\n'); + const cost = parseTranscript(lines).task_cost; + expect(cost.input_tokens).toBe(100); + expect(cost.output_tokens).toBe(20); + expect(cost.cache_read_input_tokens).toBe(500); + expect(cost.cache_creation_input_tokens).toBe(50); + // new fields still 0 (populated retroactively by controller scripts) + expect(cost.classifier_input_tokens).toBe(0); + expect(cost.reviewer_subagent_usd).toBe(0); + }); +});