Skip to content

Commit 16e3002

Browse files
committed
Preserve CLI error_during_execution text for initialize failures
1 parent 7e9b502 commit 16e3002

File tree

2 files changed

+86
-2
lines changed

2 files changed

+86
-2
lines changed

src/claude_agent_sdk/_internal/query.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
ListToolsRequest,
1515
)
1616

17+
from .._errors import ProcessError
1718
from ..types import (
1819
PermissionResultAllow,
1920
PermissionResultDeny,
@@ -112,6 +113,10 @@ def __init__(
112113

113114
# Track first result for proper stream closure with SDK MCP servers
114115
self._first_result_event = anyio.Event()
116+
# Preserve CLI execution error text (from result subtype=error_during_execution)
117+
# so initialize/control callers receive actionable errors instead of generic
118+
# process-exit placeholders.
119+
self._last_execution_error: str | None = None
115120
self._stream_close_timeout = (
116121
float(os.environ.get("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "60000")) / 1000.0
117122
) # Convert ms to seconds
@@ -209,6 +214,13 @@ async def _read_messages(self) -> None:
209214
# Track results for proper stream closure
210215
if msg_type == "result":
211216
self._first_result_event.set()
217+
if (
218+
message.get("subtype") == "error_during_execution"
219+
and message.get("is_error") is True
220+
):
221+
result_text = message.get("result")
222+
if isinstance(result_text, str) and result_text.strip():
223+
self._last_execution_error = result_text.strip()
212224

213225
# Regular SDK messages go to the stream
214226
await self._message_send.send(message)
@@ -219,13 +231,23 @@ async def _read_messages(self) -> None:
219231
raise # Re-raise to properly handle cancellation
220232
except Exception as e:
221233
logger.error(f"Fatal error in message reader: {e}")
234+
235+
# If the CLI emitted an explicit execution error result before exiting,
236+
# prefer that actionable message for control waiters (e.g. initialize)
237+
# over generic process-exit placeholders.
238+
pending_error: Exception = e
239+
if isinstance(e, ProcessError) and self._last_execution_error:
240+
pending_error = Exception(self._last_execution_error)
241+
222242
# Signal all pending control requests so they fail fast instead of timing out
223243
for request_id, event in list(self.pending_control_responses.items()):
224244
if request_id not in self.pending_control_results:
225-
self.pending_control_results[request_id] = e
245+
self.pending_control_results[request_id] = pending_error
226246
event.set()
227247
# Put error in stream so iterators can handle it
228-
await self._message_send.send({"type": "error", "error": str(e)})
248+
await self._message_send.send(
249+
{"type": "error", "error": str(pending_error)}
250+
)
229251
finally:
230252
# Unblock any waiters (e.g. string-prompt path waiting for first
231253
# result) so they don't stall for the full timeout on early exit.

tests/test_query.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from claude_agent_sdk import (
1717
AssistantMessage,
1818
ClaudeAgentOptions,
19+
ProcessError,
1920
ResultMessage,
2021
create_sdk_mcp_server,
2122
query,
@@ -436,3 +437,64 @@ async def prompt_stream():
436437
assert len(control_responses) == 2
437438

438439
anyio.run(_test)
440+
441+
442+
class TestInitializeErrorPropagation:
443+
"""Test initialize error propagation for process exit cases."""
444+
445+
def test_initialize_uses_error_during_execution_result_text(self):
446+
"""When CLI exits after error_during_execution, propagate real error text."""
447+
448+
async def _test():
449+
control_request_received = anyio.Event()
450+
451+
class FailingInitializeTransport:
452+
async def connect(self):
453+
return None
454+
455+
async def close(self):
456+
return None
457+
458+
async def end_input(self):
459+
return None
460+
461+
def is_ready(self) -> bool:
462+
return True
463+
464+
async def write(self, data: str):
465+
payload = json.loads(data)
466+
if payload.get("type") == "control_request":
467+
control_request_received.set()
468+
469+
async def read_messages(self):
470+
await control_request_received.wait()
471+
yield {
472+
"type": "result",
473+
"subtype": "error_during_execution",
474+
"duration_ms": 1,
475+
"duration_api_ms": 0,
476+
"is_error": True,
477+
"num_turns": 0,
478+
"session_id": "session_123",
479+
"result": "No conversation found with session ID ab2c985b",
480+
}
481+
raise ProcessError(
482+
"Command failed with exit code 1",
483+
exit_code=1,
484+
stderr="Check stderr output for details",
485+
)
486+
487+
transport = FailingInitializeTransport()
488+
489+
caught: Exception | None = None
490+
try:
491+
async for _msg in query(prompt="Hello", transport=transport):
492+
pass
493+
except Exception as e:
494+
caught = e
495+
496+
assert caught is not None
497+
assert "No conversation found with session ID ab2c985b" in str(caught)
498+
assert "Check stderr output for details" not in str(caught)
499+
500+
anyio.run(_test)

0 commit comments

Comments
 (0)