github / github/copilot-cli

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

Đang mở
#4,561 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

area:non-interactive area:sessions
Ngôn ngữ chính
Shell
Star
11.2k
Fork
1.9k
Merge trung bình
14 giờ 16 phút
Pull request đã merge (30 ngày)
6

Mô tả

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.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu với script đính kèm acp-cancel-repro.mjs và chạy cả hai chế độ cancelling và --control với copilot --acp --stdio. So sánh các phản hồi session/prompt và số lượng session/update, sau đó lần theo cách xử lý hủy session/prompt của ACP; báo cáo không nêu tên tệp triển khai hay bài kiểm thử nào. Được coi là hoàn tất khi một yêu cầu hủy từ client trả về stopReason "cancelled", trong khi một lượt chạy không bị gián đoạn vẫn trả về "end_turn".

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
javascript, node.js, shell
Lĩnh vực
api, cli
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
56/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.