Skip to content

ACP: session/prompt unconditionally aborts the session, cancelling all running background sub-agents (interactive TUI does not) #4555

Description

@Agreyddous

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:

  1. initialize (protocolVersion: 1)
  2. session/new
  3. session/prompt Create ownership.yaml #1 — asks the agent to launch 2 background agents that each run sleep 600, and to not wait for them
  4. Waits for stopReason: "end_turn", then sleeps 15 s so no turn is in flight
  5. session/prompt Update ownership.yaml #2 — "Reply with exactly: SECOND"
  6. 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:

  1. Guard the abort on there actually being an active turn (the adjacent cancel handler already does exactly this, via its pendingPrompt check); or

  2. 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:

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/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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions