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