Honor a cancel that arrives before the turn starts, and answer audio-only prompts

A prompt only registered for session/cancel once it reached inference, so a cancel that landed while the turn waited on the workspace, switched threads, loaded a model or ran a slash command was dropped and the turn ended end_turn. The registration now happens at the top of handle_prompt and a drop guard clears it, with a check after each wait. A prompt holding only audio ended the turn at once because the emptiness check ignored clips, although the agent advertises audio. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.6-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 10, 2026 at 17:03 UTC fbcdaa35c974f60813cb6ad0c3c37a5ab79d84d3
4 files changed +199 -16
.agents/AGENTS.md
+5 -1
@@ -310,7 +310,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
310 conversation); lifecycle and config handlers hold it throughout, and `handle_prompt` holds it
311 through a `WorkspaceHold` that it releases while waiting on an HTTP endpoint or a permission
312 answer and retakes (reinstalling its session via `resume_workspace`) before any tool runs. An
313 - on-device turn never releases it. Take the session lock first, never the other way round. Tool
313 + on-device turn never releases it. Take the session lock first, never the other way round. A turn registers its `PromptCancellation` at
314 + the top of `handle_prompt`, before it waits on either lock, and a `PromptRegistration`
315 + guard clears it on every return, so a `session/cancel` that arrives while the turn
316 + is still queued, switching threads, loading a model or running a slash command ends
317 + it as `cancelled`. The checks sit after each of those waits. Tool
318 execution, subagents included, still runs one session at a time.
319 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
320 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
CHANGELOG.md
+11
@@ -2,6 +2,17 @@
2
3 ## Unreleased
4
5 +### Fixed
6 +
7 +- **A cancel that arrives before the turn starts is no longer lost.** A prompt
8 + now registers for `session/cancel` as soon as it begins, not once it reaches
9 + inference. A cancel that lands while the turn is queued on the workspace,
10 + switching threads, loading a model, or running a slash command ends it with
11 + `cancelled` instead of `end_turn`.
12 +- **A prompt that is only audio is answered.** A clip with no text or image used
13 + to end the turn at once with nothing sent to the model, even though the agent
14 + advertises audio.
15 +
16 ## 1.6.6
17
18 ### Added
src/main.rs
+138 -15
@@ -465,6 +465,21 @@ impl PromptCancellation {
465 }
466 }
467
468 +/// Removes a turn's cancellation from the agent's registry when the turn ends,
469 +/// whichever way it returns.
470 +struct PromptRegistration<'a> {
471 + agent: &'a SiGitAgent,
472 + session_id: SessionId,
473 + cancellation: Arc<PromptCancellation>,
474 +}
475 +
476 +impl Drop for PromptRegistration<'_> {
477 + fn drop(&mut self) {
478 + self.agent
479 + .finish_prompt(&self.session_id, &self.cancellation);
480 + }
481 +}
482 +
483 enum DrainTurnError {
484 Backend(backend::BackendError),
485 Cancelled,
@@ -3063,10 +3078,31 @@ impl SiGitAgent {
3078 return Ok(PromptResponse::new(StopReason::Cancelled));
3079 }
3080
3081 + // Registered before anything that can wait, so a `session/cancel` that
3082 + // lands while this turn is still queued on the workspace, switching
3083 + // sessions or loading a model is seen. The session lock is held, so the
3084 + // slot can only be this turn's. The guard clears it on every return.
3085 + let cancellation = Arc::new(PromptCancellation::default());
3086 + self.prompt_cancellations
3087 + .lock()
3088 + .unwrap()
3089 + .insert(session_id.to_string(), Arc::clone(&cancellation));
3090 + let _registration = PromptRegistration {
3091 + agent: self,
3092 + session_id: session_id.clone(),
3093 + cancellation: Arc::clone(&cancellation),
3094 + };
3095 +
3096 // Everything below (slash commands included) acts on the live session,
3097 // so it has to be this one. Resource links are read relative to its cwd.
3098 let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
3099 + if cancellation.cancelled.load(Ordering::Acquire) {
3100 + return Ok(PromptResponse::new(StopReason::Cancelled));
3101 + }
3102 self.activate_session(&session_id).await?;
3103 + if cancellation.cancelled.load(Ordering::Acquire) {
3104 + return Ok(PromptResponse::new(StopReason::Cancelled));
3105 + }
3106
3107 // log every block so we can debug @ references and file context
3108 for (i, block) in args.prompt.iter().enumerate() {
@@ -3204,7 +3240,7 @@ impl SiGitAgent {
3240
3241 let user_text = parts.join("\n");
3242
3207 - if user_text.trim().is_empty() && images.is_empty() {
3243 + if user_text.trim().is_empty() && images.is_empty() && audio.is_empty() {
3244 return Ok(PromptResponse::new(StopReason::EndTurn));
3245 }
3246
@@ -3230,17 +3266,18 @@ impl SiGitAgent {
3266 match commands::resolve_command(&command) {
3267 Some(custom) => commands::render(&custom.body, argument.as_deref()),
3268 None => {
3233 - return exec_slash_acp(
3234 - self,
3235 - cx,
3236 - session_id,
3237 - SlashCommand::Unknown(command, argument),
3238 - )
3239 - .await;
3269 + return self
3270 + .run_slash(
3271 + cx,
3272 + session_id,
3273 + SlashCommand::Unknown(command, argument),
3274 + &cancellation,
3275 + )
3276 + .await;
3277 }
3278 }
3279 }
3243 - Some(command) => return exec_slash_acp(self, cx, session_id, command).await,
3280 + Some(command) => return self.run_slash(cx, session_id, command, &cancellation).await,
3281 None => user_text,
3282 };
3283
@@ -3267,7 +3304,12 @@ impl SiGitAgent {
3304 {
3305 if self.auto_load_local_model {
3306 self.start_startup_model_load_if_needed();
3270 - self.await_model_ready(cx, &session_id).await?;
3307 + tokio::select! {
3308 + ready = self.await_model_ready(cx, &session_id) => ready?,
3309 + () = cancellation.cancelled() => {
3310 + return Ok(PromptResponse::new(StopReason::Cancelled));
3311 + }
3312 + }
3313 } else {
3314 self.send_assistant_message(
3315 cx,
@@ -3280,11 +3322,9 @@ impl SiGitAgent {
3322 }
3323 }
3324
3283 - let cancellation = Arc::new(PromptCancellation::default());
3284 - self.prompt_cancellations
3285 - .lock()
3286 - .unwrap()
3287 - .insert(session_id.to_string(), Arc::clone(&cancellation));
3325 + if cancellation.cancelled.load(Ordering::Acquire) {
3326 + return Ok(PromptResponse::new(StopReason::Cancelled));
3327 + }
3328
3329 // ── tool-calling loop ────────────────────────────────────────────
3330 // send message → execute any tool calls → feed results back
@@ -3987,6 +4027,24 @@ impl SiGitAgent {
4027 }
4028 }
4029
4030 + /// Run a built-in slash command as a turn. A `session/cancel` ends it with
4031 + /// `Cancelled`; only `/compact` and `/load` take long enough to notice.
4032 + async fn run_slash(
4033 + &self,
4034 + cx: &ConnectionTo<Client>,
4035 + session_id: SessionId,
4036 + command: SlashCommand,
4037 + cancellation: &PromptCancellation,
4038 + ) -> agent_client_protocol::Result<PromptResponse> {
4039 + if cancellation.cancelled.load(Ordering::Acquire) {
4040 + return Ok(PromptResponse::new(StopReason::Cancelled));
4041 + }
4042 + tokio::select! {
4043 + response = exec_slash_acp(self, cx, session_id, command) => response,
4044 + () = cancellation.cancelled() => Ok(PromptResponse::new(StopReason::Cancelled)),
4045 + }
4046 + }
4047 +
4048 fn finish_prompt(&self, session_id: &SessionId, cancellation: &Arc<PromptCancellation>) {
4049 let key = session_id.to_string();
4050 let mut active = self.prompt_cancellations.lock().unwrap();
@@ -6406,6 +6464,71 @@ mod tests {
6464 assert!(agent.active_session.lock().unwrap().is_none());
6465 }
6466
6467 + #[tokio::test]
6468 + async fn a_turn_leaves_no_cancellation_behind_however_it_returns() {
6469 + let agent = SiGitAgent::new(
6470 + Arc::new(ChatEngine::new()),
6471 + default_local_model_config(),
6472 + Arc::new(AtomicBool::new(true)),
6473 + Arc::new(AtomicBool::new(false)),
6474 + Arc::new(std::sync::Mutex::new(None)),
6475 + false,
6476 + false,
6477 + );
6478 + let session_id = SessionId::new("early");
6479 + let turn = Arc::new(PromptCancellation::default());
6480 + agent
6481 + .prompt_cancellations
6482 + .lock()
6483 + .unwrap()
6484 + .insert("early".into(), Arc::clone(&turn));
6485 + let registration = PromptRegistration {
6486 + agent: &agent,
6487 + session_id: session_id.clone(),
6488 + cancellation: Arc::clone(&turn),
6489 + };
6490 +
6491 + // A cancel that arrives before the turn reaches inference is recorded
6492 + // and stays visible to every check the turn makes afterwards.
6493 + agent
6494 + .handle_cancel(CancelNotification::new(session_id.clone()))
6495 + .await
6496 + .unwrap();
6497 + assert!(turn.cancelled.load(Ordering::Acquire));
6498 + tokio::time::timeout(std::time::Duration::from_millis(50), turn.cancelled())
6499 + .await
6500 + .expect("a pre-cancelled turn must resolve at once");
6501 +
6502 + drop(registration);
6503 + assert!(agent.prompt_cancellations.lock().unwrap().is_empty());
6504 + }
6505 +
6506 + #[tokio::test]
6507 + async fn a_finished_turn_does_not_remove_its_successors_cancellation() {
6508 + let agent = SiGitAgent::new(
6509 + Arc::new(ChatEngine::new()),
6510 + default_local_model_config(),
6511 + Arc::new(AtomicBool::new(true)),
6512 + Arc::new(AtomicBool::new(false)),
6513 + Arc::new(std::sync::Mutex::new(None)),
6514 + false,
6515 + false,
6516 + );
6517 + let old = Arc::new(PromptCancellation::default());
6518 + let new = Arc::new(PromptCancellation::default());
6519 + agent
6520 + .prompt_cancellations
6521 + .lock()
6522 + .unwrap()
6523 + .insert("s".into(), Arc::clone(&new));
6524 + drop(PromptRegistration {
6525 + agent: &agent,
6526 + session_id: SessionId::new("s"),
6527 + cancellation: old,
6528 + });
6529 + assert!(agent.prompt_cancellations.lock().unwrap().contains_key("s"));
6530 + }
6531 +
6532 #[tokio::test]
6533 async fn cancel_signals_only_the_matching_active_prompt() {
6534 let agent = SiGitAgent::new(
tests/acp_audio.rs
+45
@@ -308,3 +308,48 @@ fn a_cloud_tier_gets_the_text_and_the_user_is_told() {
308 "the user must be told the clip was left out: {updates}"
309 );
310 }
311 +
312 +#[test]
313 +fn an_audio_only_prompt_reaches_the_endpoint() {
314 + let endpoint = start_fake_endpoint(vec![sse_text("heard it")]);
315 + let scratch = std::env::temp_dir().join(format!("sigit_acp_audio_only_{}", std::process::id()));
316 + let config_dir = scratch.join("config");
317 + let project = scratch.join("project");
318 + std::fs::create_dir_all(&config_dir).unwrap();
319 + std::fs::create_dir_all(&project).unwrap();
320 +
321 + let mut agent = spawn_agent(endpoint.port, &config_dir, "scripted-audio-model");
322 + let id = agent.request(
323 + "initialize",
324 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
325 + );
326 + agent.wait_for_response(id);
327 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
328 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
329 + .as_str()
330 + .expect("session id")
331 + .to_string();
332 +
333 + let id = agent.request(
334 + "session/prompt",
335 + json!({
336 + "sessionId": session_id,
337 + "prompt": [{"type": "audio", "mimeType": "audio/wav", "data": CLIP}],
338 + }),
339 + );
340 + let response = agent.wait_for_response(id);
341 + assert_eq!(response["result"]["stopReason"], "end_turn");
342 +
343 + let requests = endpoint.requests.lock().unwrap();
344 + let request = requests
345 + .first()
346 + .expect("a clip with no text is still a prompt, so the endpoint must see it");
347 + assert!(
348 + request.to_string().contains(CLIP),
349 + "the clip must reach the endpoint: {request}"
350 + );
351 +
352 + drop(requests);
353 + drop(agent);
354 + let _ = std::fs::remove_dir_all(&scratch);
355 +}