Skip to content

Commit 5a20c5a

Browse files
authored
Fix OpenCode tool error normalization (#6933)
1 parent 499eb40 commit 5a20c5a

2 files changed

Lines changed: 265 additions & 3 deletions

File tree

apps/daemon/src/runtimes/json-event-stream.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ type ParserState = {
77
cursorTextSoFar: string;
88
cursorTurnStart: number;
99
openCodeToolUses: Set<string>;
10+
openCodeToolResults: Set<string>;
1011
codexToolUses: Set<string>;
1112
codexErrorEmitted: boolean;
1213
codexPreviousEventWasAgentMessage: boolean;
@@ -153,6 +154,48 @@ function formatOpenCodeUsage(tokens: unknown): Usage | null {
153154
return Object.keys(usage).length > 0 ? usage : null;
154155
}
155156

157+
function isPowerShellErrorRecord(toolName: string, output: unknown): boolean {
158+
const normalizedTool = toolName.toLowerCase();
159+
if (normalizedTool !== 'bash' && normalizedTool !== 'shell') return false;
160+
if (typeof output !== 'string') return false;
161+
162+
// A PowerShell non-terminating error can leave the shell process at exit 0.
163+
// Require both canonical ErrorRecord fields so ordinary output containing a
164+
// word such as "failed" does not become an error result.
165+
return (
166+
/(?:^|\r?\n)\s*\+\s*CategoryInfo\s*:/u.test(output) &&
167+
/(?:^|\r?\n)\s*\+\s*FullyQualifiedErrorId\s*:/u.test(output)
168+
);
169+
}
170+
171+
function openCodeToolResult(
172+
toolName: string,
173+
statePart: JsonObject,
174+
): { content: string; isError: boolean } | null {
175+
const status = typeof statePart.status === 'string' ? statePart.status.toLowerCase() : '';
176+
if (status !== 'completed' && status !== 'error' && status !== 'failed') return null;
177+
178+
const metadata = isRecord(statePart.metadata) ? statePart.metadata : {};
179+
const exitCodes = [statePart.exit, statePart.exitCode, metadata.exit];
180+
const hasNonZeroExit = exitCodes.some(
181+
(exitCode) => typeof exitCode === 'number' && Number.isFinite(exitCode) && exitCode !== 0,
182+
);
183+
const explicitError =
184+
(typeof statePart.error === 'string' && statePart.error.trim().length > 0) ||
185+
(isRecord(statePart.error) && Object.keys(statePart.error).length > 0)
186+
? statePart.error
187+
: null;
188+
const isError =
189+
status === 'error' ||
190+
status === 'failed' ||
191+
explicitError !== null ||
192+
hasNonZeroExit ||
193+
isPowerShellErrorRecord(toolName, statePart.output);
194+
const contentSource = explicitError ?? statePart.output;
195+
196+
return { content: stringifyContent(contentSource), isError };
197+
}
198+
156199
function handleOpenCodeEvent(obj: unknown, onEvent: StreamEventHandler, state: ParserState): boolean {
157200
if (!isRecord(obj)) return false;
158201
const part = isRecord(obj.part) ? obj.part : {};
@@ -188,12 +231,14 @@ function handleOpenCodeEvent(obj: unknown, onEvent: StreamEventHandler, state: P
188231
input: safeParseJson(statePart?.input) ?? statePart?.input ?? null,
189232
});
190233
}
191-
if (statePart?.status === 'completed') {
234+
const result = statePart ? openCodeToolResult(part.tool, statePart) : null;
235+
if (result && !state.openCodeToolResults.has(key)) {
236+
state.openCodeToolResults.add(key);
192237
onEvent({
193238
type: 'tool_result',
194239
toolUseId: part.callID,
195-
content: stringifyContent(statePart.output),
196-
isError: false,
240+
content: result.content,
241+
isError: result.isError,
197242
});
198243
}
199244
return true;
@@ -876,6 +921,7 @@ export function createJsonEventStreamHandler(kind: ParserKind, onEvent: StreamEv
876921
cursorTextSoFar: '',
877922
cursorTurnStart: 0,
878923
openCodeToolUses: new Set<string>(),
924+
openCodeToolResults: new Set<string>(),
879925
codexToolUses: new Set<string>(),
880926
codexErrorEmitted: false,
881927
codexPreviousEventWasAgentMessage: false,

apps/daemon/tests/runtimes/json-event-stream.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { createJsonEventStreamHandler } from '../../src/runtimes/json-event-stream.js';
4+
import { createToolLoopGuard, type ToolLoopVerdict } from '../../src/tool-loop-guard.js';
45

56
type JsonStreamEvent = Record<string, unknown>;
67

@@ -76,6 +77,221 @@ test('opencode json stream emits tool events', () => {
7677
]);
7778
});
7879

80+
test('opencode json stream marks structured tool failures as errors', () => {
81+
const cases = [
82+
{
83+
name: 'completed tool with a non-zero metadata exit',
84+
state: { status: 'completed', output: 'command failed', metadata: { exit: 1 } },
85+
content: 'command failed',
86+
},
87+
{
88+
name: 'completed tool with a direct non-zero exitCode',
89+
state: { status: 'completed', output: 'command failed', exitCode: 2 },
90+
content: 'command failed',
91+
},
92+
{
93+
name: 'completed tool with a direct non-zero exit',
94+
state: { status: 'completed', output: 'command failed', exit: 3 },
95+
content: 'command failed',
96+
},
97+
{
98+
name: 'completed tool with an explicit error',
99+
state: { status: 'completed', output: 'partial output', error: 'tool failed' },
100+
content: 'tool failed',
101+
},
102+
{
103+
name: 'official error state',
104+
state: { status: 'error', output: 'partial output', error: 'permission denied' },
105+
content: 'permission denied',
106+
},
107+
{
108+
name: 'legacy failed state',
109+
state: { status: 'failed', output: 'process failed' },
110+
content: 'process failed',
111+
},
112+
{
113+
name: 'PowerShell non-terminating ErrorRecord with exit zero',
114+
state: {
115+
status: 'completed',
116+
output:
117+
"Get-Content : Cannot find path 'missing.txt' because it does not exist.\r\n" +
118+
' + CategoryInfo : ObjectNotFound: (missing.txt:String) [Get-Content], ItemNotFoundException\r\n' +
119+
' + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand',
120+
metadata: { exit: 0 },
121+
},
122+
content:
123+
"Get-Content : Cannot find path 'missing.txt' because it does not exist.\r\n" +
124+
' + CategoryInfo : ObjectNotFound: (missing.txt:String) [Get-Content], ItemNotFoundException\r\n' +
125+
' + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand',
126+
},
127+
];
128+
129+
for (const [index, testCase] of cases.entries()) {
130+
const { events, handler } = collectEvents('opencode');
131+
handler.feed(
132+
JSON.stringify({
133+
type: 'tool_use',
134+
part: {
135+
tool: 'bash',
136+
callID: `call-${index}`,
137+
state: {
138+
input: { command: 'exit 1' },
139+
...testCase.state,
140+
},
141+
},
142+
}) + '\n',
143+
);
144+
145+
assert.deepEqual(
146+
events.at(-1),
147+
{
148+
type: 'tool_result',
149+
toolUseId: `call-${index}`,
150+
content: testCase.content,
151+
isError: true,
152+
},
153+
testCase.name,
154+
);
155+
}
156+
});
157+
158+
test('opencode json stream preserves successful and non-terminal tool states', () => {
159+
const { events, handler } = collectEvents('opencode');
160+
161+
for (const [callID, state] of [
162+
['success', { status: 'completed', output: 'done', metadata: { exit: 0 } }],
163+
['false-error', { status: 'completed', output: 'done', error: false }],
164+
['zero-error', { status: 'completed', output: 'done', error: 0 }],
165+
['pending', { status: 'pending', input: { command: 'pwd' } }],
166+
['running', { status: 'running', input: { command: 'pwd' } }],
167+
] as const) {
168+
handler.feed(
169+
JSON.stringify({ type: 'tool_use', part: { tool: 'bash', callID, state } }) + '\n',
170+
);
171+
}
172+
173+
assert.deepEqual(events, [
174+
{ type: 'tool_use', id: 'success', name: 'bash', input: null },
175+
{ type: 'tool_result', toolUseId: 'success', content: 'done', isError: false },
176+
{ type: 'tool_use', id: 'false-error', name: 'bash', input: null },
177+
{ type: 'tool_result', toolUseId: 'false-error', content: 'done', isError: false },
178+
{ type: 'tool_use', id: 'zero-error', name: 'bash', input: null },
179+
{ type: 'tool_result', toolUseId: 'zero-error', content: 'done', isError: false },
180+
{ type: 'tool_use', id: 'pending', name: 'bash', input: { command: 'pwd' } },
181+
{ type: 'tool_use', id: 'running', name: 'bash', input: { command: 'pwd' } },
182+
]);
183+
});
184+
185+
test('opencode json stream emits a terminal tool result only once per call', () => {
186+
const { events, handler } = collectEvents('opencode');
187+
const terminal = JSON.stringify({
188+
type: 'tool_use',
189+
sessionID: 'session-1',
190+
part: {
191+
tool: 'bash',
192+
callID: 'call-1',
193+
state: { status: 'completed', output: 'failed', metadata: { exit: 1 } },
194+
},
195+
});
196+
197+
handler.feed(`${terminal}\n${terminal}\n`);
198+
199+
assert.deepEqual(events, [
200+
{ type: 'tool_use', id: 'call-1', name: 'bash', input: null },
201+
{ type: 'tool_result', toolUseId: 'call-1', content: 'failed', isError: true },
202+
]);
203+
});
204+
205+
test('opencode PowerShell signatures stay narrow to canonical shell ErrorRecords', () => {
206+
const { events, handler } = collectEvents('opencode');
207+
const errorRecord =
208+
'+ CategoryInfo : ObjectNotFound: (missing.txt:String) [Get-Content], ItemNotFoundException\n' +
209+
'+ FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand';
210+
const documentation =
211+
'CategoryInfo : copied from a troubleshooting document\n' +
212+
'FullyQualifiedErrorId : copied-example';
213+
214+
for (const [tool, callID, output] of [
215+
['read', 'read-error-record', errorRecord],
216+
['bash', 'bash-documentation', documentation],
217+
] as const) {
218+
handler.feed(
219+
JSON.stringify({
220+
type: 'tool_use',
221+
part: {
222+
tool,
223+
callID,
224+
state: { status: 'completed', output, metadata: { exit: 0 } },
225+
},
226+
}) + '\n',
227+
);
228+
}
229+
230+
assert.deepEqual(events, [
231+
{ type: 'tool_use', id: 'read-error-record', name: 'read', input: null },
232+
{
233+
type: 'tool_result',
234+
toolUseId: 'read-error-record',
235+
content: errorRecord,
236+
isError: false,
237+
},
238+
{ type: 'tool_use', id: 'bash-documentation', name: 'bash', input: null },
239+
{
240+
type: 'tool_result',
241+
toolUseId: 'bash-documentation',
242+
content: documentation,
243+
isError: false,
244+
},
245+
]);
246+
});
247+
248+
test('opencode structured failures reach the repeated-failure tool-loop halt', () => {
249+
const guard = createToolLoopGuard({ mode: 'halt' });
250+
const verdicts: ToolLoopVerdict[] = [];
251+
const handler = createJsonEventStreamHandler('opencode', (event) => {
252+
if (event.type === 'tool_use') {
253+
guard.observeToolUse(String(event.id), String(event.name), event.input);
254+
}
255+
if (event.type === 'tool_result') {
256+
const verdict = guard.observeToolResult(
257+
String(event.toolUseId),
258+
event.isError === true,
259+
typeof event.content === 'string' ? event.content : undefined,
260+
);
261+
if (verdict) verdicts.push(verdict);
262+
}
263+
});
264+
265+
for (let index = 0; index < 8; index += 1) {
266+
handler.feed(
267+
JSON.stringify({
268+
type: 'tool_use',
269+
part: {
270+
tool: 'bash',
271+
callID: `call-${index}`,
272+
state: {
273+
status: 'completed',
274+
input: { command: 'Get-Content -LiteralPath missing.txt' },
275+
output:
276+
"Get-Content : Cannot find path 'missing.txt' because it does not exist.\r\n" +
277+
' + CategoryInfo : ObjectNotFound: (missing.txt:String) [Get-Content], ItemNotFoundException\r\n' +
278+
' + FullyQualifiedErrorId : PathNotFound,Microsoft.PowerShell.Commands.GetContentCommand',
279+
metadata: { exit: 0 },
280+
},
281+
},
282+
}) + '\n',
283+
);
284+
}
285+
286+
assert.deepEqual(
287+
verdicts.map(({ reason, action, count }) => ({ reason, action, count })),
288+
[
289+
{ reason: 'repeated-failure', action: 'warn', count: 4 },
290+
{ reason: 'repeated-failure', action: 'halt', count: 8 },
291+
],
292+
);
293+
});
294+
79295
test('opencode json stream emits structured errors as error events', () => {
80296
const { events, handler } = collectEvents('opencode');
81297

0 commit comments

Comments
 (0)