ACP: session/prompt unconditionally aborts the session, cancelling all running background sub-agents (interactive TUI does not)
Nobody has claimed this yet.
- Dominant language
- Shell
- Stars
- 11.2k
- Forks
- 1.9k
- Avg merge
- 14h 16m
- Merged PRs (30d)
- 6
Description
Describe the bug
In ACP mode (copilot --acp --stdio), the session/prompt handler calls session.abort() unconditionally as its first action, before doing anything else with the request. When the session has background sub-agents running (launched via the task tool with mode=background), that abort tears all of them down.
This happens even when:
- no prompt turn is in flight — the previous turn already returned
stopReason: "end_turn", and the client waited before sending the next prompt; and - the client never sent
session/cancel.
The abort is issued with no reason argument, so it defaults to the user-abort constant. The event log therefore records abort {"reason":"user_initiated"} although no user initiated anything, and it is written ~140 ms before the user.message that appears to have caused it.
The interactive TUI does not do this. Sending follow-up messages while background agents are running is safe there. This makes the behaviour a property of the ACP transport specifically, not of the session or the agent runtime.
Impact
Any ACP client is unable to send a follow-up message to a session that has background sub-agents out without silently destroying that work. Because the agent had already reported end_turn, the session looks idle, so this is exactly the moment a client would reasonably prompt. There is no client-side workaround (see below).
Affected version
GitHub Copilot CLI 1.0.81-6
Also present in every version I have on disk to compare against: 1.0.80, 1.0.81-0, 1.0.81-3, 1.0.81-5, 1.0.81-6. Not a recent regression.
Steps to reproduce the behavior
Save the script below as acp-abort-repro.py and run it (requires a logged-in CLI):
python3 acp-abort-repro.py --agents
acp-abort-repro.py
#!/usr/bin/env python3
"""Minimal reproduction: GitHub Copilot CLI ACP mode aborts the session on every
session/prompt, even when no turn is in flight and the client never sent
session/cancel.
Usage: python3 acp-abort-repro.py [--agents]
Requires a logged-in CLI (`copilot login`).
Without --agents: shows the spurious abort event (mechanism).
With --agents: also shows background sub-agents being cancelled (impact).
"""
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
WITH_AGENTS = "--agents" in sys.argv
workdir = tempfile.mkdtemp(prefix="acp-repro-")
copilot_home = Path(os.environ.get("COPILOT_HOME", Path.home() / ".copilot"))
proc = subprocess.Popen(
["copilot", "--acp", "--stdio", "--no-color", "--allow-all-tools"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
cwd=workdir,
)
_id = 0
def send(method, params):
global _id
_id += 1
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": _id, "method": method, "params": params}) + "\n")
proc.stdin.flush()
return _id
def wait_for(req_id):
"""Read until the response to req_id; auto-approve any permission request."""
while True:
line = proc.stdout.readline()
if not line:
raise SystemExit("agent exited unexpectedly")
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
if msg.get("method") == "session/request_permission":
opts = msg["params"]["options"]
pick = next((o for o in opts if "allow" in o["optionId"]), opts[0])
proc.stdin.write(json.dumps({
"jsonrpc": "2.0", "id": msg["id"],
"result": {"outcome": {"outcome": "selected", "optionId": pick["optionId"]}},
}) + "\n")
proc.stdin.flush()
elif msg.get("id") == req_id:
return msg
wait_for(send("initialize", {
"protocolVersion": 1,
"clientCapabilities": {"fs": {"readTextFile": False, "writeTextFile": False}, "terminal": False},
}))
res = wait_for(send("session/new", {"cwd": workdir, "mcpServers": []}))
session_id = res["result"]["sessionId"]
print(f"session: {session_id}")
if WITH_AGENTS:
p1 = ("Use the task tool to launch 2 background agents (mode=background). "
"Each agent must run the shell command `sleep 600`. "
"Do NOT wait for them to finish. Reply with just: LAUNCHED")
else:
p1 = "Reply with exactly: FIRST"
r = wait_for(send("session/prompt", {"sessionId": session_id,
"prompt": [{"type": "text", "text": p1}]}))
print(f"prompt #1 stopReason: {r['result']['stopReason']}")
print("waiting 15s (no turn is in flight; client sends NO session/cancel)...")
time.sleep(15)
r = wait_for(send("session/prompt", {"sessionId": session_id,
"prompt": [{"type": "text", "text": "Reply with exactly: SECOND"}]}))
print(f"prompt #2 stopReason: {r['result']['stopReason']}")
time.sleep(3)
proc.terminate()
print("\n--- event log ---")
log = copilot_home / "session-state" / session_id / "events.jsonl"
if log and log.exists():
for line in log.read_text().splitlines():
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
t = e.get("type", "")
if t in ("user.message", "abort", "subagent.completed", "assistant.turn_end",
"tool.execution_start"):
extra = ""
if t == "abort":
extra = f" reason={e.get('reason') or e.get('data', {}).get('reason')}"
if t == "subagent.completed":
extra = f" cancelled={e.get('cancelled', e.get('data', {}).get('cancelled'))}"
print(f"{e.get('timestamp', '')[-13:]} {t}{extra}")
else:
print(f"log not found: {log}")
print(f"\nworkdir: {workdir}")
It performs a plain ACP exchange with no cancellation of any kind:
initialize(protocolVersion: 1)session/newsession/prompt#1 — asks the agent to launch 2 background agents that each runsleep 600, and to not wait for them- Waits for
stopReason: "end_turn", then sleeps 15 s so no turn is in flight session/prompt#2 — "Reply with exactly: SECOND"- Prints the relevant lines of
~/.copilot/session-state/<id>/events.jsonl
Actual output
prompt #1 stopReason: end_turn
waiting 15s (no turn is in flight; client sends NO session/cancel)...
prompt #2 stopReason: end_turn
--- event log ---
13:50:31.797Z user.message
13:50:37.239Z tool.execution_start
13:50:37.242Z tool.execution_start
13:50:37.278Z assistant.turn_end
13:50:38.955Z assistant.turn_end
13:50:39.097Z user.message
13:50:39.143Z user.message
13:50:42.129Z tool.execution_start
13:50:42.228Z tool.execution_start
13:50:53.979Z abort reason=user_initiated <-- nobody cancelled anything
13:50:53.992Z subagent.completed cancelled=True
13:50:53.986Z subagent.completed cancelled=True
13:50:54.118Z user.message <-- prompt #2 lands 139 ms LATER
13:50:55.979Z assistant.turn_end
Both background agents are cancelled 8 minutes into a 10-minute sleep.
Control: the same scenario in the interactive TUI
Driving copilot (TUI) through a pty with the same prompts — 2 background agents out, then 3 consecutive follow-up messages — produces zero abort events and all agents survive and complete normally.
| driver | agents launched | follow-up prompts | abort events |
agents surviving |
|---|---|---|---|---|
copilot --acp --stdio |
2 | 1 | 1 | 0 |
copilot (interactive TUI) |
2 | 3 | 0 | 2 |
Note on reproducing without background agents
If you run the script without --agents, no abort event is logged. The call still happens, but with no abortable work it is a no-op and nothing is recorded. The bug is only observable — and only harmful — when background work is outstanding.
Expected behavior
Sending a session/prompt to a session whose previous turn has already completed should not cancel background sub-agents. Following up should be non-destructive, matching the interactive TUI.
Concretely, one of:
-
Guard the abort on there actually being an active turn (the adjacent
cancelhandler already does exactly this, via itspendingPromptcheck); or -
Use
interruptMainTurn()instead ofabort(). That method already exists and is precisely the narrower primitive — it stops the main turn and leaves background agents alone. The TUI keeps these two operations distinct, and the changelog for 1.0.69 documents that distinction as intentional user-facing behaviour:"Double-press Esc now interrupts the running main turn (flushing queued messages), or stops background agents when the main agent is idle"
The ACP path currently performs the second of those on every prompt.
At minimum, if the abort must stay, it should not pass the user-initiated reason when no user initiated it, since that misattributes the cancellation in the event log and in telemetry.
Additional context
Where it is
The bundled application in the runtime cache (~/.cache/copilot/pkg/<platform>/<version>/app.js) contains the ACP session/prompt handler:
async prompt(t){
let n = this.sessions.get(t.sessionId);
if (!n) throw new _o(-32602, `Session ${t.sessionId} not found`);
await n.session.abort(), // <-- unconditional, first statement, no guard
n.pendingPrompt = !0;
...
await n.session.send(d) // <-- the actual delivery
}
n.session.send(d) is the same call the TUI uses to deliver a message. The extra abort() is the only difference between the two paths.
The cancel handler a few lines below is guarded:
async cancel(t){
let n = this.sessions.get(t.sessionId);
n?.pendingPrompt && (n.pendingPrompt = !1, await n.session.abort())
}
Why it reaches background agents
abort() → abortInProcessAndNative(t) → cancelProcessing(...), which dispatches cancelSidekicks (sidekickManagerCancelAllNative) and cancelActiveAgents. Because prompt() calls abort() with no argument, the reason falls back to oAt = sessionConstantsAbortReasonUserAbort(), which is where the "user_initiated" in the log comes from.
Why there is no client-side workaround
session/promptis the only method that delivers user input;session/fork,session/resumeandsession/loaddo not.- The abort precedes any use of the request parameters other than
sessionId, so no field,_meta, capability or session config option can influence it. - Sending
session/cancelfirst does not help:cancelis a no-op whenpendingPromptis false (i.e. when idle), andpromptthen aborts unconditionally regardless. - ACP v2, whose prompt lifecycle explicitly supports background activity continuing while the agent reports idle, is not implemented here —
initializereturns a hardcodedprotocolVersion: 1.
The only way for a client to protect background agents today is to refuse to send the prompt until they finish, which defeats the purpose of background agents.
Relation to the ACP spec
The spec does not require this behaviour. ACP v1 states:
"Once a prompt turn completes, the Client may send another
session/promptto continue the conversation"
and scopes cancellation to in-flight turns:
"Clients MAY cancel an ongoing prompt turn at any time by sending a
session/cancelnotification"
Zed's ACP client — the reference client — behaves the same way this repro does: in acp_thread.rs, cancel_inner returns immediately when there is no running_turn, so a follow-up prompt after end_turn is sent with no preceding cancellation.
The ACP v2 RFD names this exact scenario as a v1 gap:
"if an agent finishes its turn, wants to wait for the next user action, but has a background subagent or task running, can it only submit updates about that status after the user prompts again?"
So v1 is silent on background work rather than mandating that it be cancelled, and the CLI's own TUI demonstrates the intended non-destructive behaviour.
Environment
- OS: Debian GNU/Linux 12 (bookworm), Linux 6.12.96 aarch64
- CPU architecture: ARM (aarch64)
- Shell: bash
- Installed via the platform binary package; version 1.0.81-6
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the ACP session/prompt handler in the bundled app.js under ~/.cache/copilot/pkg/// and compare it with the guarded session/cancel handler. Reproduce the issue with acp-abort-repro.py --agents, then verify that a follow-up after end_turn does not cancel background agents or log an unrelated user_initiated abort; compare the interactive TUI behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, python
- Domain
- api, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 50/100