Skip to content

ACP: end_turn precedes background-shell completion and autonomous follow-up tool calls; no observable session-idle signal #4743

Description

@ronak-guliani

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()

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:non-interactiveNon-interactive mode (-p), CI/CD, ACP protocol, and headless automationarea:sessionsSession management, resume, history, session picker, and session state

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions