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 a1e3a87a69b27aa1e8f68d684d645b3db9c71ebd
4 files changed +199 -16
.agents/AGENTS.md
+5 -1
@@ -331,7 +331,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
331 conversation); lifecycle and config handlers hold it throughout, and `handle_prompt` holds it
332 through a `WorkspaceHold` that it releases while waiting on an HTTP endpoint or a permission
333 answer and retakes (reinstalling its session via `resume_workspace`) before any tool runs. An
334 - on-device turn never releases it. Take the session lock first, never the other way round. Tool
334 + on-device turn never releases it. Take the session lock first, never the other way round. A turn registers its `PromptCancellation` at
335 + the top of `handle_prompt`, before it waits on either lock, and a `PromptRegistration`
336 + guard clears it on every return, so a `session/cancel` that arrives while the turn
337 + is still queued, switching threads, loading a model or running a slash command ends
338 + it as `cancelled`. The checks sit after each of those waits. Tool
339 execution, subagents included, still runs one session at a time.
340 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
341 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
@@ -484,6 +484,21 @@ impl PromptCancellation {
484 }
485 }
486
487 +/// Removes a turn's cancellation from the agent's registry when the turn ends,
488 +/// whichever way it returns.
489 +struct PromptRegistration<'a> {
490 + agent: &'a SiGitAgent,
491 + session_id: SessionId,
492 + cancellation: Arc<PromptCancellation>,
493 +}
494 +
495 +impl Drop for PromptRegistration<'_> {
496 + fn drop(&mut self) {
497 + self.agent
498 + .finish_prompt(&self.session_id, &self.cancellation);
499 + }
500 +}
501 +
502 enum DrainTurnError {
503 Backend(backend::BackendError),
504 Cancelled,
@@ -3211,10 +3226,31 @@ impl SiGitAgent {
3226 return Ok(PromptResponse::new(StopReason::Cancelled));
3227 }
3228
3229 + // Registered before anything that can wait, so a `session/cancel` that
3230 + // lands while this turn is still queued on the workspace, switching
3231 + // sessions or loading a model is seen. The session lock is held, so the
3232 + // slot can only be this turn's. The guard clears it on every return.
3233 + let cancellation = Arc::new(PromptCancellation::default());
3234 + self.prompt_cancellations
3235 + .lock()
3236 + .unwrap()
3237 + .insert(session_id.to_string(), Arc::clone(&cancellation));
3238 + let _registration = PromptRegistration {
3239 + agent: self,
3240 + session_id: session_id.clone(),
3241 + cancellation: Arc::clone(&cancellation),
3242 + };
3243 +
3244 // Everything below (slash commands included) acts on the live session,
3245 // so it has to be this one. Resource links are read relative to its cwd.
3246 let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
3247 + if cancellation.cancelled.load(Ordering::Acquire) {
3248 + return Ok(PromptResponse::new(StopReason::Cancelled));
3249 + }
3250 self.activate_session(&session_id).await?;
3251 + if cancellation.cancelled.load(Ordering::Acquire) {
3252 + return Ok(PromptResponse::new(StopReason::Cancelled));
3253 + }
3254
3255 // log every block so we can debug @ references and file context
3256 for (i, block) in args.prompt.iter().enumerate() {
@@ -3352,7 +3388,7 @@ impl SiGitAgent {
3388
3389 let user_text = parts.join("\n");
3390
3355 - if user_text.trim().is_empty() && images.is_empty() {
3391 + if user_text.trim().is_empty() && images.is_empty() && audio.is_empty() {
3392 return Ok(PromptResponse::new(StopReason::EndTurn));
3393 }
3394
@@ -3378,17 +3414,18 @@ impl SiGitAgent {
3414 match commands::resolve_command(&command) {
3415 Some(custom) => commands::render(&custom.body, argument.as_deref()),
3416 None => {
3381 - return exec_slash_acp(
3382 - self,
3383 - cx,
3384 - session_id,
3385 - SlashCommand::Unknown(command, argument),
3386 - )
3387 - .await;
3417 + return self
3418 + .run_slash(
3419 + cx,
3420 + session_id,
3421 + SlashCommand::Unknown(command, argument),
3422 + &cancellation,
3423 + )
3424 + .await;
3425 }
3426 }
3427 }
3391 - Some(command) => return exec_slash_acp(self, cx, session_id, command).await,
3428 + Some(command) => return self.run_slash(cx, session_id, command, &cancellation).await,
3429 None => user_text,
3430 };
3431
@@ -3415,7 +3452,12 @@ impl SiGitAgent {
3452 {
3453 if self.auto_load_local_model {
3454 self.start_startup_model_load_if_needed();
3418 - self.await_model_ready(cx, &session_id).await?;
3455 + tokio::select! {
3456 + ready = self.await_model_ready(cx, &session_id) => ready?,
3457 + () = cancellation.cancelled() => {
3458 + return Ok(PromptResponse::new(StopReason::Cancelled));
3459 + }
3460 + }
3461 } else {
3462 self.send_assistant_message(
3463 cx,
@@ -3428,11 +3470,9 @@ impl SiGitAgent {
3470 }
3471 }
3472
3431 - let cancellation = Arc::new(PromptCancellation::default());
3432 - self.prompt_cancellations
3433 - .lock()
3434 - .unwrap()
3435 - .insert(session_id.to_string(), Arc::clone(&cancellation));
3473 + if cancellation.cancelled.load(Ordering::Acquire) {
3474 + return Ok(PromptResponse::new(StopReason::Cancelled));
3475 + }
3476
3477 // ── tool-calling loop ────────────────────────────────────────────
3478 // send message → execute any tool calls → feed results back
@@ -4198,6 +4238,24 @@ impl SiGitAgent {
4238 }
4239 }
4240
4241 + /// Run a built-in slash command as a turn. A `session/cancel` ends it with
4242 + /// `Cancelled`; only `/compact` and `/load` take long enough to notice.
4243 + async fn run_slash(
4244 + &self,
4245 + cx: &ConnectionTo<Client>,
4246 + session_id: SessionId,
4247 + command: SlashCommand,
4248 + cancellation: &PromptCancellation,
4249 + ) -> agent_client_protocol::Result<PromptResponse> {
4250 + if cancellation.cancelled.load(Ordering::Acquire) {
4251 + return Ok(PromptResponse::new(StopReason::Cancelled));
4252 + }
4253 + tokio::select! {
4254 + response = exec_slash_acp(self, cx, session_id, command) => response,
4255 + () = cancellation.cancelled() => Ok(PromptResponse::new(StopReason::Cancelled)),
4256 + }
4257 + }
4258 +
4259 fn finish_prompt(&self, session_id: &SessionId, cancellation: &Arc<PromptCancellation>) {
4260 let key = session_id.to_string();
4261 let mut active = self.prompt_cancellations.lock().unwrap();
@@ -6722,6 +6780,71 @@ mod tests {
6780 assert!(agent.active_session.lock().unwrap().is_none());
6781 }
6782
6783 + #[tokio::test]
6784 + async fn a_turn_leaves_no_cancellation_behind_however_it_returns() {
6785 + let agent = SiGitAgent::new(
6786 + Arc::new(ChatEngine::new()),
6787 + default_local_model_config(),
6788 + Arc::new(AtomicBool::new(true)),
6789 + Arc::new(AtomicBool::new(false)),
6790 + Arc::new(std::sync::Mutex::new(None)),
6791 + false,
6792 + false,
6793 + );
6794 + let session_id = SessionId::new("early");
6795 + let turn = Arc::new(PromptCancellation::default());
6796 + agent
6797 + .prompt_cancellations
6798 + .lock()
6799 + .unwrap()
6800 + .insert("early".into(), Arc::clone(&turn));
6801 + let registration = PromptRegistration {
6802 + agent: &agent,
6803 + session_id: session_id.clone(),
6804 + cancellation: Arc::clone(&turn),
6805 + };
6806 +
6807 + // A cancel that arrives before the turn reaches inference is recorded
6808 + // and stays visible to every check the turn makes afterwards.
6809 + agent
6810 + .handle_cancel(CancelNotification::new(session_id.clone()))
6811 + .await
6812 + .unwrap();
6813 + assert!(turn.cancelled.load(Ordering::Acquire));
6814 + tokio::time::timeout(std::time::Duration::from_millis(50), turn.cancelled())
6815 + .await
6816 + .expect("a pre-cancelled turn must resolve at once");
6817 +
6818 + drop(registration);
6819 + assert!(agent.prompt_cancellations.lock().unwrap().is_empty());
6820 + }
6821 +
6822 + #[tokio::test]
6823 + async fn a_finished_turn_does_not_remove_its_successors_cancellation() {
6824 + let agent = SiGitAgent::new(
6825 + Arc::new(ChatEngine::new()),
6826 + default_local_model_config(),
6827 + Arc::new(AtomicBool::new(true)),
6828 + Arc::new(AtomicBool::new(false)),
6829 + Arc::new(std::sync::Mutex::new(None)),
6830 + false,
6831 + false,
6832 + );
6833 + let old = Arc::new(PromptCancellation::default());
6834 + let new = Arc::new(PromptCancellation::default());
6835 + agent
6836 + .prompt_cancellations
6837 + .lock()
6838 + .unwrap()
6839 + .insert("s".into(), Arc::clone(&new));
6840 + drop(PromptRegistration {
6841 + agent: &agent,
6842 + session_id: SessionId::new("s"),
6843 + cancellation: old,
6844 + });
6845 + assert!(agent.prompt_cancellations.lock().unwrap().contains_key("s"));
6846 + }
6847 +
6848 #[tokio::test]
6849 async fn cancel_signals_only_the_matching_active_prompt() {
6850 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 +}