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}