feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)

Clôt le cold-start de coordination du programme live-state :
- domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState
  (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate.
- application : section bornée « # État du projet » injectée au lancement
  (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ;
  LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch.
- infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping,
  tool_returns_reply), compteur d'outils MCP 12→14.
- app-tauri : providers câblés, garde du nombre d'outils 12→14.
- tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service,
  infrastructure mcp_server, app-tauri state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:09:57 +02:00
parent 9d46e6cd21
commit 533a9c57f3
12 changed files with 1503 additions and 68 deletions

View File

@ -56,6 +56,7 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_context_read"
| "idea_memory_read"
| "idea_skill_read"
| "idea_workstate_read"
)
}
@ -220,6 +221,37 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_workstate_read",
description: "Read what every IdeA agent is doing right now (the project live-state). \
Returns a JSON array, one row per agent: name, status \
(idle|working|blocked|waiting|done), short intent, and optional \
current/last-delegation ticket. Last-writer-wins, not real-time.",
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_workstate_set",
description: "Publish your own current status to the project live-state so other \
agents can coordinate. Writes only YOUR row (keyed on your identity). \
`status` is required; `intent`/`progress`/`ticket`/`lastDelegation` are \
optional. Acknowledged only (no reply).",
input_schema: json!({
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["idle", "working", "blocked", "waiting", "done"], "description": "Your coarse status right now." },
"intent": { "type": "string", "description": "Short description of what you are doing." },
"progress": { "type": "string", "description": "Optional finer-grained progress note." },
"ticket": { "type": "string", "description": "Optional id of the ticket you are currently handling." },
"lastDelegation": { "type": "string", "description": "Optional id of the most recent delegation you issued." }
},
"required": ["status"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_create_skill",
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
@ -347,6 +379,25 @@ pub fn map_tool_call(
name: s("name"),
..base()
},
"idea_workstate_read" => OrchestratorRequest {
request_type: Some("workstate.read".to_owned()),
// The requester (handshake identity) is carried for symmetry with the
// other read tools; the read is project-wide.
requested_by: Some(requester.to_owned()),
..base()
},
"idea_workstate_set" => OrchestratorRequest {
request_type: Some("workstate.set".to_owned()),
// The written key is the connected peer's handshake identity (never a tool
// argument): injected as `requestedBy` so `validate` keys the row on it.
requested_by: Some(requester.to_owned()),
status: s("status"),
intent: s("intent"),
progress: s("progress"),
ticket: s("ticket"),
last_delegation: s("lastDelegation"),
..base()
},
"idea_create_skill" => OrchestratorRequest {
request_type: Some("skill.create".to_owned()),
name: s("name"),
@ -405,6 +456,10 @@ fn base() -> OrchestratorRequest {
ticket: None,
content: None,
slug: None,
status: None,
intent: None,
progress: None,
last_delegation: None,
}
}
@ -670,4 +725,90 @@ mod tests {
// ACK only: the result is routed to the awaiting requester, not echoed.
assert!(!tool_returns_reply("idea_reply"));
}
#[test]
fn workstate_read_maps_with_requester_party() {
let cmd = map_tool_call("idea_workstate_read", &json!({}), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::ReadWorkState {
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
// Read carries an inline reply (the live-state JSON array); set does not.
assert!(tool_returns_reply("idea_workstate_read"));
assert!(!tool_returns_reply("idea_workstate_set"));
}
#[test]
fn workstate_set_keys_on_handshake_requester() {
let tkt = "22222222-2222-2222-2222-222222222222";
let cmd = map_tool_call(
"idea_workstate_set",
&json!({ "status": "working", "intent": "ship", "ticket": tkt }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::SetWorkState {
agent: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
status: domain::live_state::WorkStatus::Working,
intent: Some("ship".to_owned()),
progress: None,
ticket: Some(domain::mailbox::TicketId::from_uuid(
uuid::Uuid::parse_str(tkt).unwrap()
)),
last_delegation: None,
}
);
// Missing status ⇒ validation error.
let err = map_tool_call("idea_workstate_set", &json!({ "intent": "x" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
// Status outside the enum ⇒ validation error too (UnknownWorkStatus surfaces
// as the generic Invalid map error).
let bad = map_tool_call("idea_workstate_set", &json!({ "status": "busy" }), REQ);
assert!(
matches!(bad, Err(ToolMapError::Invalid(_))),
"an out-of-enum status must be rejected, got {bad:?}"
);
}
/// The catalogue advertises the two live-state tools (lot LS4) with schemas
/// matching the frozen contract: `set` requires `status` constrained to the exact
/// five-label enum; `read` takes no parameter.
#[test]
fn catalogue_advertises_workstate_tools_with_conformant_schemas() {
let cat = catalogue();
let by_name = |name: &str| {
cat.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("catalogue must contain {name}"))
};
// read — object schema, no declared properties, no required fields.
let read = by_name("idea_workstate_read");
assert_eq!(read.input_schema["type"], json!("object"));
assert_eq!(
read.input_schema["properties"],
json!({}),
"read takes no parameter"
);
assert!(
read.input_schema.get("required").is_none(),
"read requires nothing"
);
// set — `status` required, enum exactly the five canonical labels.
let set = by_name("idea_workstate_set");
assert_eq!(set.input_schema["type"], json!("object"));
assert_eq!(set.input_schema["required"], json!(["status"]));
assert_eq!(
set.input_schema["properties"]["status"]["enum"],
json!(["idle", "working", "blocked", "waiting", "done"]),
"status enum must match WorkStatus labels exactly"
);
}
}

View File

@ -513,6 +513,9 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
"idea_context_propose",
"idea_memory_read",
"idea_memory_write",
// Live-state tools (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
] {
assert!(
names.contains(&expected),
@ -521,8 +524,8 @@ async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
}
assert_eq!(
tools.len(),
12,
"exactly the twelve idea_* tools (7 base + idea_skill_read + 4 FileGuard C7); got {names:?}"
14,
"exactly the fourteen idea_* tools (7 base + idea_skill_read + 4 FileGuard C7 + 2 live-state LS4); got {names:?}"
);
// Every tool advertises an object input schema.