Repository navigation
fix(BashTool): include captured output in non-zero-exit error result (#1231) - #1249
Conversation
…wigpine#1231) When a Bash tool command exits non-zero, the error result reaching the model and the UI was supposed to carry the merged stdout/stderr so the failure can actually be debugged. In practice users were seeing the result collapse to just "Error: Exit code N" with no diagnostic detail (see Twigpine#1231 — succeed_with_output / fail_with_output reduced case). The failure path was sourcing the output from `result.stdout` directly while the success path used `stdoutAccumulator.toString()`. The accumulator is the canonical buffer — it captures the streamed output exactly as the success path returns it (with the trimEnd + EOL normalization at the top of the post-completion block), independent of whether the underlying ExecResult.stdout slot is populated. Whenever the shell runner streamed everything through the accumulator and left result.stdout unset (or only partially set), the failure path emitted an empty error body. Switch the ShellError throw to use the accumulator content as the primary source, with `result.stdout` as a fallback. Strip the trailing "Exit code N" marker so it isn't duplicated by getErrorParts(), which already prepends the code from ShellError.code. Behaviour is identical when both buffers agree.
BlockersNone found. Non-BlockingNone. Looks Good
Verdict: Approve — clean bash output fix. |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Clean bash output fix.
jatmn
left a comment
There was a problem hiding this comment.
Findings
- [P1] Use the actual streamed buffer when
result.stdoutis empty
src/tools/BashTool/BashTool.tsx:729
This still collapses to the same empty failure body for the case described in #1231. The newaccumulatedOutputis built fromstdoutAccumulator, but that accumulator was populated at line 696 from(result.stdout || '')and then line 708 only appends the syntheticExit code Nmarker. Ifresult.stdoutis empty because the shell runner streamed output elsewhere,stdoutAccumulator.toString()is just\nExit code N; the new replacement strips the marker back to an empty string, and the fallback is the same emptyresult.stdout. So the PR does not actually recoverstdout: failure/stderr: failurein the reported failure mode. Please source the failure body from the real streamed/task output buffer, or add a focused test that reproduces #1231 and fails without that source.
…tdout slot is empty Addresses @jatmn's P1 finding on Twigpine#1249. Previously the failure-body recovery picked between: 1. the truncating accumulator (after stripping the synthetic "Exit code N") 2. result.stdout Both sources can be empty in the failure mode reported in Twigpine#1231: the shell runner streams every line through progress callbacks but the final ExecResult.stdout slot ends up empty (flush-after-result race, exit before EOF, output persisted to a file path, etc.). With both empty the patch collapsed back to the original "Error: Exit code N" body. Add the most recent progress.fullOutput value yielded by the streaming generator as a third fallback source, captured in the consumer loop. The selection logic is extracted into a pure selectFailureOutput() helper so it can be exercised directly by unit tests — including a reproducer for the exact failure mode jatmn called out (accumulator empty, result.stdout undefined, fullOutput non-empty). Local: 39 / 39 utils.test.ts pass, including 7 new selectFailureOutput cases.
|
Pushed You were right that the prior patch collapsed back to an empty body in the streamed-only failure mode. The recovery now picks between three sources in order of trust:
Selection logic is extracted into a pure test('recovers from progress.fullOutput when result.stdout is undefined (shell runner left slot empty)', () => {
expect(selectFailureOutput('', undefined, 'tsc error TS2305\nbuild halted'))
.toBe('tsc error TS2305\nbuild halted')
})39/39 |
jatmn
left a comment
There was a problem hiding this comment.
Thanks for the update. The new progress.fullOutput fallback plus the focused selectFailureOutput() coverage address my earlier streamed-output concern.
No issues here, LGTM.
…wigpine#1231) (Twigpine#1249) Co-Authored-By: Claude <noreply@anthropic.com>
Upstream tier 2 KEEPs, applied 2 of 7 candidates (5 already applied under prior syncs but missed by subject-match dedup): f1013df fix(api): honor OpenAI-compatible retry classification (Twigpine#1547) 8065f8d fix(vscode): send schema-valid permission responses (Twigpine#1401) Already-applied (real DIFFERS = local fork divergence only): 1fc5116 fix(api): tighten reasoning_content heuristic (Twigpine#1201) -- byte-equal 2bed184 perf(attachments): skip skill listings for utility forks (Twigpine#1545) -- diff is feature('TRANSCRIPT_CLASSIFIER')→true + CLAUDE.md→AGENTS.md 8416faa fix(BashTool): include captured output in non-zero-exit error (Twigpine#1249) -- diff is feature('MONITOR_TOOL')→drop + local @ts-ignore f7d42c2 fix(cron): enforce MAX_CRON_PROMPT_CHARS cap (Twigpine#1224) -- diff is 'Claude'→'Open CC' brand string in description Notes: - f1013df: openaiErrorClassification.ts adds RETRYABLE_OPENAI_COMPATIBILITY_ FAILURE_CATEGORIES set + isRetryableOpenAICompatibilityFailureCategory(); withRetry.ts integrates the new classifier. withRetry.test.ts had a merge conflict in the 'retry configuration' describe block (upstream added OPENCLAUDE_MAX_RETRIES/OPENCLAUDE_RETRY_DELAY_MS env var tests that fork doesn't support -- AGENTS.md keeps CLAUDE_CODE_* env vars). Resolved by KEEPING THEIRS for the new 'OpenAI-compatible retry classification' block, then DELETED the 'retry configuration' block (9 unsupported tests) per fork policy. - 8065f8d: 2 new files (permissionResponse.js + .test.js) pulled; 2 existing files patched cleanly. Fork has 2 separate vscode extensions (opencc-vscode v0.1.1 simplified + openclaude-vscode v0.2.0 full); the upstream sync only touches the openclaude-vscode chat/ subdir. permissionResponse.test.js uses jest-style globals; bun test passes 3/3 (bun:test is jest-compatible for describe/it/expect). Verification: typecheck: 0 errors bun test: 2532 pass / 0 fail / 34 skip (full suite) build: Built v0.16.1 → dist/cli.mjs vscode: permissionResponse.test.js 3/3 pass naming: no new openclaude/gitlawb leaks (pre-existing openclaude-vscode dir name is intentional)
Upstream tier 2 KEEPs, applied 2 of 7 candidates (5 already applied under prior syncs but missed by subject-match dedup): f1013df fix(api): honor OpenAI-compatible retry classification (Twigpine#1547) 8065f8d fix(vscode): send schema-valid permission responses (Twigpine#1401) Already-applied (real DIFFERS = local fork divergence only): 1fc5116 fix(api): tighten reasoning_content heuristic (Twigpine#1201) -- byte-equal 2bed184 perf(attachments): skip skill listings for utility forks (Twigpine#1545) -- diff is feature('TRANSCRIPT_CLASSIFIER')→true + CLAUDE.md→AGENTS.md 8416faa fix(BashTool): include captured output in non-zero-exit error (Twigpine#1249) -- diff is feature('MONITOR_TOOL')→drop + local @ts-ignore f7d42c2 fix(cron): enforce MAX_CRON_PROMPT_CHARS cap (Twigpine#1224) -- diff is 'Claude'→'Open CC' brand string in description Notes: - f1013df: openaiErrorClassification.ts adds RETRYABLE_OPENAI_COMPATIBILITY_ FAILURE_CATEGORIES set + isRetryableOpenAICompatibilityFailureCategory(); withRetry.ts integrates the new classifier. withRetry.test.ts had a merge conflict in the 'retry configuration' describe block (upstream added OPENCLAUDE_MAX_RETRIES/OPENCLAUDE_RETRY_DELAY_MS env var tests that fork doesn't support -- AGENTS.md keeps CLAUDE_CODE_* env vars). Resolved by KEEPING THEIRS for the new 'OpenAI-compatible retry classification' block, then DELETED the 'retry configuration' block (9 unsupported tests) per fork policy. - 8065f8d: 2 new files (permissionResponse.js + .test.js) pulled; 2 existing files patched cleanly. Fork has 2 separate vscode extensions (opencc-vscode v0.1.1 simplified + openclaude-vscode v0.2.0 full); the upstream sync only touches the openclaude-vscode chat/ subdir. permissionResponse.test.js uses jest-style globals; bun test passes 3/3 (bun:test is jest-compatible for describe/it/expect). Verification: typecheck: 0 errors bun test: 2532 pass / 0 fail / 34 skip (full suite) build: Built v0.16.1 → dist/cli.mjs vscode: permissionResponse.test.js 3/3 pass naming: no new openclaude/gitlawb leaks (pre-existing openclaude-vscode dir name is intentional)
Closes #1231.
Summary
When a Bash tool command exits non-zero, the resulting error reaching the model and the UI was supposed to carry the merged stdout/stderr so the failure can actually be debugged. The issue reporter shows it collapsing to just
Error: Exit code Nwith no diagnostic detail. The reduced case in the issue:prints the merged output on success but drops it entirely on failure.
Root cause
The success path in
BashTool.call()sources its output fromstdoutAccumulator.toString()— the buffer that the streaming runner appends to, with thetrimEnd + EOLnormalization at the top of the post-completion block. The failure path was instead readingresult.stdoutdirectly:Whenever the shell runner streams everything through the accumulator and the returned
ExecResult.stdoutslot is left empty (or only partially populated by a late fd flush), the failure path emits an empty error body — and the user seesError: Exit code Nalone.Fix
ShellErrorthrow to use the accumulator content as the primary source.result.stdoutas the fallback so an unrelated regression in the accumulator can't make this worse.Exit code Nmarker the failure-path block appends sogetErrorParts()doesn't duplicate it (the code is already prepended fromShellError.code).Behaviour is identical when both buffers already agree.
Test plan
bun test— 2754 / 2754 passecho hi; echo err >&2; exit 1and confirm the error now readsExit code 1\nhi\nerrrather thanExit code 1aloneHappy to fold in a focused integration test on the BashTool failure path if you'd like — I left it out because the repo doesn't currently have a
BashTool.test.tsxand I didn't want to drag in a fixture just for one assertion.