From 0eca4366d0378ecaeaefbbc3b320942b91f38302 Mon Sep 17 00:00:00 2001 From: NealimeKenna Date: Fri, 6 Nov 2026 22:26:28 +0100 Subject: [PATCH] feat(agents): show the agent's background tasks above the composer (#1860) * fix(agent): don't release an idle native agent while background tasks are running A turn's background tasks (Claude Code's run_in_background commands) outlive the turn: release_when_idle only checked for a running turn and open permission requests, so five minutes after the turn ended it stopped the agent process and orphaned whatever still ran, its result never reaching the conversation. Claude Code reports its live background tasks in stream-json mode: a system/background_tasks_changed event carries the full list on every change. The driver now translates it into an openidea_background_tasks session update (like openidea_retry), the task keeps the list, and the idle release waits while it isn't empty: it checks again after the usual delay, and when the last task is done its report voids the pending schedule and starts the delay over from then. A (re)started process starts with no tasks, so a stale list can never hold the agent up. Signed-off-by: Inf1mum * feat(agents): show the agent's background tasks above the composer A grey bar above the composer lists what the agent runs in the background (shells, monitors), with how long each has been running and a Stop button per task. The task now carries the background tasks the agent reports, with when each was first seen, and clears them when the process ends. Stop asks Claude Code to end that one task through a new _openidea/stop_task request; the composer's Stop keeps ending only the turn (perTaskStopAffordance). Closes #1788 --------- Signed-off-by: Inf1mum Co-authored-by: Inf1mum Co-authored-by: NealimeKenna <22195793+NealimeKenna@users.noreply.github.com> --- crates/ide-agent/src/lib.rs | 106 +++++++++++++++ crates/ide-agent/src/model.rs | 18 +++ crates/ide-agent/src/native/claude/mod.rs | 12 +- crates/ide-agent/src/native/claude/tests.rs | 13 ++ .../ide-agent/src/native/claude/translate.rs | 11 +- crates/ide-agent/src/tests.rs | 124 +++++++++++++++++- crates/ide-host/src/ops/agent.rs | 8 ++ crates/ide-host/src/trust.rs | 1 + src-tauri/src/commands/agent.rs | 12 ++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 16 +++ src/features/agents/Session.module.css | 25 ++++ src/features/agents/SessionEditor.tsx | 35 +++++ src/features/agents/store.ts | 2 + src/platform/locales/text/en.json | 4 +- src/platform/locales/text/zh.json | 4 +- 16 files changed, 386 insertions(+), 6 deletions(-) diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 708ac8f1..df7475d1 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -54,6 +54,9 @@ const SESSION_STATE: [&str; 5] = pub(crate) const RETRY_UPDATE: &str = "openidea_retry"; /// A native agent's own update: the usage limits of its account (see [`AgentLimit`]). pub(crate) const LIMITS_UPDATE: &str = "openidea_limits"; +/// A native agent's own update: the background tasks its session runs +/// (see [`TaskState::background_tasks`]). +pub(crate) const BACKGROUND_TASKS_UPDATE: &str = "openidea_background_tasks"; /// Referenced files up to this size are embedded in the prompt (when the agent takes it). const MAX_EMBED: u64 = 256 * 1024; /// How long a probe waits for the agent to answer `initialize`. @@ -209,6 +212,10 @@ struct TaskState { pending_rollback: u32, /// A turn is running or about to (prompts arriving now are queued). busy: bool, + /// Background tasks the agent reported as running (`run_in_background` commands and + /// the like), as it sent them: unlike a turn they outlive it, so an idle release + /// waits for the last one. A (re)started process runs nothing yet. + background_tasks: Vec, } /// A running agent and its session. @@ -244,6 +251,9 @@ struct Task { turn_started: AtomicBool, /// Turns sent so far: an idle release scheduled before the latest one is void. turns: AtomicU64, + /// Bumped when the agent's last background task finishes: an idle release scheduled + /// before that is void, the usual delay starts over from the report. + idle_epoch: AtomicU64, /// Settings already sent this session (`(option, value)`), so a refusal is not retried. config_sent: Mutex>, dirty: Mutex>, @@ -302,6 +312,7 @@ impl AgentManager { f.task.status = AgentStatus::Ready; } f.task.retry = None; + f.task.background.clear(); let task = Task::restore(f, shared.clone()); task.state.lock().conversation.settle(now_ms()); tasks.insert(task.id.clone(), task); @@ -470,6 +481,7 @@ impl AgentManager { capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(), resets_ms: None, retry: None, + background: Box::default(), }; let file = TaskFile { task: task.clone(), @@ -664,6 +676,7 @@ impl AgentManager { queue: Vec::new(), resets_ms: None, retry: None, + background: Box::default(), ..s.task.clone() }; TaskFile { @@ -802,6 +815,21 @@ impl AgentManager { Ok(()) } + /// Stops background task `background` of task `id`. + pub async fn stop_background(&self, id: &str, background: &str) -> IdeResult<()> { + let task = self.task(id)?; + let live = task.live.lock().clone().ok_or_else(|| IdeError::invalid("the agent is not running"))?; + if !live.capabilities.background_tasks { + return Err(IdeError::invalid("this agent can't stop a background task")); + } + let _: Value = live + .peer + .request("_openidea/stop_task", json!({ "sessionId": live.session, "taskId": background }), None) + .await + .map_err(|error| IdeError::internal(task.describe(error)))?; + Ok(()) + } + /// Answers permission request `permission` with option `option` (`None` cancels it). pub fn respond(&self, id: &str, permission: u32, option: Option) -> IdeResult<()> { let task = self.task(id)?; @@ -914,6 +942,7 @@ impl Task { before: Arc::new(file.before), pending_rollback: file.pending_rollback, busy: false, + background_tasks: Vec::new(), }), live: Mutex::new(None), connecting: tokio::sync::Mutex::new(()), @@ -924,6 +953,7 @@ impl Task { cancel_pending: AtomicBool::new(false), turn_started: AtomicBool::new(false), turns: AtomicU64::new(0), + idle_epoch: AtomicU64::new(0), config_sent: Mutex::new(HashSet::new()), dirty: Mutex::new(BTreeSet::new()), flush_scheduled: AtomicBool::new(false), @@ -960,6 +990,39 @@ impl Task { self.emit_task(); } + /// Keeps the agent's background tasks that outlive its turn. Returns whether the last + /// one just finished, so the idle release starts over from this report. + fn set_background_tasks(&self, tasks: Vec) -> bool { + let (emptied, shown) = { + let mut state = self.state.lock(); + if state.background_tasks == tasks { + return false; + } + let emptied = !state.background_tasks.is_empty() && tasks.is_empty(); + let background = shown_background(&tasks, &state.task.background); + let shown = background != *state.task.background; + *state.task.background = background; + state.background_tasks = tasks; + if emptied { + // Void a release scheduled while the tasks still ran: it would stop the + // agent sooner than the usual delay after this one. + self.idle_epoch.fetch_add(1, Ordering::AcqRel); + } + (emptied, shown) + }; + if shown { + self.emit_task(); + } + emptied + } + + fn clear_shown_background(&self) { + let cleared = !std::mem::take(&mut self.state.lock().task.background).is_empty(); + if cleared { + self.emit_task(); + } + } + fn set_status(&self, status: AgentStatus, error: Option) { { let mut s = self.state.lock(); @@ -1078,19 +1141,32 @@ impl Task { /// Stops the agent's process once the task has stayed idle for [`IDLE_RELEASE`], so /// finished tasks don't keep an agent (and what it started) running. Only an agent that /// reloads its session: the next message starts it again with the conversation intact. + /// A turn's background tasks outlive it: the release waits for the last one, and the + /// delay runs again once the agent reports it done. fn release_when_idle(self: &Arc) { let turn = self.turns.load(Ordering::Acquire); + let epoch = self.idle_epoch.load(Ordering::Acquire); let delay = Duration::from_millis(self.shared.idle_release_ms.load(Ordering::Relaxed)); let task = Arc::downgrade(self); self.shared.runtime.spawn(async move { tokio::time::sleep(delay).await; let Some(task) = task.upgrade() else { return }; if task.turns.load(Ordering::Acquire) != turn + || task.idle_epoch.load(Ordering::Acquire) != epoch || task.state.lock().busy || !task.permissions.lock().is_empty() { return; } + // Stopping now would orphan what the agent still runs in the background: its + // result would never reach the conversation. Wait for the last task; when it + // is done, its report voids this schedule and starts the delay over. + let waiting = !task.state.lock().background_tasks.is_empty(); + if waiting && task.live.lock().as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { + tracing::debug!("agent task {}: idle, waiting for its background tasks", task.id); + task.release_when_idle(); + return; + } let live = { let mut live = task.live.lock(); if live.as_ref().is_some_and(|l| l.resumable && !l.peer.is_closed()) { live.take() } else { None } @@ -1098,6 +1174,7 @@ impl Task { if let Some(live) = live { tracing::debug!("agent task {}: idle, stopping the agent until the next message", task.id); live.peer.kill(); + task.clear_shown_background(); } }); } @@ -1116,6 +1193,7 @@ impl Task { if !gone && let Some(live) = self.live.lock().take() { live.peer.kill(); } + self.clear_shown_background(); match failure.limit { Some(resets) => { self.state.lock().task.resets_ms = resets; @@ -1356,6 +1434,9 @@ impl Task { s.task.config = options; s.task.capabilities = capabilities; s.session_id = Some(session_id.clone()); + // The (re)started process runs nothing yet: whatever it reports next is all of it. + s.background_tasks = Vec::new(); + s.task.background.clear(); } self.persist(); self.emit_task(); @@ -1448,6 +1529,16 @@ impl Task { } return; } + if kind == BACKGROUND_TASKS_UPDATE { + if !self.replaying.load(Ordering::Acquire) { + let tasks = update.get("tasks").and_then(Value::as_array).cloned().unwrap_or_default(); + // The last one finished while the agent is up: the usual delay starts over. + if self.set_background_tasks(tasks) { + self.release_when_idle(); + } + } + return; + } if kind == "session_info_update" { // The agent's own title for the session replaces the first line of the prompt. let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()); @@ -1622,6 +1713,7 @@ impl Task { self.cancel_permissions(); let live = self.live.lock().take()?; live.peer.kill(); + self.clear_shown_background(); Some(live.peer) } } @@ -1735,6 +1827,19 @@ fn client_info() -> Value { } /// What the agent said it can do, and whether it takes embedded files. +fn shown_background(tasks: &[Value], known: &[AgentBackgroundTask]) -> Vec { + let now = now_ms(); + tasks + .iter() + .filter_map(|task| { + let id = task.get("task_id")?.as_str()?.to_owned(); + let started_ms = known.iter().find(|seen| seen.id == id).map_or(now, |seen| seen.started_ms); + let text = |key: &str| task.get(key).and_then(Value::as_str).unwrap_or_default().to_owned(); + Some(AgentBackgroundTask { id, kind: text("task_type"), description: text("description"), started_ms }) + }) + .collect() +} + fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) { let meta = |p: &str| init.meta(p).and_then(Value::as_bool) == Some(true); let capabilities = AgentCapabilities { @@ -1742,6 +1847,7 @@ fn capabilities_of(init: &acp::Negotiated) -> (AgentCapabilities, bool) { fork: init.session("fork"), rollback: meta("/openidea/rollback"), steer: meta("/openidea/steer"), + background_tasks: meta("/openidea/backgroundTasks"), }; (capabilities, init.prompt("embeddedContext")) } diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index 41cf2875..a1ca7842 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -34,6 +34,8 @@ pub struct AgentCapabilities { pub rollback: bool, /// A message can join the running turn instead of waiting for it. pub steer: bool, + /// It reports its background tasks and stops them one by one. + pub background_tasks: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -89,6 +91,22 @@ pub struct AgentTask { /// by its next output or any status change. #[serde(default)] pub retry: Option, + /// What the agent runs in the background (shells, subagents, monitors), oldest first. + // Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows. + #[serde(default)] + pub background: Box>, +} + +/// A task the agent runs in the background while the conversation goes on. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentBackgroundTask { + pub id: String, + /// What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…). + pub kind: String, + pub description: String, + /// When the client first saw it (ms since the epoch). + pub started_ms: f64, } /// An API request the agent is about to retry. diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs index 65152647..68012958 100644 --- a/crates/ide-agent/src/native/claude/mod.rs +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -312,7 +312,7 @@ impl Shim { "loadSession": true, "promptCapabilities": { "image": true, "embeddedContext": true }, "sessionCapabilities": { "fork": {} }, - "_meta": { "openidea": { "rollback": true, "steer": true } }, + "_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } }, }, "agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() }, "authMethods": [], @@ -396,6 +396,13 @@ impl Shim { session.steer(params.get("prompt").cloned().unwrap_or_default()).map_err(invalid)?; Ok(json!({})) } + "_openidea/stop_task" => { + let session = self.session(¶ms)?; + let task = params.get("taskId").and_then(Value::as_str).ok_or_else(|| invalid("no taskId"))?; + let proc = session.st.lock().proc.clone().ok_or_else(|| internal("Claude Code is not running"))?; + rt.block_on(proc.control(json!({ "subtype": "stop_task", "task_id": task }))).map_err(internal)?; + Ok(json!({})) + } "_openidea/rollback" => { let session = self.session(¶ms)?; let turns = params.get("turns").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -611,7 +618,8 @@ impl Session { session.on_exit(&reader_proc); } }); - let init = proc.control(json!({ "subtype": "initialize" })).await?; + // Stop ends the turn only: the client stops background tasks one by one. + let init = proc.control(json!({ "subtype": "initialize", "perTaskStopAffordance": true })).await?; let applied = proc.control(json!({ "subtype": "get_settings" })).await.unwrap_or_default(); let chosen = { let mut st = self.st.lock(); diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index 49c9f467..5cf0b419 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -976,6 +976,19 @@ fn api_retries_are_announced() { ); } +#[test] +fn background_tasks_are_announced_wholesale() { + let mut t = Translator::new("s", PathBuf::from("/w"), true); + let tasks = json!([{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]); + let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": tasks})); + assert_eq!(out.len(), 1); + assert_eq!(out[0].session, "s"); + assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": tasks})); + // The list is replaced with every report: an empty one says every task is done. + let out = t.on_message(&json!({"type": "system", "subtype": "background_tasks_changed", "tasks": []})); + assert_eq!(out[0].update, json!({"sessionUpdate": crate::BACKGROUND_TASKS_UPDATE, "tasks": []})); +} + #[test] fn streamed_text_is_not_sent_twice() { let mut t = Translator::new("s", PathBuf::from("/w"), true); diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index 7d9a91f3..04435584 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -5,7 +5,7 @@ //! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`. use super::tools; -use crate::{LIMITS_UPDATE, RETRY_UPDATE}; +use crate::{BACKGROUND_TASKS_UPDATE, LIMITS_UPDATE, RETRY_UPDATE}; use serde_json::{Value, json}; use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; @@ -458,6 +458,15 @@ impl Translator { let last = self.children.get(&tool_use).map(|c| c.last_text.clone()).filter(|t| !t.is_empty()); self.finish_child(&tool_use, stop, last.or(summary)) } + // A turn's background tasks (`run_in_background`) outlive it: the task keeps + // the list and stays up while it isn't empty (see `Task::release_when_idle`). + Some("background_tasks_changed") => { + let Some(tasks) = msg.get("tasks").and_then(Value::as_array).cloned() else { return Vec::new() }; + vec![Update { + session: self.root.clone(), + update: json!({ "sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": tasks }), + }] + } _ => Vec::new(), } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index d92ad84f..861b6bee 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -94,7 +94,7 @@ impl Handler for FakeAgent { self.calls.lock().push("subagents".into()); } Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load, - "sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true}}}})) + "sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}})) } "session/new" => { let mut options = vec![json!({"id": "model", "category": "model", "type": "select", @@ -106,6 +106,10 @@ impl Handler for FakeAgent { "configOptions": options})) } "session/fork" => Ok(json!({"sessionId": "s2"})), + "_openidea/stop_task" => { + self.calls.lock().push(format!("{method} {}", params["taskId"])); + Ok(json!({})) + } "_openidea/rollback" | "_openidea/steer" => { let detail = params.get("turns").cloned().unwrap_or_else(|| params["prompt"][0]["text"].clone()); self.calls.lock().push(format!("{method} {detail}")); @@ -125,6 +129,16 @@ impl Handler for FakeAgent { self.say(&session, "second turn"); return Ok(json!({"stopReason": "end_turn"})); } + if text == "background" { + // A command that outlives the turn, as the agent reports it. + self.update( + &session, + json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, + "tasks": [{"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}]}), + ); + self.say(&session, "running it in the background"); + return Ok(json!({"stopReason": "end_turn"})); + } if text == "delegate" { self.delegate(&session); return Ok(json!({"stopReason": "end_turn"})); @@ -1146,6 +1160,114 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() { mgr.shutdown(); } +/// A turn's background tasks outlive it: an idle agent stops only once its last one is +/// done, and the usual delay runs from that report. +#[tokio::test(flavor = "multi_thread")] +async fn an_idle_agent_waits_for_its_background_tasks_before_it_stops() { + let live = + |mgr: &AgentManager, id: &str| mgr.task(id).unwrap().live.lock().as_ref().is_some_and(|l| !l.peer.is_closed()); + let new = || NewAgentTask { + project_root: None, + prompt: text("background"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }; + + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + mgr.set_idle_release(Duration::from_millis(200)); + let task = mgr.create(new()).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + // The task runs long past the idle delay: the agent stays up for it. + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(live(&mgr, &task.id)); + // It is done: the delay starts over from this report, then the agent stops. + agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []})); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(live(&mgr, &task.id), "the delay runs from when the last task is done"); + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(!live(&mgr, &task.id)); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "not an error"); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_task_shows_what_runs_in_the_background() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("background"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let shown = || mgr.task(&task.id).unwrap().state.lock().task.background.clone(); + let first = shown(); + assert_eq!(first.len(), 1); + assert_eq!( + (first[0].id.as_str(), first[0].kind.as_str(), first[0].description.as_str()), + ("bsh1", "local_bash", "sleep 900") + ); + + agent.update( + "s1", + json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": [ + {"task_id": "bsh1", "task_type": "local_bash", "description": "sleep 900"}, + {"task_id": "mon1", "task_type": "monitor", "description": "Watch the deploy"}, + ]}), + ); + tokio::time::timeout(Duration::from_secs(5), async { + while shown().len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(shown()[0].started_ms, first[0].started_ms, "a task keeps when it was first seen"); + + agent.update("s1", json!({"sessionUpdate": BACKGROUND_TASKS_UPDATE, "tasks": []})); + tokio::time::timeout(Duration::from_secs(5), async { + while !shown().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + mgr.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_background_task_is_stopped_on_its_own() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "claude".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + mgr.stop_background(&task.id, "b1").await.unwrap(); + assert!(agent.calls.lock().contains(&"_openidea/stop_task \"b1\"".to_owned())); + mgr.shutdown(); +} + /// Deleting a task whose agent still runs in the worktree removes both and leaves no /// process behind, whether it is waiting for permission or idle without session/load. #[tokio::test(flavor = "multi_thread")] diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 3a30a7b7..d764923a 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -238,6 +238,11 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option) host.project(window)?.agents.cancel(&task, subagent) } +/// Stops one of the task's background tasks. +pub async fn stop_background(host: &Host, window: &str, task: String, background: String) -> IdeResult<()> { + host.project(window)?.agents.stop_background(&task, &background).await +} + /// Answers a permission request; `option: None` cancels it. pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option) -> IdeResult<()> { host.require_trust(window)?; @@ -297,6 +302,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_rollback", |c| async move { rollback(&c.host, &c.window, c.arg("task")?, c.arg("turns")?).await }); d.add("agent_probe", |c| async move { probe(&c.host, &c.window, c.arg("provider")?, c.arg("refresh")?).await }); d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) }); + d.add("agent_stop_background", |call| async move { + stop_background(&call.host, &call.window, call.arg("task")?, call.arg("background")?).await + }); d.add("agent_respond", |c| async move { respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?) }); diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index a1ffe89a..759eddb8 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -284,6 +284,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "agent_commands" | "agent_unqueue" | "agent_cancel" + | "agent_stop_background" | "agent_archive" | "agent_unarchive" | "agent_registry_list" diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index f26c37bf..561665ee 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -225,6 +225,18 @@ pub fn agent_cancel( agent::cancel(&state.host, window.label(), task, subagent) } +/// Stops one of the task's background tasks. +#[tauri::command] +#[specta::specta] +pub async fn agent_stop_background( + window: Window, + state: State<'_, AppState>, + task: String, + background: String, +) -> IdeResult<()> { + agent::stop_background(&state.host, window.label(), task, background).await +} + /// Answers a permission request; `option: null` cancels it. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c642d42b..c2de94ad 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -121,6 +121,7 @@ pub fn builder() -> tauri_specta::Builder { agent::agent_rollback, agent::agent_probe, agent::agent_cancel, + agent::agent_stop_background, agent::agent_respond, agent::agent_archive, agent::agent_unarchive, diff --git a/src/bindings.ts b/src/bindings.ts index 3feb2ea7..9f4fb684 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -155,6 +155,8 @@ export const commands = { agentProbe: (provider: string, refresh: boolean) => typedError(__TAURI_INVOKE("agent_probe", { provider, refresh })), /** Stops the task's current turn, or with `subagent` only that subagent's work. */ agentCancel: (task: string, subagent: string | null) => typedError(__TAURI_INVOKE("agent_cancel", { task, subagent })), + /** Stops one of the task's background tasks. */ + agentStopBackground: (task: string, background: string) => typedError(__TAURI_INVOKE("agent_stop_background", { task, background })), /** Answers a permission request; `option: null` cancels it. */ agentRespond: (task: string, permission: number, option: string | null) => typedError(__TAURI_INVOKE("agent_respond", { task, permission, option })), agentArchive: (task: string) => typedError(__TAURI_INVOKE("agent_archive", { task })), @@ -1362,6 +1364,16 @@ export type ActiveTheme = { theme: ThemeDef, }; +/** A task the agent runs in the background while the conversation goes on. */ +export type AgentBackgroundTask = { + id: string, + /** What it is, as the agent names it (`local_bash`, `local_agent`, `monitor`…). */ + kind: string, + description: string, + /** When the client first saw it (ms since the epoch). */ + startedMs: number | null, +}; + /** * What a task's agent can do beyond a plain conversation, as it reported when its * session started. @@ -1375,6 +1387,8 @@ export type AgentCapabilities = { rollback?: boolean, /** A message can join the running turn instead of waiting for it. */ steer?: boolean, + /** It reports its background tasks and stops them one by one. */ + backgroundTasks?: boolean, }; /** A file the task changed, relative to [`AgentTask::base_commit`]. */ @@ -1665,6 +1679,8 @@ export type AgentTask = { * by its next output or any status change. */ retry?: AgentRetry | null, + /** What the agent runs in the background (shells, subagents, monitors), oldest first. */ + background?: AgentBackgroundTask[], }; export type AgentToolDiff = { diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index bbbb0652..d5975531 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -566,6 +566,31 @@ padding: 6px 12px 12px; } +.background { + display: flex; + flex-direction: column; + gap: 2px; +} +.backgroundTask { + display: flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 0 4px 0 8px; + border-radius: var(--radius-row); + background: var(--bg-elev); + color: var(--fg-muted); + font-size: var(--fs-small); + font-variant-numeric: tabular-nums; +} +.backgroundText { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .queue { display: flex; flex-direction: column; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index cabef1ee..b5d041c4 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -33,6 +33,7 @@ import { selectableText, useSelectionBoundary, showContextMenu, + type IconName, } from "@/ui"; import { useTx, tx } from "@/platform/i18n"; import { isImeKey } from "@/platform/ime"; @@ -59,6 +60,7 @@ import { } from "./model"; import { cancelTask, + stopBackgroundTask, loadEntries, markVisible, refreshChanges, @@ -1318,6 +1320,7 @@ function SessionComposer({ )} + {task.queue.length > 0 && } = { local_bash: "terminal", monitor: "eye" }; + +/** What the agent runs in the background; its subagents show as chips of their own. */ +function BackgroundTasks({ task }: { task: AgentTask }) { + const tx = useTx(); + const running = (task.background ?? []).filter((background) => background.kind !== "local_agent"); + if (!running.length) return null; + const fail = (e: unknown) => + notifications().notify({ level: "error", title: "Couldn't stop the background task", body: String(e) }); + return ( +
+ {running.map((background) => ( +
+ + + {background.description || background.kind} + + {background.startedMs != null && } + {task.capabilities?.backgroundTasks && ( + void stopBackgroundTask(task.id, background.id).catch(fail)} + /> + )} +
+ ))} +
+ ); +} + function Queue({ task }: { task: AgentTask }) { const fail = (e: unknown) => notifications().notify({ level: "error", title: "Couldn't update the queue", body: String(e) }); diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 4bb32f11..bd6a632a 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -625,6 +625,8 @@ export function addReferences(refs: Array<{ path: string; rel: string; mention: /** Stops the task's turn, or with `subagent` only that subagent's work. */ export const cancelTask = (task: string, subagent: string | null = null) => call(commands.agentCancel(task, subagent)); +export const stopBackgroundTask = (task: string, background: string) => + call(commands.agentStopBackground(task, background)); export const respond = (task: string, permission: number, option: string | null) => call(commands.agentRespond(task, permission, option)); export const unarchiveTask = (task: string) => call(commands.agentUnarchive(task)); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index a141a7e5..c4c7aae6 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -50,5 +50,7 @@ "Remote connection cancelled": "Remote connection cancelled", "Xdebug is not loaded in PHP {version}": "Xdebug is not loaded in PHP {version}", "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.", - "Open PHP Settings": "Open PHP Settings" + "Open PHP Settings": "Open PHP Settings", + "Running in the background": "Running in the background", + "Couldn't stop the background task": "Couldn't stop the background task" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 29aab683..46cb6a38 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6410,5 +6410,7 @@ "Remote connection cancelled": "远程连接已取消", "Xdebug is not loaded in PHP {version}": "PHP {version} 未加载 Xdebug", "{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} 运行时没有 Xdebug,断点会被跳过。请为此 PHP 安装 Xdebug 3,或选择其他 PHP 解释器。", - "Open PHP Settings": "打开 PHP 设置" + "Open PHP Settings": "打开 PHP 设置", + "Running in the background": "后台运行中", + "Couldn't stop the background task": "无法停止后台任务" }