Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/claude_agent_sdk/_internal/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ListToolsRequest,
)

from .._errors import ProcessError
from ..types import (
PermissionMode,
PermissionResultAllow,
Expand Down Expand Up @@ -116,6 +117,10 @@ def __init__(

# Track first result for proper stream closure with SDK MCP servers
self._first_result_event = anyio.Event()
# Preserve CLI execution error text (from result subtype=error_during_execution)
# so initialize/control callers receive actionable errors instead of generic
# process-exit placeholders.
self._last_execution_error: str | None = None

async def initialize(self) -> dict[str, Any] | None:
"""Initialize control protocol if in streaming mode.
Expand Down Expand Up @@ -231,6 +236,13 @@ async def _read_messages(self) -> None:
# Track results for proper stream closure
if msg_type == "result":
self._first_result_event.set()
if (
message.get("subtype") == "error_during_execution"
and message.get("is_error") is True
):
result_text = message.get("result")
if isinstance(result_text, str) and result_text.strip():
self._last_execution_error = result_text.strip()

# Regular SDK messages go to the stream
await self._message_send.send(message)
Expand All @@ -241,13 +253,23 @@ async def _read_messages(self) -> None:
raise # Re-raise to properly handle cancellation
except Exception as e:
logger.error(f"Fatal error in message reader: {e}")

# If the CLI emitted an explicit execution error result before exiting,
# prefer that actionable message for control waiters (e.g. initialize)
# over generic process-exit placeholders.
pending_error: Exception = e
if isinstance(e, ProcessError) and self._last_execution_error:
pending_error = Exception(self._last_execution_error)
Comment on lines +261 to +262
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 ProcessError is downgraded to a bare Exception on line 262: pending_error = Exception(self._last_execution_error). This means callers using the documented except ProcessError as e: pattern (README line 263) will not catch this error, and the structured exit_code/stderr attributes are lost. Should be ProcessError(self._last_execution_error, exit_code=e.exit_code, stderr=e.stderr).

Extended reasoning...

What the bug is

On line 262 of query.py, when a ProcessError is caught and _last_execution_error is set, the code creates a new bare Exception to replace it:

if isinstance(e, ProcessError) and self._last_execution_error:
    pending_error = Exception(self._last_execution_error)

This discards the ProcessError type and its structured attributes (exit_code, stderr), replacing them with a plain Exception carrying only the error message string.

How it propagates

The pending_error is stored in pending_control_results[request_id] (line 267). When _send_control_request() retrieves this result, it checks isinstance(result, Exception) and raises it (lines 434-435). This propagates through initialize() and ultimately to the caller of query(). The caller receives a bare Exception instead of the expected ProcessError.

Why existing code doesn't prevent it

The test added by this PR (test_initialize_uses_error_during_execution_result_text) catches with except Exception as e:, which catches all exception types indiscriminately. If the test used except ProcessError as e: — the pattern documented in the README — it would fail, exposing the type downgrade.

Step-by-step proof

  1. CLI emits a result message with subtype="error_during_execution" containing "No conversation found with session ID ab2c985b"
  2. _read_messages() stores this in self._last_execution_error
  3. CLI then exits non-zero, causing transport.read_messages() to raise ProcessError("Command failed with exit code 1", exit_code=1, stderr="Check stderr output for details")
  4. The except Exception as e: block catches this ProcessError
  5. Since isinstance(e, ProcessError) is True and _last_execution_error is set, line 262 creates pending_error = Exception("No conversation found with session ID ab2c985b") — a bare Exception
  6. This bare Exception is stored in pending_control_results and later raised by _send_control_request()
  7. A caller doing except ProcessError as e: (as documented in README line 263) will NOT catch this — it falls through as an unhandled Exception
  8. Even if caught with a broader handler, e.exit_code and e.stderr are unavailable since plain Exception has no such attributes

Impact

This breaks the public API contract for error handling. ProcessError is part of the SDK's public API (exported in __init__.py, documented in the README). Any SDK consumer following the recommended error handling pattern will experience unhandled exceptions in the specific scenario this PR aims to improve.

Fix

Replace line 262 with:

pending_error = ProcessError(self._last_execution_error, exit_code=e.exit_code, stderr=e.stderr)

This preserves the actionable error message (the PR's goal) while maintaining the correct exception type and structured attributes. The test should also be updated to catch ProcessError specifically to prevent future regressions.


# Signal all pending control requests so they fail fast instead of timing out
for request_id, event in list(self.pending_control_responses.items()):
if request_id not in self.pending_control_results:
self.pending_control_results[request_id] = e
self.pending_control_results[request_id] = pending_error
event.set()
# Put error in stream so iterators can handle it
await self._message_send.send({"type": "error", "error": str(e)})
await self._message_send.send(
{"type": "error", "error": str(pending_error)}
)
finally:
# Unblock any waiters (e.g. string-prompt path waiting for first
# result) so they don't stall for the full timeout on early exit.
Expand Down
62 changes: 62 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ProcessError,
ResultMessage,
create_sdk_mcp_server,
query,
Expand Down Expand Up @@ -676,3 +677,64 @@ async def _test():
assert "fast_1" not in q._inflight_requests

asyncio.run(_test())


class TestInitializeErrorPropagation:
"""Test initialize error propagation for process exit cases."""

def test_initialize_uses_error_during_execution_result_text(self):
"""When CLI exits after error_during_execution, propagate real error text."""

async def _test():
control_request_received = anyio.Event()

class FailingInitializeTransport:
async def connect(self):
return None

async def close(self):
return None

async def end_input(self):
return None

def is_ready(self) -> bool:
return True

async def write(self, data: str):
payload = json.loads(data)
if payload.get("type") == "control_request":
control_request_received.set()

async def read_messages(self):
await control_request_received.wait()
yield {
"type": "result",
"subtype": "error_during_execution",
"duration_ms": 1,
"duration_api_ms": 0,
"is_error": True,
"num_turns": 0,
"session_id": "session_123",
"result": "No conversation found with session ID ab2c985b",
}
raise ProcessError(
"Command failed with exit code 1",
exit_code=1,
stderr="Check stderr output for details",
)

transport = FailingInitializeTransport()

caught: Exception | None = None
try:
async for _msg in query(prompt="Hello", transport=transport):
pass
except Exception as e:
caught = e

assert caught is not None
assert "No conversation found with session ID ab2c985b" in str(caught)
assert "Check stderr output for details" not in str(caught)

anyio.run(_test)