711 lines
29 KiB
Rust
711 lines
29 KiB
Rust
//! The IdeA MCP **tool catalogue** and the tool → [`OrchestratorRequest`] mapping.
|
|
//!
|
|
//! Each tool here is a thin, typed face over an **already-existing**
|
|
//! [`OrchestratorCommand`] (cadrage Décision 4, principe directeur : « le serveur
|
|
//! MCP n'invente aucune sémantique »). The mapping builds an
|
|
//! [`OrchestratorRequest`] from the tool arguments and lets
|
|
//! [`OrchestratorRequest::validate`] be the **single source of truth** for
|
|
//! validation — the very same path the filesystem watcher takes. No tool
|
|
//! validates anything itself; no tool reaches a use case directly.
|
|
//!
|
|
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
|
|
//!
|
|
//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is
|
|
//! served through the **same** `OrchestratorService::dispatch` as every other tool
|
|
//! (no use-case is reached directly). Its success carries the agent list inline as
|
|
//! a JSON array in the outcome's reply — see [`tool_returns_reply`].
|
|
//!
|
|
//! Delegation maps to [`OrchestratorCommand::AskAgent`]. The MCP tool is only an
|
|
//! ergonomic IdeA entrypoint: the application orchestrator drives the target's
|
|
//! structured/headless session and returns the target turn's `Final` inline. The
|
|
//! target never receives a model-managed ticket and never has to call `idea_reply`.
|
|
|
|
use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
|
|
use serde_json::{json, Value};
|
|
|
|
/// A tool the IdeA MCP server advertises, with the JSON Schema MCP clients read
|
|
/// from `tools/list`.
|
|
pub struct ToolDef {
|
|
/// Tool name as seen by the agent (`idea_ask_agent`, …).
|
|
pub name: &'static str,
|
|
/// One-line human description shown in the client.
|
|
pub description: &'static str,
|
|
/// JSON Schema (object) for the tool's arguments.
|
|
pub input_schema: Value,
|
|
}
|
|
|
|
/// Errors mapping a tool call into a validated [`OrchestratorCommand`].
|
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
|
pub enum ToolMapError {
|
|
/// The tool name is not one this server exposes.
|
|
#[error("unknown tool: {0}")]
|
|
UnknownTool(String),
|
|
/// The `arguments` object was absent or not a JSON object.
|
|
#[error("tool `{0}` arguments must be a JSON object")]
|
|
BadArguments(String),
|
|
/// The arguments did not satisfy the underlying command's invariants.
|
|
#[error(transparent)]
|
|
Invalid(#[from] OrchestratorError),
|
|
}
|
|
|
|
/// Whether a successful call of `tool` carries an inline reply payload back to the
|
|
/// caller. `idea_ask_agent` is a synchronous delegation tool: its payload is the
|
|
/// target agent's captured final answer.
|
|
#[must_use]
|
|
pub fn tool_returns_reply(tool: &str) -> bool {
|
|
matches!(
|
|
tool,
|
|
"idea_ask_agent"
|
|
| "idea_list_agents"
|
|
| "idea_context_read"
|
|
| "idea_memory_read"
|
|
| "idea_skill_read"
|
|
| "idea_workstate_read"
|
|
)
|
|
}
|
|
|
|
/// The full catalogue advertised on `tools/list`.
|
|
///
|
|
/// Exactly the tools whose mapping target already exists as an
|
|
/// [`OrchestratorCommand`].
|
|
#[must_use]
|
|
pub fn catalogue() -> Vec<ToolDef> {
|
|
vec![
|
|
ToolDef {
|
|
name: "idea_list_agents",
|
|
description: "List the IdeA agents declared in the project's manifest. Returns the \
|
|
agents inline as a JSON array (id, name, profile, origin, …).",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_ask_agent",
|
|
description: "Ask another IdeA agent to handle a task and wait for its final answer. \
|
|
IdeA launches or reattaches the target through its structured/headless \
|
|
profile, captures the target turn's Final, and returns it inline. The \
|
|
target does not call a reply tool or manage tickets.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target": { "type": "string", "description": "Target agent display name." },
|
|
"task": { "type": "string", "description": "Task or question to send to the target agent." }
|
|
},
|
|
"required": ["target", "task"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_launch_agent",
|
|
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
|
has started the agent, without waiting for any work.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target": { "type": "string", "description": "Target agent display name." },
|
|
"profile": { "type": "string", "description": "Optional profile reference for a not-yet-created agent." },
|
|
"task": { "type": "string", "description": "Optional initial task/context for the launched agent." },
|
|
"visibility": { "type": "string", "enum": ["background", "visible"], "description": "Where to place the session (default background)." },
|
|
"nodeId": { "type": "string", "description": "Target layout cell id, required when visibility is \"visible\"." }
|
|
},
|
|
"required": ["target"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_stop_agent",
|
|
description: "Stop a running IdeA agent by killing its terminal session.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target": { "type": "string", "description": "Target agent display name." }
|
|
},
|
|
"required": ["target"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_update_context",
|
|
description: "Overwrite an IdeA agent's `.md` context with a new Markdown body.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target": { "type": "string", "description": "Target agent display name." },
|
|
"context": { "type": "string", "description": "New Markdown body." }
|
|
},
|
|
"required": ["target", "context"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_context_read",
|
|
description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \
|
|
Omit `target` for the global project context, or pass an agent's display \
|
|
name for that agent's `.md`. Reading is shared (never blocks other readers).",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target": { "type": "string", "description": "Agent display name. Omit for the global project context." }
|
|
},
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_context_propose",
|
|
description: "Propose new Markdown content for an IdeA-owned context (under the file \
|
|
guard). For an agent's `.md` this writes directly; for the **global** \
|
|
project context it is only a *proposal* (the global context is \
|
|
single-writer — reserved to the orchestrator — so your change is recorded \
|
|
for validation, not applied).",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." },
|
|
"content": { "type": "string", "description": "The proposed Markdown body." }
|
|
},
|
|
"required": ["content"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_memory_read",
|
|
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
|
|
index, or pass a note slug for one note.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." }
|
|
},
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_memory_write",
|
|
description: "Write (create or replace) a project memory note under the file guard. \
|
|
Memory is shared across the project's agents.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"slug": { "type": "string", "description": "Memory note slug (kebab-case)." },
|
|
"content": { "type": "string", "description": "The Markdown body to store." }
|
|
},
|
|
"required": ["slug", "content"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
ToolDef {
|
|
name: "idea_skill_read",
|
|
description: "Read the full body of an IdeA skill assigned to you, by name — call this \
|
|
to actually execute/follow a skill listed in your « Skills disponibles » \
|
|
section. Resolves project scope first, then global. Returns the skill's \
|
|
Markdown inline.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string", "description": "Skill name (as listed in your context)." }
|
|
},
|
|
"required": ["name"],
|
|
"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.",
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string", "description": "Skill name (also the .md stem)." },
|
|
"context": { "type": "string", "description": "Markdown body of the skill." },
|
|
"scope": { "type": "string", "enum": ["project", "global"], "description": "Scope (default project)." }
|
|
},
|
|
"required": ["name", "context"],
|
|
"additionalProperties": false
|
|
}),
|
|
},
|
|
]
|
|
}
|
|
|
|
/// Maps an MCP `tools/call` (`name` + `arguments`) to a validated
|
|
/// [`OrchestratorCommand`], reusing [`OrchestratorRequest::validate`] as the one
|
|
/// validation authority.
|
|
///
|
|
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
|
|
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
|
|
/// cadrage v5 §1.4): `idea_ask_agent` carries it as `requestedBy` for A↔B routing
|
|
/// and cycle detection. Self-keyed tools also use it as their identity; the model
|
|
/// never supplies another agent's id.
|
|
///
|
|
/// # Errors
|
|
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
|
|
/// - [`ToolMapError::BadArguments`] when `arguments` is not an object,
|
|
/// - [`ToolMapError::Invalid`] when the underlying command's invariants fail.
|
|
pub fn map_tool_call(
|
|
name: &str,
|
|
arguments: &Value,
|
|
requester: &str,
|
|
) -> Result<OrchestratorCommand, ToolMapError> {
|
|
let args = arguments
|
|
.as_object()
|
|
.ok_or_else(|| ToolMapError::BadArguments(name.to_owned()))?;
|
|
|
|
// Small helpers to pull optional string fields out of the arguments object.
|
|
let s =
|
|
|key: &str| -> Option<String> { args.get(key).and_then(Value::as_str).map(str::to_owned) };
|
|
|
|
// Translate the tool into the wire-level request shape the watcher already
|
|
// uses (v2 `type`), then let `validate` enforce the invariants once.
|
|
let request = match name {
|
|
"idea_list_agents" => OrchestratorRequest {
|
|
request_type: Some("agent.list".to_owned()),
|
|
..base()
|
|
},
|
|
"idea_launch_agent" => OrchestratorRequest {
|
|
request_type: Some("agent.run".to_owned()),
|
|
target_agent: s("target"),
|
|
profile: s("profile"),
|
|
task: s("task"),
|
|
visibility: s("visibility"),
|
|
node_id: parse_node_id(args.get("nodeId")),
|
|
..base()
|
|
},
|
|
"idea_ask_agent" => OrchestratorRequest {
|
|
request_type: Some("agent.message".to_owned()),
|
|
requested_by: Some(requester.to_owned()),
|
|
target_agent: s("target"),
|
|
task: s("task"),
|
|
..base()
|
|
},
|
|
"idea_stop_agent" => OrchestratorRequest {
|
|
request_type: Some("agent.stop".to_owned()),
|
|
target_agent: s("target"),
|
|
..base()
|
|
},
|
|
"idea_update_context" => OrchestratorRequest {
|
|
request_type: Some("agent.update_context".to_owned()),
|
|
target_agent: s("target"),
|
|
context: s("context"),
|
|
..base()
|
|
},
|
|
"idea_context_read" => OrchestratorRequest {
|
|
request_type: Some("context.read".to_owned()),
|
|
// The requester (handshake identity) drives the read lease's `who`.
|
|
requested_by: Some(requester.to_owned()),
|
|
target_agent: s("target"),
|
|
..base()
|
|
},
|
|
"idea_context_propose" => OrchestratorRequest {
|
|
request_type: Some("context.propose".to_owned()),
|
|
requested_by: Some(requester.to_owned()),
|
|
target_agent: s("target"),
|
|
content: s("content"),
|
|
..base()
|
|
},
|
|
"idea_memory_read" => OrchestratorRequest {
|
|
request_type: Some("memory.read".to_owned()),
|
|
requested_by: Some(requester.to_owned()),
|
|
slug: s("slug"),
|
|
..base()
|
|
},
|
|
"idea_memory_write" => OrchestratorRequest {
|
|
request_type: Some("memory.write".to_owned()),
|
|
requested_by: Some(requester.to_owned()),
|
|
slug: s("slug"),
|
|
content: s("content"),
|
|
..base()
|
|
},
|
|
"idea_skill_read" => OrchestratorRequest {
|
|
request_type: Some("skill.read".to_owned()),
|
|
// The requester (handshake identity) is carried for symmetry with the
|
|
// other read tools; `validate` derives the requester party from it.
|
|
requested_by: Some(requester.to_owned()),
|
|
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"),
|
|
context: s("context"),
|
|
scope: s("scope"),
|
|
..base()
|
|
},
|
|
other => return Err(ToolMapError::UnknownTool(other.to_owned())),
|
|
};
|
|
|
|
request.validate().map_err(ToolMapError::Invalid)
|
|
}
|
|
|
|
/// An all-`None` request to spread over; keeps each arm above to just its fields.
|
|
fn base() -> OrchestratorRequest {
|
|
OrchestratorRequest {
|
|
action: None,
|
|
request_type: None,
|
|
requested_by: None,
|
|
name: None,
|
|
target_agent: None,
|
|
profile: None,
|
|
context: None,
|
|
task: None,
|
|
visibility: None,
|
|
node_id: None,
|
|
attach_to_cell: None,
|
|
scope: None,
|
|
result: None,
|
|
ticket: None,
|
|
content: None,
|
|
slug: None,
|
|
status: None,
|
|
intent: None,
|
|
progress: None,
|
|
last_delegation: None,
|
|
}
|
|
}
|
|
|
|
/// Parses an optional `nodeId` JSON value into a [`domain::NodeId`], silently
|
|
/// dropping a malformed/absent one (validation then rejects a `visible` launch
|
|
/// missing its node, with a precise field error).
|
|
fn parse_node_id(value: Option<&Value>) -> Option<domain::NodeId> {
|
|
let raw = value?.as_str()?;
|
|
uuid::Uuid::parse_str(raw)
|
|
.ok()
|
|
.map(domain::NodeId::from_uuid)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use domain::OrchestratorVisibility;
|
|
|
|
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
|
|
const REQ: &str = "11111111-1111-1111-1111-111111111111";
|
|
|
|
/// Maps a tool call with an empty requester (the default for tools that ignore it).
|
|
fn map(name: &str, args: &Value) -> Result<OrchestratorCommand, ToolMapError> {
|
|
map_tool_call(name, args, "")
|
|
}
|
|
|
|
#[test]
|
|
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
|
|
let requester = uuid::Uuid::from_u128(42).to_string();
|
|
assert_eq!(
|
|
map_tool_call(
|
|
"idea_ask_agent",
|
|
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
|
&requester,
|
|
)
|
|
.unwrap(),
|
|
OrchestratorCommand::AskAgent {
|
|
target: "Architect".to_owned(),
|
|
task: "Analyse §17".to_owned(),
|
|
requester: Some(domain::AgentId::from_uuid(
|
|
uuid::Uuid::parse_str(&requester).unwrap()
|
|
)),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
map_tool_call(
|
|
"idea_ask_agent",
|
|
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
|
"",
|
|
),
|
|
Ok(OrchestratorCommand::AskAgent {
|
|
target: "Architect".to_owned(),
|
|
task: "Analyse §17".to_owned(),
|
|
requester: None,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ),
|
|
Err(ToolMapError::UnknownTool("idea_reply".to_owned()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn launch_agent_maps_to_spawn_background_by_default() {
|
|
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
|
|
assert_eq!(
|
|
cmd,
|
|
OrchestratorCommand::SpawnAgent {
|
|
name: "Dev".to_owned(),
|
|
profile: None,
|
|
context: None,
|
|
visibility: OrchestratorVisibility::Background,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stop_update_and_skill_map_to_their_commands() {
|
|
assert_eq!(
|
|
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
|
OrchestratorCommand::StopAgent {
|
|
name: "Dev".to_owned()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
map(
|
|
"idea_update_context",
|
|
&json!({ "target": "Dev", "context": "# body" })
|
|
)
|
|
.unwrap(),
|
|
OrchestratorCommand::UpdateAgentContext {
|
|
name: "Dev".to_owned(),
|
|
context: "# body".to_owned(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
map(
|
|
"idea_create_skill",
|
|
&json!({ "name": "deploy", "context": "# steps" })
|
|
)
|
|
.unwrap(),
|
|
OrchestratorCommand::CreateSkill {
|
|
name: "deploy".to_owned(),
|
|
content: "# steps".to_owned(),
|
|
scope: domain::SkillScope::Project,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_required_field_surfaces_validation_error() {
|
|
let err = map("idea_update_context", &json!({ "target": "Architect" }));
|
|
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn list_agents_maps_to_list_command() {
|
|
let cmd = map("idea_list_agents", &json!({})).unwrap();
|
|
assert_eq!(cmd, OrchestratorCommand::ListAgents);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_tool_is_rejected() {
|
|
let err = map("idea_made_up_tool", &json!({}));
|
|
assert_eq!(
|
|
err,
|
|
Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_object_arguments_are_rejected() {
|
|
let err = map("idea_launch_agent", &json!("nope"));
|
|
assert_eq!(
|
|
err,
|
|
Err(ToolMapError::BadArguments("idea_launch_agent".to_owned()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn context_read_maps_with_requester_party() {
|
|
// Global (no target) with a uuid requester ⇒ Agent party.
|
|
let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap();
|
|
assert_eq!(
|
|
cmd,
|
|
OrchestratorCommand::ReadContext {
|
|
target: None,
|
|
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
|
uuid::Uuid::parse_str(REQ).unwrap()
|
|
)),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn context_propose_requires_content() {
|
|
let ok = map_tool_call(
|
|
"idea_context_propose",
|
|
&json!({ "target": "Dev", "content": "# body" }),
|
|
REQ,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
ok,
|
|
OrchestratorCommand::ProposeContext {
|
|
target: Some("Dev".to_owned()),
|
|
content: "# body".to_owned(),
|
|
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
|
uuid::Uuid::parse_str(REQ).unwrap()
|
|
)),
|
|
}
|
|
);
|
|
let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ);
|
|
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn memory_read_and_write_map_to_their_commands() {
|
|
assert_eq!(
|
|
map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(),
|
|
OrchestratorCommand::ReadMemory {
|
|
slug: Some("note-a".to_owned()),
|
|
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
|
uuid::Uuid::parse_str(REQ).unwrap()
|
|
)),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
map_tool_call(
|
|
"idea_memory_write",
|
|
&json!({ "slug": "note-a", "content": "hi" }),
|
|
REQ
|
|
)
|
|
.unwrap(),
|
|
OrchestratorCommand::WriteMemory {
|
|
slug: "note-a".to_owned(),
|
|
content: "hi".to_owned(),
|
|
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
|
uuid::Uuid::parse_str(REQ).unwrap()
|
|
)),
|
|
}
|
|
);
|
|
// memory.write without content ⇒ validation error.
|
|
let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ);
|
|
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
|
|
assert!(tool_returns_reply("idea_ask_agent"));
|
|
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"
|
|
);
|
|
}
|
|
}
|