Skip to content

fix(BashTool): include captured output in non-zero-exit error result (#1231) - #1249

Merged
kevincodex1 merged 2 commits into
Twigpine:mainfrom
0xghost42:fix/1231-bash-error-includes-output
Jun 3, 2026
Merged

kevincodex1 merged 2 commits into
Twigpine:mainfrom
0xghost42:fix/1231-bash-error-includes-output

Conversation

@0xghost42

Copy link
Copy Markdown
Contributor

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 N with no diagnostic detail. The reduced case in the issue:

fail_with_output() {
  echo "stdout: failure"
  echo "stderr: failure" >&2
  return 1
}
fail_with_output

prints the merged output on success but drops it entirely on failure.

Root cause

The success path in BashTool.call() sources its output from stdoutAccumulator.toString() — the buffer that the streaming runner appends to, with the trimEnd + EOL normalization at the top of the post-completion block. The failure path was instead reading result.stdout directly:

const outputWithSbFailures = SandboxManager.annotateStderrWithSandboxFailures(input.command, result.stdout || '');
// …
throw new ShellError('', outputWithSbFailures, result.code, result.interrupted);

Whenever the shell runner streams everything through the accumulator and the returned ExecResult.stdout slot is left empty (or only partially populated by a late fd flush), the failure path emits an empty error body — and the user sees Error: Exit code N alone.

Fix

  • Switch the ShellError throw to use the accumulator content as the primary source.
  • Keep result.stdout as the fallback so an unrelated regression in the accumulator can't make this worse.
  • Strip the trailing Exit code N marker the failure-path block appends so getErrorParts() doesn't duplicate it (the code is already prepended from ShellError.code).

Behaviour is identical when both buffers already agree.

Test plan

  • bun test — 2754 / 2754 pass
  • Manual: run a command like echo hi; echo err >&2; exit 1 and confirm the error now reads Exit code 1\nhi\nerr rather than Exit code 1 alone

Happy 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.tsx and I didn't want to drag in a fixture just for one assertion.

…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.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

Blockers

None found.

Non-Blocking

None.

Looks Good


Verdict: Approve — clean bash output fix.

Vasanthdev2004
Vasanthdev2004 previously approved these changes May 19, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean bash output fix.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [P1] Use the actual streamed buffer when result.stdout is empty
    src/tools/BashTool/BashTool.tsx:729
    This still collapses to the same empty failure body for the case described in #1231. The new accumulatedOutput is built from stdoutAccumulator, but that accumulator was populated at line 696 from (result.stdout || '') and then line 708 only appends the synthetic Exit code N marker. If result.stdout is 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 empty result.stdout. So the PR does not actually recover stdout: failure / stderr: failure in 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.
@0xghost42

Copy link
Copy Markdown
Contributor Author

Pushed fe0c698 addressing the P1 finding.

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:

  1. The truncating accumulator (after stripping the synthetic Exit code N marker).
  2. result.stdout from the shell runner.
  3. lastProgressFullOutput — the most recent fullOutput value yielded by the streaming generator. Captured in the consumer loop. Recovers stdout for the failure mode you described: shell runner streamed every line through progress callbacks but the final ExecResult.stdout slot was left empty (flush-after-result race, exit before EOF, persisted to a file path, etc.).

Selection logic is extracted into a pure selectFailureOutput() helper in src/tools/BashTool/utils.ts so it can be exercised directly. Added 7 focused cases to utils.test.ts, including the exact reproducer for #1231:

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 utils.test.ts pass locally. Thanks for the careful read — would have shipped a no-op without this.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. The new progress.fullOutput fallback plus the focused selectFailureOutput() coverage address my earlier streamed-output concern.

No issues here, LGTM.

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! thanks you

@kevincodex1
kevincodex1 merged commit 8416faa into Twigpine:main Jun 3, 2026
2 checks passed
hotmanxp added a commit to hotmanxp/openclaude that referenced this pull request Jun 7, 2026
hotmanxp added a commit to hotmanxp/openclaude that referenced this pull request Jun 7, 2026
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)
hotmanxp added a commit to hotmanxp/openclaude that referenced this pull request Jun 11, 2026
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Should show stdout/stderr when Bash tool exits with a non-zero status

4 participants