[BUG][SDK] 2.1.280: resuming a session with an unfinished background shell emits an empty `num_turns=0` result before the next prompt runs
Summary
Resume a session whose previous process exited while an auto-backgrounded Bash command was still running. In 2.1.280, Claude Code queues a <task-notification status=stopped> for that task ahead of the caller's prompt. It answers the notification without a model call and emits a ResultMessage for it (subtype=success, num_turns=0, result="", a few ms). The real result for the caller's prompt follows about 1.5 s later.
SDK hosts commonly treat the first ResultMessage after query() as the end of that query. Such hosts stop reading, tear down the process, and never get the answer to their prompt. In 2.1.178 the same notification is handled without emitting a result, so the stream carries exactly one result, for the caller's prompt.
This looks like a side effect of the 2.1.280 change "Fixed resuming a session with unfinished background agents, shells or workflows starting a model turn on its own before you typed anything" (related: #90534). The unprompted model turn is gone, but its ResultMessage still reaches SDK consumers. The 2.1.274 note "completions already queued are now answered by one call" suggests the notification and the next prompt were meant to be handled together.
Environment
- Claude Code 2.1.280 (regression), compared with 2.1.178 (not affected)
claude-agent-sdk(Python) 0.2.102, streaming input viaClaudeSDKClient- Reproduced on macOS arm64 locally and seen in production on Linux containers
- The provider doesn't matter: reproduced via Bedrock (Haiku 4.5) and seen in production on Bedrock (Opus)
Reproduction
The script is below. It is deterministic: 2 out of 2 runs per version.
- Session 1: the model runs a Bash command that exceeds the default timeout, so it is auto-backgrounded (
Command did not complete within its 5s timeout and was moved to the background). The model then ends its turn, and the client disconnects while the command is still running. - Session 2: resume that session, send one prompt, and log every message without stopping at the first
ResultMessage.
Note: the command must not start with sleep. Commands whose first word is sleep are never auto-backgrounded; they are killed with exit 143 instead.
import asyncio, os, sys
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage, SystemMessage
CLI = sys.argv[1] # path to the claude binary under test
WORK = os.path.abspath("repro-work"); os.makedirs(WORK, exist_ok=True)
ENV = {"CLAUDE_CONFIG_DIR": os.path.abspath("repro-config"), "BASH_DEFAULT_TIMEOUT_MS": "5000"}
def opts(resume=None):
return ClaudeAgentOptions(cli_path=CLI, cwd=WORK, env=ENV, allowed_tools=["Bash"],
permission_mode="bypassPermissions", resume=resume)
async def main():
async with ClaudeSDKClient(opts()) as c:
await c.query("Run the Bash command `cd /tmp && sleep 900` once (no timeout param, "
"no run_in_background). When it is moved to the background, do not wait "
"for it; reply PAUSED and stop.")
async for m in c.receive_messages():
if isinstance(m, ResultMessage):
sid = m.session_id
break
# The process exits here with the background task still running.
async with ClaudeSDKClient(opts(resume=sid)) as c:
await c.query("Reply with exactly: RESUMED-OK")
it, results = c.receive_messages().__aiter__(), 0
while results < 2:
try:
m = await asyncio.wait_for(it.__anext__(), 60)
except (asyncio.TimeoutError, StopAsyncIteration):
break
if isinstance(m, SystemMessage) and m.subtype == "task_notification":
print("task_notification")
if isinstance(m, ResultMessage):
results += 1
print(f"RESULT num_turns={m.num_turns} duration_ms={m.duration_ms} result={m.result!r}")
asyncio.run(main())
Actual (2.1.280)
task_notification
RESULT num_turns=0 duration_ms=6 result=''
RESULT num_turns=1 duration_ms=1365 result='RESUMED-OK'
The session transcript shows the notification queued and dequeued ahead of the prompt:
queue-operation enqueue '<task-notification>\n<task-id>…'
queue-operation dequeue
user '<task-notification>…'
queue-operation enqueue 'Reply with exactly: RESUMED-OK'
queue-operation dequeue
user 'Reply with exactly: RESUMED-OK'
assistant 'RESUMED-OK'
Expected (and 2.1.178 behaviour)
Exactly one ResultMessage per caller query:
RESULT num_turns=1 duration_ms=1931 result='RESUMED-OK'
In 2.1.178 the transcript still records the notification and a synthetic "No response requested." reply, but no ResultMessage reaches the SDK stream.
Impact
Any SDK host that ends a query at its first ResultMessage silently loses the caller's prompt after such a resume. For example, a host that resumes a paused session to deliver a user's answer gets "success" with no work done. In production this failed a long-running agent job at the point where it resumed after waiting for human input. It needs only a command that outlasted its timeout and was still running when the session paused.
Suggested fix
Don't emit a ResultMessage for a CLI-originated notification that is answered without a model turn. Alternatively, fold the notification into the next queued prompt's turn, as 2.1.274 describes, so each caller query maps to exactly one result.