Summary
Copilot ACP returns stopReason: "end_turn" while an attached background shell is still running. When the shell finishes, Copilot autonomously calls tools and emits assistant text through session/update, after the prompt RPC has already completed.
This is related to #4555, but separates the missing completion contract from the unconditional abort on the next prompt. The minimal reproduction below sends exactly one prompt and never sends session/cancel or a second prompt.
Environment
- GitHub Copilot CLI 1.0.84-1 (confirmed in the ACP initialize response)
- macOS, Apple Silicon
- Model:
gpt-5.6-luna
- ACP protocol version 1
- Reproduced September 6, 2026
- Empty temporary working directory; no project files or MCP servers needed
Minimal reproduction
Send this prompt through ACP:
Use bash to run exactly sleep 12; printf ACP_SHELL_DONE with mode async (not detached). Immediately reply WAITING without calling read_bash yet. When the shell completion notification arrives, read that shell result with read_bash and reply FINAL_DONE. Do not read or write files, access the network, or run any other command.
Keep reading ACP notifications after session/prompt returns. Do not issue another prompt.
Observed wire timeline
Times below are seconds after sending the prompt, from an actual run of the included harness:
| Time |
ACP traffic |
| 2.476 |
tool_call: Run lifecycle wait command |
| 2.524 |
Client approves the exact sleep/printf command |
| 3.057-3.063 |
agent_message_chunk: WAITING |
| 3.149 |
Response to session/prompt: stopReason: "end_turn" |
| 15.444 |
New tool_call: Reading shell output |
| 16.037-16.043 |
agent_message_chunk: FINAL_DONE |
The new tool call and final assistant response arrive about 12-13 seconds after the prompt response. No new prompt triggered them.
The initialize result advertised loadSession, HTTP/SSE MCP, image/embedded-context prompt support, and session close/list. No background-continuation or session-idle capability was advertised. No completion extension was observed on this ACP stream.
Why this matters to an ACP client
Our client treated the prompt response as completion of the logical turn:
- It cleared active-turn ownership and stopped displaying subsequent tool/text updates.
- The provider nevertheless continued reading test results and editing files.
- Completion triggered checkpoint finalization, so the client's captured turn no longer covered all provider activity.
- Automated feedback became eligible for dispatch because the client believed the turn was finished.
The invisible activity and checkpoint behavior are client consequences of that assumption, not claims that Copilot itself manages our UI or checkpoints.
In the original incident, sending queued feedback during this hidden continuation also caused abort: user_initiated, consistent with #4555. The second ACP prompt received its own end_turn in about 10 ms, before the native history recorded the new user message; processing of that new message then continued after its RPC response as well. Request IDs were distinct: we did not observe a JSON-RPC response-ID mixup.
The original incident's initial completion preceded compaction. Both subsequent compactions succeeded. The isolated reproduction above requires neither compaction nor a large conversation.
Control: the same installed runtime has a stronger completion signal
For diagnosis only, we exercised the supported native/headless protocol with the same CLI version and model in a separate disposable session. That transport reported protocol version 3.
With an equivalent background-shell prompt:
| Time |
Native event |
| 5.44s |
Assistant message WAITING |
| 5.46s |
assistant.idle, but no session.idle |
| 17.95s |
Automatic continuation calls read_bash |
| 18.52s |
Assistant message FINAL_DONE |
| 18.53s |
assistant.idle, followed by session.idle |
The public SDK event definitions distinguish assistant idle from session idle with no background agents or attached shell commands in flight:
https://github.com/github/copilot-sdk/blob/main/nodejs/src/generated/session-events.ts
These idle events are ephemeral. Their absence from persisted events.jsonl is not evidence that the runtime lacks them.
We want to retain ACP, not switch our client to the SDK.
Expected behavior / requested resolution
Please define and expose a reliable ACP completion contract for task-associated background work and the automatic continuation that consumes its result.
Preferred: keep the original session/prompt open while that work continues, forward its tool/message/permission updates, and resolve the prompt once the session-level completion condition is reached. A paused assistant waiting for a shell should not look indistinguishable from a fully completed interaction.
If background activity is intentionally allowed to outlive the prompt RPC, please expose a documented, capability-advertised ACP extension for busy/idle/continuation lifecycle with session/interaction correlation and ordering guarantees.
This needs to distinguish task-associated attached commands from deliberately long-lived/detached services; it should not make unrelated development servers block every prompt forever.
Related but separate: follow-up prompt handling should not unconditionally abort outstanding work (#4555). Fixing that abort alone would not tell a client when it can safely finalize a turn.
ACP's prompt-turn documentation permits a new prompt after completion:
https://agentclientprotocol.com/protocol/v1/prompt-turn
ACP already provides negotiated extension mechanisms:
https://agentclientprotocol.com/protocol/v1/extensibility
We are not claiming that ACP v1 already specifies every background-process case. We are asking how clients can distinguish these two actual runtime states without inferring completion from a quiet interval.
Acceptance cases
- A background shell and its follow-up tool/model work have an observable completion point.
- New background work launched during continuation is included.
- Compaction, usage updates, or an intermediate assistant pause do not masquerade as completion.
- Explicit user cancellation remains available; automated follow-up delivery does not silently cancel previous work.
- Foreground-only interactions complete promptly.
Standalone harness
Requires Python 3 on macOS/Linux and an authenticated Copilot CLI. Run python3 acp-background-repro.py; use --cli /path/to/copilot or --model MODEL if needed.
The harness approves only the exact sleep/printf command, keeps listening after the prompt response, suppresses reasoning output, and terminates only its own spawned process group. Its 90-second observation bound and short post-marker collection interval are harness limits, not proposed production idle heuristics.
Tested Python reproduction
"""Reproduce late ACP continuation using only a short sleep and fixed output."""
import argparse
import json
import os
import selectors
import signal
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument("--cli", default="copilot")
parser.add_argument("--model", default="gpt-5.6-luna")
args = parser.parse_args()
command = "sleep 12; printf ACP_SHELL_DONE"
with tempfile.TemporaryDirectory(prefix="acp-background-repro-") as cwd:
proc = subprocess.Popen(
[args.cli, "--acp", "--no-auto-update", "--disable-builtin-mcps"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
cwd=cwd,
start_new_session=True,
)
selector = selectors.DefaultSelector()
selector.register(proc.stdout, selectors.EVENT_READ)
buffer = b""
responses = {}
next_id = 0
started = time.monotonic()
prompt_id = None
prompt_returned = None
late_tools = 0
final_seen = None
assistant_text = ""
def write(message):
proc.stdin.write((json.dumps(message) + "\n").encode())
proc.stdin.flush()
def send(method, params):
global next_id
next_id += 1
write({"jsonrpc": "2.0", "id": next_id, "method": method, "params": params})
return next_id
def report(kind, **fields):
print(json.dumps({"seconds": round(time.monotonic() - started, 3),
"event": kind, **fields}), flush=True)
def pump():
global buffer, prompt_returned, late_tools, final_seen, assistant_text
if not selector.select(1):
return
chunk = os.read(proc.stdout.fileno(), 65536)
if not chunk:
raise RuntimeError("ACP process exited")
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
if not line.strip():
continue
message = json.loads(line)
if "id" in message and "method" not in message:
responses[message["id"]] = message
if message["id"] == prompt_id:
prompt_returned = time.monotonic()
report("prompt_response", result=message.get("result"),
error=message.get("error"))
continue
params = message.get("params", {})
if message.get("method") == "session/request_permission":
tool = params.get("toolCall", {})
requested = tool.get("rawInput", {}).get("command")
allow = requested == command
options = params.get("options", [])
option = next(
(o for o in options if o.get("kind") ==
("allow_once" if allow else "reject_once")), None
)
outcome = (
{"outcome": "selected", "optionId": option["optionId"]}
if option else {"outcome": "cancelled"}
)
report("permission", allowed=allow)
write({"jsonrpc": "2.0", "id": message["id"],
"result": {"outcome": outcome}})
elif message.get("method") == "session/update":
update = params.get("update", {})
kind = update.get("sessionUpdate")
if kind == "tool_call":
if prompt_returned is not None:
late_tools += 1
report("tool_call", title=update.get("title"),
after_prompt_response=prompt_returned is not None)
elif kind == "agent_message_chunk":
text = update.get("content", {}).get("text", "")
assistant_text += text
if text:
report("assistant_text", text=text,
after_prompt_response=prompt_returned is not None)
if "FINAL_DONE" in assistant_text:
final_seen = time.monotonic()
def request(method, params):
request_id = send(method, params)
deadline = time.monotonic() + 30
while request_id not in responses:
if time.monotonic() > deadline:
raise TimeoutError(method)
pump()
response = responses.pop(request_id)
if "error" in response:
raise RuntimeError(response["error"])
return response["result"]
try:
initialization = request("initialize", {
"protocolVersion": 1,
"clientCapabilities": {},
})
report("initialize", agentInfo=initialization.get("agentInfo"),
agentCapabilities=initialization.get("agentCapabilities"))
session = request("session/new", {"cwd": cwd, "mcpServers": []})
session_id = session["sessionId"]
request("session/set_model", {"sessionId": session_id, "modelId": args.model})
started = time.monotonic()
prompt_id = send("session/prompt", {
"sessionId": session_id,
"prompt": [{
"type": "text",
"text": (
"Lifecycle experiment in an empty temporary directory. "
"Do not read or write files or access the network. "
f"Use bash to run exactly `{command}` with mode async "
"(not detached). Immediately reply WAITING without calling "
"read_bash yet. When the shell completion notification arrives, "
"read that shell result with read_bash and reply FINAL_DONE. "
"Do not run any other command or use other tools."
),
}],
})
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
pump()
if final_seen is not None and time.monotonic() - final_seen > 2:
break
report("summary", prompt_returned=prompt_returned is not None,
late_tool_calls=late_tools, final_seen=final_seen is not None)
finally:
selector.close()
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
Summary
Copilot ACP returns
stopReason: "end_turn"while an attached background shell is still running. When the shell finishes, Copilot autonomously calls tools and emits assistant text throughsession/update, after the prompt RPC has already completed.This is related to #4555, but separates the missing completion contract from the unconditional abort on the next prompt. The minimal reproduction below sends exactly one prompt and never sends
session/cancelor a second prompt.Environment
gpt-5.6-lunaMinimal reproduction
Send this prompt through ACP:
Keep reading ACP notifications after
session/promptreturns. Do not issue another prompt.Observed wire timeline
Times below are seconds after sending the prompt, from an actual run of the included harness:
tool_call: Run lifecycle wait commandagent_message_chunk:WAITINGsession/prompt:stopReason: "end_turn"tool_call: Reading shell outputagent_message_chunk:FINAL_DONEThe new tool call and final assistant response arrive about 12-13 seconds after the prompt response. No new prompt triggered them.
The initialize result advertised loadSession, HTTP/SSE MCP, image/embedded-context prompt support, and session close/list. No background-continuation or session-idle capability was advertised. No completion extension was observed on this ACP stream.
Why this matters to an ACP client
Our client treated the prompt response as completion of the logical turn:
The invisible activity and checkpoint behavior are client consequences of that assumption, not claims that Copilot itself manages our UI or checkpoints.
In the original incident, sending queued feedback during this hidden continuation also caused
abort: user_initiated, consistent with #4555. The second ACP prompt received its ownend_turnin about 10 ms, before the native history recorded the new user message; processing of that new message then continued after its RPC response as well. Request IDs were distinct: we did not observe a JSON-RPC response-ID mixup.The original incident's initial completion preceded compaction. Both subsequent compactions succeeded. The isolated reproduction above requires neither compaction nor a large conversation.
Control: the same installed runtime has a stronger completion signal
For diagnosis only, we exercised the supported native/headless protocol with the same CLI version and model in a separate disposable session. That transport reported protocol version 3.
With an equivalent background-shell prompt:
WAITINGassistant.idle, but nosession.idleread_bashFINAL_DONEassistant.idle, followed bysession.idleThe public SDK event definitions distinguish assistant idle from session idle with no background agents or attached shell commands in flight:
https://github.com/github/copilot-sdk/blob/main/nodejs/src/generated/session-events.ts
These idle events are ephemeral. Their absence from persisted
events.jsonlis not evidence that the runtime lacks them.We want to retain ACP, not switch our client to the SDK.
Expected behavior / requested resolution
Please define and expose a reliable ACP completion contract for task-associated background work and the automatic continuation that consumes its result.
Preferred: keep the original
session/promptopen while that work continues, forward its tool/message/permission updates, and resolve the prompt once the session-level completion condition is reached. A paused assistant waiting for a shell should not look indistinguishable from a fully completed interaction.If background activity is intentionally allowed to outlive the prompt RPC, please expose a documented, capability-advertised ACP extension for busy/idle/continuation lifecycle with session/interaction correlation and ordering guarantees.
This needs to distinguish task-associated attached commands from deliberately long-lived/detached services; it should not make unrelated development servers block every prompt forever.
Related but separate: follow-up prompt handling should not unconditionally abort outstanding work (#4555). Fixing that abort alone would not tell a client when it can safely finalize a turn.
ACP's prompt-turn documentation permits a new prompt after completion:
https://agentclientprotocol.com/protocol/v1/prompt-turn
ACP already provides negotiated extension mechanisms:
https://agentclientprotocol.com/protocol/v1/extensibility
We are not claiming that ACP v1 already specifies every background-process case. We are asking how clients can distinguish these two actual runtime states without inferring completion from a quiet interval.
Acceptance cases
Standalone harness
Requires Python 3 on macOS/Linux and an authenticated Copilot CLI. Run
python3 acp-background-repro.py; use--cli /path/to/copilotor--model MODELif needed.The harness approves only the exact sleep/printf command, keeps listening after the prompt response, suppresses reasoning output, and terminates only its own spawned process group. Its 90-second observation bound and short post-marker collection interval are harness limits, not proposed production idle heuristics.
Tested Python reproduction