From 4a4720cb6230a428c8417a5243ed2b5909655c68 Mon Sep 17 00:00:00 2001 From: lior Date: Tue, 28 Jul 2026 23:18:10 +0300 Subject: [PATCH] fix(transcripts): complete tools from user results --- src/transcript-watcher.ts | 49 ++++++++++------ test/transcript-watcher.test.ts | 99 ++++++++++++++++++++++----------- 2 files changed, 101 insertions(+), 47 deletions(-) diff --git a/src/transcript-watcher.ts b/src/transcript-watcher.ts index 8eb01644..8d44865c 100644 --- a/src/transcript-watcher.ts +++ b/src/transcript-watcher.ts @@ -40,6 +40,7 @@ interface TranscriptContentBlock { text?: string; name?: string; input?: Record; + tool_use_id?: string; content?: string; is_error?: boolean; } @@ -328,10 +329,7 @@ export class TranscriptWatcher extends EventEmitter { this.handleResultEntry(entry); break; case 'user': - // User message means new turn, reset some state - this.state.isComplete = false; - this.state.hasError = false; - this.state.errorMessage = null; + this.handleUserEntry(entry); break; case 'system': // System messages are informational @@ -360,23 +358,42 @@ export class TranscriptWatcher extends EventEmitter { this.state.currentTool = block.name; this.emit('transcript:tool_start', block.name); } else if (block.type === 'tool_result') { - // Tool completed - const wasError = block.is_error === true; - const toolName = this.state.currentTool; - this.state.toolExecuting = false; - this.state.currentTool = null; - if (toolName) { - this.emit('transcript:tool_end', toolName, wasError); - } - if (wasError && block.content) { - this.state.hasError = true; - this.state.errorMessage = String(block.content).slice(0, 200); - } + this.handleToolResult(block); } } } } + private handleUserEntry(entry: TranscriptEntry): void { + // A user-authored prompt starts a turn, while Claude tool results also use + // user entries. Reset turn state first, then close any completed tool. + this.state.isComplete = false; + this.state.hasError = false; + this.state.errorMessage = null; + + const content = entry.message?.content; + if (!Array.isArray(content)) return; + for (const block of content) { + if (block.type === 'tool_result') { + this.handleToolResult(block); + } + } + } + + private handleToolResult(block: TranscriptContentBlock): void { + const wasError = block.is_error === true; + const toolName = this.state.currentTool; + this.state.toolExecuting = false; + this.state.currentTool = null; + if (toolName) { + this.emit('transcript:tool_end', toolName, wasError); + } + if (wasError && block.content) { + this.state.hasError = true; + this.state.errorMessage = String(block.content).slice(0, 200); + } + } + private handleResultEntry(entry: TranscriptEntry): void { // Result entry indicates completion this.state.isComplete = true; diff --git a/test/transcript-watcher.test.ts b/test/transcript-watcher.test.ts index c286dd0f..badba35e 100644 --- a/test/transcript-watcher.test.ts +++ b/test/transcript-watcher.test.ts @@ -88,14 +88,16 @@ describe('TranscriptWatcher', () => { watcher.start(testFile); // Add user entry - const userEntry = { type: 'user', timestamp: new Date().toISOString(), message: { role: 'user', content: 'test' } }; + const userEntry = { + type: 'user', + timestamp: new Date().toISOString(), + message: { role: 'user', content: 'test' }, + }; appendFileSync(testFile, JSON.stringify(userEntry) + '\n'); - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 100)); - - const state = watcher.getState(); - expect(state.entryCount).toBeGreaterThanOrEqual(1); + await vi.waitFor(() => { + expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1); + }); }); it('should emit transcript:complete on result entry', async () => { @@ -109,10 +111,9 @@ describe('TranscriptWatcher', () => { const resultEntry = { type: 'result', timestamp: new Date().toISOString() }; appendFileSync(testFile, JSON.stringify(resultEntry) + '\n'); - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(completeHandler).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(completeHandler).toHaveBeenCalled(); + }); const state = watcher.getState(); expect(state.isComplete).toBe(true); }); @@ -130,22 +131,62 @@ describe('TranscriptWatcher', () => { timestamp: new Date().toISOString(), message: { role: 'assistant', - content: [ - { type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } } - ] - } + content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }], + }, }; appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n'); - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(toolStartHandler).toHaveBeenCalledWith('Read'); + await vi.waitFor(() => { + expect(toolStartHandler).toHaveBeenCalledWith('Read'); + }); const state = watcher.getState(); expect(state.toolExecuting).toBe(true); expect(state.currentTool).toBe('Read'); }); + it('should complete a tool when Claude writes tool_result in a user entry', async () => { + writeFileSync(testFile, ''); + watcher.start(testFile); + + const toolEndHandler = vi.fn(); + watcher.on('transcript:tool_end', toolEndHandler); + + appendFileSync( + testFile, + JSON.stringify({ + type: 'assistant', + timestamp: new Date().toISOString(), + message: { + role: 'assistant', + content: [{ type: 'tool_use', name: 'Bash', input: { command: 'printf done' } }], + }, + }) + '\n' + ); + await vi.waitFor(() => { + expect(watcher.getState().toolExecuting).toBe(true); + }); + + appendFileSync( + testFile, + JSON.stringify({ + type: 'user', + timestamp: new Date().toISOString(), + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done', is_error: false }], + }, + }) + '\n' + ); + + await vi.waitFor(() => { + expect(toolEndHandler).toHaveBeenCalledWith('Bash', false); + }); + expect(watcher.getState()).toMatchObject({ + toolExecuting: false, + currentTool: null, + }); + }); + it('should detect plan mode from AskUserQuestion tool', async () => { writeFileSync(testFile, ''); watcher.start(testFile); @@ -159,17 +200,14 @@ describe('TranscriptWatcher', () => { timestamp: new Date().toISOString(), message: { role: 'assistant', - content: [ - { type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } } - ] - } + content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }], + }, }; appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n'); - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 200)); - - expect(planModeHandler).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(planModeHandler).toHaveBeenCalled(); + }); const state = watcher.getState(); expect(state.planModeDetected).toBe(true); }); @@ -182,15 +220,14 @@ describe('TranscriptWatcher', () => { const resultEntry = { type: 'result', timestamp: new Date().toISOString(), - error: { type: 'api_error', message: 'Rate limited' } + error: { type: 'api_error', message: 'Rate limited' }, }; appendFileSync(testFile, JSON.stringify(resultEntry) + '\n'); - // Wait for processing - await new Promise(resolve => setTimeout(resolve, 200)); - + await vi.waitFor(() => { + expect(watcher.getState().hasError).toBe(true); + }); const state = watcher.getState(); - expect(state.hasError).toBe(true); expect(state.errorMessage).toContain('Rate limited'); }); });