From 8eb7a0925ce0e4c5cc276f7d8a2601d5d31e3cc3 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:44:13 +0100 Subject: [PATCH 1/6] mason: cover cancelled-call notices on visible tool replies Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit a47835c54947f59e8d3cb72e1f39419724da1177) --- .../next/cancelled-call-notice-any-tool.md | 1 + crates/aft/src/subc/bash_selector_tests.rs | 155 +++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 .alfonso/release-notes/next/cancelled-call-notice-any-tool.md diff --git a/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md b/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md new file mode 100644 index 0000000000..948b79b44f --- /dev/null +++ b/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md @@ -0,0 +1 @@ +an agent whose turn stopped mid-command now learns the command is still running on its next call to any tool, not only the next shell call diff --git a/crates/aft/src/subc/bash_selector_tests.rs b/crates/aft/src/subc/bash_selector_tests.rs index d75bc2b7ca..71bcb7516c 100644 --- a/crates/aft/src/subc/bash_selector_tests.rs +++ b/crates/aft/src/subc/bash_selector_tests.rs @@ -141,6 +141,29 @@ async fn route_bash( corr: u64, arguments: Value, ) { + route_cancel_test_tool( + executor, + root, + connection_cancel, + bash_tx, + corr, + "bash", + arguments, + ) + .await; +} + +#[cfg(unix)] +#[allow(clippy::too_many_arguments)] +async fn route_cancel_test_tool( + executor: &Arc, + root: &ProjectRootId, + connection_cancel: &PersistentCancelSignal, + bash_tx: &mpsc::Sender, + corr: u64, + name: &str, + arguments: Value, +) -> mpsc::Receiver { let identity = RouteIdentity(Arc::new(RouteIdentityData { root: root.clone(), project_root: root.as_path().into(), @@ -165,10 +188,10 @@ async fn route_bash( 41, 1, corr, - serde_json::to_vec(&json!({"name": "bash", "arguments": arguments})).unwrap(), + serde_json::to_vec(&json!({"name": name, "arguments": arguments})).unwrap(), ) .unwrap(); - let (writer, _replies) = mpsc::channel(8); + let (writer, replies) = mpsc::channel(8); let (touch_tx, _touch_rx) = mpsc::channel(8); let (deferred_tx, _deferred_rx) = mpsc::unbounded_channel(); let deferred_tx = super::DeferredResponseSender { @@ -200,7 +223,12 @@ async fn route_bash( &mut HashMap::new(), &mut HashMap::new(), &mut HashMap::new(), - |request, ctx| crate::commands::bash::handle(&request, ctx), + |request, ctx| match request.command.as_str() { + "bash" => crate::commands::bash::handle(&request, ctx), + "bash_status" => crate::commands::bash_status::handle(&request, ctx), + "read" => crate::commands::read::handle_read(&request, ctx), + other => panic!("unexpected cancelled-call test command: {other}"), + }, &deferred_tx, false, 1024 * 1024, @@ -209,6 +237,7 @@ async fn route_bash( ) .await .unwrap(); + replies } /// The text and response data of the next bash call answered on `bash_rx`. @@ -224,6 +253,126 @@ async fn next_bash_reply( (text, done.response_for_test().data.clone()) } +#[cfg(unix)] +async fn next_cancel_test_tool_reply(replies: &mut mpsc::Receiver) -> String { + let reply = tokio::time::timeout(Duration::from_secs(10), replies.recv()) + .await + .expect("tool call settles") + .expect("tool call reply"); + let body: Value = serde_json::from_slice(&reply.payload).unwrap(); + body["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("tool reply has no text: {body}")) + .to_string() +} + +#[cfg(unix)] +#[tokio::test] +async fn cancelled_call_notice_appears_on_read_once() { + cancelled_call_notice_read_case(false).await; +} + +#[cfg(unix)] +#[tokio::test] +async fn cancelled_call_notice_survives_native_status_and_appears_on_read_once() { + cancelled_call_notice_read_case(true).await; +} + +#[cfg(unix)] +async fn cancelled_call_notice_read_case(poll_native: bool) { + struct Shutdown(Arc); + impl Drop for Shutdown { + fn drop(&mut self) { + self.0.bash_background().shutdown(); + } + } + let (dir, root) = test_support::test_root("cancelled-call-read"); + let ctx = cancel_test_context(&root, &dir.path().join("storage")); + let _shutdown = Shutdown(Arc::clone(&ctx)); + let executor = Arc::new(Executor::new()); + assert!(executor.register_actor(root.clone(), Arc::clone(&ctx))); + let (bash_tx, mut bash_rx) = mpsc::channel(8); + let connection = PersistentCancelSignal::new(); + route_bash( + &executor, + &root, + &connection, + &bash_tx, + 1, + json!({"command": "sleep 60", "background": true}), + ) + .await; + let (_, data) = next_bash_reply(&mut bash_rx).await; + let task_id = data["task_id"].as_str().expect("background task id"); + ctx.bash_background() + .note_cancelled_call(task_id, CANCEL_SESSION); + let notice = format!("task {task_id} from your cancelled call is still running; check it with bash_status or stop it with bash_kill"); + + if poll_native { + let mut plumbing = route_cancel_test_tool( + &executor, + &root, + &connection, + &bash_tx, + 2, + "bash_status", + json!({"task_id": task_id}), + ) + .await; + let text = next_cancel_test_tool_reply(&mut plumbing).await; + assert!(!text.contains("cancelled call"), "{text}"); + } + + let path = root.as_path().join("notice.txt"); + std::fs::write(&path, "file contents").unwrap(); + let mut read = route_cancel_test_tool( + &executor, + &root, + &connection, + &bash_tx, + 3, + "read", + json!({"path": path}), + ) + .await; + let text = next_cancel_test_tool_reply(&mut read).await; + assert!(text.contains("file contents"), "{text}"); + assert_eq!(text.matches(¬ice).count(), 1, "{text}"); + + let mut status = route_cancel_test_tool( + &executor, + &root, + &connection, + &bash_tx, + 4, + "bash_status", + json!({"taskId": task_id}), + ) + .await; + let text = next_cancel_test_tool_reply(&mut status).await; + assert!(!text.contains("cancelled call"), "{text}"); + + // The agent's taskId spelling must receive notices; the plugin's task_id poll must not. + ctx.bash_background() + .note_cancelled_call(task_id, CANCEL_SESSION); + let mut status = route_cancel_test_tool( + &executor, + &root, + &connection, + &bash_tx, + 5, + "bash_status", + json!({"taskId": task_id}), + ) + .await; + let text = next_cancel_test_tool_reply(&mut status).await; + assert_eq!(text.matches(¬ice).count(), 1, "{text}"); + assert!(ctx + .bash_background() + .take_cancelled_call_notices(CANCEL_SESSION) + .is_empty()); +} + /// A `wait: true` call whose transport is lost while the command still runs /// answers with only the cancellation. The command keeps running as a task; /// across an AFT restart, the session's next bash call names it once, and its From a42c1072e9f1baaf91082f9243abe7c2c22a8619 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:49:47 +0100 Subject: [PATCH 2/6] mason: read projected tool frames in notice regressions Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit b42c3e76b6289c3d9090d531f5d509b7b51c1b41) --- crates/aft/src/subc/bash_selector_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/aft/src/subc/bash_selector_tests.rs b/crates/aft/src/subc/bash_selector_tests.rs index 71bcb7516c..f78906cdc0 100644 --- a/crates/aft/src/subc/bash_selector_tests.rs +++ b/crates/aft/src/subc/bash_selector_tests.rs @@ -163,7 +163,7 @@ async fn route_cancel_test_tool( corr: u64, name: &str, arguments: Value, -) -> mpsc::Receiver { +) -> mpsc::Receiver { let identity = RouteIdentity(Arc::new(RouteIdentityData { root: root.clone(), project_root: root.as_path().into(), @@ -254,12 +254,13 @@ async fn next_bash_reply( } #[cfg(unix)] -async fn next_cancel_test_tool_reply(replies: &mut mpsc::Receiver) -> String { - let reply = tokio::time::timeout(Duration::from_secs(10), replies.recv()) +async fn next_cancel_test_tool_reply(replies: &mut mpsc::Receiver) -> String { + let mut reply = tokio::time::timeout(Duration::from_secs(10), replies.recv()) .await .expect("tool call settles") .expect("tool call reply"); - let body: Value = serde_json::from_slice(&reply.payload).unwrap(); + reply.project_tool_result().unwrap(); + let body: Value = serde_json::from_slice(reply.body()).unwrap(); body["content"][0]["text"] .as_str() .unwrap_or_else(|| panic!("tool reply has no text: {body}")) From 0facbdc6a8edd48d736c757ee6aec014aa380bef Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:56:28 +0100 Subject: [PATCH 3/6] mason: seed a running task for notice routing regressions Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit cc6912a1280eb457c88fe4454ec9a75b0e9adc55) --- crates/aft/src/subc/bash_selector_tests.rs | 36 +++++++++++++--------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/aft/src/subc/bash_selector_tests.rs b/crates/aft/src/subc/bash_selector_tests.rs index f78906cdc0..3e78165810 100644 --- a/crates/aft/src/subc/bash_selector_tests.rs +++ b/crates/aft/src/subc/bash_selector_tests.rs @@ -292,21 +292,29 @@ async fn cancelled_call_notice_read_case(poll_native: bool) { let _shutdown = Shutdown(Arc::clone(&ctx)); let executor = Arc::new(Executor::new()); assert!(executor.register_actor(root.clone(), Arc::clone(&ctx))); - let (bash_tx, mut bash_rx) = mpsc::channel(8); + let (bash_tx, _bash_rx) = mpsc::channel(8); let connection = PersistentCancelSignal::new(); - route_bash( - &executor, - &root, - &connection, - &bash_tx, - 1, - json!({"command": "sleep 60", "background": true}), - ) - .await; - let (_, data) = next_bash_reply(&mut bash_rx).await; - let task_id = data["task_id"].as_str().expect("background task id"); + let task_id = ctx + .bash_background() + .spawn_with_shell_in_slot( + crate::sandbox_spawn::SpawnPlan::Unsandboxed, + "sleep 60", + crate::bash_background::BashShell::Bash, + crate::bash_background::registry::resolve_posix_shell(), + CANCEL_SESSION.into(), + root.as_path().into(), + HashMap::new(), + crate::bash_background::HardKill::After(Duration::from_secs(60)), + None, + dir.path().join("storage"), + crate::bash_background::TaskSlot::Background { max: 10 }, + true, + false, + Some(root.as_path().into()), + ) + .unwrap(); ctx.bash_background() - .note_cancelled_call(task_id, CANCEL_SESSION); + .note_cancelled_call(&task_id, CANCEL_SESSION); let notice = format!("task {task_id} from your cancelled call is still running; check it with bash_status or stop it with bash_kill"); if poll_native { @@ -355,7 +363,7 @@ async fn cancelled_call_notice_read_case(poll_native: bool) { // The agent's taskId spelling must receive notices; the plugin's task_id poll must not. ctx.bash_background() - .note_cancelled_call(task_id, CANCEL_SESSION); + .note_cancelled_call(&task_id, CANCEL_SESSION); let mut status = route_cancel_test_tool( &executor, &root, From c03801b48b90d5ee162459c1bdbac77cd42b9951 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:11:12 +0100 Subject: [PATCH 4/6] mason: deliver cancelled-call notices on any visible tool reply Use the shared per-call native-plumbing classifier before taskId translation, so native polls leave notices pending while agent calls consume them once. Management and health replies remain outside tool-result finalization. The owner-cancellation test now expects the next read reply to carry the notice, replacing its shell-only expectation as required by the approved behavior change. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 6aac658bcf7660f2c104ef71eee05fd9b90a40e1) --- .../aft/src/commands/bash_kill_scope_tests.rs | 14 +------ crates/aft/src/response_finalize.rs | 42 ++++++++++++------- crates/aft/src/subc/mod.rs | 5 ++- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/crates/aft/src/commands/bash_kill_scope_tests.rs b/crates/aft/src/commands/bash_kill_scope_tests.rs index bea57dcd2d..f1d322b4a2 100644 --- a/crates/aft/src/commands/bash_kill_scope_tests.rs +++ b/crates/aft/src/commands/bash_kill_scope_tests.rs @@ -288,7 +288,7 @@ fn bash_kill_scope_completion_is_an_expected_owner_stop() { } #[test] -fn bash_kill_scope_next_bash_family_reply_names_cancellation_once() { +fn bash_kill_scope_next_agent_reply_names_cancellation_once() { let dir = tempfile::tempdir().unwrap(); let mut config = crate::config::Config::default(); config.project_root = Some(dir.path().into()); @@ -301,23 +301,13 @@ fn bash_kill_scope_next_bash_family_reply_names_cancellation_once() { let task = tasks.start(&stamp, "session", "sleep 30", true); assert!(tasks.cancel(&stamp).success); let mut response = Response::success("next", json!({})); - let mut unrelated = String::new(); - crate::response_finalize::finalize_tool_response( - &mut response, - &mut unrelated, - &ctx, - "session", - "read", - false, - ); - assert!(!unrelated.contains("cancelled by owner")); let mut next = String::new(); crate::response_finalize::finalize_tool_response( &mut response, &mut next, &ctx, "session", - "bash_status", + "read", false, ); assert!( diff --git a/crates/aft/src/response_finalize.rs b/crates/aft/src/response_finalize.rs index 3ee7216746..c3cfde804b 100644 --- a/crates/aft/src/response_finalize.rs +++ b/crates/aft/src/response_finalize.rs @@ -150,6 +150,29 @@ pub fn finalize_tool_response( session_id: &str, attach_command: &str, allow_bg_completions: bool, +) { + finalize_tool_response_with_visibility( + response, + text, + ctx, + session_id, + attach_command, + allow_bg_completions, + true, + ); +} + +/// Native plugin calls share tool dispatch with agent calls, but their replies are not +/// shown to the agent. They must leave cancelled-call notices pending for a visible reply. +#[allow(clippy::too_many_arguments)] +pub(crate) fn finalize_tool_response_with_visibility( + response: &mut Response, + text: &mut String, + ctx: &AppContext, + session_id: &str, + attach_command: &str, + allow_bg_completions: bool, + agent_visible: bool, ) { let (publish, counts) = finalize_prefix( response, @@ -158,7 +181,9 @@ pub fn finalize_tool_response( attach_command, allow_bg_completions, ); - append_cancelled_call_notices(text, ctx, session_id, attach_command); + if agent_visible { + append_cancelled_call_notices(text, ctx, session_id); + } if let Some(line) = status_bar_line(ctx, publish, attach_command, session_id, counts) { append_trailing_line(text, &line); } @@ -209,21 +234,10 @@ fn acknowledge_terminal_bash_reply( } } -/// A bash-family reply names tasks the session has not heard about: commands +/// An agent-visible reply names tasks the session has not heard about: commands /// still running after their waiting call was cancelled, and commands stopped /// by an owner cancellation. The registry supplies the appropriate status for each. -fn append_cancelled_call_notices( - text: &mut String, - ctx: &AppContext, - session_id: &str, - command: &str, -) { - if !matches!( - command, - "bash" | "powershell" | "bash_status" | "bash_watch" | "bash_kill" | "bash_write" - ) { - return; - } +fn append_cancelled_call_notices(text: &mut String, ctx: &AppContext, session_id: &str) { for line in ctx .bash_background() .take_cancelled_call_notices(session_id) diff --git a/crates/aft/src/subc/mod.rs b/crates/aft/src/subc/mod.rs index a7c3d1bf8a..38ddc9464f 100644 --- a/crates/aft/src/subc/mod.rs +++ b/crates/aft/src/subc/mod.rs @@ -9739,13 +9739,15 @@ impl PlainToolRun { log_ctx::with_session(Some(identity_for_run.session.clone()), || { // Both the tool's run and the reply's finishing use the trace. let phase_trace = std::cell::RefCell::new(Some(phase_trace)); + // Classify before translation changes an agent's taskId to native task_id. + let agent_visible = !manifest::is_native_plumbing_call(&bare_name, &arguments); let finalizer = |response: &mut Response, text: &mut String| { // A UI copy must leave completions and cancellation notices // available to the session that actually owns the command. if observation_only { return; } - crate::response_finalize::finalize_tool_response( + crate::response_finalize::finalize_tool_response_with_visibility( response, text, ctx, @@ -9753,6 +9755,7 @@ impl PlainToolRun { &bare_name, bind_trust.allows_bash_observation() && !crate::context::confinement::active(), + agent_visible, ); }; let work = |arguments: Value| { From 70fc89c457d04fc04f103939ae8afdeed291a4fd Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:15:59 +0100 Subject: [PATCH 5/6] mason: preserve notices across hidden preview and consent replies Share notice rendering with standalone tool_call replies, without adding trailers to native plumbing, previews, or internal consent asks. Keep the existing admitted-call boundary: argument refusals leave notices pending for the next dispatched read, as covered by a routing regression. Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> (cherry picked from commit 9fe3a58a4552dcfcc7ca230c3e9a021a4b64f6c1) --- crates/aft/src/commands/tool_call.rs | 14 ++- crates/aft/src/response_finalize.rs | 15 ++- crates/aft/src/subc/bash_selector_tests.rs | 101 +++++++++++++++++++-- crates/aft/src/subc/mod.rs | 3 +- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/crates/aft/src/commands/tool_call.rs b/crates/aft/src/commands/tool_call.rs index dc38885495..45687a9dcb 100644 --- a/crates/aft/src/commands/tool_call.rs +++ b/crates/aft/src/commands/tool_call.rs @@ -119,11 +119,23 @@ fn handle_on_route(req: &RawRequest, ctx: &AppContext, dispatch: &DispatchFn<'_> }; let sanitized_arguments = strip_agent_preview_arg_owned(arguments); + let agent_visible = + !preview && !crate::subc::is_native_plumbing_call(name, &sanitized_arguments); let format_context = crate::subc_format::FormatContext::from_tool_call( name, &sanitized_arguments, tool_ctx.project_root.as_path(), ); + let finalizer = |response: &mut Response, text: &mut String| { + if agent_visible { + crate::response_finalize::append_cancelled_call_notices( + response, + text, + ctx, + req.session(), + ); + } + }; match run_tool_call( name, @@ -132,7 +144,7 @@ fn handle_on_route(req: &RawRequest, ctx: &AppContext, dispatch: &DispatchFn<'_> &tool_ctx, ctx, dispatch, - None, + Some(&finalizer), None, ) { ToolCallOutcome::Unary(result) => response_with_text(result.response, result.text), diff --git a/crates/aft/src/response_finalize.rs b/crates/aft/src/response_finalize.rs index c3cfde804b..d5e2a50854 100644 --- a/crates/aft/src/response_finalize.rs +++ b/crates/aft/src/response_finalize.rs @@ -182,7 +182,7 @@ pub(crate) fn finalize_tool_response_with_visibility( allow_bg_completions, ); if agent_visible { - append_cancelled_call_notices(text, ctx, session_id); + append_cancelled_call_notices(response, text, ctx, session_id); } if let Some(line) = status_bar_line(ctx, publish, attach_command, session_id, counts) { append_trailing_line(text, &line); @@ -237,7 +237,18 @@ fn acknowledge_terminal_bash_reply( /// An agent-visible reply names tasks the session has not heard about: commands /// still running after their waiting call was cancelled, and commands stopped /// by an owner cancellation. The registry supplies the appropriate status for each. -fn append_cancelled_call_notices(text: &mut String, ctx: &AppContext, session_id: &str) { +pub(crate) fn append_cancelled_call_notices( + response: &Response, + text: &mut String, + ctx: &AppContext, + session_id: &str, +) { + // An internal access ask is held for consent and replaced by a later tool reply. + if response.data.get("code").and_then(Value::as_str) + == Some(crate::access_policy::ACCESS_ASK_REQUIRED) + { + return; + } for line in ctx .bash_background() .take_cancelled_call_notices(session_id) diff --git a/crates/aft/src/subc/bash_selector_tests.rs b/crates/aft/src/subc/bash_selector_tests.rs index 3e78165810..ceb67115c4 100644 --- a/crates/aft/src/subc/bash_selector_tests.rs +++ b/crates/aft/src/subc/bash_selector_tests.rs @@ -149,6 +149,7 @@ async fn route_bash( corr, "bash", arguments, + false, ) .await; } @@ -163,6 +164,7 @@ async fn route_cancel_test_tool( corr: u64, name: &str, arguments: Value, + preview: bool, ) -> mpsc::Receiver { let identity = RouteIdentity(Arc::new(RouteIdentityData { root: root.clone(), @@ -188,7 +190,8 @@ async fn route_cancel_test_tool( 41, 1, corr, - serde_json::to_vec(&json!({"name": name, "arguments": arguments})).unwrap(), + serde_json::to_vec(&json!({"name": name, "arguments": arguments, "preview": preview})) + .unwrap(), ) .unwrap(); let (writer, replies) = mpsc::channel(8); @@ -270,17 +273,36 @@ async fn next_cancel_test_tool_reply(replies: &mut mpsc::Receiver); impl Drop for Shutdown { fn drop(&mut self) { @@ -317,34 +339,91 @@ async fn cancelled_call_notice_read_case(poll_native: bool) { .note_cancelled_call(&task_id, CANCEL_SESSION); let notice = format!("task {task_id} from your cancelled call is still running; check it with bash_status or stop it with bash_kill"); - if poll_native { + let arguments = match intervening { + NoticeInterveningCall::None => None, + NoticeInterveningCall::NativeStatus => Some(("bash_status", json!({"task_id": task_id}))), + NoticeInterveningCall::ArgumentRefusal => Some(("read", json!([]))), + }; + if let Some((name, arguments)) = arguments { let mut plumbing = route_cancel_test_tool( &executor, &root, &connection, &bash_tx, 2, - "bash_status", - json!({"task_id": task_id}), + name, + arguments, + false, ) .await; let text = next_cancel_test_tool_reply(&mut plumbing).await; assert!(!text.contains("cancelled call"), "{text}"); + if name == "read" { + assert!(text.contains("tool arguments must be an object"), "{text}"); + } } let path = root.as_path().join("notice.txt"); std::fs::write(&path, "file contents").unwrap(); - let mut read = route_cancel_test_tool( + let mut held = Response::error("held", crate::access_policy::ACCESS_ASK_REQUIRED, "ask"); + let mut text = String::new(); + crate::response_finalize::finalize_tool_response( + &mut held, + &mut text, + &ctx, + CANCEL_SESSION, + "read", + false, + ); + assert!(!text.contains("cancelled call"), "{text}"); + + let mut preview = route_cancel_test_tool( &executor, &root, &connection, &bash_tx, - 3, + 6, "read", json!({"path": path}), + true, ) .await; - let text = next_cancel_test_tool_reply(&mut read).await; + let text = next_cancel_test_tool_reply(&mut preview).await; + assert!(!text.contains("cancelled call"), "{text}"); + + let text = if standalone { + let mut request = RawRequest { + id: "standalone".into(), + command: "tool_call".into(), + params: json!({"name": "read", "arguments": {"path": path}, "preview": true}), + session_id: Some(CANCEL_SESSION.into()), + lsp_hints: None, + }; + let dispatch = |request: RawRequest, ctx: &AppContext| { + crate::commands::read::handle_read(&request, ctx) + }; + let preview = crate::commands::tool_call::handle_with_dispatch(&request, &ctx, &dispatch); + assert!(!preview.data["text"] + .as_str() + .unwrap() + .contains("cancelled call")); + request.params["preview"] = json!(false); + let read = crate::commands::tool_call::handle_with_dispatch(&request, &ctx, &dispatch); + read.data["text"].as_str().unwrap().to_string() + } else { + let mut read = route_cancel_test_tool( + &executor, + &root, + &connection, + &bash_tx, + 3, + "read", + json!({"path": path}), + false, + ) + .await; + next_cancel_test_tool_reply(&mut read).await + }; assert!(text.contains("file contents"), "{text}"); assert_eq!(text.matches(¬ice).count(), 1, "{text}"); @@ -356,6 +435,7 @@ async fn cancelled_call_notice_read_case(poll_native: bool) { 4, "bash_status", json!({"taskId": task_id}), + false, ) .await; let text = next_cancel_test_tool_reply(&mut status).await; @@ -372,6 +452,7 @@ async fn cancelled_call_notice_read_case(poll_native: bool) { 5, "bash_status", json!({"taskId": task_id}), + false, ) .await; let text = next_cancel_test_tool_reply(&mut status).await; diff --git a/crates/aft/src/subc/mod.rs b/crates/aft/src/subc/mod.rs index 38ddc9464f..107356ec44 100644 --- a/crates/aft/src/subc/mod.rs +++ b/crates/aft/src/subc/mod.rs @@ -9740,7 +9740,8 @@ impl PlainToolRun { // Both the tool's run and the reply's finishing use the trace. let phase_trace = std::cell::RefCell::new(Some(phase_trace)); // Classify before translation changes an agent's taskId to native task_id. - let agent_visible = !manifest::is_native_plumbing_call(&bare_name, &arguments); + let agent_visible = !tool_call_context.preview + && !manifest::is_native_plumbing_call(&bare_name, &arguments); let finalizer = |response: &mut Response, text: &mut String| { // A UI copy must leave completions and cancellation notices // available to the session that actually owns the command. From 7f363de7748c67885e16963e9d8ed780f6a9550b Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:32:55 +0100 Subject: [PATCH 6/6] Release note wording and search-quality descriptor for any-tool cancelled-call notices Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- .../next/cancelled-call-notice-any-tool.md | 2 +- .../land-cancelled-call-notice-any-tool.json | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 benchmarks/aft-search/slice-descriptors/land-cancelled-call-notice-any-tool.json diff --git a/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md b/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md index 948b79b44f..d61f6ea2bc 100644 --- a/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md +++ b/.alfonso/release-notes/next/cancelled-call-notice-any-tool.md @@ -1 +1 @@ -an agent whose turn stopped mid-command now learns the command is still running on its next call to any tool, not only the next shell call +- An agent whose turn stopped mid-command now learns the command is still running on its next call to any tool, not only on its next shell call. diff --git a/benchmarks/aft-search/slice-descriptors/land-cancelled-call-notice-any-tool.json b/benchmarks/aft-search/slice-descriptors/land-cancelled-call-notice-any-tool.json new file mode 100644 index 0000000000..cdd8da8ebe --- /dev/null +++ b/benchmarks/aft-search/slice-descriptors/land-cancelled-call-notice-any-tool.json @@ -0,0 +1,12 @@ +{ + "slice_class": "non_ranking", + "targeted_mechanism": "none", + "kind": "harness", + "fixtures": [ + "harness-goldens" + ], + "slices": [ + "cancelled-call-notice-any-tool" + ], + "note": "Cancelled-call notices ride the next agent-visible reply of any tool. No changed path matches RANKING_FENCE_PREFIXES." +}