From a1273fc5a63fb5197a1413e4ba344ec9759baee4 Mon Sep 17 00:00:00 2001 From: Marko Nieminen Date: Thu, 29 Jan 2026 06:41:45 +0200 Subject: [PATCH 1/4] fix: capture actual stderr in ProcessError instead of hardcoded message When the CLI subprocess exits with non-zero code, ProcessError was raised with stderr="Check stderr output for details" instead of the actual stderr content. This makes debugging impossible since real error messages (e.g. "No conversation found with session ID") are never surfaced. Changes: - Add _stderr_lines buffer to collect all stderr output - Drain stderr task group before checking exit code - Pass collected stderr content to ProcessError Fixes #437 --- .../_internal/transport/subprocess_cli.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 1f0aac58..c689413c 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -59,6 +59,7 @@ def __init__( if options.max_buffer_size is not None else _DEFAULT_MAX_BUFFER_SIZE ) + self._stderr_lines: list[str] = [] self._write_lock: anyio.Lock = anyio.Lock() def _find_cli(self) -> str: @@ -420,6 +421,9 @@ async def _handle_stderr(self) -> None: if not line_str: continue + # Always collect stderr lines for error reporting + self._stderr_lines.append(line_str) + # Call the stderr callback if provided if self._options.stderr: self._options.stderr(line_str) @@ -575,12 +579,21 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: except Exception: returncode = -1 + # Wait for stderr reader to finish draining + if self._stderr_task_group: + with suppress(Exception): + with anyio.move_on_after(5): + self._stderr_task_group.cancel_scope.cancel() + await self._stderr_task_group.__aexit__(None, None, None) + self._stderr_task_group = None + # Use exit code for error detection if returncode is not None and returncode != 0: + stderr_output = "\n".join(self._stderr_lines) if self._stderr_lines else None self._exit_error = ProcessError( f"Command failed with exit code {returncode}", exit_code=returncode, - stderr="Check stderr output for details", + stderr=stderr_output, ) raise self._exit_error From f3e290de0a06083f031c8ffa1cdf38fc4d78e569 Mon Sep 17 00:00:00 2001 From: Marko Nieminen Date: Thu, 29 Jan 2026 06:50:58 +0200 Subject: [PATCH 2/4] fix: always pipe stderr to ensure capture on process failure stderr was only piped when a callback or debug mode was set. Without either, stderr went to the terminal and _stderr_lines stayed empty, making the ProcessError fix ineffective. --- .../_internal/transport/subprocess_cli.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index c689413c..24d53679 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -358,11 +358,10 @@ async def connect(self) -> None: if self._cwd: process_env["PWD"] = self._cwd - # Pipe stderr if we have a callback OR debug mode is enabled - should_pipe_stderr = ( - self._options.stderr is not None - or "debug-to-stderr" in self._options.extra_args - ) + # Always pipe stderr so we can capture it for error reporting. + # The callback and debug mode flags control whether lines are + # forwarded in real-time, but we always collect them. + should_pipe_stderr = True # For backward compat: use debug_stderr file object if no callback and debug is on stderr_dest = PIPE if should_pipe_stderr else None From 3fa38b98f17ac17418e8fabc331095615aa1b83f Mon Sep 17 00:00:00 2001 From: Marko Nieminen Date: Thu, 29 Jan 2026 07:02:58 +0200 Subject: [PATCH 3/4] feat: add errors field to ResultMessage The CLI sends an 'errors' list in result messages (e.g. when a session cannot be found), but ResultMessage was missing this field so the error details were silently dropped during parsing. --- src/claude_agent_sdk/_internal/message_parser.py | 1 + src/claude_agent_sdk/types.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index f91081c3..e1defa66 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -157,6 +157,7 @@ def parse_message(data: dict[str, Any]) -> Message: usage=data.get("usage"), result=data.get("result"), structured_output=data.get("structured_output"), + errors=data.get("errors"), ) except KeyError as e: raise MessageParseError( diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 3ea89d5a..8c6ccd5b 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -681,6 +681,7 @@ class ResultMessage: usage: dict[str, Any] | None = None result: str | None = None structured_output: Any = None + errors: list[str] | None = None @dataclass From 28571631fe3c0b3e843be5b126e82f3229d814cc Mon Sep 17 00:00:00 2001 From: Marko Nieminen Date: Fri, 20 Feb 2026 07:04:28 +0200 Subject: [PATCH 4/4] fix: avoid task group cleanup race in _read_messages_impl The stderr task group cleanup after stdout exhaustion could race with anyio's cancel scope propagation, causing CancelledError to escape into the query's task group. Instead of manually tearing down the stderr task group in _read_messages_impl, let close() handle it and just give a brief yield for stderr to drain on error paths. --- .../_internal/transport/subprocess_cli.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py index 24d53679..b89c18c0 100644 --- a/src/claude_agent_sdk/_internal/transport/subprocess_cli.py +++ b/src/claude_agent_sdk/_internal/transport/subprocess_cli.py @@ -572,22 +572,18 @@ async def _read_messages_impl(self) -> AsyncIterator[dict[str, Any]]: # Client disconnected pass - # Check process completion and handle errors - try: - returncode = await self._process.wait() - except Exception: - returncode = -1 - - # Wait for stderr reader to finish draining - if self._stderr_task_group: - with suppress(Exception): - with anyio.move_on_after(5): - self._stderr_task_group.cancel_scope.cancel() - await self._stderr_task_group.__aexit__(None, None, None) - self._stderr_task_group = None + # Check process exit code (non-blocking if already exited) + returncode = self._process.returncode + if returncode is None: + try: + returncode = await self._process.wait() + except Exception: + returncode = -1 # Use exit code for error detection if returncode is not None and returncode != 0: + # Give stderr reader a moment to drain remaining output + await anyio.sleep(0.1) stderr_output = "\n".join(self._stderr_lines) if self._stderr_lines else None self._exit_error = ProcessError( f"Command failed with exit code {returncode}",