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
+}