Honor $/cancel_request for a prompt and a sign-in

The SDK records a $/cancel_request against the request it names, but a handler has to read responder.cancellation() for anything to happen, and none did. A client that cancels by JSON-RPC id instead of session/cancel was ignored. The prompt handler now watches the marker and stops the turn through cancel_turn, the path session/cancel already takes, so the running command is killed too. The prompt is still answered with a result and the cancelled stop reason, which keeps what was streamed. authenticate is dropped instead, which closes the loopback listener, and answers -32800. The other handlers still ignore the notification, as the spec allows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.7-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 12, 2026 at 09:46 UTC 0f3d1f09bdcd49b668980bac7a0da6345fc3fac4
2 files changed +95 -15
src/main.rs
+41 -8
@@ -4313,18 +4313,25 @@ impl SiGitAgent {
4313
4314 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
4315 log::info!("cancel requested for session {}", args.session_id);
4316 + self.cancel_turn(&args.session_id);
4317 + Ok(())
4318 + }
4319 +
4320 + /// Stop the turn running on `session_id`, if there is one, and whatever
4321 + /// command it is running. Reached from `session/cancel` and from a
4322 + /// `$/cancel_request` naming the prompt.
4323 + fn cancel_turn(&self, session_id: &SessionId) {
4324 if let Some(cancellation) = self
4325 .prompt_cancellations
4326 .lock()
4327 .unwrap()
4320 - .get(&args.session_id.to_string())
4328 + .get(&session_id.to_string())
4329 .cloned()
4330 {
4331 cancellation.cancelled.store(true, Ordering::Release);
4332 cancellation.notify.notify_one();
4333 }
4326 - tools::abort_session_tools(&args.session_id.to_string());
4327 - Ok(())
4334 + tools::abort_session_tools(&session_id.to_string());
4335 }
4336
4337 /// Swap the active backend to a siGit Code Cloud tier and reflect it as the
@@ -6186,11 +6193,17 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
6193 let state = Arc::clone(&state);
6194 let task_cx = cx.clone();
6195 let request_id = responder.id().clone();
6196 + // A sign-in waits on the browser for minutes. Dropping it
6197 + // on `$/cancel_request` closes the loopback listener, and
6198 + // the request is answered `-32800`.
6199 + let cancellation = responder.cancellation();
6200 cx.spawn(async move {
6190 - handle_response(
6191 - responder,
6192 - state.handle_authenticate(&task_cx, request_id, req).await,
6193 - )
6201 + let result = cancellation
6202 + .run_until_cancelled(state.handle_authenticate(
6203 + &task_cx, request_id, req,
6204 + ))
6205 + .await;
6206 + handle_response(responder, result)
6207 })
6208 }
6209 },
@@ -6327,9 +6340,29 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
6340 async move |req: PromptRequest, responder, cx: ConnectionTo<Client>| {
6341 let state = Arc::clone(&state);
6342 let task_cx = cx.clone();
6343 + let cancellation = responder.cancellation();
6344 cx.spawn(async move {
6345 let _session = state.lock_session(&req.session_id).await;
6332 - handle_response(responder, state.handle_prompt(&task_cx, req).await)
6346 + let session_id = req.session_id.clone();
6347 + let turn = state.handle_prompt(&task_cx, req);
6348 + tokio::pin!(turn);
6349 + // `$/cancel_request` for the prompt's own id stops the
6350 + // turn the way `session/cancel` does, and the request
6351 + // is still answered with a `cancelled` stop reason so
6352 + // the client keeps what was streamed. The turn is
6353 + // polled first: it registers for cancellation on its
6354 + // first poll, which a cancel that arrived while the
6355 + // prompt was queued would otherwise beat.
6356 + let result = tokio::select! {
6357 + biased;
6358 + result = &mut turn => result,
6359 + () = cancellation.cancelled() => {
6360 + log::info!("$/cancel_request for the prompt on session {session_id}");
6361 + state.cancel_turn(&session_id);
6362 + turn.await
6363 + }
6364 + };
6365 + handle_response(responder, result)
6366 })
6367 }
6368 },
tests/acp_client_terminal.rs
+54 -7
@@ -295,9 +295,26 @@ impl AgentUnderTest {
295 }
296
297 /// Send a prompt, then `session/cancel` as soon as the agent starts
298 - /// waiting on the editor's terminal, which never answers. Returns the
299 - /// prompt's response.
298 + /// waiting on the editor's terminal. Returns the prompt's response.
299 fn prompt_cancelled_mid_command(&mut self, session_id: &str, editor: &mut Editor) -> Value {
300 + self.prompt_stopped_mid_command(session_id, editor, |_prompt_id| {
301 + json!({
302 + "jsonrpc": "2.0",
303 + "method": "session/cancel",
304 + "params": {"sessionId": session_id},
305 + })
306 + })
307 + }
308 +
309 + /// Send a prompt and, once the agent is waiting on the editor's terminal
310 + /// (which never answers), send whatever `stop` builds from the prompt's
311 + /// request id. Returns the prompt's response.
312 + fn prompt_stopped_mid_command(
313 + &mut self,
314 + session_id: &str,
315 + editor: &mut Editor,
316 + stop: impl Fn(u64) -> Value,
317 + ) -> Value {
318 let id = self.request(
319 "session/prompt",
320 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "go"}]}),
@@ -327,11 +344,7 @@ impl AgentUnderTest {
344 "terminal/create" => json!({"terminalId": "term-1"}),
345 "terminal/wait_for_exit" => {
346 // The command is still running; the user presses stop.
330 - self.send(json!({
331 - "jsonrpc": "2.0",
332 - "method": "session/cancel",
333 - "params": {"sessionId": session_id},
334 - }));
347 + self.send(stop(id));
348 continue;
349 }
350 "terminal/output" => json!({
@@ -597,3 +610,37 @@ fn a_cancel_mid_command_kills_the_client_terminal_and_ends_the_turn_cancelled()
610 "a cancelled turn must not ask the model for another round"
611 );
612 }
613 +
614 +/// `$/cancel_request` names the prompt by its JSON-RPC id instead of the
615 +/// session. It has to stop the turn the same way, and the prompt is still
616 +/// answered with a result, so the client keeps what was streamed.
617 +#[test]
618 +fn a_cancel_request_for_the_prompt_ends_the_turn_cancelled() {
619 + let scratch = Scratch::new("cancel_request");
620 + let endpoint = start_fake_endpoint(vec![
621 + echo_call("call_1", &scratch.work, false),
622 + sse_text("never asked for"),
623 + ]);
624 + let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
625 + let session_id = agent.open_session(true, &scratch.work);
626 +
627 + let mut editor = Editor::default();
628 + let response = agent.prompt_stopped_mid_command(&session_id, &mut editor, |prompt_id| {
629 + json!({
630 + "jsonrpc": "2.0",
631 + "method": "$/cancel_request",
632 + "params": {"requestId": prompt_id},
633 + })
634 + });
635 +
636 + assert_eq!(response["result"]["stopReason"], "cancelled", "{response}");
637 + assert_eq!(
638 + editor.request("terminal/kill")["params"]["terminalId"],
639 + "term-1"
640 + );
641 + assert_eq!(
642 + endpoint.requests.lock().unwrap().len(),
643 + 1,
644 + "a cancelled turn must not ask the model for another round"
645 + );
646 +}