Merge pull request #177 from Lint111/agent/split-transcript-tool-results

fix(transcripts): complete tools from user results
This commit is contained in:
Ark0N
2026-08-03 14:05:38 +02:00
committed by GitHub
2 changed files with 101 additions and 47 deletions
+33 -16
View File
@@ -40,6 +40,7 @@ interface TranscriptContentBlock {
text?: string; text?: string;
name?: string; name?: string;
input?: Record<string, unknown>; input?: Record<string, unknown>;
tool_use_id?: string;
content?: string; content?: string;
is_error?: boolean; is_error?: boolean;
} }
@@ -328,10 +329,7 @@ export class TranscriptWatcher extends EventEmitter {
this.handleResultEntry(entry); this.handleResultEntry(entry);
break; break;
case 'user': case 'user':
// User message means new turn, reset some state this.handleUserEntry(entry);
this.state.isComplete = false;
this.state.hasError = false;
this.state.errorMessage = null;
break; break;
case 'system': case 'system':
// System messages are informational // System messages are informational
@@ -360,23 +358,42 @@ export class TranscriptWatcher extends EventEmitter {
this.state.currentTool = block.name; this.state.currentTool = block.name;
this.emit('transcript:tool_start', block.name); this.emit('transcript:tool_start', block.name);
} else if (block.type === 'tool_result') { } else if (block.type === 'tool_result') {
// Tool completed this.handleToolResult(block);
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 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 { private handleResultEntry(entry: TranscriptEntry): void {
// Result entry indicates completion // Result entry indicates completion
this.state.isComplete = true; this.state.isComplete = true;
+68 -31
View File
@@ -88,14 +88,16 @@ describe('TranscriptWatcher', () => {
watcher.start(testFile); watcher.start(testFile);
// Add user entry // 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'); appendFileSync(testFile, JSON.stringify(userEntry) + '\n');
// Wait for processing await vi.waitFor(() => {
await new Promise(resolve => setTimeout(resolve, 100)); expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1);
});
const state = watcher.getState();
expect(state.entryCount).toBeGreaterThanOrEqual(1);
}); });
it('should emit transcript:complete on result entry', async () => { it('should emit transcript:complete on result entry', async () => {
@@ -109,10 +111,9 @@ describe('TranscriptWatcher', () => {
const resultEntry = { type: 'result', timestamp: new Date().toISOString() }; const resultEntry = { type: 'result', timestamp: new Date().toISOString() };
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n'); appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
// Wait for processing await vi.waitFor(() => {
await new Promise(resolve => setTimeout(resolve, 200)); expect(completeHandler).toHaveBeenCalled();
});
expect(completeHandler).toHaveBeenCalled();
const state = watcher.getState(); const state = watcher.getState();
expect(state.isComplete).toBe(true); expect(state.isComplete).toBe(true);
}); });
@@ -130,22 +131,62 @@ describe('TranscriptWatcher', () => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }],
{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } } },
]
}
}; };
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n'); appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
// Wait for processing await vi.waitFor(() => {
await new Promise(resolve => setTimeout(resolve, 200)); expect(toolStartHandler).toHaveBeenCalledWith('Read');
});
expect(toolStartHandler).toHaveBeenCalledWith('Read');
const state = watcher.getState(); const state = watcher.getState();
expect(state.toolExecuting).toBe(true); expect(state.toolExecuting).toBe(true);
expect(state.currentTool).toBe('Read'); 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 () => { it('should detect plan mode from AskUserQuestion tool', async () => {
writeFileSync(testFile, ''); writeFileSync(testFile, '');
watcher.start(testFile); watcher.start(testFile);
@@ -159,17 +200,14 @@ describe('TranscriptWatcher', () => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }],
{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } } },
]
}
}; };
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n'); appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
// Wait for processing await vi.waitFor(() => {
await new Promise(resolve => setTimeout(resolve, 200)); expect(planModeHandler).toHaveBeenCalled();
});
expect(planModeHandler).toHaveBeenCalled();
const state = watcher.getState(); const state = watcher.getState();
expect(state.planModeDetected).toBe(true); expect(state.planModeDetected).toBe(true);
}); });
@@ -182,15 +220,14 @@ describe('TranscriptWatcher', () => {
const resultEntry = { const resultEntry = {
type: 'result', type: 'result',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
error: { type: 'api_error', message: 'Rate limited' } error: { type: 'api_error', message: 'Rate limited' },
}; };
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n'); appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
// Wait for processing await vi.waitFor(() => {
await new Promise(resolve => setTimeout(resolve, 200)); expect(watcher.getState().hasError).toBe(true);
});
const state = watcher.getState(); const state = watcher.getState();
expect(state.hasError).toBe(true);
expect(state.errorMessage).toContain('Rate limited'); expect(state.errorMessage).toContain('Rate limited');
}); });
}); });