diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 708ac8f11..df7475d19 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 41cf2875c..a1ca78421 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 65152647e..68012958b 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 49c9f4676..5cf0b4195 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 7d9a91f3e..044355844 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 d92ad84f3..861b6bee5 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 3a30a7b73..d764923a2 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 a1ffe89a4..759eddb8f 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 f26c37bf8..561665ee5 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 c642d42b0..c2de94ad3 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 3feb2ea77..9f4fb684a 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 bbbb06524..d5975531a 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 cabef1ee3..b5d041c47 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 4bb32f11f..bd6a632a7 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 a141a7e5a..c4c7aae6d 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 29aab6835..46cb6a388 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": "无法停止后台任务" }