ACP: session/cancel is answered with stopReason "end_turn" instead of "cancelled"
还没有人认领这个 Issue。
- 主要语言
- 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/promptrequest with thecancelledstop reason.
…
Agents MUST catch these errors and return the semantically meaningfulcancelledstop 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/promptaborts 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 withend_turnfor work that did not finish. - Environment: Linux 6.18 (WSL2), x86_64, bash, Node v20.20.2.
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 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