Skip to content

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

Closed

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

Activity

  1. added
    area:non-interactiveNon-interactive mode (-p), CI/CD, ACP protocol, and headless automation
    area:sessionsSession management, resume, history, session picker, and session state
    and removed on Aug 23, 2026
  2. copilot-cli-bot commented on Oct 5, 2026

    @copilot-cli-bot

    This appears to be resolved as of GitHub Copilot CLI v1.0.91. Please update to that release or a newer stable version. We're closing this as completed. If it still happens, please comment with your version and reproduction steps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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