//! 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::conversation::ConversationParty;
use crate::ids::{AgentId, NodeId, SkillId};
use crate::live_state::WorkStatus;
use crate::mailbox::TicketId;
use crate::skill::{SkillKind, SkillScope};

/// Current schema version for orchestration capabilities injected into an
/// agent's effective runtime context.
pub const ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION: u32 = 1;

/// Runtime snapshot of one skill assigned to an agent.
///
/// This is the agent-facing capability contract: launch context rendering and
/// `idea_skill_read` authorization are both derived from these snapshots, not
/// from ad hoc skill-name lookups.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssignedSkillSnapshot {
    /// Stable skill id from the selected scope.
    pub skill_id: SkillId,
    /// Store scope used to resolve the skill body.
    pub scope: SkillScope,
    /// Display name shown to the model and accepted by `idea_skill_read`.
    pub name: String,
    /// One-line affordance description shown in the model context.
    pub description: String,
    /// Explicit nature of the assigned skill capability.
    #[serde(default)]
    pub kind: SkillKind,
    /// Position in the agent assignment list, preserving manifest order.
    pub assignment_index: u32,
}

/// Provider-agnostic orchestration capability snapshot for one agent at runtime.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrchestrationCapabilitySnapshot {
    /// Snapshot schema version.
    pub version: u32,
    /// Skills assigned to the agent and resolved for this runtime.
    pub assigned_skills: Vec<AssignedSkillSnapshot>,
}

impl OrchestrationCapabilitySnapshot {
    /// Builds an empty snapshot at the current schema version.
    #[must_use]
    pub const fn empty() -> Self {
        Self {
            version: ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION,
            assigned_skills: Vec::new(),
        }
    }

    /// Builds a snapshot from assigned skills.
    #[must_use]
    pub fn new(assigned_skills: Vec<AssignedSkillSnapshot>) -> Self {
        Self {
            version: ORCHESTRATION_CAPABILITY_SNAPSHOT_VERSION,
            assigned_skills,
        }
    }
}

/// 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<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>,
    /// 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<String>,
    /// 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<String>,
    /// 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<String>,
    /// Optional optimistic-concurrency version for `context.update`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub if_match: Option<String>,
    /// 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<String>,
    /// 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<String>,
    /// 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<String>,
    /// 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<String>,
    /// 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<String>,
    /// Human-facing label for `background.run`. Required by that action, ignored
    /// by the other actions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Executable for `background.run`. Required by that action, ignored by the
    /// other actions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// Arguments for `background.run`. Optional and defaults to an empty list.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
    /// Optional working directory for `background.run`, resolved by the
    /// application layer under the project root.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Optional absolute deadline for `background.run`, epoch milliseconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deadline_ms: Option<u64>,
}

/// 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,
        /// 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<AgentId>,
    },
    /// 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 <id>]` 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<TicketId>,
        /// 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<String>,
        /// 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<String>,
        /// The proposed Markdown body.
        content: String,
        /// The proposing party (handshake identity).
        requester: ConversationParty,
    },
    /// Directly update the global project context. Strict, orchestrator-only
    /// counterpart to [`Self::ProposeContext`].
    UpdateProjectContext {
        /// The new Markdown body.
        content: String,
        /// Optional expected current version.
        if_match: Option<String>,
        /// The writing 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<String>,
        /// The reading party (handshake identity).
        requester: ConversationParty,
    },
    /// Read a reusable skill's Markdown body **by name** (`idea_skill_read`).
    /// The application layer authorizes the read against the requester's assigned
    /// runtime skill snapshot; unassigned skills are not readable. 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). Must be an agent
        /// requester for authorization against assigned skills.
        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<String>,
        /// Optional finer-grained progress note (domain-bounded on write).
        progress: Option<String>,
        /// The ticket the agent is currently handling, if any.
        ticket: Option<TicketId>,
        /// The ticket of the most recent delegation it issued, if any.
        last_delegation: Option<TicketId>,
    },
    /// Start a command-backed first-class background task (`idea_run_in_background`).
    ///
    /// The owner is the connected peer's handshake identity (`requestedBy`), never
    /// a model-supplied parameter. The application layer resolves the project from
    /// the dispatch context and forces `WakeOwner`.
    RunInBackground {
        /// Owning agent, parsed from `requestedBy`.
        owner: AgentId,
        /// Human-facing label.
        label: String,
        /// Executable to run.
        command: String,
        /// Command arguments.
        args: Vec<String>,
        /// Optional working directory, resolved under the project root downstream.
        cwd: Option<String>,
        /// Optional absolute deadline, epoch milliseconds.
        deadline_ms: Option<u64>,
    },
}

