Note: writeup written by AI, but I reviewed it deeply to ensure it aligned with what we're running into. I'm less confident on the 'root cause' section -- I did not dig as deeply into the actual implementation of the ACP adapter -- but I am leaving it here in case the pointer is correct.
Summary
When Claude Code launches background tasks via the Task tool with run_in_background=true, the ACP adapter silently drops the SDK's task_notification and task_started system messages. This causes two problems:
- During a turn: Completion notifications that arrive while
prompt() is active are consumed from the generator but discarded, so the ACP client never learns about them. This is less problematic, but could be an issue for consumers that want to know about these messages. (Note: I as a dev care about this less)
- Between turns: The generator is not consumed between
prompt() calls, so notifications accumulate internally in the SDK. They only surface on the next prompt() call, one turn at a time, requiring the user to send N-1 extra messages to drain N completions. (Note: I as a dev care about this far more)
Reproduction
- Start a session via ACP
- Ask the agent to launch 3 background tasks (e.g., "Spin up 3 background agents using
Task with run_in_background. Agent A sleeps 5s, B sleeps 10s, C sleeps 15s.")
- Wait for all tasks to complete (>15s)
- Observe: only 1 completion appears in the client
- Send any message → a second completion appears
- Send another → the third appears
- From this point, all future messages are 'out of sync' because the agent will always be responding to a message from ~3 messages ago, while the user sees the most recent response.
Root Cause
In acp-agent.ts, the prompt() method's message processing loop handles SDKMessageTemp from the Query async generator. Under the case "system" branch, task_notification and task_started subtypes fall through to a no-op:
// acp-agent.ts, prompt() method
case "system":
switch (message.subtype) {
case "init":
break;
case "compact_boundary":
case "hook_started":
case "task_notification": // <-- silently dropped
case "hook_progress":
case "hook_response":
case "status":
case "files_persisted":
case "task_started": // <-- silently dropped
// Todo: process via status api
break;
}
These messages carry background task lifecycle events that could be forwarded to the ACP client via this.client.sessionUpdate().
Proposed Fix
Part 1 — Within-turn delivery (straightforward)
Handle task_notification and task_started in the switch statement by converting them to ACP SessionNotifications and calling this.client.sessionUpdate(), similar to how stream_event and assistant message types are already handled.
Part 2 — Between-turn delivery (needs design)
After prompt() returns, the Query generator is no longer being consumed. System messages (including task completions) accumulate in the SDK's internal buffer with no way to reach the ACP client.
One approach: run a background drain loop between turns that continues calling .next() on the generator and forwards system messages via sessionUpdate(), stopping when the next prompt() call begins. This doesn't trigger LLM work — it's purely reading from the SDK's local event buffer.
The correctness concern is ensuring the drain loop doesn't accidentally consume messages belonging to the next prompt() turn. This depends on how the SDK sequences user input pushes vs generator yields.
Impact
Any ACP client using background agents (e.g., the Task tool with run_in_background) will experience:
- Missing real-time notifications for background task completion
- Messaging that falls out of sync, requiring extra user messages to drain buffered completions
- Stale status information (client thinks tasks are still running when they've finished)
Note: writeup written by AI, but I reviewed it deeply to ensure it aligned with what we're running into. I'm less confident on the 'root cause' section -- I did not dig as deeply into the actual implementation of the ACP adapter -- but I am leaving it here in case the pointer is correct.
Summary
When Claude Code launches background tasks via the
Tasktool withrun_in_background=true, the ACP adapter silently drops the SDK'stask_notificationandtask_startedsystem messages. This causes two problems:prompt()is active are consumed from the generator but discarded, so the ACP client never learns about them. This is less problematic, but could be an issue for consumers that want to know about these messages. (Note: I as a dev care about this less)prompt()calls, so notifications accumulate internally in the SDK. They only surface on the nextprompt()call, one turn at a time, requiring the user to send N-1 extra messages to drain N completions. (Note: I as a dev care about this far more)Reproduction
Taskwithrun_in_background. Agent A sleeps 5s, B sleeps 10s, C sleeps 15s.")Root Cause
In
acp-agent.ts, theprompt()method's message processing loop handlesSDKMessageTempfrom theQueryasync generator. Under thecase "system"branch,task_notificationandtask_startedsubtypes fall through to a no-op:These messages carry background task lifecycle events that could be forwarded to the ACP client via
this.client.sessionUpdate().Proposed Fix
Part 1 — Within-turn delivery (straightforward)
Handle
task_notificationandtask_startedin the switch statement by converting them to ACPSessionNotifications and callingthis.client.sessionUpdate(), similar to howstream_eventandassistantmessage types are already handled.Part 2 — Between-turn delivery (needs design)
After
prompt()returns, theQuerygenerator is no longer being consumed. System messages (including task completions) accumulate in the SDK's internal buffer with no way to reach the ACP client.One approach: run a background drain loop between turns that continues calling
.next()on the generator and forwards system messages viasessionUpdate(), stopping when the nextprompt()call begins. This doesn't trigger LLM work — it's purely reading from the SDK's local event buffer.The correctness concern is ensuring the drain loop doesn't accidentally consume messages belonging to the next
prompt()turn. This depends on how the SDK sequences user input pushes vs generator yields.Impact
Any ACP client using background agents (e.g., the
Tasktool withrun_in_background) will experience: