[BUG] Deferred tool resume: a later PreToolUse "defer" is ignored when the resume also sends a user message (the Agent SDK always does)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
In headless mode, after resuming a session whose tool call was deferred by a PreToolUse hook (permissionDecision: "defer", as documented in the hooks docs), a later "defer" in the same resumed run is ignored when the resume also carries a new user message. The later tool does not run, the model receives the tool_result [Tool result missing due to internal error] for it, and the run ends with stop_reason: "end_turn", subtype: "success" and no deferred_tool_use. The caller cannot tell that a pause was requested.
Resuming without a new message (claude -p --resume <id>) works: the later tool call is deferred correctly. Any new message breaks it: a prompt argument (claude -p "continue" --resume <id>), or a user message on --input-format stream-json, even an empty one.
This matters most for the Agent SDK: query(prompt="", options=ClaudeAgentOptions(resume=...)) sends an empty user message on resume, so SDK apps that use "defer" for human approvals lose every pause after the first one in a session.
What Should Happen?
The later tool call is deferred: the resumed run ends with stop_reason: "tool_deferred" and deferred_tool_use set to that call, the same as when resuming without a new message. If a new user message is not supported while a deferred call is pending, the CLI should reject it with a clear error instead of silently dropping the later defer.
Error Messages/Logs
Claude Code 2.1.282 (Claude Code)
variant run 2 stop_reason executed model got for tool 2 second defer
A claude -p --resume ID (no new message) tool_deferred first (none, paused) honored
B claude -p "continue" --resume ID end_turn first [Tool result missing due to internal error] IGNORED
C stream-json resume, no message tool_deferred first (none, paused) honored
D stream-json resume, empty user message end_turn first [Tool result missing due to internal error] IGNORED
E Agent SDK query(prompt="", resume=ID) end_turn first [Tool result missing due to internal error] IGNORED
In every variant run 1 ends with stop_reason ['tool_deferred']
(Output of the script below with `--sdk`. `executed` lists the Bash commands that really ran. In variants B, D and E the second tool never runs, the model is told `[Tool result missing due to internal error]`, and the run reports success.)
Steps to Reproduce
- Save the script below as
repro_defer_resume.py(Python 3.9+, standard library only). - Run
python3 repro_defer_resume.py(usesclaudeon PATH; or--cli /path/to/claude). Add--sdkto also run variant E throughclaude-agent-sdk(0.1.74 or newer). - The script starts a local stub of the Messages API (
ANTHROPIC_BASE_URL, no API key) that replays two Bash tool calls (tool 1, then tool 2, then an answer), and a PreToolUse command hook in a settings file that returns"defer", except"allow"for tool 1 in run 2 (the documented resume step). - Run 1:
claude -p "Run the two steps." --output-format json --settings settings.json --tools Bash --allowedTools Bashends withstop_reason: "tool_deferred"at tool 1 (correct). - Run 2, variant A:
claude -p --resume <id> ...runs tool 1 and defers tool 2 (correct). - Run 2, variant B:
claude -p "continue" --resume <id> ...runs tool 1, then ignores the defer for tool 2 (bug).
<details><summary>repro_defer_resume.py</summary>
"""Reproduction: after resuming a deferred tool call, a later PreToolUse "defer" is ignored
when the resume also sends a user message.
Needs only the `claude` CLI and Python 3.9+ (standard library). A tiny local stub of the Messages API
(ANTHROPIC_BASE_URL) replays two tool calls, so the result is deterministic and needs no API key:
the model calls Bash `echo first`, then Bash `echo second`, then answers. CLAUDE* and ANTHROPIC*
variables from your shell (provider, auth, model, nested-session flags) are ignored, so every run
talks only to the local stub and behaves the same on every machine.
Run 1: a PreToolUse command hook defers tool call 1 -> stop_reason "tool_deferred" (correct).
Run 2: resume the session (hooks docs: the deferred call fires PreToolUse again, the hook returns
"allow", the tool executes). Claude then calls tool 2 and the hook returns "defer".
Expected: run 2 stops with stop_reason "tool_deferred" at tool call 2.
python3 repro_defer_resume.py # uses `claude` on PATH
python3 repro_defer_resume.py --cli /path/to/claude
python3 repro_defer_resume.py --sdk # also run it through claude-agent-sdk (pip install claude-agent-sdk)
"""
from __future__ import annotations
import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
SEEN: list = [] # tool_result blocks the stub model received in its final request
def make_stub(calls: list) -> type:
class Stub(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args: object) -> None:
pass
def _json(self, payload: dict) -> None:
data = json.dumps(payload).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self) -> None:
self._json({})
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers.get("content-length", 0))) or b"{}")
if "count_tokens" in self.path:
return self._json({"input_tokens": 10})
if not self.path.startswith("/v1/messages"):
return self._json({})
results = [b for m in body.get("messages", []) if m.get("role") == "user" and isinstance(m.get("content"), list)
for b in m["content"] if b.get("type") == "tool_result"]
if "Bash" not in {t.get("name") for t in body.get("tools") or []}:
block = {"type": "text", "text": "ok"} # side requests
elif len(results) < len(calls):
block = {"type": "tool_use", **calls[len(results)]}
else:
SEEN[:] = results
block = {"type": "text", "text": "Done."}
stop = "tool_use" if block["type"] == "tool_use" else "end_turn"
events = [{"type": "message_start", "message": {"id": "msg_stub", "type": "message", "role": "assistant",
"model": body.get("model", "stub"), "content": [], "stop_reason": None, "stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 1}}}]
if block["type"] == "text":
events += [{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": block["text"]}}]
else:
events += [{"type": "content_block_start", "index": 0, "content_block": {
"type": "tool_use", "id": block["id"], "name": "Bash", "input": {}}},
{"type": "content_block_delta", "index": 0, "delta": {
"type": "input_json_delta", "partial_json": json.dumps(block["input"])}}]
events += [{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": stop, "stop_sequence": None}, "usage": {"output_tokens": 9}},
{"type": "message_stop"}]
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.send_header("connection", "close")
self.end_headers()
for e in events:
self.wfile.write(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n".encode())
self.wfile.flush()
self.close_connection = True
return Stub
class Case:
"""A fresh session, stub model, hook and side-effect log for one variant."""
def __init__(self, cli: str) -> None:
self.cli = cli
self.work = Path(tempfile.mkdtemp(prefix="defer-resume-"))
self.side = self.work / "executed.log" # each real Bash execution appends a line here
self.side.touch()
calls = [{"id": "toolu_1", "input": {"command": f"echo first >> {self.side}", "description": "first"}},
{"id": "toolu_2", "input": {"command": f"echo second >> {self.side}", "description": "second"}}]
server = ThreadingHTTPServer(("127.0.0.1", 0), make_stub(calls))
threading.Thread(target=server.serve_forever, daemon=True).start()
hook = self.work / "hook.py"
hook.write_text(
"import json, os, sys\n"
"tid = json.load(sys.stdin).get('tool_use_id')\n"
"d = 'allow' if (os.environ.get('RUN') == '2' and tid == 'toolu_1') else 'defer'\n"
"print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': d}}))\n")
self.settings = self.work / "settings.json"
self.settings.write_text(json.dumps({"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
{"type": "command", "command": f"{shlex.quote(sys.executable)} {shlex.quote(str(hook))}"}]}]}}))
self.env = {**os.environ, "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{server.server_address[1]}",
"ANTHROPIC_API_KEY": "sk-ant-stub", "CLAUDE_CONFIG_DIR": str(self.work / "config"),
"DISABLE_TELEMETRY": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"}
def claude(self, args: list, stdin: str, run: int, stream: bool) -> dict:
fmt = ["--output-format", "stream-json", "--verbose", "--input-format", "stream-json"] if stream \
else ["--output-format", "json"]
# --tools/--allowedTools take lists, so they go last
cmd = [self.cli, "-p"] + args + fmt + ["--settings", str(self.settings), "--tools", "Bash", "--allowedTools", "Bash"]
out = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=180, cwd=self.work,
env={**self.env, "RUN": str(run)})
results = [json.loads(line) for line in out.stdout.splitlines()
if line.strip().startswith("{") and json.loads(line).get("type") == "result"]
if not results:
raise SystemExit(f"claude returned no result (exit {out.returncode}):\n{out.stderr[-1500:]}")
return results[-1]
def user_message(text: str) -> str:
return json.dumps({"type": "user", "message": {"role": "user", "content": text},
"parent_tool_use_id": None, "session_id": ""}) + "\n"
def report(name: str, case: Case, r1: dict, r2: dict) -> dict:
d2 = r2.get("deferred_tool_use") or {}
saw = [b for b in SEEN if b.get("tool_use_id") == "toolu_2"]
row = {"variant": name, "run1_stop_reason": r1.get("stop_reason"), "run2_stop_reason": r2.get("stop_reason"),
"run2_subtype": r2.get("subtype"), "run2_deferred_tool_use": d2.get("id"),
"executed": case.side.read_text().split(),
"model_got_for_tool_2": (saw[0].get("content") if saw else None),
"second_defer_honored": d2.get("id") == "toolu_2"}
SEEN.clear()
return row
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--cli", default=shutil.which("claude") or "claude")
parser.add_argument("--sdk", action="store_true", help="also run the resume through claude-agent-sdk")
args = parser.parse_args()
for key in [k for k in os.environ if k.startswith(("CLAUDE", "ANTHROPIC"))]:
del os.environ[key] # never reach a real model or inherit a parent Claude Code session's flags
if args.sdk: # check first, so a missing or old SDK fails at once with a clear message
try:
from claude_agent_sdk import ResultMessage
except ImportError:
raise SystemExit("--sdk needs claude-agent-sdk: pip install claude-agent-sdk")
if "deferred_tool_use" not in getattr(ResultMessage, "__dataclass_fields__", {}):
raise SystemExit("--sdk needs claude-agent-sdk 0.1.74 or newer (ResultMessage.deferred_tool_use): "
"pip install -U claude-agent-sdk")
# make the path absolute: claude is started from a temporary folder, so "./..." would not resolve
args.cli = os.path.abspath(shutil.which(args.cli) or os.path.expanduser(args.cli))
version = subprocess.run([args.cli, "--version"], capture_output=True, text=True).stdout.strip()
print(f"Claude Code {version}\n")
rows = []
first = "Run the two steps."
c = Case(args.cli)
r1 = c.claude([first], "", 1, stream=False)
rows.append(report("A claude -p --resume ID (no new message)", c, r1, c.claude(["--resume", r1["session_id"]], "", 2, stream=False)))
c = Case(args.cli)
r1 = c.claude([first], "", 1, stream=False)
rows.append(report("B claude -p \"continue\" --resume ID", c, r1, c.claude(["continue", "--resume", r1["session_id"]], "", 2, stream=False)))
c = Case(args.cli)
r1 = c.claude([], user_message(first), 1, stream=True)
rows.append(report("C stream-json resume, no message", c, r1, c.claude(["--resume", r1["session_id"]], "", 2, stream=True)))
c = Case(args.cli)
r1 = c.claude([], user_message(first), 1, stream=True)
rows.append(report("D stream-json resume, empty user message", c, r1, c.claude(["--resume", r1["session_id"]], user_message(""), 2, stream=True)))
if args.sdk:
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
c = Case(args.cli)
async def sdk_run(prompt: str, run: int, **more: object) -> dict:
opts = ClaudeAgentOptions(cli_path=args.cli, cwd=str(c.work), setting_sources=[], settings=str(c.settings),
tools=["Bash"], allowed_tools=["Bash"], env={**c.env, "RUN": str(run)}, **more)
res = None
async for m in query(prompt=prompt, options=opts):
if isinstance(m, ResultMessage):
res = m
d = res.deferred_tool_use
return {"stop_reason": res.stop_reason, "subtype": res.subtype, "session_id": res.session_id,
"deferred_tool_use": {"id": d.id} if d else None}
r1 = asyncio.run(sdk_run(first, 1))
rows.append(report("E Agent SDK query(prompt=\"\", resume=ID)", c, r1, asyncio.run(sdk_run("", 2, resume=r1["session_id"]))))
print(f"{'variant':<44} {'run 2 stop_reason':<18} {'executed':<14} {'model got for tool 2':<44} second defer")
for r in rows:
print(f"{r['variant']:<44} {str(r['run2_stop_reason']):<18} {','.join(r['executed']) or '-':<14} "
f"{str(r['model_got_for_tool_2'] or '(none, paused)')[:43]:<44} {'honored' if r['second_defer_honored'] else 'IGNORED'}")
print("\nIn every variant run 1 ends with stop_reason", sorted({r['run1_stop_reason'] for r in rows}))
print("\nFull results:\n" + json.dumps(rows, indent=2))
if __name__ == "__main__":
main()
Claude Model
Other
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.282 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Non-interactive/CI environment
Additional Information
- Model-independent: the stub only replays tool calls; the result depends only on how the resume is sent.
- Same table on macOS (Apple Silicon, Python 3.12) and on Ubuntu 24.04 with Claude Code 2.1.282. Also reproduced on 2.1.277 and 2.1.281 (the version bundled in claude-agent-sdk 0.2.159) on Ubuntu 24.04. In every variant, run 1 pauses correctly.
- The hooks docs describe resuming with
claude -p --resume <session-id>without a new prompt, and that works. But with the usual SDK resume,query(prompt="", options=ClaudeAgentOptions(resume=...))on claude-agent-sdk 0.2.159, the later defer was lost in 12 of 12 runs: engines 2.1.277, 2.1.281 and 2.1.282, with SDK hook callbacks and with settings-file command hooks, with SDK MCP tools and with Bash. - Resuming from the SDK with a prompt stream that sends no message avoids it only for Bash with a settings-file hook. With SDK MCP tools the resumed run fails with
CLIConnectionError: ProcessTransport is not ready for writing, and with an SDK hook callback the later defer is still lost. So SDK users have no working way to chain two pauses in one session on the documented path. - Workaround we use: instead of letting the resume re-run the deferred call, send that call's result as a
tool_resultuser message on resume, so the call is not auto-resumed. Later defers are then honored (settings-file command hooks, 2.1.277 and 2.1.282). - Related, but different cases: #64389 (defer on a first call in interactive mode), anthropics/claude-agent-sdk-python#993 (SDK callback not re-run on deferred resume), anthropics/claude-agent-sdk-typescript#474 (defer with parallel tool calls).
- Found while building temporal-claude-agent, durable Claude Agent SDK agents on Temporal: https://github.com/osamastro7-droid/temporal-claude-agent