You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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";constCONTROL=process.argv.includes("--control");constcwd=awaitmkdtemp(join(tmpdir(),"acp-cancel-repro-"));// Three files to read, so the turn is long enough to interrupt.for(constnof["alpha.txt","beta.txt","gamma.txt"])awaitwriteFile(join(cwd,n),`${n}: `+"lorem ipsum ".repeat(40)+"\n");constproc=spawn("copilot",["--acp","--stdio","--no-color","--allow-all-tools"],{
cwd,stdio: ["pipe","pipe","inherit"],});constt0=Date.now();constms=()=>Date.now()-t0;constpending=newMap();letnextId=1;letcancelledAt=null;letevents=0;constsend=(m)=>proc.stdin.write(JSON.stringify(m)+"\n");constrequest=(method,params)=>newPromise((resolve)=>{constid=nextId++;pending.set(id,resolve);send({jsonrpc: "2.0", id, method, params });});createInterface({input: proc.stdout}).on("line",(line)=>{if(!line.trim())return;letmsg;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++;constkind=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: {}});});constinit=awaitrequest("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 }=awaitrequest("session/new",{ cwd,mcpServers: []});constprompt="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"})`);constres=awaitrequest("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.
Describe the bug
In ACP mode (
copilot --acp --stdio), a prompt turn that the client cancels withsession/cancelis answered withstopReason: "end_turn", the same value a turn that ran to completion returns. ACP reserves"cancelled"for exactly this case, and requires it:— 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/updatearrives. 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:
The other two ACP harnesses I test against both return
cancelledhere (opencode 1.18.18,@agentclientprotocol/claude-agent-acp0.49.0), so a client that follows the spec has to special-case copilot.Affected version
Steps to reproduce the behavior
Save the script below and run it twice (requires a logged-in CLI):
It creates a throwaway directory with three text files, prompts the agent to summarise each one, and — in the default mode — sends
session/cancelwhen the firsttool_callnotification arrives. Zero dependencies, Node >= 20.acp-cancel-repro.mjs
Actual output
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_callthat triggered the cancel never completing. The turn really was cut short — the two runs are simply indistinguishable bystopReason.Expected behavior
session/promptresolves withstopReason: "cancelled"when the turn ended because the client sentsession/cancel, and"end_turn"only when the agent finished on its own.Additional context
end_turn, always within 64 ms of the cancel.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.