diff --git a/Cargo.lock b/Cargo.lock index 1782d0e..4b506f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,9 +311,9 @@ dependencies = [ [[package]] name = "agentkit-task-manager" -version = "0.10.5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b0ff043a61611fb3b867ac367cfb9beb3a1c71487a15754acb17b2f4d8920fd" +checksum = "4e5783e77661abcf2739325383c5bb348b83ba8d6c066bebb47b8ba3671ba921" dependencies = [ "agentkit-core", "agentkit-tools-core", @@ -2418,7 +2418,7 @@ dependencies = [ [[package]] name = "kit" -version = "0.1.85" +version = "0.1.86" dependencies = [ "a2a-protocol-client", "a2a-protocol-server", diff --git a/Cargo.toml b/Cargo.toml index 601cb4f..a49773e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kit" -version = "0.1.85" +version = "0.1.86" edition = "2024" rust-version = "1.94.0" publish = false @@ -21,7 +21,7 @@ agentkit-mcp = "=0.10.6" agentkit-http = "=0.10.5" agentkit-plugins = "=0.10.7" agentkit-provider-openrouter = "=0.10.7" -agentkit-task-manager = "=0.10.5" +agentkit-task-manager = "=0.10.6" agentkit-tool-compose = { version = "=0.10.9", default-features = false, features = ["runlet"] } agentkit-tool-skills = "=0.10.7" agentkit-tools-core = "=0.10.5" diff --git a/README.md b/README.md index 487dbf3..7fcc619 100644 --- a/README.md +++ b/README.md @@ -377,8 +377,11 @@ top-level `background` argument. `background: true` starts it in the background; it if it is still running. `false` or omission keeps normal foreground behavior. The delay must be an integer from 1 through 86,400 seconds. -Detached calls remain visible and selectable in the TUI runtime graph. Interrupting -the originating turn does not stop them. The model receives each detached call's ID +Press `Command+B` in the TUI to move the newest running foreground top-level +compose call into the background. This shortcut requires a terminal that reports +the Command key through the Kitty keyboard protocol; there is no control-key equivalent. Detached calls +remain visible and selectable in the TUI runtime graph. Interrupting the originating +turn does not stop them. The model receives each detached call's ID and can cancel it with `close({ call_id: "call_..." })`; the selected running background call can also be killed with `Ctrl+K` in the TUI. Completion and cancellation are delivered through the normal background-result lifecycle. Detached @@ -542,6 +545,7 @@ child process with `KIT_RUNTIME_EVENTS=1`; other ACP hosts never see them. | `/model name` | switch immediately to the closest catalog match | | `⇧⏎`, `⌥⏎`, `^j` | newline | | `esc` | interrupt the running turn | +| `⌘b` | move the newest running foreground compose call to the background | | `^c` | interrupt, or quit when idle | | `⌥←/→`, `^a`/`^e`, `home`/`end` | word and line movement | | `⌥⌫`, `^w` | delete the previous word | diff --git a/docs/user/tui-and-sessions.md b/docs/user/tui-and-sessions.md index 4ae61cf..9df8e2c 100644 --- a/docs/user/tui-and-sessions.md +++ b/docs/user/tui-and-sessions.md @@ -25,6 +25,7 @@ A session ID must be 1–128 ASCII letters, digits, `-`, or `_`. `kit prompt` us | `Enter` | Send a non-empty prompt when idle | | `Shift+Enter`, `Option+Enter`, `Ctrl+J` | Insert a newline | | `Esc` | Interrupt a running turn; dismiss a notice when idle | +| `Command+B` | Move the newest running foreground top-level compose call to the background | | `Ctrl+C` | Interrupt a running turn; clear a non-empty idle prompt; quit when idle with an empty prompt | | `Ctrl+D` | Quit when the prompt is empty | | `Option+Left/Right`, `Ctrl+A`/`Ctrl+E`, `Home`/`End` | Move by word or to the start/end of a line | @@ -63,7 +64,7 @@ Press `Esc` or `Ctrl+C` once to request cancellation. The TUI shows `interruptin If a turn does not stop, press `Ctrl+C` again while Kit is cancelling to leave the TUI and terminate its agent child. On normal exit during a turn, Kit first requests cancellation and briefly allows the turn to unwind so tool outcomes can be persisted, then closes the session and releases its lock. -Interrupting a turn does not stop detached background calls. Select a running background tool card and press `Ctrl+K` to kill only that call; the selected title is accented and shows `^k kill`. When a background result starts an autonomous agent continuation, the TUI displays it as an active turn, and `Esc` or `Ctrl+C` interrupts it normally. +Press `Command+B` to detach the newest running foreground top-level compose call without waiting for it to finish. This shortcut requires a terminal that reports the Command key through the Kitty keyboard protocol; it has no control-key equivalent. Interrupting a turn does not stop detached background calls. Select a running background tool card and press `Ctrl+K` to kill only that call; the selected title is accented and shows `^k kill`. When a background result starts an autonomous agent continuation, the TUI displays it as an active turn, and `Esc` or `Ctrl+C` interrupts it normally. At an idle, non-empty editor, `Ctrl+C` clears the prompt instead of unexpectedly discarding it and quitting in one step; press it again with the empty editor to quit. diff --git a/src/protocols/acp.rs b/src/protocols/acp.rs index c21fb39..57f922e 100644 --- a/src/protocols/acp.rs +++ b/src/protocols/acp.rs @@ -39,7 +39,7 @@ use tokio::{ use crate::{ provider::{ModelGroup, ModelSelection, ReasoningEffort, SelectableAdapter, model_catalog}, - runtime::{AcpDriverContext, BackgroundJobs, Runtime}, + runtime::{AcpDriverContext, BackgroundJobs, DetachRegistration, Runtime}, }; const MODEL_CONFIG_ID: &str = "model"; @@ -289,6 +289,19 @@ pub(crate) struct CancelBackgroundResponse { pub cancelled: bool, } +/// Kit-private ACP extension used by the bundled TUI to detach one running compose call. +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcRequest)] +#[request(method = "kit/compose/detach", response = DetachComposeResponse)] +pub(crate) struct DetachComposeRequest { + pub session_id: agentkit_acp::SessionId, + pub call_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcResponse)] +pub(crate) struct DetachComposeResponse { + pub detached: bool, +} + /// Kit-private ACP notification that keeps the bundled TUI synchronized with /// turns started autonomously by background task results. #[derive(Debug, Clone, Serialize, Deserialize, agent_client_protocol::JsonRpcNotification)] @@ -323,6 +336,7 @@ struct SessionHandle { token: u64, commands: mpsc::Sender, background_jobs: BackgroundJobs, + tasks: TaskManagerHandle, } #[derive(Clone)] @@ -651,6 +665,7 @@ impl Server { let catalog = model_catalog(¤t).await; let config_options = config_options(¤t, reasoning_effort, &catalog); let background_jobs = driver.background_jobs.clone(); + let tasks = driver.tasks.clone(); let canonical_transcript = driver.canonical_transcript; let (tx, rx) = mpsc::channel(8); let actor = SessionActor { @@ -714,6 +729,7 @@ impl Server { token, commands: tx, background_jobs, + tasks, }, ); drop(sessions); @@ -797,6 +813,22 @@ impl Server { .ok_or_else(|| AcpRuntimeError::SessionNotFound(session_id.to_string())) } + async fn detach_compose( + &self, + request: DetachComposeRequest, + ) -> Result { + let (background_jobs, tasks) = self + .sessions + .lock() + .expect("ACP session map poisoned") + .get(&request.session_id) + .map(|session| (session.background_jobs.clone(), session.tasks.clone())) + .ok_or_else(|| AcpRuntimeError::SessionNotFound(request.session_id.to_string()))?; + Ok(DetachComposeResponse { + detached: detach_compose_call(&tasks, &background_jobs, &request.call_id).await, + }) + } + async fn cancel_background( &self, request: CancelBackgroundRequest, @@ -814,6 +846,31 @@ impl Server { } } +async fn detach_compose_call( + tasks: &TaskManagerHandle, + background_jobs: &BackgroundJobs, + call_id: &str, +) -> bool { + let Some(task) = tasks.list_running().await.into_iter().find(|task| { + task.call_id.0 == call_id + && task.tool_name == agentkit_tool_compose::COMPOSE_TOOL_NAME + && task.kind == agentkit_task_manager::TaskKind::Foreground + }) else { + return false; + }; + match background_jobs.detach(call_id) { + Some(DetachRegistration::AlreadyDetached) => true, + Some(DetachRegistration::Registered) => { + if tasks.detach(task.id).await.is_err() { + background_jobs.restore_foreground(call_id); + return false; + } + true + } + None => false, + } +} + struct SessionActor { session_id: agentkit_acp::SessionId, integration: Arc, @@ -1309,6 +1366,21 @@ fn component( }, agent_client_protocol::on_receive_request!(), ) + .on_receive_request( + { + let state = Arc::clone(&state); + async move |request: DetachComposeRequest, responder, cx| { + let state = Arc::clone(&state); + cx.spawn(async move { + responder.respond_with_result( + state.detach_compose(request).await.map_err(sdk_error), + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) .on_receive_request( { let state = Arc::clone(&state); @@ -1752,7 +1824,7 @@ mod tests { request: TurnRequest, _cancellation: Option, ) -> Result { - self.turns.fetch_add(1, Ordering::SeqCst); + let turn = self.turns.fetch_add(1, Ordering::SeqCst) + 1; self.user_items_seen.store( request .transcript @@ -1769,12 +1841,12 @@ mod tests { .count(), Ordering::SeqCst, ); - let completed = request.transcript.iter().any(|item| { + let called = request.transcript.iter().any(|item| { item.parts .iter() - .any(|part| matches!(part, Part::ToolResult(_))) + .any(|part| matches!(part, Part::ToolCall(_))) }); - let events = if completed { + let events = if turn >= 3 { let text = "autonomous background completion"; VecDeque::from([ ModelTurnEvent::Delta(Delta::BeginPart { @@ -1794,10 +1866,30 @@ mod tests { metadata: MetadataMap::new(), }), ]) + } else if called { + let text = "compose detached"; + VecDeque::from([ + ModelTurnEvent::Delta(Delta::BeginPart { + part_id: PartId::new("detached"), + kind: PartKind::Text, + }), + ModelTurnEvent::Delta(Delta::AppendText { + part_id: PartId::new("detached"), + chunk: text.into(), + }), + ModelTurnEvent::Finished(ModelTurnResult { + model: None, + response_id: None, + finish_reason: FinishReason::Completed, + output_items: vec![Item::text(ItemKind::Assistant, text)], + usage: None, + metadata: MetadataMap::new(), + }), + ]) } else { let call = ToolCallPart { id: ToolCallId::new("background-call"), - name: "background-test".into(), + name: agentkit_tool_compose::COMPOSE_TOOL_NAME.into(), input: json!({}), metadata: MetadataMap::new(), }; @@ -1921,7 +2013,7 @@ mod tests { } #[tokio::test] - async fn completed_background_task_advances_actor_and_emits_unsolicited_update() { + async fn foreground_compose_detaches_out_of_band_and_completes_autonomously() { let turns = Arc::new(AtomicUsize::new(0)); let user_items_seen = Arc::new(AtomicUsize::new(0)); let notification_items_seen = Arc::new(AtomicUsize::new(0)); @@ -1962,19 +2054,14 @@ mod tests { } }); - let task_manager = - AsyncTaskManager::new().routing(|request: &agentkit_tools_core::ToolRequest| { - if request.tool_name.0 == "background-test" { - RoutingDecision::Background - } else { - RoutingDecision::Foreground - } - }); + let task_manager = AsyncTaskManager::new() + .routing(|_request: &agentkit_tools_core::ToolRequest| RoutingDecision::Foreground); let tasks = task_manager.handle(); + let background_jobs = BackgroundJobs::default(); let tools = ToolRegistry::new().with(BlockingTool { spec: ToolSpec { - name: ToolName::new("background-test"), - description: "controlled background tool".into(), + name: ToolName::new(agentkit_tool_compose::COMPOSE_TOOL_NAME), + description: "controlled compose tool".into(), input_schema: json!({"type": "object", "additionalProperties": false}), output_schema: None, annotations: ToolAnnotations::default(), @@ -2007,7 +2094,7 @@ mod tests { integration: Arc::clone(&integration), binding: SessionBindingGuard::new(Arc::clone(&integration), acp_session_id.clone()), driver, - tasks, + tasks: tasks.clone(), adapter: SelectableAdapter::new(crate::ProviderKind::OpenAiSubscription, "gpt-5.4") .unwrap(), catalog: Vec::new(), @@ -2029,21 +2116,35 @@ mod tests { }) .await .unwrap(); + while !entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + assert!(detach_compose_call(&tasks, &background_jobs, "background-call").await); + background_jobs.register_foreground_for_test("background-call"); + assert!(background_jobs.is_detached_for_test("background-call")); timeout(Duration::from_secs(1), reply_rx) .await - .expect("first prompt remained blocked on the background tool") + .expect("prompt remained blocked after the out-of-band detach") .unwrap() .unwrap(); - assert_eq!(turns.load(Ordering::SeqCst), 1); - timeout(Duration::from_secs(1), async { - while !entered.load(Ordering::SeqCst) { + assert_eq!(turns.load(Ordering::SeqCst), 2); + + release.notify_one(); + let completed = timeout(Duration::from_secs(1), async { + loop { + let completed = tasks.list_completed().await; + if !completed.is_empty() { + break completed; + } tokio::task::yield_now().await; } }) .await - .expect("background tool never started"); - - release.notify_one(); + .expect("detached compose did not complete"); + assert_eq!( + completed[0].kind, + agentkit_task_manager::TaskKind::Background + ); let notification = timeout(Duration::from_secs(1), async { loop { let notification = updates_rx.recv().await.expect("update stream closed"); @@ -2064,7 +2165,7 @@ mod tests { assert!(!ended.active); assert_eq!(started.turn_id, ended.turn_id); assert_eq!(started.session_id, acp_session_id); - assert_eq!(turns.load(Ordering::SeqCst), 2); + assert_eq!(turns.load(Ordering::SeqCst), 3); assert_eq!( user_items_seen.load(Ordering::SeqCst), 1, @@ -2081,8 +2182,8 @@ mod tests { let mcp_ended = turn_states_rx.recv().await.expect("missing MCP turn end"); assert!(mcp_started.active); assert!(!mcp_ended.active); - assert_eq!(turns.load(Ordering::SeqCst), 3); - assert_eq!(notification_items_seen.load(Ordering::SeqCst), 1); + assert_eq!(turns.load(Ordering::SeqCst), 4); + assert_eq!(notification_items_seen.load(Ordering::SeqCst), 2); assert_eq!(user_items_seen.load(Ordering::SeqCst), 1); let (close_tx, close_rx) = oneshot::channel(); diff --git a/src/runtime.rs b/src/runtime.rs index 2c3f981..00b830d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1054,10 +1054,25 @@ impl ToolSource for ComposeOnly { } } +struct BackgroundJob { + controller: CancellationController, + foreground_cancellation: Option, + cancellation_relay: Option, + detached: bool, + manual_detach: bool, +} + #[derive(Default)] struct BackgroundJobState { - running: HashMap, + running: HashMap, pending_cancellations: std::collections::HashSet, + pending_detaches: std::collections::HashSet, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DetachRegistration { + Registered, + AlreadyDetached, } #[derive(Clone, Default)] @@ -1069,10 +1084,10 @@ impl BackgroundJobs { let Ok(jobs) = self.0.lock() else { return false; }; - let Some(controller) = jobs.running.get(&call_id) else { + let Some(job) = jobs.running.get(&call_id) else { return false; }; - controller.interrupt(); + job.controller.interrupt(); true } @@ -1081,8 +1096,8 @@ impl BackgroundJobs { let Ok(mut jobs) = self.0.lock() else { return false; }; - if let Some(controller) = jobs.running.get(&call_id) { - controller.interrupt(); + if let Some(job) = jobs.running.get(&call_id) { + job.controller.interrupt(); } else { // ACP can expose the call just before its execution future registers. // Remember the request so registration and cancellation are atomic @@ -1091,6 +1106,119 @@ impl BackgroundJobs { } true } + + pub(crate) fn detach(&self, call_id: &str) -> Option { + let call_id = agentkit_core::ToolCallId::new(call_id); + let Ok(mut jobs) = self.0.lock() else { + return None; + }; + if let Some(job) = jobs.running.get_mut(&call_id) { + if job.manual_detach { + return Some(DetachRegistration::AlreadyDetached); + } + job.detached = true; + job.manual_detach = true; + if job + .foreground_cancellation + .as_ref() + .is_some_and(agentkit_core::TurnCancellation::is_cancelled) + { + job.detached = false; + job.manual_detach = false; + job.controller.interrupt(); + return None; + } + return Some(DetachRegistration::Registered); + } + Some(if jobs.pending_detaches.insert(call_id) { + DetachRegistration::Registered + } else { + DetachRegistration::AlreadyDetached + }) + } + + pub(crate) fn restore_foreground(&self, call_id: &str) { + let call_id = agentkit_core::ToolCallId::new(call_id); + let Ok(mut jobs) = self.0.lock() else { + return; + }; + let Some(job) = jobs.running.get_mut(&call_id) else { + jobs.pending_detaches.remove(&call_id); + return; + }; + job.manual_detach = false; + if job.foreground_cancellation.is_some() { + job.detached = false; + } + if job + .foreground_cancellation + .as_ref() + .is_some_and(agentkit_core::TurnCancellation::is_cancelled) + { + job.controller.interrupt(); + } + } + + fn propagate_foreground_cancellation(&self, call_id: &agentkit_core::ToolCallId) { + let Ok(jobs) = self.0.lock() else { + return; + }; + if let Some(job) = jobs.running.get(call_id) + && !job.detached + { + job.controller.interrupt(); + } + } + + fn finish(&self, call_id: &agentkit_core::ToolCallId) { + if let Ok(mut jobs) = self.0.lock() { + if let Some(job) = jobs.running.remove(call_id) + && let Some(relay) = job.cancellation_relay + { + relay.abort(); + } + jobs.pending_cancellations.remove(call_id); + jobs.pending_detaches.remove(call_id); + } + } + + #[cfg(test)] + pub(crate) fn register_foreground_for_test(&self, call_id: &str) { + if let Ok(mut jobs) = self.0.lock() { + let call_id = agentkit_core::ToolCallId::new(call_id); + let manual_detach = jobs.pending_detaches.remove(&call_id); + jobs.running.insert( + call_id, + BackgroundJob { + controller: CancellationController::new(), + foreground_cancellation: None, + cancellation_relay: None, + detached: manual_detach, + manual_detach, + }, + ); + } + } + + #[cfg(test)] + pub(crate) fn is_detached_for_test(&self, call_id: &str) -> bool { + let call_id = agentkit_core::ToolCallId::new(call_id); + self.0.lock().is_ok_and(|jobs| { + jobs.running.get(&call_id).is_some_and(|job| job.detached) + || jobs.pending_detaches.contains(&call_id) + }) + } +} + +struct BackgroundJobGuard { + jobs: BackgroundJobs, + call_id: agentkit_core::ToolCallId, +} + +impl Drop for BackgroundJobGuard { + fn drop(&mut self) { + self.jobs.finish(&self.call_id); + } } #[derive(Clone)] @@ -1159,8 +1287,8 @@ impl Tool for BackgroundableCompose { let artifact_directory = crate::artifacts::directory(&self.root, &request.session_id.0, &call_id.0); let request = Self::sanitized(request)?; - let cancellation = self.begin_background(background, &call_id, ctx); - let result = match self.inner.invoke(request, ctx).await { + let _job = self.begin_background(background, &call_id, ctx); + match self.inner.invoke(request, ctx).await { Ok(mut result) => { match crate::compose_output::guard(&artifact_directory, result.result.output).await { @@ -1172,9 +1300,7 @@ impl Tool for BackgroundableCompose { } } Err(error) => Err(error), - }; - self.finish_background(cancellation, &call_id); - result + } } async fn invoke_outcome( @@ -1190,8 +1316,8 @@ impl Tool for BackgroundableCompose { Ok(request) => request, Err(error) => return ToolExecutionOutcome::Failed(error), }; - let cancellation = self.begin_background(background, &call_id, ctx); - let result = match self.inner.invoke_outcome(request, ctx).await { + let _job = self.begin_background(background, &call_id, ctx); + match self.inner.invoke_outcome(request, ctx).await { ToolExecutionOutcome::Completed(mut result) => { match crate::compose_output::guard(&artifact_directory, result.result.output).await { @@ -1203,9 +1329,7 @@ impl Tool for BackgroundableCompose { } } other => other, - }; - self.finish_background(cancellation, &call_id); - result + } } } @@ -1215,10 +1339,8 @@ impl BackgroundableCompose { background: bool, call_id: &agentkit_core::ToolCallId, ctx: &mut ToolContext<'_>, - ) -> bool { - if !background { - return false; - } + ) -> BackgroundJobGuard { + let foreground_cancellation = (!background).then(|| ctx.cancellation.clone()).flatten(); let controller = CancellationController::new(); let cancellation = controller.handle().checkpoint(); ctx.cancellation = Some(cancellation.clone()); @@ -1229,15 +1351,43 @@ impl BackgroundableCompose { if jobs.pending_cancellations.remove(call_id) { controller.interrupt(); } - jobs.running.insert(call_id.clone(), controller); + let manual_detach = jobs.pending_detaches.remove(call_id); + let detached = background || manual_detach; + if !detached + && foreground_cancellation + .as_ref() + .is_some_and(agentkit_core::TurnCancellation::is_cancelled) + { + controller.interrupt(); + } + jobs.running.insert( + call_id.clone(), + BackgroundJob { + controller, + foreground_cancellation: foreground_cancellation.clone(), + cancellation_relay: None, + detached, + manual_detach, + }, + ); } - true - } - - fn finish_background(&self, background: bool, call_id: &agentkit_core::ToolCallId) { - if background && let Ok(mut jobs) = self.background_jobs.0.lock() { - jobs.running.remove(call_id); - jobs.pending_cancellations.remove(call_id); + if let Some(cancellation) = foreground_cancellation { + let jobs = self.background_jobs.clone(); + let relay_call_id = call_id.clone(); + let relay = tokio::spawn(async move { + cancellation.cancelled().await; + jobs.propagate_foreground_cancellation(&relay_call_id); + }) + .abort_handle(); + if let Ok(mut jobs) = self.background_jobs.0.lock() + && let Some(job) = jobs.running.get_mut(call_id) + { + job.cancellation_relay = Some(relay); + } + } + BackgroundJobGuard { + jobs: self.background_jobs.clone(), + call_id: call_id.clone(), } } } diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index 733c67e..e92a067 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -1,6 +1,8 @@ use std::{sync::Arc, time::Duration}; -use agentkit_core::{ItemKind, MetadataMap, Part, SessionId, ToolCallId, ToolOutput, TurnId}; +use agentkit_core::{ + CancellationController, ItemKind, MetadataMap, Part, SessionId, ToolCallId, ToolOutput, TurnId, +}; use agentkit_task_manager::RoutingDecision; use agentkit_tools_core::{ AllowAllPermissions, BasicToolExecutor, OwnedToolContext, Tool, ToolExecutionOutcome, @@ -9,8 +11,8 @@ use agentkit_tools_core::{ use serde_json::{Value, json}; use super::{ - BackgroundableCompose, Runtime, SessionRequest, SessionSelection, background_route, - load_initial_transcript, + BackgroundJobs, BackgroundableCompose, DetachRegistration, Runtime, SessionRequest, + SessionSelection, background_route, load_initial_transcript, }; #[test] @@ -582,13 +584,16 @@ async fn close_tool_can_cancel_a_detached_compose() { let call_id = ToolCallId::new("call"); let mut context = owned.borrowed(); - assert!( - compose - .backgroundable - .begin_background(true, &call_id, &mut context) - ); + let job = compose + .backgroundable + .begin_background(true, &call_id, &mut context); let cancellation = context.cancellation.clone().expect("job cancellation"); assert!(!cancellation.is_cancelled()); + assert_eq!( + compose.backgroundable.background_jobs.detach("call"), + Some(DetachRegistration::Registered), + "an initially backgroundable call can still be detached immediately", + ); let close = ToolSource::get(&compose.compose, &ToolName::new("close")) .expect("close tool is registered"); @@ -607,7 +612,94 @@ async fn close_tool_can_cancel_a_detached_compose() { .unwrap(); assert!(cancellation.is_cancelled()); - compose.backgroundable.finish_background(true, &call_id); + drop(job); +} + +#[tokio::test] +async fn foreground_compose_can_detach_from_turn_cancellation_and_still_be_killed() { + let root = tempfile::tempdir().unwrap(); + let runtime = Runtime::new(root.path(), "gpt-5.4").unwrap(); + let compose = runtime.compose(0); + let executor: Arc = + Arc::new(BasicToolExecutor::new(Vec::>::new())); + let permissions = Arc::new(AllowAllPermissions); + let resources: Arc = Arc::new(()); + let parent = CancellationController::new(); + let owned = OwnedToolContext { + session_id: SessionId::new("session"), + turn_id: TurnId::new("turn"), + metadata: MetadataMap::new(), + permissions: permissions.clone(), + resources: resources.clone(), + cancellation: Some(parent.handle().checkpoint()), + execution_scope: Some(ToolExecutionScope { + executor, + session_id: SessionId::new("session"), + turn_id: TurnId::new("turn"), + permissions, + resources, + cancellation: Some(parent.handle().checkpoint()), + }), + approved_request: None, + }; + let call_id = ToolCallId::new("call"); + let mut context = owned.borrowed(); + let job = compose + .backgroundable + .begin_background(false, &call_id, &mut context); + let cancellation = context.cancellation.clone().expect("compose cancellation"); + + assert_eq!( + compose.backgroundable.background_jobs.detach("call"), + Some(DetachRegistration::Registered) + ); + parent.interrupt(); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(!cancellation.is_cancelled()); + + assert!(compose.backgroundable.background_jobs.cancel("call")); + assert!(cancellation.is_cancelled()); + drop(job); + drop(context); + + let already_cancelled_id = ToolCallId::new("already-cancelled"); + let mut already_cancelled_context = owned.borrowed(); + let already_cancelled_job = compose.backgroundable.begin_background( + false, + &already_cancelled_id, + &mut already_cancelled_context, + ); + assert!( + already_cancelled_context + .cancellation + .as_ref() + .expect("compose cancellation") + .is_cancelled() + ); + drop(already_cancelled_job); + + assert_eq!( + compose + .backgroundable + .background_jobs + .detach("pending-detach"), + Some(DetachRegistration::Registered) + ); + let pending_detach_id = ToolCallId::new("pending-detach"); + let mut pending_detach_context = owned.borrowed(); + let pending_detach_job = compose.backgroundable.begin_background( + false, + &pending_detach_id, + &mut pending_detach_context, + ); + assert!( + !pending_detach_context + .cancellation + .as_ref() + .expect("compose cancellation") + .is_cancelled() + ); + drop(pending_detach_job); } #[tokio::test] @@ -742,6 +834,37 @@ async fn compose_background_sanitization_rejects_invalid_and_strips_before_dispa } } +#[test] +fn pending_detach_is_applied_when_compose_registers() { + let jobs = BackgroundJobs::default(); + + assert_eq!( + jobs.detach("pending-call"), + Some(DetachRegistration::Registered) + ); + jobs.register_foreground_for_test("pending-call"); + + assert!(jobs.is_detached_for_test("pending-call")); +} + +#[test] +fn duplicate_detach_does_not_take_rollback_ownership() { + let jobs = BackgroundJobs::default(); + jobs.register_foreground_for_test("duplicate-call"); + + assert_eq!( + jobs.detach("duplicate-call"), + Some(DetachRegistration::Registered) + ); + let duplicate = jobs.detach("duplicate-call"); + assert_eq!(duplicate, Some(DetachRegistration::AlreadyDetached)); + if duplicate == Some(DetachRegistration::Registered) { + jobs.restore_foreground("duplicate-call"); + } + + assert!(jobs.is_detached_for_test("duplicate-call")); +} + #[test] fn system_prompt_guides_compose_and_subagent_hygiene() { let root = tempfile::tempdir().unwrap(); diff --git a/src/tui/app.rs b/src/tui/app.rs index 8f329ed..35e4563 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -156,6 +156,7 @@ pub enum Action { }, Copy(String), Cancel, + DetachCompose(String), CancelBackground(String), Quit, } @@ -902,6 +903,19 @@ impl App { None } + fn newest_foreground_compose(&self) -> Option<&ToolCall> { + self.blocks.iter().rev().find_map(|block| match block { + Block::Tool(call) + if call.title == agentkit_tool_compose::COMPOSE_TOOL_NAME + && call.running() + && !call.backgrounded => + { + Some(call) + } + _ => None, + }) + } + pub fn show_graph(&self) -> bool { match self.graph_pinned { Some(pinned) => pinned, @@ -1501,6 +1515,14 @@ impl App { let line = command || control; match key.code { + KeyCode::Char('b') if key.modifiers == KeyModifiers::SUPER => { + let Some(call_id) = self.newest_foreground_compose().map(|call| call.id.clone()) + else { + self.toast("no foreground compose call is running"); + return Action::None; + }; + return Action::DetachCompose(call_id); + } KeyCode::Char('c') if control => { // A turn that will not stop must still be escapable: the second // ctrl+c leaves, which takes the agent process with it. @@ -2094,6 +2116,46 @@ mod tests { assert!(!app.working()); } + #[test] + fn command_b_detaches_the_newest_running_foreground_compose_only() { + let mut app = app(); + for (id, title, backgrounded) in [ + ("older", "compose", false), + ("other", "shell", false), + ("newest", "compose", false), + ("background", "compose", true), + ] { + app.apply(Update::ToolStarted { + id: id.into(), + title: title.into(), + kind: ToolKind::Other, + script: None, + backgrounded, + }); + } + + let action = app.handle_key(modified_press(KeyCode::Char('b'), KeyModifiers::SUPER)); + assert!(matches!(action, Action::DetachCompose(id) if id == "newest")); + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('b'), KeyModifiers::CONTROL)), + Action::None + )); + } + + #[test] + fn command_b_reports_when_no_foreground_compose_is_running() { + let mut app = app(); + + assert!(matches!( + app.handle_key(modified_press(KeyCode::Char('b'), KeyModifiers::SUPER)), + Action::None + )); + assert_eq!( + app.toast_text(), + Some("no foreground compose call is running") + ); + } + #[test] fn control_k_kills_the_focused_background_call() { let mut app = app(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1081187..7ad94f9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -52,7 +52,7 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::{ events::{self, EVENTS_ENV}, - protocols::acp::{CancelBackgroundRequest, TurnStateNotification}, + protocols::acp::{CancelBackgroundRequest, DetachComposeRequest, TurnStateNotification}, tools::mcp::CredentialStorage, }; @@ -639,6 +639,24 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( CancelNotification::new(session_id.clone()), ); } + Action::DetachCompose(call_id) => { + match connection + .send_request(DetachComposeRequest { + session_id: session_id.clone(), + call_id, + }) + .block_task() + .await + { + Ok(response) if !response.detached => { + app.note("compose call is no longer running in the foreground"); + } + Err(error) => app.note(format!( + "could not background compose call: {}", error.message + )), + Ok(_) => {} + } + } Action::CancelBackground(call_id) => { let response = connection .send_request(CancelBackgroundRequest {