Skip to content

Fix deferred tool resume executing without re-running PreToolUse callback#1008

Open
Oxygen56 wants to merge 2 commits into
anthropics:mainfrom
Oxygen56:fix/deferred-tool-hook-resume-993
Open

Fix deferred tool resume executing without re-running PreToolUse callback#1008
Oxygen56 wants to merge 2 commits into
anthropics:mainfrom
Oxygen56:fix/deferred-tool-hook-resume-993

Conversation

@Oxygen56
Copy link
Copy Markdown

Summary

When using Python SDK in-process PreToolUse hook callbacks, a deferred tool on resume executes without re-running the PreToolUse callback. The CLI replays deferred tools during startup BEFORE the initialize request sends hook callback IDs, so the pending tool executes unconditionally.

Root Cause

The startup sequence in both query() (via InternalClient._process_query_inner) and ClaudeSDKClient.connect() (via _connect_inner) called transport.connect() — which starts the CLI subprocess with --resume — before extracting Query parameters, creating the Query object, and calling initialize().

On resume, the CLI replays deferred tools during its synchronous startup, before it reads the initialize control request from stdin. Since Python callback hooks are only sent via this initialize request (unlike settings-based command hooks which are loaded from --settings at CLI startup), they arrive too late.

Fix

Two-part fix:

  1. Restructure startup ordering: Pre-compute all Query parameters (SDK MCP servers, agents, hooks, exclude_dynamic_sections, timeout) and create the Query object before calling connect(). After connect() starts the subprocess, immediately call start() and initialize() with no intervening work. This eliminates the window between process start and hook registration.

  2. Early data in connect(): Added an early_data parameter to SubprocessCLITransport.connect() that writes data to stdin immediately after the process starts. This provides a mechanism for putting the initialize message in the pipe buffer before the CLI enters its main read loop, further reducing the race window.

Test Results

All 761 existing tests pass.

Related Issue

Fixes #993

Autumn and others added 2 commits June 1, 2026 02:11
…back (anthropics#993)

Bug: When using Python SDK in-process PreToolUse hook callbacks, a
deferred tool on resume executes WITHOUT re-running the PreToolUse
callback. The CLI replays deferred tools during startup BEFORE the
initialize request sends hook callback IDs, so the pending tool
executes unconditionally.

Root cause: The startup sequence in both query() and ClaudeSDKClient
called transport.connect() (which starts the CLI subprocess with
--resume) before extracting Query parameters, creating the Query
object, and calling initialize(). On resume, the CLI replays
deferred tools during its synchronous startup before it reads the
initialize control request from stdin. Since Python callback hooks
are only sent via this initialize request (unlike settings-based
command hooks which are loaded from --settings at startup), they
arrive too late.

Fix (two-part):
1. Restructure _process_query_inner and _connect_inner to pre-
   compute all Query parameters and create the Query object BEFORE
   connect(). This eliminates all intervening work between the
   subprocess starting and hooks being registered, so initialize()
   is called at the earliest possible moment.
2. Add an early_data parameter to SubprocessCLITransport.connect()
   that writes data to stdin immediately after the process starts.
   This provides a mechanism for further reducing the race window
   by putting the initialize message in the pipe buffer before the
   CLI enters its main read loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…scope error

Query.close() previously used anyio.TaskGroup which raised
RuntimeError("Attempted to exit cancel scope in a different task")
when close() was called from a different task during async-generator
teardown (GeneratorExit). This was fixed by PRs anthropics#746 and anthropics#870, which
replaced the task group with backend-agnostic spawn_detached()
from _task_compat.py.

This PR adds targeted regression tests that reproduce the exact
pattern from the issue:
- An async generator wrapping the message stream calls close() in
  its `finally` block, matching the pattern in _process_query_inner
- The consumer breaks the loop, triggering GeneratorExit which
  runs the finally block on a (potentially) different task
- Verifies that no RuntimeError is raised on either backend

Closes anthropics#983.

Co-Authored-By: Claude Opus 4.8 <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.

Deferred tool resume executes pending tool without re-running Python SDK callback PreToolUse hook

1 participant