github / github/copilot-cli

ACP: session/cancel is answered with stopReason "end_turn" instead of "cancelled"

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

还没有人认领这个 Issue。

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

描述

Describe the bug

In ACP mode (copilot --acp --stdio), a prompt turn that the client cancels with session/cancel is answered with stopReason: "end_turn", the same value a turn that ran to completion returns. ACP reserves "cancelled" for exactly this case, and requires it:

After all ongoing operations have been successfully aborted and pending updates have been sent, the Agent MUST respond to the original session/prompt request with the cancelled stop reason.

Agents MUST catch these errors and return the semantically meaningful cancelled stop reason, so that Clients can reliably confirm the cancellation.

https://agentclientprotocol.com/protocol/prompt-turn

The cancellation itself works correctly: the turn stops 26 ms after the notification is written, and no further session/update arrives. Only the reported reason is wrong.

Impact

A client cannot tell "the agent finished" from "I stopped the agent". Concretely, for a supervisor that runs unattended agents:

  • a watchdog that cancels a task on a TTL cannot mark it timed-out from the protocol response — it has to keep its own side-channel record of whether it cancelled;
  • a turn cancelled mid-tool-call leaves half-finished work on disk while reporting the same status as a clean run, so "success" cannot be trusted to mean the task is complete;
  • usage/cost accounting attributes a truncated turn to a normal completion.

The other two ACP harnesses I test against both return cancelled here (opencode 1.18.18, @agentclientprotocol/claude-agent-acp 0.49.0), so a client that follows the spec has to special-case copilot.

Affected version

GitHub Copilot CLI 1.0.80.

Steps to reproduce the behavior

Save the script below and run it twice (requires a logged-in CLI):

node acp-cancel-repro.mjs            # cancels on the first tool_call
node acp-cancel-repro.mjs --control  # identical run, never cancels

It creates a throwaway directory with three text files, prompts the agent to summarise each one, and — in the default mode — sends session/cancel when the first tool_call notification arrives. Zero dependencies, Node >= 20.

acp-cancel-repro.mjs
#!/usr/bin/env node
// Minimal reproduction: GitHub Copilot CLI in ACP mode answers session/prompt with
// stopReason "end_turn" after the client sends session/cancel, where ACP requires
// "cancelled".
//
//   node acp-cancel-repro.mjs            # cancel on the first tool_call
//   node acp-cancel-repro.mjs --control  # identical run, no cancel (baseline)
//
// Zero dependencies, Node >= 20. Requires a logged-in CLI (`copilot login`).

import { spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const CONTROL = process.argv.includes("--control");
const cwd = await mkdtemp(join(tmpdir(), "acp-cancel-repro-"));
// Three files to read, so the turn is long enough to interrupt.
for (const n of ["alpha.txt", "beta.txt", "gamma.txt"])
  await writeFile(join(cwd, n), `${n}: ` + "lorem ipsum ".repeat(40) + "\n");

const proc = spawn("copilot", ["--acp", "--stdio", "--no-color", "--allow-all-tools"], {
  cwd, stdio: ["pipe", "pipe", "inherit"],
});

const t0 = Date.now();
const ms = () => Date.now() - t0;
const pending = new Map();
let nextId = 1;
let cancelledAt = null;
let events = 0;

const send = (m) => proc.stdin.write(JSON.stringify(m) + "\n");
const request = (method, params) =>
  new Promise((resolve) => { const id = nextId++; pending.set(id, resolve); send({ jsonrpc: "2.0", id, method, params }); });

createInterface({ input: proc.stdout }).on("line", (line) => {
  if (!line.trim()) return;
  let msg; try { msg = JSON.parse(line); } catch { return; }

  if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
    pending.get(msg.id)?.(msg.result ?? { error: msg.error });
    pending.delete(msg.id);
    return;
  }
  if (msg.method === "session/update") {
    events++;
    const kind = msg.params?.update?.sessionUpdate;
    if (kind === "tool_call" && !cancelledAt && !CONTROL) {
      cancelledAt = ms();
      console.log(`[${cancelledAt}ms] first tool_call -> sending session/cancel`);
      send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
    }
    return;
  }
  // Answer anything else so the turn cannot stall on us.
  if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, result: {} });
});

const init = await request("initialize", {
  protocolVersion: 1,
  clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
  clientInfo: { name: "acp-cancel-repro", version: "1.0.0" },
});
console.log(`initialize: ${init.agentInfo?.name} ${init.agentInfo?.version} (protocol v${init.protocolVersion})`);

const { sessionId } = await request("session/new", { cwd, mcpServers: [] });

const prompt =
  "Read every file in this directory one at a time, and for each one write a two-sentence " +
  "summary. Work through them slowly and thoroughly, one file per step.";
console.log(`[${ms()}ms] session/prompt (${CONTROL ? "control: no cancel" : "will cancel on first tool_call"})`);
const res = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] });

console.log(`\nstopReason:        ${JSON.stringify(res.stopReason)}`);
console.log(`turn ended at:     ${ms()} ms`);
if (cancelledAt) console.log(`cancel -> answer:  ${ms() - cancelledAt} ms`);
console.log(`session/update events: ${events}`);
proc.stdin.end(); proc.kill();
Actual output
$ node acp-cancel-repro.mjs
initialize: Copilot 1.0.80 (protocol v1)
[2856ms] session/prompt (will cancel on first tool_call)
[5170ms] first tool_call -> sending session/cancel

stopReason:        "end_turn"        <-- expected "cancelled"
turn ended at:     5196 ms
cancel -> answer:  26 ms
session/update events: 8

$ node acp-cancel-repro.mjs --control
initialize: Copilot 1.0.80 (protocol v1)
[1904ms] session/prompt (control: no cancel)

stopReason:        "end_turn"
turn ended at:     15450 ms
session/update events: 31

The control run is what makes this unambiguous: left alone, the same prompt runs 15.5 s and emits 31 notifications; cancelled, it stops after 5.2 s and 8 notifications, with the tool_call that triggered the cancel never completing. The turn really was cut short — the two runs are simply indistinguishable by stopReason.

Expected behavior

session/prompt resolves with stopReason: "cancelled" when the turn ended because the client sent session/cancel, and "end_turn" only when the agent finished on its own.

Additional context

  • Reproduced on 1.0.80 across three separate workspaces and four runs (two with the script above, two with a different ACP client), always end_turn, always within 64 ms of the cancel.
  • Related but distinct: #4555 reports that session/prompt aborts the session unconditionally, including when idle. That is about when work gets aborted; this is about what the protocol response says after a client-requested cancel. Both leave the client with end_turn for work that did not finish.
  • Environment: Linux 6.18 (WSL2), x86_64, bash, Node v20.20.2.

贡献指南

打开贡献指南

从这里开始

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

调研方向

从附加的脚本 acp-cancel-repro.mjs 开始,针对 copilot --acp --stdio 运行 cancelling 和 --control 两种模式。比较 session/prompt 响应和 session/update 计数,然后跟踪 ACP 的 session/prompt 取消处理;报告没有指定实现文件或测试。完成的标准是:客户端请求的取消返回 stopReason "cancelled",而未被中断的轮次仍然返回 "end_turn"。

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

评估

技术栈
javascript, node.js, shell
领域
api, cli
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
活跃
描述清晰度
基本清楚
新手友好度
56/100

把新 issue 发到你的邮箱

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