fix: async task routing, Monitor tasks, structured task ids, and abandoned tool calls (#1249)

It affects AIR clients with the `asyncTasks` capability.

## Problem

- A subagent can start a background Bash or Monitor and then end its
turn. The task ends after the subagent finished. The route of the task
dropped every update of a finished child generation, so the client never
got the terminal `async_task_state_update`. The task stayed running in
the client forever. One AIR log held 440 such dropped updates.
- The `Monitor` tool streams the stdout of a background command to the
model only. The SDK reports its task as `local_bash`, so the client
showed it as a background shell. In one real session, 18 of 18 Monitor
tasks had no output file, no progress and no result. The Stop button
only broke the agent's wait.
- The adapter read a task id from the text of a Bash tool result. A
foreground `sed`, `strings` or `python3` that printed adapter source,
test fixtures or CLI strings became a background task, for example with
the id `";\n const start = text` or `${e}`. The client got a spawn and a
`backgrounded` marker on a foreground call, and the task never ended.
- Claude Code 2.1.287 reports a background command in four texts, and
only one gave an output path. A subagent tool result almost never has
`tool_use_result` (0 of 48 in one session). So a subagent command that
hit its timeout got no output path (9 of 10).

## Fix

- `NativeSubagentRuntime.routeOfToolCall` still sends
`async_task_spawned`, `async_task_progress` and
`async_task_state_update` to the child generation that owned the tool
call after that child finished. It never sends them to a later
generation. Every other late update is dropped as before. The spawn is
in the list because a held task can get its tool call id after the
subagent finished.
- `AsyncTaskRuntime` takes a `toolNameOf` lookup. A task of a `Monitor`
tool call is ignored, like a subagent task: no spawn, no progress, no
state, and no stop. A held task that gets its tool call id later goes
through the same check. A `local_monitor` task is ignored too. The
`Monitor` tool call stays in the transcript.
- A task comes only from structured data:
`tool_use_result.backgroundTaskId` or the SDK task events. The text id
parser is gone.
- The output path of a known task can come from the result of its own
tool call. The path ends with `tasks/<id>.output` and starts after the
nearest `": "` on the same line, so a path with spaces works, for
example under `C:\Users\John Doe\AppData\Local\Temp`. It must be
absolute (POSIX, a Windows drive or UNC), and it never holds the end of
another path. The parse is linear in the text length. All four texts
give the path in both event orders: the tool result before or after the
SDK names the task. The memory is bounded. A structured path still wins,
and the terminal `output_file` reaches the client also after a level
close.
- The `backgrounded` marker comes only from structured data: the
structured task, a known background task of the tool call, or a
`run_in_background` input. The "both event orders" above holds for the
path, not for this marker. The marker rides the tool result update, so a
command that the SDK names only after its tool result gets the marker
only from a `run_in_background` input. A command that the user moved to
the background by hand gets no marker in that order.
- The replay behaves the same in each case.

## Tests

- Native subagent and agent tests: a task update and a late spawn after
the child finished go to `worker-1`; a held task of a subagent gets its
spawn and state in the child session after the subagent finished; a late
message chunk is still dropped; a subagent's background Bash ends after
the subagent and the client gets the terminal state in the child
session.
- Monitor: runtime, live and replay tests, also a task that names its
tool call after the tool result. A Monitor task cannot be stopped.
- Structured ids: foreground output with each marker text creates no
task; each of the four texts gives the path in both orders, also a
Windows and a POSIX path with spaces and a UNC path; no path of another
task, another tool call, `.outputs`, a relative path, a path without a
`": "` boundary, or a path across a line; two paths on one line are not
glued; a 2 MB text without `": "` parses in milliseconds; a structured
path wins. An agent test covers a subagent without `tool_use_result`,
and a replay test takes the path from the tool result.
- The new tests fail without the change. Build, lint, format and all
tests pass.




## Abandoned streamed tool calls

This part affects every client, Zed included.

**Problem.** The adapter opens a tool call at the `content_block_start`
of a streamed `tool_use`. Claude can abandon that block before it is
complete, for example for a steering message. The tool then never runs.
At end_turn the settle check reported the session failure "Claude ended
the turn without returning results for tool calls"
(`incomplete_tool_call`). A real AIR session showed a red service error
after 27 minutes of normal work, for a call that never had input.

**Fix.**
- The session records the tool calls that Claude Code sent to the tool
runner: the `tool_use` blocks of a complete assistant message, and the
tool calls of a permission request. The record is bounded and is dropped
when the call ends.
- At end_turn, an open foreground call without that record is abandoned.
The adapter closes it as failed with "Claude stopped this tool call
before it ran." and reports no session failure. The turn ends as
`end_turn`.
- An open call with that record keeps the old behaviour: the old failure
text and the `incomplete_tool_call` session failure.
- The cleanup and the cancellation checks are the same for both groups.
- The replay does not change, because the history holds no partial
`tool_use`. No scenario golden file changes, for AIR, plain or Zed.

**Tests.** A partial `tool_use` and then text ends as `end_turn` with
the neutral close. A mixed turn still fails for the unfinished call. A
steering message during a partial `tool_use` gives no failure. The
existing incomplete-tool tests now send the complete `tool_use` and keep
their failure. The new tests fail without the change.
This commit is contained in:
nikita-ashihmin
2026-10-05 18:59:32 +04:00
committed by GitHub
parent 7b5c61a4ed
commit 0e83a06c4a
12 changed files with 1235 additions and 141 deletions

View File

@@ -860,10 +860,17 @@ The live test only initializes the SDK. It sends no model prompt.
## Async tasks
Claude can run work in the background, for example a backgrounded Bash command, a workflow, or a monitor.
Claude can run work in the background, for example a backgrounded Bash command or a workflow.
The adapter publishes that work as async tasks when the client declares `asyncTasks`.
Without the capability, the adapter sends no async task update.
A subagent task (`local_agent`) is not an async task. Native subagent sessions report it.
A task that a `Monitor` tool call started is not an async task either.
Monitor streams its output to the model only, so the client gets nothing for it.
The SDK reports such a task as `local_bash`, so the adapter finds it by the tool of its tool call.
The `Monitor` tool call stays in the transcript.
The adapter also ignores a `local_monitor` task.
A task that a subagent tool call started goes to the subagent session.
Its spawn, progress and state updates go there also after the subagent finished.
### Updates
@@ -874,12 +881,27 @@ A subagent task (`local_agent`) is not an async task. Native subagent sessions r
- `async_task_state_update` carries `state` (`running`, `paused`, `completed`, `failed`, or `stopped`) and an optional `summary`.
- The Bash `tool_call_update` of a backgrounded command carries `_meta.jetbrains.air.asyncTasks.backgrounded: true`.
The card then shows backgrounded work instead of finished work.
Only structured data sets the marker: the `backgroundTaskId` of the tool result, a known background task of the tool call, or a `run_in_background` input.
### Task id and output path
An async task comes only from structured data.
The sources are `tool_use_result.backgroundTaskId` and the SDK `task_started`, `task_updated`, and `task_notification`.
The text of a tool result never creates a task, because a foreground command can print any text.
The output path comes from `output_file` of the SDK events when they have it.
Otherwise the adapter reads the text of the tool result of the task's own tool call.
It takes only an absolute path that ends with `tasks/<task id>.output`, followed by a period, whitespace or the end of the text.
The path starts after the nearest `": "` before that end on the same line, so it can hold spaces.
A path never holds the end of another path.
A POSIX, a Windows drive, and a UNC path count. A path across a line does not.
The tool result can come before or after the SDK names the task.
A subagent tool result has no `tool_use_result`, so this text is the only path source there.
### Tool call of a task
The adapter sends `async_task_spawned` only after it knows the tool call that started the task.
The SDK gives that id as `tool_use_id` of `task_started`, `task_progress`, or `task_notification`.
The Bash result of a backgrounded command also gives it.
The structured Bash result of a backgrounded command also gives it.
Until the id arrives, the adapter holds the task. It sends no update of a held task.
The progress and state updates of a held task follow its spawn, in their order.
A held task gets its spawn without `toolCallId` when it ends first, or when the prompt result ends the turn.