/// 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())?,
                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(),
            }),
            "context.update" => Ok(OrchestratorCommand::UpdateProjectContext {
                content: self.require("content", action, self.content.as_deref())?,
                if_match: self.if_match.clone(),
                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())?,
            }),
            "background.run" => Ok(OrchestratorCommand::RunInBackground {
                owner: self.require_from(action)?,
                label: self.require("label", action, self.label.as_deref())?,
                command: self.require("command", action, self.command.as_deref())?,
                args: self.args.clone(),
                cwd: self
                    .cwd
                    .as_deref()
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .map(str::to_owned),
                deadline_ms: self.deadline_ms,
            }),
            "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())
    }

    /// 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<AgentId, OrchestratorError> {
        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<Option<AgentId>, 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<Option<TicketId>, 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<Option<TicketId>, 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<WorkStatus, OrchestratorError> {
        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<String> {
        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<String> {
        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<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(),
                // `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 context_update_command_parses_content_and_if_match() {
        let uid = uuid::Uuid::from_u128(42);
        let r = req(&format!(
            r##"{{ "type":"context.update", "requestedBy":"{uid}", "content":"# body", "ifMatch":"abc123" }}"##
        ));
        assert_eq!(
            r.validate().unwrap(),
            OrchestratorCommand::UpdateProjectContext {
                content: "# body".to_owned(),
                if_match: Some("abc123".to_owned()),
                requester: ConversationParty::agent(AgentId::from_uuid(uid)),
            }
        );
    }

    #[test]
    fn context_update_requires_content() {
        let missing = req(r#"{ "type":"context.update", "ifMatch":"abc123" }"#);
        assert_eq!(
            missing.validate(),
            Err(OrchestratorError::MissingField {
                action: "context.update".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 background_run_keys_owner_on_requester_and_parses_command() {
        let owner = uuid::Uuid::from_u128(33);
        let r = req(&format!(
            r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"long test",
                  "command":"bash", "args":["-lc","sleep 1 && echo done"],
                  "cwd":"subdir", "deadlineMs":12345 }}"#
        ));

        assert_eq!(
            r.validate().unwrap(),
            OrchestratorCommand::RunInBackground {
                owner: AgentId::from_uuid(owner),
                label: "long test".to_owned(),
                command: "bash".to_owned(),
                args: vec!["-lc".to_owned(), "sleep 1 && echo done".to_owned()],
                cwd: Some("subdir".to_owned()),
                deadline_ms: Some(12345),
            }
        );
    }

    #[test]
    fn background_run_requires_handshake_owner_label_and_command() {
        assert_eq!(
            req(r#"{ "type":"background.run", "label":"x", "command":"bash" }"#).validate(),
            Err(OrchestratorError::MissingField {
                action: "background.run".to_owned(),
                field: "requestedBy".to_owned(),
            })
        );
        let owner = uuid::Uuid::from_u128(33);
        assert_eq!(
            req(&format!(
                r#"{{ "type":"background.run", "requestedBy":"{owner}", "command":"bash" }}"#
            ))
            .validate(),
            Err(OrchestratorError::MissingField {
                action: "background.run".to_owned(),
                field: "label".to_owned(),
            })
        );
        assert_eq!(
            req(&format!(
                r#"{{ "type":"background.run", "requestedBy":"{owner}", "label":"x" }}"#
            ))
            .validate(),
            Err(OrchestratorError::MissingField {
                action: "background.run".to_owned(),
                field: "command".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 { .. })
        ));
    }
}
