feat(background): déclencheur in-app des tâches de fond via MCP idea_run_in_background (BE-1+BE-2)
Ferme la boucle B8 côté surface agent : un agent peut lancer une commande en tâche de fond depuis l'app, sans dépendance à un déclencheur externe. - domain : variante OrchestratorCommand::RunInBackground. - infrastructure : outil MCP idea_run_in_background (déclaration + schéma + map_tool_call), tests de mapping et compteur de catalogue. - application : handler → SpawnBackgroundCommand (owner=requester, WakeOwner). - app-tauri : câblage .with_spawn_background_command + catalogue MCP 23→24. Tests verts : domain 452 / application 467 / infrastructure 489 / app-tauri 236, build --release vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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<ToolDef> {
|
||||
"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<domain::NodeId> {
|
||||
.map(domain::NodeId::from_uuid)
|
||||
}
|
||||
|
||||
fn string_array(value: Option<&Value>, tool: &str) -> Result<Vec<String>, 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<Option<u64>, 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();
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user