Skip to content

fix: preserve ProcessError context from failed CLI subprocesses#843

Open
lawrence3699 wants to merge 1 commit intoanthropics:mainfrom
lawrence3699:fix/preserve-processerror-context
Open

fix: preserve ProcessError context from failed CLI subprocesses#843
lawrence3699 wants to merge 1 commit intoanthropics:mainfrom
lawrence3699:fix/preserve-processerror-context

Conversation

@lawrence3699
Copy link
Copy Markdown

Summary

Fixes #800. Related to #798.

When the Claude CLI subprocess exits non-zero, the Python SDK currently loses most of the failure context in two places:

  • Query stringifies the original exception and re-raises a generic Exception
  • SubprocessCLITransport replaces the CLI stderr with the placeholder "Check stderr output for details"

This change preserves the original ProcessError through the query message pipeline and captures the stderr tail so callers can inspect the real CLI failure.

Before

  • query() surfaced a generic Exception instead of the original ProcessError
  • ProcessError.exit_code / ProcessError.stderr were not available to SDK consumers
  • subprocess failures reported a placeholder stderr string instead of the CLI output

After

  • query() re-raises the original ProcessError when the CLI subprocess fails
  • exit_code and stderr remain available on the exception object
  • the transport buffers the stderr tail and includes it in ProcessError.stderr while still forwarding stderr to the configured sink

Tests

  • /tmp/fix-claude-agent-sdk-834/.venv/bin/python -m pytest tests/test_query.py tests/test_subprocess_buffering.py tests/test_transport.py -q
  • /tmp/fix-claude-agent-sdk-834/.venv/bin/python -m ruff check src/ tests/
  • /tmp/fix-claude-agent-sdk-834/.venv/bin/python -m mypy src/

Copilot AI review requested due to automatic review settings April 18, 2026 02:13
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Preserves CLI subprocess failure context by keeping ProcessError intact through the query message pipeline and capturing a bounded tail of CLI stderr so callers can inspect real failure output.

Changes:

  • Buffer and attach captured CLI stderr tail to ProcessError on non-zero subprocess exit.
  • Preserve and re-raise the original exception (e.g., ProcessError) from Query.receive_messages() instead of stringifying and raising a generic Exception.
  • Add tests covering stderr buffering/forwarding and exception preservation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/claude_agent_sdk/_internal/transport/subprocess_cli.py Always pipes stderr, buffers its tail, forwards to sink, and includes captured stderr in ProcessError.
src/claude_agent_sdk/_internal/query.py Sends the original exception object through the internal message stream and re-raises it in receive_messages().
tests/test_subprocess_buffering.py Adds coverage for captured stderr inclusion, forwarding to sink, and waiting for background stderr reader before raising.
tests/test_query.py Adds a regression test ensuring ProcessError is preserved and surfaced to callers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +446 to 451
# Pipe stderr so ProcessError can include the CLI's actual stderr.
# We only stream it live when callers asked for callback/debug output.
should_pipe_stderr = (
self._options.stderr is not None
or "debug-to-stderr" in self._options.extra_args
)
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

The comment says stderr is only streamed live when callers request callback/debug output, but with stderr=PIPE and the subsequent or self._supports_live_stderr_reading() condition, stderr will typically be read in the background even when should_pipe_stderr is false. Consider rewording/removing this comment (or adjusting the condition) so it matches the actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +265 to +267
await self._message_send.send(
{"type": "error", "error": str(e), "exception": e}
)
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

Because the error message is still logged via logger.error(... {e}) (just above), adding the raw exception object here will commonly mean the log line now includes captured CLI stderr (since ProcessError embeds stderr in __str__). This may leak sensitive subprocess output into logs and duplicates stderr already forwarded to the sink. Consider redacting what gets logged (e.g., exception type + exit_code) or switching to logger.error("Fatal error in message reader", exc_info=True) without interpolating the message.

Copilot uses AI. Check for mistakes.
Comment on lines +547 to 552
# Preserve inherited-stderr behavior by forwarding stderr to
# the configured sink (defaults to sys.stderr).
elif self._options.debug_stderr:
self._options.debug_stderr.write(line_str + "\n")
if hasattr(self._options.debug_stderr, "flush"):
self._options.debug_stderr.flush()
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

This changes the meaning of debug_stderr: previously stderr was only forwarded to debug_stderr when "debug-to-stderr" was set in extra_args (and no stderr callback was provided). Now all CLI stderr is forwarded to debug_stderr whenever it’s non-null, which can be a backward-incompatible behavior change for callers who set debug_stderr to capture debug-only output. Consider restoring the old gating (or introducing a separate explicit stderr sink option) and updating the surrounding comment accordingly.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants