[BUG] Deferred tool resume: a later PreToolUse "defer" is ignored when the resume also sends a user message (the Agent SDK always does)

Status Open
Reported on v2.1.282
Maintainer reply None cached
Activity 0 comments · opened Sep 25, 2026

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

  1. Save the script below as repro_defer_resume.py (Python 3.9+, standard library only).
  2. Run python3 repro_defer_resume.py (uses claude on PATH; or --cli /path/to/claude). Add --sdk to also run variant E through claude-agent-sdk (0.1.74 or newer).
  3. 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).
  4. Run 1: claude -p "Run the two steps." --output-format json --settings settings.json --tools Bash --allowedTools Bash ends with stop_reason: "tool_deferred" at tool 1 (correct).
  5. Run 2, variant A: claude -p --resume <id> ... runs tool 1 and defers tool 2 (correct).
  6. 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_result user 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

View original on GitHub ↗