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:
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user