fix(observer): infer blocked from unrecovered_error tail, not raw error/retry count (A-1)
Bug: inferOutcome flagged `blocked` whenever errorCount > retryCount across the turn's events. But the parser emits an `error` event for ANY tool_result with is_error=true — including expected failures: TDD failing-test-first, grep returning nothing, git commands with intentional non-zero exit. On TDD-heavy turns (project's standard discipline) this systematically marked turns as blocked even when they ended on a successful tool_use. Fix: - Parser (extractProcessEvents): walk turn from end, find the LAST tool_result; if its is_error=true, emit a single `unrecovered_error` event. Distinguishes "turn ended on failure" from "errors recovered later". The original per-is_error `error` events remain (useful as raw factor signals). - Analyzer (inferOutcome): replace `errorCount > retryCount → blocked` with `events.some(kind === 'unrecovered_error') → blocked`. Same ordering preserved (interrupt > blocked > rework/success/unknown). Tests: - Parser: emits unrecovered_error when last tool_result is_error; does NOT emit when turn ended on a successful tool_result; does NOT emit for turns with no tool_results. - Analyzer: blocked iff unrecovered_error event present (not raw count); events=[error, error, retry] → success (no unrecovered_error). 142/142 vitest green (was 128). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -382,6 +382,31 @@ describe('extractProcessEvents', () => {
|
||||
it('emits nothing for a clean empty turn', () => {
|
||||
expect(extractProcessEvents([], 0, 0, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits unrecovered_error when the LAST tool_result in the turn is is_error', () => {
|
||||
const turn = [
|
||||
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
|
||||
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
|
||||
];
|
||||
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does NOT emit unrecovered_error when the turn ends on a successful tool_result', () => {
|
||||
const turn = [
|
||||
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u1', name: 'Bash', input: {} }] } },
|
||||
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u1', is_error: true }] } },
|
||||
{ message: { role: 'assistant', content: [{ type: 'tool_use', id: 'u2', name: 'Bash', input: {} }] } },
|
||||
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'u2', is_error: false }] } },
|
||||
];
|
||||
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT emit unrecovered_error for a turn with no tool_results at all', () => {
|
||||
const turn = [
|
||||
{ message: { role: 'assistant', content: [{ type: 'text', text: 'just talking' }] } },
|
||||
];
|
||||
expect(extractProcessEvents(turn, 0, 0, 0).filter((e) => e.kind === 'unrecovered_error')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRoutingTag', () => {
|
||||
|
||||
Reference in New Issue
Block a user