Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
598 lines
23 KiB
Rust
598 lines
23 KiB
Rust
//! 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 `<project_root>/.ideai/requests/<requester-id>/*.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::ids::NodeId;
|
|
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 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<String>,
|
|
/// 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<String>,
|
|
/// Optional requester id/name, informational at this layer.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub requested_by: Option<String>,
|
|
/// Target agent display name (required by every v1 action).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// V2 target agent display name (`agent.run`/`agent.stop`).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub target_agent: Option<String>,
|
|
/// Runtime profile slug/name (required by `spawn_agent`).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub profile: Option<String>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
/// Target layout leaf for `visibility: "visible"`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub node_id: Option<NodeId>,
|
|
/// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub attach_to_cell: Option<NodeId>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// Optional initial `.md` body for a freshly-created agent.
|
|
context: Option<String>,
|
|
/// 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,
|
|
},
|
|
/// 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,
|
|
},
|
|
}
|
|
|
|
/// 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<OrchestratorCommand, OrchestratorError> {
|
|
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())?,
|
|
}),
|
|
"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<SkillScope, OrchestratorError> {
|
|
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<OrchestratorVisibility, OrchestratorError> {
|
|
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<String, OrchestratorError> {
|
|
self.require("name", action, self.name.as_deref())
|
|
}
|
|
|
|
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
|
|
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: "<request>".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<String, OrchestratorError> {
|
|
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(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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 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 { .. })
|
|
));
|
|
}
|
|
}
|