Skip to main content

distri_types/
invocation.rs

1//! The unified sub-agent invocation model.
2//!
3//! Replaces the older `CallMode` (`InProcess`/`Fork`/`Offload`/`Transfer`)
4//! enum which conflated three independent decisions — what context the
5//! child sees, how the parent waits, and which orchestrator runs the
6//! loop — into a single string mode. Each axis is now its own type.
7//!
8//! See `distri/docs/invocation-model.md` (TODO) for the full design notes.
9//! Quick summary:
10//!
11//! - [`ContextScope`] — Independent / Inherited / Shared.
12//! - [`Join`] — Single / All / Detached.
13//! - [`Executor`] — Local / Remote{runner}. The agent loop is always
14//!   server-side; the question is whether THIS orchestrator runs it or
15//!   another orchestrator does.
16//!
17//! `Invocation` carries `Vec<Target>` (1..N) so a single sub-agent call
18//! is just `targets.len() == 1`. Validation rejects combinations that
19//! don't make sense (e.g. `Join::Single` with 2 targets).
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::agent::ToolsConfig;
25use crate::core::{Message, TaskStatus};
26
27// ── Top-level invocation ──────────────────────────────────────────────────
28
29/// One agent dispatch — synchronous or asynchronous, single or fan-out,
30/// local or remote. The orchestrator validates this at the entry point and
31/// then stamps the resolved fields onto the child task row(s).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Invocation {
34    /// 1..N targets. `Join::Single` requires exactly 1; the others accept
35    /// any positive count.
36    pub targets: Vec<Target>,
37
38    /// What the child task sees on its first turn.
39    #[serde(default)]
40    pub context: ContextScope,
41
42    /// How the parent waits.
43    #[serde(default)]
44    pub join: Join,
45
46    /// Which orchestrator runs the agent loop. `Auto` resolves at
47    /// invocation time from (agent.runtime ∩ caller.runtime ∩ available
48    /// runners). `Force` is for tests and debugging.
49    #[serde(default)]
50    pub executor: ExecutorHint,
51
52    /// Tool inheritance policy for the child. Defaults to `Inherit`
53    /// (`external = ["*"]` — child borrows the parent session's full
54    /// external tool pool, like claude-code's `useExactTools`).
55    #[serde(default)]
56    pub tools: ToolPolicy,
57}
58
59/// One leaf of a (possibly fan-out) invocation.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Target {
62    pub agent: AgentRef,
63    /// The user-facing message handed to the child as its first turn.
64    pub message: Message,
65    /// Per-target executor override. Falls back to `Invocation.executor`
66    /// when absent. Rare — used by tests and "force this one to a
67    /// specific sandbox" debugging cases.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub executor: Option<ExecutorHint>,
70}
71
72/// How to identify the agent for a target.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(tag = "type", rename_all = "snake_case")]
75pub enum AgentRef {
76    /// Named agent looked up by `agent_id` in the agent store.
77    ///
78    /// `instructions_overlay`, when `Some`, is **appended** to the named
79    /// agent's own instructions for this invocation only (via
80    /// `DefinitionOverrides::instructions_append`). This is how a *skill-fork*
81    /// is expressed: "the same agent that's running, plus this skill body as
82    /// task-specific direction." The agent keeps its identity, tools and
83    /// scaffolding; the overlay adds the brief. `None` = the agent runs exactly
84    /// as defined.
85    Named {
86        agent_id: String,
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        instructions_overlay: Option<String>,
89    },
90    /// Ad-hoc agent built on the fly. The `system_prompt` is appended to
91    /// `_adhoc_base.md`'s body; tools (if `Some`) replace the seeded
92    /// ToolsConfig. Mirrors today's `call_agent({system_prompt, tools})`.
93    AdHoc {
94        system_prompt: String,
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        tools: Option<ToolsConfig>,
97    },
98}
99
100impl AgentRef {
101    /// A plain named-agent reference (no overlay).
102    pub fn named(agent_id: impl Into<String>) -> Self {
103        AgentRef::Named {
104            agent_id: agent_id.into(),
105            instructions_overlay: None,
106        }
107    }
108
109    /// A named-agent reference with an instruction overlay appended for this
110    /// invocation (the skill-fork shape).
111    pub fn named_with_overlay(agent_id: impl Into<String>, overlay: impl Into<String>) -> Self {
112        AgentRef::Named {
113            agent_id: agent_id.into(),
114            instructions_overlay: Some(overlay.into()),
115        }
116    }
117}
118
119// ── Axis 1: ContextScope ──────────────────────────────────────────────────
120
121/// What the child task sees when it starts its first LLM turn.
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
123#[serde(rename_all = "snake_case")]
124pub enum ContextScope {
125    /// Fresh task, empty history. Self-contained workers (one-shot
126    /// summarisation, validation, single-purpose lookups). Replaces the
127    /// old `CallMode::InProcess`.
128    #[default]
129    Independent,
130
131    /// Fresh task, but parent's `task_messages` are copied in (with
132    /// orphan tool_calls filtered — see `universal_agent.rs`'s parent
133    /// history filter). The child sees the conversation up to the
134    /// invocation point. Used when the worker needs the parent's
135    /// conversational context to do its job (default for `run_skill`).
136    /// Replaces the old `CallMode::Fork`.
137    Inherited,
138
139    /// SAME task as the parent. Hard handover — the parent's loop ends
140    /// when the child finishes; the child's final result becomes the
141    /// parent's. Replaces the old `CallMode::Transfer`.
142    Shared,
143}
144
145// ── Axis 2: Join ──────────────────────────────────────────────────────────
146
147/// How the parent waits for the dispatched task(s).
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
149#[serde(rename_all = "snake_case")]
150pub enum Join {
151    /// Wait for the (single) target's terminal event. Result: scalar.
152    /// Validation: `targets.len() == 1`.
153    #[default]
154    Single,
155
156    /// Wait for ALL listed targets to terminate. Result: `Vec<Result>`
157    /// in input order. Validation: `targets.len() >= 1` (with len == 1
158    /// this is equivalent to Single but returns a Vec — use Single for
159    /// scalar). True fan-out join.
160    All,
161
162    /// Fire-and-forget. Returns `Vec<task_id>` immediately. Subsequent
163    /// turns can use the supervisor tools (`get_task` / `wait_task` /
164    /// `cancel_task`) to manage the dispatched tasks. Replaces the old
165    /// `CallMode::Offload`.
166    Detached,
167}
168
169// ── Axis 3: Executor ──────────────────────────────────────────────────────
170
171/// Which orchestrator runs the agent loop.
172///
173/// **Note**: the loop is ALWAYS server-side — clients (browser SDK,
174/// distri-cli) only execute external tools, not agent loops. So the only
175/// real distinction is "this orchestrator" vs "another orchestrator".
176///
177/// Note that the *kind* of remote runner (sandbox / loopback / k8s / fly /
178/// …) is NOT a closed enum here. Adding a new runner is purely an
179/// orchestrator-side concern — register a new
180/// [`RunnerInitializer`](crate::stores::dummy_phantom) under a fresh
181/// [`RunnerConfig::kind`] string and the schema is unchanged. The DB only
182/// records `remote = true|false`.
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(tag = "type", rename_all = "snake_case")]
185pub enum Executor {
186    /// THIS orchestrator runs the loop. Tools the agent calls execute on
187    /// this server (or are dispatched to whoever is driving the loop —
188    /// the JS client, the local distri-cli, etc. — via `is_external`
189    /// tool-result POSTs).
190    Local,
191
192    /// Another orchestrator runs the loop. The `RunnerConfig` selects
193    /// which runner (`kind` is the registry key) and carries the
194    /// implementation-specific config the registered
195    /// [`RunnerInitializer`] parses. We follow the runner's A2A stream
196    /// and relay events back onto our task's broadcaster.
197    Remote { runner: RunnerConfig },
198}
199
200/// How to start a remote runner. The `kind` field is dispatched against
201/// the orchestrator's `RunnerInitializer` registry; `config` is the
202/// initializer's private payload (image name, k8s namespace, sandbox
203/// flags, ...). The orchestrator does not interpret `config`.
204///
205/// Examples (the strings are conventions, not a closed set):
206/// - `{ "kind": "sandbox", "config": { "image": "..." } }` — browsr
207///   container running distri-cli.
208/// - `{ "kind": "loopback", "config": {} }` — loopback HTTP to another
209///   orchestrator instance (DEV_MODE / OSS distri-server).
210/// - `{ "kind": "k8s", "config": { "namespace": "...", "image": "..." } }` —
211///   future Kubernetes runner.
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
213pub struct RunnerConfig {
214    /// Registry key for the [`RunnerInitializer`] that knows how to
215    /// start and talk to this runner.
216    pub kind: String,
217    /// Initializer-private payload. Default `{}` for runners that need
218    /// no config beyond their kind.
219    #[serde(default = "default_config_value")]
220    pub config: serde_json::Value,
221}
222
223fn default_config_value() -> serde_json::Value {
224    serde_json::Value::Object(Default::default())
225}
226
227impl RunnerConfig {
228    pub fn new(kind: impl Into<String>) -> Self {
229        Self {
230            kind: kind.into(),
231            config: default_config_value(),
232        }
233    }
234
235    pub fn with_config(mut self, config: serde_json::Value) -> Self {
236        self.config = config;
237        self
238    }
239}
240
241/// What the caller HINTS for axis 3. Final decision is the orchestrator's:
242/// it intersects `(agent.allowed_runtimes, caller.runtime_mode,
243/// available_runners)`.
244#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
245#[serde(tag = "kind", rename_all = "snake_case")]
246pub enum ExecutorHint {
247    /// Resolve from agent runtime + caller + available runners. Default.
248    #[default]
249    Auto,
250    /// Override the resolution. Rare — tests, debugging.
251    Force(Executor),
252}
253
254// ── Tool policy ───────────────────────────────────────────────────────────
255
256/// How the child inherits external tools from the parent session.
257#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
258#[serde(tag = "kind", rename_all = "snake_case")]
259pub enum ToolPolicy {
260    /// Child gets parent's external tools (`external = ["*"]`). Default
261    /// — matches claude-code's `useExactTools` semantics.
262    #[default]
263    Inherit,
264    /// Explicit tool list for the child. The orchestrator filters the
265    /// parent's tool pool to just these names.
266    Exact { tools: Vec<String> },
267    /// Child has only its own builtin tools; nothing inherited.
268    None,
269}
270
271// ── Result shape (mirrors Join) ───────────────────────────────────────────
272
273/// One agent's final result, returned to the parent's tool-call response.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct AgentResult {
276    /// The final text or structured payload the child produced via its
277    /// `final` tool call.
278    pub content: serde_json::Value,
279    /// Child's task_id — surfaced so the parent (or downstream
280    /// supervision tools) can join later events.
281    pub task_id: String,
282    /// Status at completion: `done` / `error` / `cancelled`. A successful
283    /// run produces `done`; an LLM error / failed final produces `error`;
284    /// an explicit cancel via `cancel_task` produces `cancelled`.
285    pub status: TaskStatus,
286}
287
288/// Result returned to the parent's tool call. Shape mirrors `Join`.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(tag = "kind", rename_all = "snake_case")]
291pub enum InvocationResult {
292    /// `Join::Single` → scalar.
293    Scalar { result: AgentResult },
294    /// `Join::All` → ordered Vec, positions match input target order.
295    Vector { results: Vec<AgentResult> },
296    /// `Join::Detached` → ordered Vec of task_ids, positions match input.
297    TaskIds { task_ids: Vec<String> },
298}
299
300// `TaskStatus` is re-exported from `crate::core::TaskStatus` — the same
301// enum the schema column `tasks.status` and the existing TaskStore /
302// A2AService stack uses. There's no separate Invocation-specific status
303// taxonomy; that drift would just produce two enums to keep in sync.
304
305/// Snapshot returned by the supervisor tools (`get_task`, `list_my_tasks`).
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct TaskSnapshot {
308    pub task_id: String,
309    pub agent_id: String,
310    pub status: TaskStatus,
311    pub executor: Executor,
312    pub started_at: i64, // ms epoch
313    pub last_event_at: i64,
314    pub ended_at: Option<i64>,
315    /// Optional — best-effort partial result (last assistant text) if
316    /// running, or final result content if done.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub preview: Option<String>,
319}
320
321// ── Validation ────────────────────────────────────────────────────────────
322
323/// Errors returned by `Invocation::validate`.
324#[derive(Debug, thiserror::Error, PartialEq, Eq)]
325pub enum InvocationValidationError {
326    #[error("invocation requires at least one target")]
327    NoTargets,
328    #[error("Join::Single requires exactly 1 target, got {got}")]
329    SingleNeedsOneTarget { got: usize },
330    #[error("AdHoc target with empty system_prompt")]
331    AdHocEmptyPrompt,
332    #[error("Named target with empty agent_id")]
333    NamedEmptyAgentId,
334}
335
336impl Invocation {
337    /// One-shot validation called at the orchestrator's entry point.
338    /// Downstream code can assume the invariants below hold:
339    ///
340    /// - `targets.len() >= 1`
341    /// - `Join::Single` ⇒ `targets.len() == 1`
342    /// - every target has a non-empty agent identity
343    pub fn validate(&self) -> Result<(), InvocationValidationError> {
344        if self.targets.is_empty() {
345            return Err(InvocationValidationError::NoTargets);
346        }
347        if matches!(self.join, Join::Single) && self.targets.len() != 1 {
348            return Err(InvocationValidationError::SingleNeedsOneTarget {
349                got: self.targets.len(),
350            });
351        }
352        for target in &self.targets {
353            match &target.agent {
354                AgentRef::Named { agent_id, .. } if agent_id.is_empty() => {
355                    return Err(InvocationValidationError::NamedEmptyAgentId);
356                }
357                AgentRef::AdHoc { system_prompt, .. } if system_prompt.is_empty() => {
358                    return Err(InvocationValidationError::AdHocEmptyPrompt);
359                }
360                _ => {}
361            }
362        }
363        Ok(())
364    }
365}
366
367// ── Convenience constructors ──────────────────────────────────────────────
368
369impl Target {
370    pub fn named(agent_id: impl Into<String>, message: Message) -> Self {
371        Self {
372            agent: AgentRef::named(agent_id),
373            message,
374            executor: None,
375        }
376    }
377
378    /// A named-agent target with a per-invocation instruction overlay appended
379    /// (the skill-fork shape). The agent keeps its own definition; `overlay` is
380    /// added below it for this run only.
381    pub fn named_with_overlay(
382        agent_id: impl Into<String>,
383        overlay: impl Into<String>,
384        message: Message,
385    ) -> Self {
386        Self {
387            agent: AgentRef::named_with_overlay(agent_id, overlay),
388            message,
389            executor: None,
390        }
391    }
392
393    pub fn adhoc(system_prompt: impl Into<String>, message: Message) -> Self {
394        Self {
395            agent: AgentRef::AdHoc {
396                system_prompt: system_prompt.into(),
397                tools: None,
398            },
399            message,
400            executor: None,
401        }
402    }
403}
404
405impl Invocation {
406    /// Build a `Join::Single` invocation. The simplest path; matches
407    /// today's default `call_agent({agent, prompt})`.
408    pub fn single(target: Target) -> Self {
409        Self {
410            targets: vec![target],
411            context: ContextScope::default(),
412            join: Join::Single,
413            executor: ExecutorHint::default(),
414            tools: ToolPolicy::default(),
415        }
416    }
417
418    /// Build a `Join::All` fan-out.
419    pub fn all(targets: Vec<Target>) -> Self {
420        Self {
421            targets,
422            context: ContextScope::default(),
423            join: Join::All,
424            executor: ExecutorHint::default(),
425            tools: ToolPolicy::default(),
426        }
427    }
428
429    /// Build a `Join::Detached` fire-and-forget. Cancellation cascades
430    /// from the parent (no opt-out yet).
431    pub fn detached(targets: Vec<Target>) -> Self {
432        Self {
433            targets,
434            context: ContextScope::default(),
435            join: Join::Detached,
436            executor: ExecutorHint::default(),
437            tools: ToolPolicy::default(),
438        }
439    }
440
441    /// Builder: set context scope.
442    pub fn with_context(mut self, context: ContextScope) -> Self {
443        self.context = context;
444        self
445    }
446
447    /// Builder: set executor hint.
448    pub fn with_executor(mut self, executor: ExecutorHint) -> Self {
449        self.executor = executor;
450        self
451    }
452
453    /// Builder: set tool policy.
454    pub fn with_tools(mut self, tools: ToolPolicy) -> Self {
455        self.tools = tools;
456        self
457    }
458}
459
460// ── Tests ─────────────────────────────────────────────────────────────────
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::core::{MessageRole, Part};
466
467    fn msg(text: &str) -> Message {
468        Message::user(text.to_string(), None)
469    }
470
471    fn named(agent: &str) -> Target {
472        Target::named(agent, msg("hi"))
473    }
474
475    fn adhoc(prompt: &str) -> Target {
476        Target::adhoc(prompt, msg("hi"))
477    }
478
479    // ── Validation ────────────────────────────────────────────────────────
480
481    #[test]
482    fn validates_zero_targets() {
483        let inv = Invocation {
484            targets: vec![],
485            context: ContextScope::Independent,
486            join: Join::Single,
487            executor: ExecutorHint::Auto,
488            tools: ToolPolicy::Inherit,
489        };
490        assert_eq!(inv.validate(), Err(InvocationValidationError::NoTargets));
491    }
492
493    #[test]
494    fn validates_single_with_one_target_passes() {
495        let inv = Invocation::single(named("worker"));
496        assert!(inv.validate().is_ok());
497    }
498
499    #[test]
500    fn validates_single_with_two_targets_fails() {
501        let inv = Invocation {
502            targets: vec![named("a"), named("b")],
503            context: ContextScope::Independent,
504            join: Join::Single,
505            executor: ExecutorHint::Auto,
506            tools: ToolPolicy::Inherit,
507        };
508        assert_eq!(
509            inv.validate(),
510            Err(InvocationValidationError::SingleNeedsOneTarget { got: 2 })
511        );
512    }
513
514    #[test]
515    fn validates_all_with_one_target_passes() {
516        let inv = Invocation::all(vec![named("a")]);
517        assert!(inv.validate().is_ok());
518    }
519
520    #[test]
521    fn validates_all_with_many_targets_passes() {
522        let inv = Invocation::all(vec![named("a"), named("b"), named("c")]);
523        assert!(inv.validate().is_ok());
524    }
525
526    #[test]
527    fn validates_named_empty_agent_id_fails() {
528        let inv = Invocation::single(Target::named("", msg("x")));
529        assert_eq!(
530            inv.validate(),
531            Err(InvocationValidationError::NamedEmptyAgentId)
532        );
533    }
534
535    #[test]
536    fn validates_adhoc_empty_prompt_fails() {
537        let inv = Invocation::single(Target::adhoc("", msg("x")));
538        assert_eq!(
539            inv.validate(),
540            Err(InvocationValidationError::AdHocEmptyPrompt)
541        );
542    }
543
544    // ── Defaults ──────────────────────────────────────────────────────────
545
546    #[test]
547    fn defaults_are_sane() {
548        assert_eq!(ContextScope::default(), ContextScope::Independent);
549        assert_eq!(Join::default(), Join::Single);
550        assert!(matches!(ExecutorHint::default(), ExecutorHint::Auto));
551        assert!(matches!(ToolPolicy::default(), ToolPolicy::Inherit));
552    }
553
554    // ── Builders ──────────────────────────────────────────────────────────
555
556    #[test]
557    fn single_builder_produces_valid_invocation() {
558        let inv = Invocation::single(named("w"));
559        assert_eq!(inv.targets.len(), 1);
560        assert!(matches!(inv.join, Join::Single));
561        assert!(inv.validate().is_ok());
562    }
563
564    #[test]
565    fn fluent_builders_chain() {
566        let inv = Invocation::all(vec![named("a"), named("b")])
567            .with_context(ContextScope::Inherited)
568            .with_executor(ExecutorHint::Force(Executor::Local))
569            .with_tools(ToolPolicy::Exact {
570                tools: vec!["Bash".into()],
571            });
572        assert!(matches!(inv.context, ContextScope::Inherited));
573        assert!(matches!(inv.tools, ToolPolicy::Exact { .. }));
574        assert!(inv.validate().is_ok());
575    }
576
577    // ── Serde round-trips ─────────────────────────────────────────────────
578
579    #[test]
580    fn serde_roundtrip_minimal() {
581        let inv = Invocation::single(named("worker"));
582        let v = serde_json::to_value(&inv).unwrap();
583        let back: Invocation = serde_json::from_value(v).unwrap();
584        assert_eq!(back.targets.len(), 1);
585    }
586
587    #[test]
588    fn named_overlay_round_trips_and_omits_when_absent() {
589        // A plain named ref serializes WITHOUT the overlay key (skip_if None).
590        let plain = serde_json::to_value(AgentRef::named("w")).unwrap();
591        assert_eq!(plain["type"], "named");
592        assert!(
593            plain.get("instructions_overlay").is_none(),
594            "absent overlay must not serialize: {plain}"
595        );
596
597        // With an overlay it round-trips intact.
598        let overlaid = AgentRef::named_with_overlay("w", "do the skill");
599        let v = serde_json::to_value(&overlaid).unwrap();
600        assert_eq!(v["instructions_overlay"], "do the skill");
601        let back: AgentRef = serde_json::from_value(v).unwrap();
602        match back {
603            AgentRef::Named {
604                agent_id,
605                instructions_overlay,
606            } => {
607                assert_eq!(agent_id, "w");
608                assert_eq!(instructions_overlay.as_deref(), Some("do the skill"));
609            }
610            _ => panic!("expected Named"),
611        }
612    }
613
614    #[test]
615    fn named_overlay_is_backward_compatible() {
616        // Old wire shape (no overlay field) must still deserialize → None.
617        let old = serde_json::json!({ "type": "named", "agent_id": "legacy" });
618        let back: AgentRef = serde_json::from_value(old).unwrap();
619        match back {
620            AgentRef::Named {
621                agent_id,
622                instructions_overlay,
623            } => {
624                assert_eq!(agent_id, "legacy");
625                assert!(instructions_overlay.is_none());
626            }
627            _ => panic!("expected Named"),
628        }
629    }
630
631    #[test]
632    fn serde_uses_snake_case_for_enums() {
633        let inv = Invocation::detached(vec![adhoc("be a worker")]);
634        let v = serde_json::to_value(&inv).unwrap();
635        assert_eq!(v["join"], "detached");
636        assert_eq!(v["context"], "independent");
637        assert_eq!(v["targets"][0]["agent"]["type"], "ad_hoc");
638    }
639
640    #[test]
641    fn serde_executor_remote_carries_runner_config() {
642        let inv =
643            Invocation::single(named("w")).with_executor(ExecutorHint::Force(Executor::Remote {
644                runner: RunnerConfig::new("sandbox")
645                    .with_config(serde_json::json!({ "image": "distri-cli:latest" })),
646            }));
647        let v = serde_json::to_value(&inv).unwrap();
648        assert_eq!(v["executor"]["kind"], "force");
649        assert_eq!(v["executor"]["type"], "remote");
650        assert_eq!(v["executor"]["runner"]["kind"], "sandbox");
651        assert_eq!(
652            v["executor"]["runner"]["config"]["image"],
653            "distri-cli:latest"
654        );
655        // Round-trip back to typed.
656        let back: Invocation = serde_json::from_value(v).unwrap();
657        match back.executor {
658            ExecutorHint::Force(Executor::Remote { runner }) => {
659                assert_eq!(runner.kind, "sandbox");
660                assert_eq!(runner.config["image"], "distri-cli:latest");
661            }
662            other => panic!("expected Force(Remote {{..}}); got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn serde_invocation_result_scalar() {
668        let r = InvocationResult::Scalar {
669            result: AgentResult {
670                content: serde_json::json!({"text": "ok"}),
671                task_id: "t1".into(),
672                status: TaskStatus::Completed,
673            },
674        };
675        let v = serde_json::to_value(&r).unwrap();
676        assert_eq!(v["kind"], "scalar");
677        assert_eq!(v["result"]["task_id"], "t1");
678    }
679
680    // ── Sanity: Message construction works through the type system ───────
681
682    #[test]
683    fn message_role_in_target_is_user() {
684        let t = Target::named("w", msg("hello"));
685        assert!(matches!(t.message.role, MessageRole::User));
686        let parts = &t.message.parts;
687        assert!(matches!(parts.first(), Some(Part::Text(_))));
688    }
689}