mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix(transcripts): complete tools from user results
This commit is contained in:
+33
-16
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user