From 56d7ade626f4df41eb4b7b10b0d600d1b86d8313 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 13:21:31 +0300 Subject: [PATCH] feat(observer): emit subagent_invoked events from Agent tool_use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes brain-retro 2026-05-20 #12 — each Agent tool_use produces a subagent_invoked event with subagent_type / model (if explicit) / first 80 chars of description. Visibility from parent Claude's perspective; full subagent trace lives in subagents/ directory and is out of scope for this parser. 6 new vitest tests, 315/315 GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/observer-transcript-parser.mjs | 28 +++++++++++ tools/observer-transcript-parser.test.mjs | 58 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 8880f512..7959b1df 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -532,6 +532,33 @@ export function parseRoutingTag(turn) { return null; } +/** + * Per-Agent-tool_use event (Task 12) — surfaces subagent dispatches in the + * episode `events[]`. Captures subagent_type / model (if explicit in input) + * / first 80 chars of description. + * + * Not the full subagent trace (that lives in ~/.claude/projects/.../subagents/); + * just visibility from the parent Claude's perspective. + */ +export function extractAgentInvocations(turn) { + const out = []; + for (const e of turn || []) { + const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; + for (const b of content) { + if (b && b.type === 'tool_use' && b.name === 'Agent') { + const inp = b.input || {}; + out.push({ + kind: 'subagent_invoked', + subagent_type: inp.subagent_type || 'unknown', + model: inp.model || null, + description: typeof inp.description === 'string' ? inp.description.slice(0, 80) : '', + }); + } + } + } + return out; +} + const REASONING_TAG_RE = //; @@ -611,6 +638,7 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) { } events.push(...extractProcessEvents(turn, broken, total, durationMs)); events.push(...extractAskUserQuestionEvents(turn)); + events.push(...extractAgentInvocations(turn)); const usedSuperpowers = skills.some((s) => String(s).startsWith(SUPERPOWERS_PREFIX)); const prompt = promptText(entries[start]); diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index ab2cafc7..23b8c0a6 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -1375,3 +1375,61 @@ describe('parseTranscript — reasoning-tag merges with heuristic (Task 11)', () expect(ep.primary_rationale.boundaries_applied).toContain('Pravila §16'); }); }); + +import { extractAgentInvocations } from './observer-transcript-parser.mjs'; + +describe('extractAgentInvocations (Task 12)', () => { + it('emits subagent_invoked event from Agent tool_use', () => { + const turn = [{ message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Agent', + input: { subagent_type: 'general-purpose', model: 'sonnet', description: 'check files' } } + ] } }]; + const ev = extractAgentInvocations(turn); + expect(ev).toHaveLength(1); + expect(ev[0].kind).toBe('subagent_invoked'); + expect(ev[0].subagent_type).toBe('general-purpose'); + expect(ev[0].model).toBe('sonnet'); + expect(ev[0].description).toBe('check files'); + }); + it('uses "unknown" when subagent_type missing', () => { + const turn = [{ message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Agent', input: {} } + ] } }]; + expect(extractAgentInvocations(turn)[0].subagent_type).toBe('unknown'); + }); + it('truncates description at 80 chars', () => { + const turn = [{ message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Agent', + input: { subagent_type: 'g', description: 'a'.repeat(200) } } + ] } }]; + expect(extractAgentInvocations(turn)[0].description.length).toBe(80); + }); + it('returns empty for non-Agent tool_use', () => { + const turn = [{ message: { role: 'assistant', content: [ + { type: 'tool_use', name: 'Read', input: { file_path: '/a' } } + ] } }]; + expect(extractAgentInvocations(turn)).toEqual([]); + }); + it('safe on null/empty', () => { + expect(extractAgentInvocations(null)).toEqual([]); + expect(extractAgentInvocations([])).toEqual([]); + }); +}); + +describe('parseTranscript — subagent_invoked events (Task 12)', () => { + it('emits subagent_invoked from Agent tool_use', () => { + const transcript = [ + JSON.stringify({ sessionId: 's' }), + JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ + { type: 'tool_use', id: 't1', name: 'Agent', + input: { subagent_type: 'tester', model: 'haiku', description: 'verify spec' } } + ] }, uuid: 'u2', timestamp: '2026-05-20T00:00:01Z' }), + ].join('\n'); + const ep = parseTranscript(transcript); + const subs = ep.events.filter((e) => e.kind === 'subagent_invoked'); + expect(subs).toHaveLength(1); + expect(subs[0].subagent_type).toBe('tester'); + expect(subs[0].model).toBe('haiku'); + }); +});