Skip to content

Keep stdin open for can_use_tool and allow it with string prompts - #1204

Merged
qing-ant merged 3 commits into
mainfrom
qing/can-use-tool-stdin-hold
Aug 17, 2026
Merged

qing-ant merged 3 commits into
mainfrom
qing/can-use-tool-stdin-hold

Conversation

@qing-ant

@qing-ant qing-ant commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #1105.

The can_use_tool permission callback is served over the control protocol just like hooks and SDK MCP servers: the CLI writes a control_request to stdout and blocks until the SDK writes the verdict back on stdin. But Query.wait_for_result_and_end_input() only held stdin open when hooks or SDK MCP servers were configured, so query() with a finite prompt and only can_use_tool closed stdin as soon as the prompt had been written. Every later permission request then failed CLI-side with Tool permission request failed: Error: Stream closed, the callback never ran, and the model burned turns retrying the tool. The documented workaround (register a no-op PreToolUse hook) worked only because it flipped this same condition.

Changes:

  • Add can_use_tool to the hold condition, factored into Query._has_bidirectional_needs() (mirrors the TypeScript SDK's hasBidirectionalNeeds, which already includes canUseTool).
  • Drop the "can_use_tool callback requires streaming mode" ValueError from query() and ClaudeSDKClient.connect(). String prompts have been streamed over stdin internally for a long time (the transport always runs --input-format stream-json), so with the hold in place they work with can_use_tool too — again matching the TypeScript SDK, which accepts canUseTool with a string prompt. This removes the trap where the only accepted query() shape was exactly the one that was broken.

Overlaps with #1106 (same one-clause core fix); this PR additionally lifts the string-prompt restriction and adds contract-level tests. Happy to rebase onto / defer to #1106 if preferred.

Behavior notes

  • query(prompt=<str>) / query(prompt=<AsyncIterable>) without can_use_tool, hooks or SDK MCP servers: unchanged — stdin still closes as soon as the prompt is written.
  • With can_use_tool: stdin now closes after the run-ending result (same lifecycle hooks/SDK MCP servers already use, including the in-flight background-task handling from SDK closes stdin on first result frame, breaking bidirectional control from nested Task/Agent subagents #1088).
  • Docs follow-up: the "dummy hook keeps the stream open" workaround in the permissions / user-input guide can be removed once this ships.

Test plan

  • New unit tests in tests/test_query.py:
    • TestNoTimeoutForHooksAndMcpServers::test_can_use_tool_waits_for_result — can_use_tool alone holds stdin until the result event.
    • TestCanUseToolKeepsStdinOpen — a mock transport that enforces the real CLI contract (the can_use_tool control request only arrives after the prompt is written; the assistant/result frames only arrive after the verdict is written; any write after end_input() raises). Covers both an AsyncIterable prompt and a string prompt. Both fail on main (RuntimeError: stdin closed / ValueError: ...requires streaming mode) and pass here.
  • New e2e tests in e2e-tests/test_tool_permissions.py drive query() against the real CLI with can_use_tool as the only control-protocol consumer (Bash disallowed, Write to a path outside cwd so nothing can auto-approve it), for both prompt shapes. On main the AsyncIterable variant reproduces Tool permission request failed: AbortError: Stream closed and the callback log stays empty; with this change the callback fires for Write and the file is created.
  • ruff check, ruff format, mypy src/, full pytest tests/ (1369 passed).

Review follow-up (76d4724)

  • Extracted _configure_can_use_tool() / _hooks_to_internal_format() into types.py; query() and ClaudeSDKClient.connect() now share them (no behavior change).
  • wait_for_result_and_end_input() docstring now states exactly what the hold detects and documents the pre-existing multi-message AsyncIterable limitation (one-shot event; tracked as a follow-up).

The can_use_tool permission callback is served over the control protocol
just like hooks and SDK MCP servers: the CLI writes a control_request to
stdout and blocks until the SDK writes the verdict back to stdin. But
wait_for_result_and_end_input() only held stdin open for hooks and SDK MCP
servers, so query() with a finite prompt and only can_use_tool closed
stdin as soon as the prompt was written. Every later permission request
then failed CLI-side with "Tool permission request failed: Stream closed",
the callback never ran, and the model burned turns retrying. The
documented workaround (register a no-op hook) worked only because it
flipped this same condition.

Add can_use_tool to the hold condition (factored into
_has_bidirectional_needs(), mirroring the TypeScript SDK), and drop the
"can_use_tool callback requires streaming mode" ValueError from query()
and ClaudeSDKClient.connect(): string prompts have been streamed over
stdin internally for a long time, so with the hold in place they work with
can_use_tool too, matching the TypeScript SDK.

Tests: unit tests with a mock transport that enforces the real CLI
contract (permission request only after the prompt is written, result
only after the verdict is written, writes after end_input() raise), plus
e2e tests driving query() with an AsyncIterable prompt and a string prompt
where can_use_tool is the only control-protocol consumer.
@qing-ant
qing-ant marked this pull request as ready for review August 14, 2026 21:10

@claude claude Bot left a comment

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.

Beyond the inline nits, I also checked that nothing else still depends on the removed "requires streaming mode" ValueError — no tests or docs in the repo reference it — and that dropping the check in ClaudeSDKClient.connect() is safe for the interactive client, since that path never closes stdin (only query()'s one-shot path does, and it now holds stdin open for can_use_tool).

Extended reasoning...

Two nit-level findings were reported inline (code duplication between client.py and _internal/client.py, and a pre-existing multi-message AsyncIterable limitation of the one-shot _first_result_event). Beyond those, I verified there are no stale references to the removed streaming-mode ValueError anywhere in tests or docs, and that the ClaudeSDKClient path is unaffected by the check removal because it keeps stdin open for the whole session regardless. Not approving: the change alters stdin-close lifecycle semantics on the core query path and relaxes a public-API restriction, and the author has explicitly asked maintainers whether to defer to the overlapping #1106 — a coordination decision for a human.

Comment thread src/claude_agent_sdk/client.py Outdated
Comment thread src/claude_agent_sdk/_internal/query.py
…Client

Review follow-up. The can_use_tool validation/configuration block
(mutual exclusion with permission_prompt_tool_name, the shadowing
advisory, and the permission_prompt_tool_name="stdio" rewrite) and the
HookMatcher -> dict conversion were duplicated verbatim in client.py and
_internal/client.py; the previous commit had to delete the same
"requires streaming mode" check from both copies. Move them to
_configure_can_use_tool() / _hooks_to_internal_format() in types.py next
to _warn_if_can_use_tool_shadowed and call them from both entry points so
the two paths cannot drift. No behavior change.

Also spell out in wait_for_result_and_end_input()'s docstring what the
hold actually detects (the first result frame with no tracked tasks) and
the pre-existing multi-message AsyncIterable limitation that follows
from the one-shot event.
Comment thread tests/test_query.py
Comment thread src/claude_agent_sdk/_internal/query.py
Review follow-up.

stream_input() wrapped the whole loop and the stdin close in one try
with a debug-level `except Exception`, so a user-supplied prompt
AsyncIterable that raised skipped wait_for_result_and_end_input()
entirely: stdin stayed open, the CLI waited for input forever, and the
consumer's `async for` never finished, with the real error visible only
at DEBUG. Pre-existing, but this PR makes stream_input() the supported
path for can_use_tool with an AsyncIterable prompt. Now the close always
runs after the loop (error logged at ERROR), and if nothing was written
stdin is closed immediately since no result can arrive to release the
hold — the same messageCount guard the TypeScript SDK uses. Tests cover
an iterable that raises after one message (turn and permission
round-trip still complete, then stdin closes) and one that raises before
yielding anything (closes right away instead of hanging).

Also make _make_permission_gated_transport enforce the ordering it
documents: bail out instead of emitting the can_use_tool request if the
user message was never written.

@claude claude Bot left a comment

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.

I re-reviewed after the two follow-up commits and found no bugs — all earlier inline comments are now addressed in the code — but since this changes the tool-permission callback path and stdin lifecycle, a human look before merge is still worthwhile.

What was reviewed:

  • The _configure_can_use_tool() / _hooks_to_internal_format() extraction in types.py — checked line-by-line against both deleted copies; behavior-preserving, and the string-prompt ValueError removal is the only intended difference.
  • The reworked stream_input() failure paths — the written == 0 guard closes stdin immediately, and the raise-after-one-message path still completes the in-flight permission round-trip before closing.
  • A candidate regression (an erroring connect(prompt=AsyncIterable) stream now closing a ClaudeSDKClient session's stdin) was investigated and ruled out — the normal-completion path of that shape already ended input before this PR.
Extended reasoning...

Overview

The PR (three commits since the last reviewed base) fixes the stdin lifecycle for the can_use_tool permission callback: Query._has_bidirectional_needs() in src/claude_agent_sdk/_internal/query.py now includes can_use_tool alongside hooks and SDK MCP servers, so stdin stays open until the run-ending result; stream_input() gained exception handling for failing prompt iterables with a written count guard mirroring the TypeScript SDK; the "requires streaming mode" ValueError was dropped so string prompts work with the callback; and the duplicated validation/hook-conversion logic in client.py and _internal/client.py was extracted into _configure_can_use_tool() and _hooks_to_internal_format() in types.py. New unit tests use a permission-gated mock transport that enforces the real CLI ordering contract, plus two e2e regression tests.

Security risks

The change touches the tool-permission path, which is security-relevant: can_use_tool is the SDK-side gate on tool execution. However, the change makes the callback more reliably invoked (previously it silently failed with "Stream closed" and the CLI fell back to its default permission behavior), and _configure_can_use_tool() preserves the mutual-exclusion check, the shadowing warning, and the forced permission_prompt_tool_name="stdio" routing verbatim from both deleted copies. No injection, auth-bypass, or data-exposure surface is introduced; the diff is limited to when stdin closes and where shared validation lives.

Level of scrutiny

Medium-high scrutiny is appropriate: async stdin lifecycle interacting with a subprocess control protocol is subtle, and this is the third revision of the PR. The bug-hunting run exited at max_rounds (bounded, not run-dry), which rules out approval on its own. That said, the two follow-up commits verifiably implement the earlier review feedback — the mock transport now has the if not user_message_written.is_set(): return fail-fast guard, stream_input logs the swallowed prompt-iterable error at error level and always reaches an end-input path, and the docstring now documents the pre-existing one-shot _first_result_event limitation for multi-message AsyncIterable prompts rather than overstating "run-ending result".

Other factors

This run investigated one candidate regression and ruled it out: an exception in a long-lived ClaudeSDKClient.connect(prompt=AsyncIterable) stream now triggers the close-stdin path, but the normal-completion path of that same shape already called wait_for_result_and_end_input() before this PR, so an unbounded-iterable session relying on stdin surviving an iterable crash was already outside the supported contract, and closing on error matches the TypeScript SDK. Test coverage is strong: the unit mock enforces the real ordering contract (permission request only after prompt write, frames only after verdict, writes after end_input() raise), and the e2e tests target a path outside cwd so nothing can auto-approve the Write. All prior review threads map to actual code changes, so nothing raised earlier remains outstanding; the deferral reflects only the bounded hunt and the sensitivity of the permission/stdin surface, not any known open issue.

@qing-ant
qing-ant enabled auto-merge (squash) August 17, 2026 23:28
@qing-ant
qing-ant merged commit fcdae22 into main Aug 17, 2026
14 checks passed
@qing-ant
qing-ant deleted the qing/can-use-tool-stdin-hold branch August 17, 2026 23:30
Flohs pushed a commit to Flohs/claude-agent-sdk-go that referenced this pull request Aug 18, 2026
waitForResultAndEndInput only waited for a run-ending result before
closing stdin when hooks or SDK MCP servers were configured, ignoring
Options.CanUseTool. A CanUseTool-only caller could have stdin closed
immediately after the prompt was written, before the CLI could send a
can_use_tool control_request and get back the SDK's control_response,
starving or breaking the permission callback depending on timing.

Also fix streamInput to end input immediately (skipping the wait) when
inputCh is drained without any message ever being written, since no
result will ever arrive to release that wait.

Port of Python SDK commit fcdae22 (anthropics/claude-agent-sdk-python#1204).

Closes #600
Flohs added a commit to Flohs/claude-agent-sdk-go that referenced this pull request Aug 18, 2026
waitForResultAndEndInput only waited for a run-ending result before
closing stdin when hooks or SDK MCP servers were configured, ignoring
Options.CanUseTool. A CanUseTool-only caller could have stdin closed
immediately after the prompt was written, before the CLI could send a
can_use_tool control_request and get back the SDK's control_response,
starving or breaking the permission callback depending on timing.

Also fix streamInput to end input immediately (skipping the wait) when
inputCh is drained without any message ever being written, since no
result will ever arrive to release that wait.

Port of Python SDK commit fcdae22 (anthropics/claude-agent-sdk-python#1204).

Closes #600

Co-authored-by: Claude <noreply@anthropic.com>
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.

can_use_tool callback never invoked without hooks/MCP servers: stdin closes before permission requests arrive ("Error: Stream closed")

2 participants