Skip to content

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

Description

@EdwardLiuyc

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

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