github / github/copilot-cli

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

未关闭
#4,743 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

area:non-interactive area:sessions
主要语言
Shell
星标
11.2k
派生
1.9k
平均合并
14 小时 16 分钟
30 天内合并 PR
6

描述

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

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从独立的 acp-background-repro.py harness 以及它所执行的 session/prompt 和 session/update 入口点开始。将其行为与所引用的 ACP prompt-turn 和 extensibility 文档进行比较,然后定义所需的协商完成或生命周期信号。完成的标准是:验收用例具有可观测的完成点,同时不会破坏前台交互或取消操作。

由索引模型根据 Issue 内容生成。

评估

技术栈
python, shell
领域
api, cli
Issue 类型
功能
难度
5/5
预计耗时
一周以上
活跃度
活跃
描述清晰度
基本清楚
新手友好度
35/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。