You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 everysession/prompt, even when no turn is in flight and the client never sentsession/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)."""importjsonimportosimportsubprocessimportsysimporttempfileimporttimefrompathlibimportPathWITH_AGENTS="--agents"insys.argvworkdir=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=0defsend(method, params):
global_id_id+=1proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": _id, "method": method, "params": params}) +"\n")
proc.stdin.flush()
return_iddefwait_for(req_id):
"""Read until the response to req_id; auto-approve any permission request."""whileTrue:
line=proc.stdout.readline()
ifnotline:
raiseSystemExit("agent exited unexpectedly")
try:
msg=json.loads(line)
exceptjson.JSONDecodeError:
continueifmsg.get("method") =="session/request_permission":
opts=msg["params"]["options"]
pick=next((oforoinoptsif"allow"ino["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()
elifmsg.get("id") ==req_id:
returnmsgwait_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}")
ifWITH_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"iflogandlog.exists():
forlineinlog.read_text().splitlines():
try:
e=json.loads(line)
exceptjson.JSONDecodeError:
continuet=e.get("type", "")
iftin ("user.message", "abort", "subagent.completed", "assistant.turn_end",
"tool.execution_start"):
extra=""ift=="abort":
extra=f" reason={e.get('reason') ore.get('data', {}).get('reason')}"ift=="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/new
session/promptCreate ownership.yaml #1 — asks the agent to launch 2 background agents that each run sleep 600, and to not wait for them
Waits for stopReason: "end_turn", then sleeps 15 s so no turn is in flight
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 zeroabort 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 cancel handler already does exactly this, via its pendingPrompt check); or
Use interruptMainTurn() instead of abort(). 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:
asyncprompt(t){letn=this.sessions.get(t.sessionId);if(!n)thrownew_o(-32602,`Session ${t.sessionId} not found`);awaitn.session.abort(),// <-- unconditional, first statement, no guardn.pendingPrompt=!0;
...
awaitn.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.
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/prompt is the only method that delivers user input; session/fork, session/resume and session/load do 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/cancel first does not help: cancel is a no-op when pendingPrompt is false (i.e. when idle), and prompt then aborts unconditionally regardless.
ACP v2, whose prompt lifecycle explicitly supports background activity continuing while the agent reports idle, is not implemented here — initialize returns a hardcoded protocolVersion: 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/prompt to continue the conversation"
and scopes cancellation to in-flight turns:
"Clients MAY cancel an ongoing prompt turn at any time by sending a session/cancel notification"
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
Describe the bug
In ACP mode (
copilot --acp --stdio), thesession/prompthandler callssession.abort()unconditionally as its first action, before doing anything else with the request. When the session has background sub-agents running (launched via thetasktool withmode=background), that abort tears all of them down.This happens even when:
stopReason: "end_turn", and the client waited before sending the next prompt; andsession/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 theuser.messagethat 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
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.pyand run it (requires a logged-in CLI):acp-abort-repro.py
It performs a plain ACP exchange with no cancellation of any kind:
initialize(protocolVersion: 1)session/newsession/promptCreate ownership.yaml #1 — asks the agent to launch 2 background agents that each runsleep 600, and to not wait for themstopReason: "end_turn", then sleeps 15 s so no turn is in flightsession/promptUpdate ownership.yaml #2 — "Reply with exactly: SECOND"~/.copilot/session-state/<id>/events.jsonlActual output
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 zeroabortevents and all agents survive and complete normally.aborteventscopilot --acp --stdiocopilot(interactive TUI)Note on reproducing without background agents
If you run the script without
--agents, noabortevent 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/promptto 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); orUse
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: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 ACPsession/prompthandler:n.session.send(d)is the same call the TUI uses to deliver a message. The extraabort()is the only difference between the two paths.The
cancelhandler a few lines below is guarded:Why it reaches background agents
abort()→abortInProcessAndNative(t)→cancelProcessing(...), which dispatchescancelSidekicks(sidekickManagerCancelAllNative) andcancelActiveAgents. Becauseprompt()callsabort()with no argument, the reason falls back tooAt = 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.sessionId, so no field,_meta, capability or session config option can influence it.session/cancelfirst does not help:cancelis a no-op whenpendingPromptis false (i.e. when idle), andpromptthen aborts unconditionally regardless.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:
and scopes cancellation to in-flight turns:
Zed's ACP client — the reference client — behaves the same way this repro does: in
acp_thread.rs,cancel_innerreturns immediately when there is norunning_turn, so a follow-up prompt afterend_turnis sent with no preceding cancellation.The ACP v2 RFD names this exact scenario as a v1 gap:
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