//! Orchestrator request model (ARCHITECTURE §14.3). //! //! An *orchestrator* agent does not spawn child processes itself: it **delegates** //! agent lifecycle to IdeA (the single source of truth) by dropping a JSON request //! file under `/.ideai/requests//*.json`. This module //! owns the **pure** request model: the wire-level [`OrchestratorRequest`] (serde, //! camelCase) and its validation into a well-formed [`OrchestratorCommand`]. //! //! It is I/O-free: parsing the file, dispatching to use cases and writing the //! response are infrastructure/application concerns. Keeping the model here means //! validation invariants (known action, required fields present) are unit-testable //! without touching the filesystem. use serde::{Deserialize, Serialize}; use crate::conversation::ConversationParty; use crate::ids::{AgentId, NodeId}; use crate::live_state::WorkStatus; use crate::mailbox::TicketId; use crate::skill::SkillScope; /// Errors raised while validating a raw [`OrchestratorRequest`]. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum OrchestratorError { /// The requested action/type is not one of the supported actions. #[error("unknown orchestrator action: {0}")] UnknownAction(String), /// A field required by the chosen action is missing or empty. #[error("missing required field `{field}` for action `{action}`")] MissingField { /// The action being validated. action: String, /// The required field that was absent or empty. field: String, }, /// The `scope` field is present but not a recognised skill scope. #[error("unknown skill scope `{scope}` for action `{action}`")] UnknownScope { /// The action being validated. action: String, /// The offending scope value. scope: String, }, /// The `visibility` field is present but not recognised. #[error("unknown visibility `{visibility}` for action `{action}`")] UnknownVisibility { /// The action being validated. action: String, /// The offending visibility value. visibility: String, }, /// The emitting-agent id (`requestedBy`) for `agent.reply` is not a valid id. #[error("invalid emitting-agent id `{value}` for action `{action}`")] InvalidAgentId { /// The action being validated. action: String, /// The offending id value. value: String, }, /// The `status` field of `workstate.set` is present but not a recognised /// [`WorkStatus`] label. #[error("unknown work status `{status}` for action `{action}`")] UnknownWorkStatus { /// The action being validated. action: String, /// The offending status value. status: String, }, } /// The raw, wire-level orchestrator request as deserialised from a request file. /// /// All payload fields are optional at this layer; which ones are *required* /// depends on `action` and is enforced by [`OrchestratorRequest::validate`]. This /// keeps deserialisation total (any JSON object shape parses) and pushes the /// metier invariants into one explicit, tested place. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OrchestratorRequest { /// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`). #[serde(default, skip_serializing_if = "Option::is_none")] pub action: Option, /// V2 action type (`agent.run`, `agent.stop`, `agent.message`, /// `agent.update_context`, `skill.create`). `type` is reserved in Rust, so the /// field is renamed. #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] pub request_type: Option, /// Optional requester id/name, informational at this layer. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_by: Option, /// Target agent display name (required by every v1 action). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// V2 target agent display name (`agent.run`/`agent.stop`). #[serde(default, skip_serializing_if = "Option::is_none")] pub target_agent: Option, /// Runtime profile slug/name (required by `spawn_agent`). #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, /// Context reference: for `spawn_agent` the relative `.md` path is informative /// (the manifest owns the real path); for `update_agent_context` **and** /// `create_skill` this carries the **new Markdown body** to write. #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, /// Task/message carried by `agent.run` (fire-and-forget) and required by /// `agent.message` (synchronous ask: the prompt sent to the target agent). #[serde(default, skip_serializing_if = "Option::is_none")] pub task: Option, /// Whether a spawned agent should stay in the background or attach to a cell. /// /// Accepted values are `"background"` (default) and `"visible"`. A visible /// spawn must also provide [`Self::node_id`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility: Option, /// Target layout leaf for `visibility: "visible"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub node_id: Option, /// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`. #[serde(default, skip_serializing_if = "Option::is_none")] pub attach_to_cell: Option, /// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive). /// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the /// other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, /// Result body for `agent.reply` (`idea_reply`): the content the emitting agent /// renders for the requester. Required by `agent.reply`, ignored otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, /// Optional ticket id echoed by `agent.reply` (`idea_reply`): when present, the /// reply correlates **by ticket** (deterministic, multi-thread); absent, it falls /// back to the head of the emitting agent's queue (cadrage C3 §3.3). Ignored by /// the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub ticket: Option, /// Markdown body for the FileGuard-mediated context/memory tools (`context.propose`, /// `memory.write`, cadrage C7). Required by those actions, ignored otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, /// Target memory note slug for the memory tools (`memory.read`/`memory.write`, /// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒ /// the aggregated index). Ignored by the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub slug: Option, /// Live-state status label for `workstate.set` (`idle|working|blocked|waiting|done`, /// case-insensitive). Required by that action, ignored otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, /// Short current-intent free-text for `workstate.set` (domain-bounded downstream). /// Optional, ignored by the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub intent: Option, /// Optional finer-grained progress note for `workstate.set` (domain-bounded /// downstream). Ignored by the other actions. **Never** exposed on read. #[serde(default, skip_serializing_if = "Option::is_none")] pub progress: Option, /// Optional `lastDelegation` ticket id for `workstate.set` (the most recent /// delegation the agent issued). Blank ⇒ none; non-uuid ⇒ rejected. Ignored by /// the other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_delegation: Option, } /// A validated orchestrator command — the only thing the application layer acts on. /// /// Each variant carries exactly the fields its action needs; constructing one is /// proof the request was well-formed (Parse, don't validate). #[derive(Debug, Clone, PartialEq, Eq)] pub enum OrchestratorCommand { /// Create the agent if unknown (with `profile` + optional initial context), /// then launch it — exactly as the UI would. SpawnAgent { /// Target agent display name. name: String, /// Profile slug/name to resolve against the configured profiles. Required /// for legacy `spawn_agent`; optional for `agent.run` when the agent /// already exists and its manifest owns the profile id. profile: Option, /// Optional initial `.md` body for a freshly-created agent. context: Option, /// Desired visibility for the launched session. visibility: OrchestratorVisibility, }, /// Stop a running agent by killing its terminal session. StopAgent { /// Target agent display name. name: String, }, /// Overwrite an agent's `.md` context with a new body. UpdateAgentContext { /// Target agent display name. name: String, /// New Markdown body. context: String, }, /// Ask a target agent a question/task and **wait for its reply**. /// /// Unlike [`Self::SpawnAgent`] (fire-and-forget lifecycle), this is the /// synchronous inter-agent rendezvous (ARCHITECTURE §17.4): the application /// layer drives the target's structured [`crate::ports::AgentSession`], waits /// for the turn's `Final`, and returns its content to the requester. The /// target is launched in structured mode first if it is not already live. AskAgent { /// Target agent display name (resolved case-insensitively). target: String, /// The task/message to send and await a reply for. task: String, /// The **requesting** agent (handshake identity), when the ask originates /// from another agent's `idea_ask_agent`. `None` for an ask with no carried /// requester (e.g. a file-protocol request without `requestedBy`): the /// orchestrator then routes it on the `User↔target` thread. This is what lets /// `ask_agent` resolve the **A↔B** conversation and feed the wait-for graph /// (cadrage C3 §5.2). requester: Option, }, /// The emitting agent renders the **result** of the task it is currently /// processing (Option 1 « Terminal + MCP », `idea_reply`). /// /// Correlation is **implicit and positional**: `from` is the emitting agent's id /// (carried by the MCP handshake, never a model-managed value), so the result /// resolves the ticket at the head of *that agent's* mailbox queue — the /// delegation it is working on. No ticket id is exposed to the model. Reply { /// The emitting agent (handshake identity), whose request the result resolves. from: AgentId, /// The ticket the reply correlates to (echoed by the agent from the /// `[IdeA · … · ticket ]` prefix). `Some` ⇒ correlate **by ticket** /// (deterministic, multi-thread); `None` ⇒ fall back to the head of `from`'s /// queue (compat mono-thread agents) (cadrage C3 §3.3). ticket: Option, /// The result content the requester awaits. result: String, }, /// List the project's agents (discovery) — exactly the data the UI reads from /// the manifest. Carries no argument: the dispatch resolves the agents against /// the project it is dispatched for. ListAgents, /// Create a reusable skill in the given scope — exactly as the UI would. CreateSkill { /// Display name (also the `.md` stem on disk). name: String, /// Markdown body of the skill. content: String, /// Scope the skill is created in (defaults to [`SkillScope::Project`]). scope: SkillScope, }, /// Read an IdeA-owned context `.md` under the [`crate::fileguard::FileGuard`] /// (cadrage C7). `target` absent = the **global project context**; otherwise the /// named agent's context. Reading is shared (N readers). ReadContext { /// Target agent display name; `None` ⇒ the global project context. target: Option, /// The party that issued the read (handshake identity), so the guard knows /// who acquires the read lease. requester: ConversationParty, }, /// Propose new content for a context `.md` under the /// [`crate::fileguard::FileGuard`] (cadrage C7). For an **agent** context this is /// a direct write under a write-lease; for the **global** project context it is a /// *proposal* (the guard forbids a non-orchestrator direct write — the change is /// materialised as a proposal for validation). ProposeContext { /// Target agent display name; `None` ⇒ the global project context. target: Option, /// The proposed Markdown body. content: String, /// The proposing party (handshake identity). requester: ConversationParty, }, /// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7). /// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note. ReadMemory { /// Target note slug; `None` ⇒ the aggregated index. slug: Option, /// The reading party (handshake identity). requester: ConversationParty, }, /// Read a reusable skill's Markdown body **by name** (`idea_skill_read`, /// feature « skills à la MCP »). Resolution is project-scope-first then global; /// the body is returned inline. Read-only — no [`crate::fileguard::FileGuard`] /// lease (skills are not mutated through this path). ReadSkill { /// Skill display name to resolve (case-insensitive). name: String, /// The party that issued the read (handshake identity). Carried for /// symmetry/auditing with the other read tools; skill reads need no lease. requester: ConversationParty, }, /// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7). /// Memory is project-shared; written directly under a write-lease. WriteMemory { /// Target note slug (required). slug: String, /// The Markdown body to store. content: String, /// The writing party (handshake identity). requester: ConversationParty, }, /// Read the project's agent **live-state** (`idea_workstate_read`, programme /// live-state, lot LS4): the lean "who is doing what right now" snapshot, enriched /// with each agent's display name. Read-only; `progress` is never exposed. ReadWorkState { /// The party that issued the read (handshake identity). Carried for symmetry /// with the other read tools; the read is project-wide (no per-requester /// filtering). requester: ConversationParty, }, /// Publish (insert or replace) the **current agent's** live-state row /// (`idea_workstate_set`, lot LS4). The key is the handshake `requestedBy` /// identity — an agent only ever writes its own row (no agent argument). SetWorkState { /// The agent whose row is written (the handshake identity, keyed /// last-writer-wins). Never a model-managed value. agent: AgentId, /// Coarse status the agent declares. status: WorkStatus, /// Short current-intent free-text (domain-bounded on write); `None` ⇒ cleared. intent: Option, /// Optional finer-grained progress note (domain-bounded on write). progress: Option, /// The ticket the agent is currently handling, if any. ticket: Option, /// The ticket of the most recent delegation it issued, if any. last_delegation: Option, }, } /// Where IdeA should place a launched agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OrchestratorVisibility { /// Launch or keep running without attaching to a layout cell. Background, /// Launch or re-attach visibly in one layout leaf. Visible { /// Target layout leaf. node_id: NodeId, }, } impl OrchestratorRequest { /// Validates the raw request into a well-formed [`OrchestratorCommand`]. /// /// Invariants enforced here (ARCHITECTURE §14.3): /// - `action` must be a known v1 action, /// - `name` is required (non-empty) for every action, /// - `spawn_agent` additionally requires a non-empty `profile`, /// - `update_agent_context` additionally requires a `context` body. /// /// # Errors /// [`OrchestratorError::UnknownAction`] for an unsupported action; /// [`OrchestratorError::MissingField`] when a required field is absent/empty. pub fn validate(&self) -> Result { let action = self.action_name()?; match action { "spawn_agent" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_name(action)?, profile: Some(self.require("profile", action, self.profile.as_deref())?), context: self.context.as_ref().filter(|c| !c.is_empty()).cloned(), visibility: self.parse_visibility(action)?, }), "agent.run" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_target_agent(action)?, profile: self .profile .as_ref() .filter(|p| !p.trim().is_empty()) .cloned(), context: self .context .as_ref() .or(self.task.as_ref()) .filter(|c| !c.is_empty()) .cloned(), visibility: self.parse_visibility(action)?, }), "stop_agent" => Ok(OrchestratorCommand::StopAgent { name: self.require_name(action)?, }), "agent.stop" => Ok(OrchestratorCommand::StopAgent { name: self.require_target_agent(action)?, }), "update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext { name: self.require_name(action)?, context: self.require("context", action, self.context.as_deref())?, }), "agent.update_context" => Ok(OrchestratorCommand::UpdateAgentContext { name: self.require_target_agent(action)?, context: self.require("context", action, self.context.as_deref())?, }), "agent.message" => Ok(OrchestratorCommand::AskAgent { target: self.require_target_agent(action)?, task: self.require("task", action, self.task.as_deref())?, requester: self.optional_requester(action)?, }), "agent.reply" => Ok(OrchestratorCommand::Reply { from: self.require_from(action)?, ticket: self.optional_ticket(action)?, result: self.require("result", action, self.result.as_deref())?, }), "context.read" => Ok(OrchestratorCommand::ReadContext { target: self.optional_target_agent(), requester: self.requester_party(), }), "context.propose" => Ok(OrchestratorCommand::ProposeContext { target: self.optional_target_agent(), content: self.require("content", action, self.content.as_deref())?, requester: self.requester_party(), }), "memory.read" => Ok(OrchestratorCommand::ReadMemory { slug: self.optional_slug(), requester: self.requester_party(), }), "skill.read" => Ok(OrchestratorCommand::ReadSkill { name: self.require_name(action)?, requester: self.requester_party(), }), "memory.write" => Ok(OrchestratorCommand::WriteMemory { slug: self.require("slug", action, self.slug.as_deref())?, content: self.require("content", action, self.content.as_deref())?, requester: self.requester_party(), }), "workstate.read" => Ok(OrchestratorCommand::ReadWorkState { requester: self.requester_party(), }), "workstate.set" => Ok(OrchestratorCommand::SetWorkState { // The key is the handshake identity (`requestedBy`), exactly like // `agent.reply`: an agent writes only its own row, never a peer's. agent: self.require_from(action)?, status: self.require_work_status(action)?, intent: self.intent.clone(), progress: self.progress.clone(), ticket: self.optional_ticket(action)?, last_delegation: self .parse_optional_ticket(action, self.last_delegation.as_deref())?, }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, content: self.require("context", action, self.context.as_deref())?, scope: self.parse_scope(action)?, }), other => Err(OrchestratorError::UnknownAction(other.to_owned())), } } /// Parses the optional `scope` field into a [`SkillScope`], defaulting to /// [`SkillScope::Project`] when absent or empty. /// /// # Errors /// [`OrchestratorError::UnknownScope`] when the value is neither `global` nor /// `project` (case-insensitive). fn parse_scope(&self, action: &str) -> Result { match self.scope.as_deref().map(str::trim) { None | Some("") => Ok(SkillScope::Project), Some(s) if s.eq_ignore_ascii_case("project") => Ok(SkillScope::Project), Some(s) if s.eq_ignore_ascii_case("global") => Ok(SkillScope::Global), Some(other) => Err(OrchestratorError::UnknownScope { action: action.to_owned(), scope: other.to_owned(), }), } } /// Parses `visibility` for `spawn_agent`, defaulting to background. fn parse_visibility(&self, action: &str) -> Result { match self.visibility.as_deref().map(str::trim) { None | Some("") | Some("background") => Ok(OrchestratorVisibility::Background), Some("visible") => { let node_id = self.node_id.or(self.attach_to_cell).ok_or_else(|| { OrchestratorError::MissingField { action: action.to_owned(), field: "nodeId".to_owned(), } })?; Ok(OrchestratorVisibility::Visible { node_id }) } Some(other) => Err(OrchestratorError::UnknownVisibility { action: action.to_owned(), visibility: other.to_owned(), }), } } /// Requires a non-empty `name`, shared by all actions. fn require_name(&self, action: &str) -> Result { self.require("name", action, self.name.as_deref()) } /// Parses the emitting agent id (`requestedBy`) for `agent.reply` into an /// [`AgentId`]. The MCP server injects this from the loopback **handshake** /// `requester` (never a model-managed value), so a well-formed reply always /// carries it; a missing/blank one is a `MissingField`, a non-uuid one an /// `InvalidAgentId`. fn require_from(&self, action: &str) -> Result { let raw = self.require("requestedBy", action, self.requested_by.as_deref())?; uuid::Uuid::parse_str(&raw) .map(AgentId::from_uuid) .map_err(|_| OrchestratorError::InvalidAgentId { action: action.to_owned(), value: raw, }) } /// Parses the **optional** requester id (`requestedBy`) for `agent.message` into /// an [`AgentId`]. The MCP handshake injects a real agent uuid here; the legacy /// file protocol may carry a free-form **display name** instead. So this is /// **lenient**: absent/blank/non-uuid ⇒ `None` (the ask carries no machine /// requester and routes on the `User↔target` thread); a well-formed uuid ⇒ /// `Some(agent)`, enabling A↔B conversation routing + the wait-for guard. #[allow(clippy::unnecessary_wraps, clippy::unused_self)] fn optional_requester(&self, _action: &str) -> Result, OrchestratorError> { Ok(self .requested_by .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) .map(AgentId::from_uuid)) } /// Parses the **optional** echoed ticket id for `agent.reply` into a [`TicketId`]. /// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒ /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). fn optional_ticket(&self, action: &str) -> Result, OrchestratorError> { self.parse_optional_ticket(action, self.ticket.as_deref()) } /// Parses an **optional** ticket-id field (`ticket`, `lastDelegation`) into a /// [`TicketId`]. Absent/blank ⇒ `None`; present-but-non-uuid ⇒ /// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error). #[allow(clippy::unused_self)] fn parse_optional_ticket( &self, action: &str, raw: Option<&str>, ) -> Result, OrchestratorError> { match raw.map(str::trim) { None | Some("") => Ok(None), Some(value) => uuid::Uuid::parse_str(value) .map(|u| Some(TicketId::from_uuid(u))) .map_err(|_| OrchestratorError::InvalidAgentId { action: action.to_owned(), value: value.to_owned(), }), } } /// Parses the **required** `status` of `workstate.set` into a [`WorkStatus`]. /// Missing/blank ⇒ [`OrchestratorError::MissingField`]; present-but-unknown ⇒ /// [`OrchestratorError::UnknownWorkStatus`]. fn require_work_status(&self, action: &str) -> Result { let raw = self.require("status", action, self.status.as_deref())?; WorkStatus::parse(&raw).ok_or_else(|| OrchestratorError::UnknownWorkStatus { action: action.to_owned(), status: raw, }) } /// The optional target agent name for the FileGuard context tools: `targetAgent` /// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context). fn optional_target_agent(&self) -> Option { self.target_agent .as_deref() .or(self.name.as_deref()) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) } /// The optional memory slug for `memory.read`: trimmed; absent/blank ⇒ `None` /// (the aggregated index). fn optional_slug(&self) -> Option { self.slug .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) } /// The party that issued a FileGuard-mediated request, derived from `requestedBy`. /// A well-formed agent uuid (the MCP handshake path) ⇒ [`ConversationParty::Agent`]; /// anything else (absent/blank/non-uuid, e.g. the orchestrator's own file-protocol /// path) ⇒ [`ConversationParty::User`] — IdeA's single orchestrator identity, the /// only party allowed to write the global project context directly. fn requester_party(&self) -> ConversationParty { self.requested_by .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .and_then(|raw| uuid::Uuid::parse_str(raw).ok()) .map_or(ConversationParty::User, |u| { ConversationParty::agent(AgentId::from_uuid(u)) }) } fn require_target_agent(&self, action: &str) -> Result { self.require( "targetAgent", action, self.target_agent.as_deref().or(self.name.as_deref()), ) } fn action_name(&self) -> Result<&str, OrchestratorError> { self.request_type .as_deref() .or(self.action.as_deref()) .map(str::trim) .filter(|a| !a.is_empty()) .ok_or_else(|| OrchestratorError::MissingField { action: "".to_owned(), field: "type".to_owned(), }) } /// Requires `value` to be present and non-empty (after trimming), else a /// [`OrchestratorError::MissingField`] naming `field`/`action`. fn require( &self, field: &str, action: &str, value: Option<&str>, ) -> Result { match value { Some(v) if !v.trim().is_empty() => Ok(v.trim().to_owned()), _ => Err(OrchestratorError::MissingField { action: action.to_owned(), field: field.to_owned(), }), } } } #[cfg(test)] mod tests { use super::*; fn req(json: &str) -> OrchestratorRequest { serde_json::from_str(json).expect("valid json") } #[test] fn spawn_agent_parses_and_validates() { let r = req( r#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }"#, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "dev-backend".to_owned(), profile: Some("claude-code".to_owned()), context: Some("agents/dev-backend.md".to_owned()), visibility: OrchestratorVisibility::Background, } ); } #[test] fn spawn_agent_without_context_is_valid() { let r = req(r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code" }"#); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "a".to_owned(), profile: Some("claude-code".to_owned()), context: None, visibility: OrchestratorVisibility::Background, } ); } #[test] fn spawn_agent_visible_requires_and_carries_node_id() { let node = uuid::Uuid::from_u128(42); let r = req(&format!( r#"{{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible", "nodeId": "{node}" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "a".to_owned(), profile: Some("claude-code".to_owned()), context: None, visibility: OrchestratorVisibility::Visible { node_id: NodeId::from_uuid(node) }, } ); let missing = req( r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible" }"#, ); assert_eq!( missing.validate(), Err(OrchestratorError::MissingField { action: "spawn_agent".to_owned(), field: "nodeId".to_owned(), }) ); } #[test] fn spawn_agent_missing_profile_is_rejected() { let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "spawn_agent".to_owned(), field: "profile".to_owned(), }) ); } #[test] fn agent_run_accepts_type_target_agent_and_optional_profile() { let r = req( r#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse", "visibility": "background" }"#, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "Architect".to_owned(), profile: None, context: Some("Analyse".to_owned()), visibility: OrchestratorVisibility::Background, } ); } #[test] fn agent_run_visible_accepts_attach_to_cell() { let node = uuid::Uuid::from_u128(77); let r = req(&format!( r#"{{ "type": "agent.run", "targetAgent": "Architect", "profile": "claude-code", "visibility": "visible", "attachToCell": "{node}" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "Architect".to_owned(), profile: Some("claude-code".to_owned()), context: None, visibility: OrchestratorVisibility::Visible { node_id: NodeId::from_uuid(node) }, } ); } #[test] fn agent_message_validates_target_and_task() { let r = req( r#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse §17" }"#, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::AskAgent { target: "Architect".to_owned(), task: "Analyse §17".to_owned(), // `requestedBy: "Main"` is a display name, not a uuid ⇒ lenient `None`. requester: None, } ); } /// A well-formed uuid `requestedBy` is parsed into `Some(agent)` (the MCP /// handshake path), enabling A↔B conversation routing. #[test] fn agent_message_carries_uuid_requester() { let uid = uuid::Uuid::from_u128(7); let r = req(&format!( r#"{{ "type":"agent.message", "requestedBy":"{uid}", "targetAgent":"B", "task":"go" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::AskAgent { target: "B".to_owned(), task: "go".to_owned(), requester: Some(AgentId::from_uuid(uid)), } ); } /// `agent.reply` correlates by ticket when the agent echoes it. #[test] fn agent_reply_carries_optional_ticket() { let from = uuid::Uuid::from_u128(3); let tkt = uuid::Uuid::from_u128(99); let r = req(&format!( r#"{{ "type":"agent.reply", "requestedBy":"{from}", "ticket":"{tkt}", "result":"done" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::Reply { from: AgentId::from_uuid(from), ticket: Some(TicketId::from_uuid(tkt)), result: "done".to_owned(), } ); // Without a ticket ⇒ None (head-of-queue fallback). let r2 = req(&format!( r#"{{ "type":"agent.reply", "requestedBy":"{from}", "result":"done" }}"# )); assert_eq!( r2.validate().unwrap(), OrchestratorCommand::Reply { from: AgentId::from_uuid(from), ticket: None, result: "done".to_owned(), } ); } #[test] fn agent_message_missing_task_is_rejected() { let r = req(r#"{ "type": "agent.message", "targetAgent": "Architect" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "agent.message".to_owned(), field: "task".to_owned(), }) ); } #[test] fn agent_message_missing_target_is_rejected() { let r = req(r#"{ "type": "agent.message", "task": "do it" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "agent.message".to_owned(), field: "targetAgent".to_owned(), }) ); } #[test] fn stop_agent_validates() { let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#); assert_eq!( r.validate().unwrap(), OrchestratorCommand::StopAgent { name: "dev-backend".to_owned() } ); } #[test] fn stop_agent_missing_name_is_rejected() { let r = req(r#"{ "action": "stop_agent" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "stop_agent".to_owned(), field: "name".to_owned(), }) ); } #[test] fn update_context_requires_a_body() { let ok = req(r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##); assert_eq!( ok.validate().unwrap(), OrchestratorCommand::UpdateAgentContext { name: "a".to_owned(), context: "# new body".to_owned(), } ); let missing = req(r#"{ "action": "update_agent_context", "name": "a" }"#); assert_eq!( missing.validate(), Err(OrchestratorError::MissingField { action: "update_agent_context".to_owned(), field: "context".to_owned(), }) ); } #[test] fn create_skill_defaults_to_project_scope() { let r = req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##); assert_eq!( r.validate().unwrap(), OrchestratorCommand::CreateSkill { name: "deploy".to_owned(), content: "# Deploy steps".to_owned(), scope: SkillScope::Project, } ); } #[test] fn create_skill_honours_explicit_scope_case_insensitively() { let r = req( r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "Global" }"##, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::CreateSkill { name: "deploy".to_owned(), content: "body".to_owned(), scope: SkillScope::Global, } ); } #[test] fn create_skill_missing_body_is_rejected() { let r = req(r#"{ "action": "create_skill", "name": "deploy" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "create_skill".to_owned(), field: "context".to_owned(), }) ); } #[test] fn create_skill_unknown_scope_is_rejected() { let r = req( r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "team" }"##, ); assert_eq!( r.validate(), Err(OrchestratorError::UnknownScope { action: "create_skill".to_owned(), scope: "team".to_owned(), }) ); } #[test] fn context_read_global_without_target_is_user_party() { let r = req(r#"{ "type": "context.read" }"#); assert_eq!( r.validate().unwrap(), OrchestratorCommand::ReadContext { target: None, requester: ConversationParty::User, } ); } #[test] fn context_read_with_uuid_requester_is_agent_party() { let uid = uuid::Uuid::from_u128(5); let r = req(&format!( r#"{{ "type":"context.read", "requestedBy":"{uid}", "targetAgent":"Dev" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::ReadContext { target: Some("Dev".to_owned()), requester: ConversationParty::agent(AgentId::from_uuid(uid)), } ); } #[test] fn context_propose_requires_content() { let ok = req(r##"{ "type":"context.propose", "targetAgent":"Dev", "content":"# body" }"##); assert_eq!( ok.validate().unwrap(), OrchestratorCommand::ProposeContext { target: Some("Dev".to_owned()), content: "# body".to_owned(), requester: ConversationParty::User, } ); let missing = req(r#"{ "type":"context.propose", "targetAgent":"Dev" }"#); assert_eq!( missing.validate(), Err(OrchestratorError::MissingField { action: "context.propose".to_owned(), field: "content".to_owned(), }) ); } #[test] fn memory_read_optional_slug() { assert_eq!( req(r#"{ "type":"memory.read" }"#).validate().unwrap(), OrchestratorCommand::ReadMemory { slug: None, requester: ConversationParty::User, } ); assert_eq!( req(r#"{ "type":"memory.read", "slug":"note-a" }"#) .validate() .unwrap(), OrchestratorCommand::ReadMemory { slug: Some("note-a".to_owned()), requester: ConversationParty::User, } ); } #[test] fn memory_write_requires_slug_and_content() { assert_eq!( req(r#"{ "type":"memory.write", "slug":"note-a", "content":"hi" }"#) .validate() .unwrap(), OrchestratorCommand::WriteMemory { slug: "note-a".to_owned(), content: "hi".to_owned(), requester: ConversationParty::User, } ); assert_eq!( req(r#"{ "type":"memory.write", "content":"hi" }"#).validate(), Err(OrchestratorError::MissingField { action: "memory.write".to_owned(), field: "slug".to_owned(), }) ); assert_eq!( req(r#"{ "type":"memory.write", "slug":"note-a" }"#).validate(), Err(OrchestratorError::MissingField { action: "memory.write".to_owned(), field: "content".to_owned(), }) ); } #[test] fn workstate_read_derives_requester_party() { let uid = uuid::Uuid::from_u128(5); let r = req(&format!( r#"{{ "type":"workstate.read", "requestedBy":"{uid}" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::ReadWorkState { requester: ConversationParty::agent(AgentId::from_uuid(uid)), } ); // No requester ⇒ user party (still valid: read is project-wide). assert_eq!( req(r#"{ "type":"workstate.read" }"#).validate().unwrap(), OrchestratorCommand::ReadWorkState { requester: ConversationParty::User, } ); } #[test] fn workstate_set_keys_on_requester_and_parses_fields() { let from = uuid::Uuid::from_u128(3); let tkt = uuid::Uuid::from_u128(9); let last = uuid::Uuid::from_u128(11); let r = req(&format!( r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"Working", "intent":"ship LS4", "progress":"wiring", "ticket":"{tkt}", "lastDelegation":"{last}" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SetWorkState { agent: AgentId::from_uuid(from), status: WorkStatus::Working, intent: Some("ship LS4".to_owned()), progress: Some("wiring".to_owned()), ticket: Some(TicketId::from_uuid(tkt)), last_delegation: Some(TicketId::from_uuid(last)), } ); } #[test] fn workstate_set_requires_status_and_requester() { let from = uuid::Uuid::from_u128(3); // Missing status ⇒ MissingField. assert_eq!( req(&format!( r#"{{ "type":"workstate.set", "requestedBy":"{from}" }}"# )) .validate(), Err(OrchestratorError::MissingField { action: "workstate.set".to_owned(), field: "status".to_owned(), }) ); // Unknown status ⇒ UnknownWorkStatus. assert_eq!( req(&format!( r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"busy" }}"# )) .validate(), Err(OrchestratorError::UnknownWorkStatus { action: "workstate.set".to_owned(), status: "busy".to_owned(), }) ); // Missing requester ⇒ MissingField on `requestedBy`. assert_eq!( req(r#"{ "type":"workstate.set", "status":"idle" }"#).validate(), Err(OrchestratorError::MissingField { action: "workstate.set".to_owned(), field: "requestedBy".to_owned(), }) ); // Non-uuid lastDelegation ⇒ InvalidAgentId (generic invalid-id error). assert!(matches!( req(&format!( r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "lastDelegation":"nope" }}"# )) .validate(), Err(OrchestratorError::InvalidAgentId { .. }) )); // Non-uuid `ticket` ⇒ InvalidAgentId too (same parse_optional_ticket path). assert!(matches!( req(&format!( r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "ticket":"nope" }}"# )) .validate(), Err(OrchestratorError::InvalidAgentId { .. }) )); // Non-uuid `requestedBy` ⇒ InvalidAgentId (the handshake key must be a real id). assert!(matches!( req(r#"{ "type":"workstate.set", "requestedBy":"not-a-uuid", "status":"idle" }"#) .validate(), Err(OrchestratorError::InvalidAgentId { .. }) )); // Blank `requestedBy` ⇒ MissingField (trimmed-empty is treated as absent). assert_eq!( req(r#"{ "type":"workstate.set", "requestedBy":" ", "status":"idle" }"#).validate(), Err(OrchestratorError::MissingField { action: "workstate.set".to_owned(), field: "requestedBy".to_owned(), }) ); } #[test] fn unknown_action_is_rejected() { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); assert_eq!( r.validate(), Err(OrchestratorError::UnknownAction( "delete_everything".to_owned() )) ); } #[test] fn blank_name_is_treated_as_missing() { let r = req(r#"{ "action": "stop_agent", "name": " " }"#); assert!(matches!( r.validate(), Err(OrchestratorError::MissingField { .. }) )); } }