diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 0363493..51754ca 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -41,12 +41,11 @@ use application::{ use async_trait::async_trait; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentWakePort, - BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, - Clock, Embedder, EmbedderEnvInspector, - EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, - IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, - ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, TemplateStore, - WakeError, WakeReason, + BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskStore, Clock, Embedder, + EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, + IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, + ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, + TemplateStore, WakeError, WakeReason, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -61,9 +60,9 @@ use serde_json::{json, Map, Value}; use uuid::Uuid; use infrastructure::{ - AdaptiveMemoryRecall, BackgroundCompletionSink, BackgroundTaskReadyToDeliver, - ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, - CodexPermissionProjector, CommandBackgroundRunner, embedder_from_profile, EmbedderEnvProbe, + embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink, + BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, + CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, @@ -1992,7 +1991,10 @@ impl AppState { .with_background_tasks( Arc::clone(&background_tasks_port), Arc::clone(&clock) as Arc, - ), + ) + // Producteur B8 côté agent MCP : `idea_run_in_background` passe par le + // même use case que la commande Tauri, sans aller-retour frontend. + .with_spawn_background_command(Arc::clone(&spawn_background_command)), ); let stop_live_agent = Arc::new( @@ -4104,6 +4106,7 @@ mod mcp_serve_peer_tests { for expected in [ "idea_list_agents", "idea_ask_agent", + "idea_run_in_background", "idea_launch_agent", "idea_stop_agent", "idea_update_context", @@ -4140,8 +4143,8 @@ mod mcp_serve_peer_tests { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 23, - "exactly the twenty-three exposed idea_* tools; got {names:?}" + 24, + "exactly the twenty-four exposed idea_* tools; got {names:?}" ); drop(client); // EOF ⇒ serve loop ends diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 3b3a811..619211b 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -14,6 +14,7 @@ //! infrastructure adapter's job. That keeps this fully unit-testable with fakes. use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; @@ -39,6 +40,7 @@ use crate::agent::{ ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS, }; +use crate::background::{SpawnBackgroundCommand, SpawnBackgroundCommandInput}; use crate::error::AppError; use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome}; use crate::orchestrator::{ @@ -89,6 +91,55 @@ fn bound_task_text(value: &str, max_bytes: usize) -> String { value[..end].to_owned() } +fn resolve_background_cwd( + project_root: &ProjectPath, + cwd: Option, +) -> Result { + let root = Path::new(project_root.as_str()); + let raw = cwd.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let candidate = match raw { + None => root.to_path_buf(), + Some(value) => { + let path = Path::new(value); + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } + } + }; + let normalized = normalize_path_no_parent(&candidate)?; + if !normalized.starts_with(root) { + return Err(AppError::Invalid( + "background task cwd must stay under the project root".to_owned(), + )); + } + let Some(path) = normalized.to_str() else { + return Err(AppError::Invalid( + "background task cwd is not valid UTF-8".to_owned(), + )); + }; + ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned())) +} + +fn normalize_path_no_parent(path: &Path) -> Result { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => out.push(prefix.as_os_str()), + Component::RootDir => out.push(component.as_os_str()), + Component::CurDir => {} + Component::Normal(part) => out.push(part), + Component::ParentDir => { + return Err(AppError::Invalid( + "background task cwd must not contain `..`".to_owned(), + )) + } + } + } + Ok(out) +} + /// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`). /// /// A target agent's turn can be long (reasoning + tool use), so the cap is @@ -433,6 +484,9 @@ pub struct OrchestratorService { /// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒ /// comportement historique sans persistance de tâche (call sites non câblés). background_tasks: Option>, + /// Use case B8 qui produit une tâche de fond command-backed depuis la surface + /// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré. + spawn_background_command: Option>, } /// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés @@ -504,9 +558,17 @@ impl OrchestratorService { ask_liveness_probe: None, ask_ceiling: ASK_AGENT_CEILING, background_tasks: None, + spawn_background_command: None, } } + /// Branche le producteur B8 command-backed pour `idea_run_in_background`. + #[must_use] + pub fn with_spawn_background_command(mut self, spawn: Arc) -> Self { + self.spawn_background_command = Some(spawn); + self + } + /// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents /// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6). /// @@ -1096,9 +1158,70 @@ impl OrchestratorService { ) .await } + OrchestratorCommand::RunInBackground { + owner, + label, + command, + args, + cwd, + deadline_ms, + } => { + self.run_in_background(project, owner, label, command, args, cwd, deadline_ms) + .await + } } } + async fn run_in_background( + &self, + project: &Project, + owner: AgentId, + label: String, + command: String, + args: Vec, + cwd: Option, + deadline_ms: Option, + ) -> Result { + let spawn = self.spawn_background_command.as_ref().ok_or_else(|| { + AppError::Invalid("background command runner is not configured".to_owned()) + })?; + let cwd = resolve_background_cwd(&project.root, cwd)?; + let output = spawn + .execute(SpawnBackgroundCommandInput { + project_id: project.id, + owner_agent_id: owner, + label, + command: domain::ports::SpawnSpec { + command, + args, + cwd, + env: Vec::new(), + context_plan: None, + sandbox: None, + }, + wake_policy: BackgroundTaskWakePolicy::WakeOwner, + deadline_ms, + }) + .await?; + let state = match output.task.state { + BackgroundTaskState::Queued => "Queued", + BackgroundTaskState::Running => "Running", + BackgroundTaskState::Waiting => "Waiting", + BackgroundTaskState::Completed => "Completed", + BackgroundTaskState::Failed => "Failed", + BackgroundTaskState::Cancelled => "Cancelled", + BackgroundTaskState::Expired => "Expired", + }; + Ok(OrchestratorOutcome { + detail: serde_json::json!({ + "taskId": output.task.id.to_string(), + "state": state, + }) + .to_string(), + reply: None, + }) + } + /// Returns the injected C7 use cases, or a typed error when unwired. fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> { self.context_guard.as_deref().ok_or_else(|| { diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 6631175..841a1ee 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -21,10 +21,11 @@ use domain::ids::SkillId; use domain::ids::{AgentId, NodeId, ProfileId, ProjectId}; use domain::markdown::MarkdownDoc; use domain::ports::{ - AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream, - ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryStore, OutputStream, - PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, - SessionPlan, SkillStore, SpawnSpec, StoreError, + AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle, + BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, + ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, + IdGenerator, MemoryError, MemoryStore, OutputStream, PreparedContext, ProfileStore, PtyError, + PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError, }; use domain::profile::{ AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, @@ -34,14 +35,17 @@ use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use domain::skill::{Skill, SkillScope}; use domain::terminal::{SessionKind, TerminalSession}; +use domain::{ + BackgroundTask, BackgroundTaskState, BackgroundTaskWakePolicy, OrchestratorCommand, + OrchestratorRequest, PtySize, SessionId, TaskId, +}; use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug}; -use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId}; use uuid::Uuid; use application::{ CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn, LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService, - TerminalSessions, UpdateAgentContext, UpdateLiveState, + SpawnBackgroundCommand, TerminalSessions, UpdateAgentContext, UpdateLiveState, }; use domain::live_state::{LiveEntry, LiveState, WorkStatus}; use domain::ports::LiveStateStore; @@ -370,6 +374,104 @@ impl PtyPort for FakePty { } } +#[derive(Clone, Default)] +struct RecordingBackgroundStore { + tasks: Arc>>, +} +impl RecordingBackgroundStore { + fn task(&self, id: TaskId) -> Option { + self.tasks.lock().unwrap().get(&id).cloned() + } +} +#[async_trait] +impl BackgroundTaskStore for RecordingBackgroundStore { + async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { + let mut tasks = self.tasks.lock().unwrap(); + if tasks.contains_key(&task.id) { + return Err(BackgroundTaskPortError::AlreadyExists); + } + tasks.insert(task.id, task.clone()); + Ok(()) + } + + async fn get(&self, id: TaskId) -> Result, BackgroundTaskPortError> { + Ok(self.task(id)) + } + + async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> { + self.tasks.lock().unwrap().insert(task.id, task.clone()); + Ok(()) + } + + async fn list_open_for_agent( + &self, + agent_id: AgentId, + ) -> Result, BackgroundTaskPortError> { + Ok(self + .tasks + .lock() + .unwrap() + .values() + .filter(|task| task.owner_agent_id == agent_id && !task.is_terminal()) + .cloned() + .collect()) + } + + async fn list_undelivered_completions( + &self, + ) -> Result, BackgroundTaskPortError> { + Ok(self + .tasks + .lock() + .unwrap() + .values() + .filter(|task| task.has_pending_completion_delivery()) + .cloned() + .collect()) + } + + async fn mark_completion_delivered( + &self, + task_id: TaskId, + ) -> Result<(), BackgroundTaskPortError> { + let task = self + .task(task_id) + .ok_or(BackgroundTaskPortError::NotFound)? + .mark_completion_delivered() + .map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?; + self.save(&task).await + } +} + +#[derive(Clone, Default)] +struct RecordingBackgroundRunner { + specs: Arc>>, +} +impl RecordingBackgroundRunner { + fn specs(&self) -> Vec { + self.specs.lock().unwrap().clone() + } +} +#[async_trait] +impl BackgroundTaskRunner for RecordingBackgroundRunner { + async fn spawn( + &self, + spec: BackgroundTaskSpec, + ) -> Result { + let task_id = spec.task_id; + self.specs.lock().unwrap().push(spec); + Ok(BackgroundTaskHandle { task_id }) + } + + async fn cancel(&self, _task_id: TaskId) -> Result<(), BackgroundTaskPortError> { + Ok(()) + } + + fn subscribe_completions(&self) -> BackgroundCompletionStream { + Box::new(std::iter::empty()) + } +} + #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { @@ -683,6 +785,71 @@ async fn spawn_unknown_agent_creates_then_launches() { assert!(launched, "AgentLaunched must be published"); } +#[tokio::test] +async fn run_in_background_dispatch_creates_running_command_for_requester() { + let fx = fixture(FakeContexts::new()); + let store = Arc::new(RecordingBackgroundStore::default()); + let runner = Arc::new(RecordingBackgroundRunner::default()); + let spawn = Arc::new(SpawnBackgroundCommand::new( + Arc::clone(&store) as Arc, + Arc::clone(&runner) as Arc, + Arc::new(FixedMillisClock(42)) as Arc, + Arc::new(SeqIds::new()) as Arc, + )); + let service = fx.service.with_spawn_background_command(spawn); + let owner = aid(77); + + let out = service + .dispatch( + &project(), + OrchestratorCommand::RunInBackground { + owner, + label: "B8 T1".to_owned(), + command: "bash".to_owned(), + args: vec![ + "-lc".to_owned(), + "sleep 45 && echo DONE_MARKER_DEVBACKEND_B8_T1".to_owned(), + ], + cwd: Some("crates".to_owned()), + deadline_ms: None, + }, + ) + .await + .expect("dispatch ok"); + + let payload: serde_json::Value = serde_json::from_str(&out.detail).unwrap(); + assert_eq!(payload["state"], "Running"); + let task_id = + TaskId::from_uuid(uuid::Uuid::parse_str(payload["taskId"].as_str().unwrap()).unwrap()); + let task = store.task(task_id).expect("task persisted"); + assert_eq!(task.project_id, project().id); + assert_eq!(task.owner_agent_id, owner); + assert_eq!(task.state, BackgroundTaskState::Running); + assert_eq!(task.wake_policy, BackgroundTaskWakePolicy::WakeOwner); + assert_eq!( + task.kind, + domain::BackgroundTaskKind::Command { + label: "B8 T1".to_owned(), + } + ); + + let specs = runner.specs(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].task_id, task_id); + assert_eq!(specs[0].owner_agent_id, owner); + assert_eq!(specs[0].wake_policy, BackgroundTaskWakePolicy::WakeOwner); + let command = specs[0].command.as_ref().expect("command spec"); + assert_eq!(command.command, "bash"); + assert_eq!( + command.args, + vec![ + "-lc".to_owned(), + "sleep 45 && echo DONE_MARKER_DEVBACKEND_B8_T1".to_owned(), + ] + ); + assert_eq!(command.cwd.as_str(), "/home/me/proj/crates"); +} + #[tokio::test] async fn spawn_known_agent_launches_without_recreating() { let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md"); diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index b1afc58..7e1cf39 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -159,6 +159,24 @@ pub struct OrchestratorRequest { /// the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_delegation: Option, + /// Human-facing label for `background.run`. Required by that action, ignored + /// by the other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Executable for `background.run`. Required by that action, ignored by the + /// other actions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Arguments for `background.run`. Optional and defaults to an empty list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Optional working directory for `background.run`, resolved by the + /// application layer under the project root. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Optional absolute deadline for `background.run`, epoch milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deadline_ms: Option, } /// A validated orchestrator command — the only thing the application layer acts on. @@ -323,6 +341,25 @@ pub enum OrchestratorCommand { /// The ticket of the most recent delegation it issued, if any. last_delegation: Option, }, + /// Start a command-backed first-class background task (`idea_run_in_background`). + /// + /// The owner is the connected peer's handshake identity (`requestedBy`), never + /// a model-supplied parameter. The application layer resolves the project from + /// the dispatch context and forces `WakeOwner`. + RunInBackground { + /// Owning agent, parsed from `requestedBy`. + owner: AgentId, + /// Human-facing label. + label: String, + /// Executable to run. + command: String, + /// Command arguments. + args: Vec, + /// Optional working directory, resolved under the project root downstream. + cwd: Option, + /// Optional absolute deadline, epoch milliseconds. + deadline_ms: Option, + }, } /// Where IdeA should place a launched agent session. @@ -433,6 +470,19 @@ impl OrchestratorRequest { last_delegation: self .parse_optional_ticket(action, self.last_delegation.as_deref())?, }), + "background.run" => Ok(OrchestratorCommand::RunInBackground { + owner: self.require_from(action)?, + label: self.require("label", action, self.label.as_deref())?, + command: self.require("command", action, self.command.as_deref())?, + args: self.args.clone(), + cwd: self + .cwd + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned), + deadline_ms: self.deadline_ms, + }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, @@ -1139,6 +1189,60 @@ mod tests { ); } + #[test] + fn background_run_keys_owner_on_requester_and_parses_command() { + let owner = uuid::Uuid::from_u128(33); + let r = req(&format!( + r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"long test", + "command":"bash", "args":["-lc","sleep 1 && echo done"], + "cwd":"subdir", "deadlineMs":12345 }}"# + )); + + assert_eq!( + r.validate().unwrap(), + OrchestratorCommand::RunInBackground { + owner: AgentId::from_uuid(owner), + label: "long test".to_owned(), + command: "bash".to_owned(), + args: vec!["-lc".to_owned(), "sleep 1 && echo done".to_owned()], + cwd: Some("subdir".to_owned()), + deadline_ms: Some(12345), + } + ); + } + + #[test] + fn background_run_requires_handshake_owner_label_and_command() { + assert_eq!( + req(r#"{ "type":"background.run", "label":"x", "command":"bash" }"#).validate(), + Err(OrchestratorError::MissingField { + action: "background.run".to_owned(), + field: "requestedBy".to_owned(), + }) + ); + let owner = uuid::Uuid::from_u128(33); + assert_eq!( + req(&format!( + r#"{{ "type":"background.run", "requestedBy":"{owner}", "command":"bash" }}"# + )) + .validate(), + Err(OrchestratorError::MissingField { + action: "background.run".to_owned(), + field: "label".to_owned(), + }) + ); + assert_eq!( + req(&format!( + r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"x" }}"# + )) + .validate(), + Err(OrchestratorError::MissingField { + action: "background.run".to_owned(), + field: "command".to_owned(), + }) + ); + } + #[test] fn unknown_action_is_rejected() { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 0753ff5..0d2fa79 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -60,6 +60,7 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_context_read" | "idea_memory_read" | "idea_skill_read" + | "idea_run_in_background" | "idea_workstate_read" ) || is_ticket_tool(tool) } @@ -103,6 +104,27 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_run_in_background", + description: "Run a command as a first-class IdeA background task. Returns the task id \ + immediately; completion is delivered later to your inbox.", + input_schema: json!({ + "type": "object", + "properties": { + "label": { "type": "string", "description": "Human-facing label for the background task." }, + "command": { "type": "string", "description": "Executable to run." }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Command arguments." + }, + "cwd": { "type": "string", "description": "Working directory under the project root. Defaults to the project root." }, + "deadline_ms": { "type": "number", "description": "Optional absolute deadline, epoch milliseconds." } + }, + "required": ["label", "command"], + "additionalProperties": false + }), + }, ToolDef { name: "idea_launch_agent", description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ @@ -316,6 +338,16 @@ pub fn map_tool_call( task: s("task"), ..base() }, + "idea_run_in_background" => OrchestratorRequest { + request_type: Some("background.run".to_owned()), + requested_by: Some(requester.to_owned()), + label: s("label"), + command: s("command"), + args: string_array(args.get("args"), name)?, + cwd: s("cwd"), + deadline_ms: optional_u64(args.get("deadline_ms"), name)?, + ..base() + }, "idea_stop_agent" => OrchestratorRequest { request_type: Some("agent.stop".to_owned()), target_agent: s("target"), @@ -417,6 +449,11 @@ fn base() -> OrchestratorRequest { intent: None, progress: None, last_delegation: None, + label: None, + command: None, + args: Vec::new(), + cwd: None, + deadline_ms: None, } } @@ -430,6 +467,33 @@ fn parse_node_id(value: Option<&Value>) -> Option { .map(domain::NodeId::from_uuid) } +fn string_array(value: Option<&Value>, tool: &str) -> Result, ToolMapError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let items = value + .as_array() + .ok_or_else(|| ToolMapError::BadArguments(tool.to_owned()))?; + items + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| ToolMapError::BadArguments(tool.to_owned())) + }) + .collect() +} + +fn optional_u64(value: Option<&Value>, tool: &str) -> Result, ToolMapError> { + let Some(value) = value else { + return Ok(None); + }; + value + .as_u64() + .map(Some) + .ok_or_else(|| ToolMapError::BadArguments(tool.to_owned())) +} + #[cfg(test)] mod tests { use super::*; @@ -479,6 +543,66 @@ mod tests { ); } + #[test] + fn run_in_background_maps_to_background_command_with_handshake_owner() { + let cmd = map_tool_call( + "idea_run_in_background", + &json!({ + "label": "B8 T1", + "command": "bash", + "args": ["-lc", "sleep 45 && echo DONE"], + "cwd": "crates", + "deadline_ms": 123456 + }), + REQ, + ) + .unwrap(); + + assert_eq!( + cmd, + OrchestratorCommand::RunInBackground { + owner: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()), + label: "B8 T1".to_owned(), + command: "bash".to_owned(), + args: vec!["-lc".to_owned(), "sleep 45 && echo DONE".to_owned()], + cwd: Some("crates".to_owned()), + deadline_ms: Some(123456), + } + ); + assert!(tool_returns_reply("idea_run_in_background")); + } + + #[test] + fn run_in_background_rejects_absent_requester_and_malformed_args() { + let no_requester = map_tool_call( + "idea_run_in_background", + &json!({ "label": "x", "command": "bash" }), + "", + ); + assert!(matches!(no_requester, Err(ToolMapError::Invalid(_)))); + + assert_eq!( + map_tool_call( + "idea_run_in_background", + &json!({ "label": "x", "command": "bash", "args": "nope" }), + REQ, + ), + Err(ToolMapError::BadArguments( + "idea_run_in_background".to_owned() + )) + ); + assert_eq!( + map_tool_call( + "idea_run_in_background", + &json!({ "label": "x", "command": "bash", "args": [1] }), + REQ, + ), + Err(ToolMapError::BadArguments( + "idea_run_in_background".to_owned() + )) + ); + } + #[test] fn launch_agent_maps_to_spawn_background_by_default() { let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap(); diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index 2b84c5c..96a211b 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -486,6 +486,7 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { for expected in [ "idea_list_agents", "idea_ask_agent", + "idea_run_in_background", "idea_launch_agent", "idea_stop_agent", "idea_update_context", @@ -520,8 +521,8 @@ async fn tools_list_advertises_the_idea_tools_with_schemas() { assert!(!names.contains(&"idea_reply")); assert_eq!( tools.len(), - 23, - "exactly the twenty-three exposed idea_* tools; got {names:?}" + 24, + "exactly the twenty-four exposed idea_* tools; got {names:?}" ); // Every tool advertises an object input schema.