diff --git a/src/agent/claude/stream-json.ts b/src/agent/claude/stream-json.ts index b30db89a..a4ab19d0 100644 --- a/src/agent/claude/stream-json.ts +++ b/src/agent/claude/stream-json.ts @@ -15,6 +15,8 @@ interface ContentBlock { interface ClaudeRawEvent { type?: string; subtype?: string; + /** Parent tool ID for nested messages; null on top-level messages. */ + parent_tool_use_id?: string | null; session_id?: string; cwd?: string; model?: string; @@ -31,6 +33,9 @@ export function* translateEvent(raw: unknown): Generator { if (!raw || typeof raw !== 'object') return; const evt = raw as ClaudeRawEvent; + // Nested assistant/user messages are internal and must not reach Lark. + if (isNestedMessage(evt)) return; + if (evt.type === 'system' && evt.subtype === 'init') { yield { type: 'system', @@ -83,3 +88,10 @@ export function* translateEvent(raw: unknown): Generator { yield { type: 'done', sessionId: evt.session_id, terminationReason: 'normal' }; } } + +function isNestedMessage(evt: ClaudeRawEvent): boolean { + return ( + (evt.type === 'assistant' || evt.type === 'user') && + typeof evt.parent_tool_use_id === 'string' + ); +} diff --git a/tests/unit/agent/claude-stream-json.test.ts b/tests/unit/agent/claude-stream-json.test.ts index fc340087..7b384b62 100644 --- a/tests/unit/agent/claude-stream-json.test.ts +++ b/tests/unit/agent/claude-stream-json.test.ts @@ -41,6 +41,70 @@ describe('Claude stream-json translator', () => { ]); }); + it('filters nested assistant/user messages by their explicit parent tool-use ID', () => { + const nestedAssistant = { + type: 'assistant', + parent_tool_use_id: 'tool-agent-1', + message: { + content: [ + { type: 'text', text: 'private research report' }, + { type: 'thinking', thinking: 'private reasoning' }, + { type: 'tool_use', id: 'tool-child-1', name: 'WebSearch', input: { query: 'x' } }, + ], + }, + }; + const nestedToolResult = { + type: 'user', + parent_tool_use_id: 'tool-agent-1', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'tool-child-1', content: 'private result' }, + ], + }, + }; + + expect([...translateEvent(nestedAssistant)]).toEqual([]); + expect([...translateEvent(nestedToolResult)]).toEqual([]); + }); + + it('preserves top-level messages and lifecycle events', () => { + expect([ + ...translateEvent({ + type: 'assistant', + parent_tool_use_id: null, + message: { content: [{ type: 'text', text: 'public synthesis' }] }, + }), + ]).toEqual([{ type: 'text', delta: 'public synthesis' }]); + expect([ + ...translateEvent({ + type: 'user', + parent_tool_use_id: null, + message: { + content: [ + { type: 'tool_result', tool_use_id: 'tool-agent-1', content: 'agent report' }, + ], + }, + }), + ]).toEqual([ + { type: 'tool_result', id: 'tool-agent-1', output: 'agent report', isError: false }, + ]); + expect([ + ...translateEvent({ + type: 'system', + subtype: 'init', + parent_tool_use_id: 'unexpected', + session_id: 'sess-1', + }), + ]).toEqual([{ type: 'system', sessionId: 'sess-1', cwd: undefined, model: undefined }]); + expect([ + ...translateEvent({ + type: 'result', + parent_tool_use_id: 'unexpected', + session_id: 'sess-1', + }), + ]).toEqual([{ type: 'done', sessionId: 'sess-1', terminationReason: 'normal' }]); + }); + it('translates user tool_result blocks including structured output and errors', () => { expect([ ...translateEvent({