|
1 | 1 | import { test } from 'vitest'; |
2 | 2 | import assert from 'node:assert/strict'; |
3 | 3 | import { createJsonEventStreamHandler } from '../../src/runtimes/json-event-stream.js'; |
| 4 | +import { createToolLoopGuard, type ToolLoopVerdict } from '../../src/tool-loop-guard.js'; |
4 | 5 |
|
5 | 6 | type JsonStreamEvent = Record<string, unknown>; |
6 | 7 |
|
@@ -76,6 +77,221 @@ test('opencode json stream emits tool events', () => { |
76 | 77 | ]); |
77 | 78 | }); |
78 | 79 |
|
| 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 | + |
79 | 295 | test('opencode json stream emits structured errors as error events', () => { |
80 | 296 | const { events, handler } = collectEvents('opencode'); |
81 | 297 |
|
|
0 commit comments