ACP: session/cancel is answered with stopReason "end_turn" instead of "cancelled"
Nobody has claimed this yet.
- Dominant language
- Shell
- Stars
- 11.2k
- Forks
- 1.9k
- Avg merge
- 14h 16m
- Merged PRs (30d)
- 6
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the attached acp-cancel-repro.mjs script and run both the cancelling and --control modes against copilot --acp --stdio. Compare the session/prompt responses and session/update counts, then trace the ACP session/prompt cancellation handling; the report names no implementation file or test. Done means a client-requested cancel returns stopReason "cancelled" while an uninterrupted turn still returns "end_turn".
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, shell
- Domain
- api, cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100