Skip to main content

mlua_swarm_server/operator_ws/
session.rs

1//! `WSOperatorSession`: 1 sid = 1 session = 3 traits co-hosted (`SeniorBridge` /
2//! `SpawnHook` / `Operator`). Registered simultaneously into 3 registries under
3//! the same sid — the canonical pattern where 1 WS connection covers all 3
4//! faces of the Operator role (judgment / observation / execution).
5//!
6//! `tx` is a `Mutex<Option<Sender>>`: `None` on disconnect, swappable to
7//! `Some(new_tx)` on reconnect. The `pending` `HashMap` persists on the session
8//! side, so a client holding answer/ack values across a disconnect can reconnect
9//! and resend them.
10//!
11//! For the detailed S↔C message flow, see the overview figure in `mod.rs`.
12
13use async_trait::async_trait;
14use mlua_swarm::core::agent_context::AgentContextView;
15use mlua_swarm::{
16    CapToken, Ctx, Operator, SeniorBridge, SessionId, SpawnHook, StepId, WorkerBinding,
17    WorkerError, WorkerResult,
18};
19use serde_json::Value;
20use std::collections::HashMap;
21use tokio::sync::{mpsc, oneshot, Mutex};
22
23use super::protocol::{current_parent_req_id, PendingReply, ServerMsg};
24
25/// 1 sid = 1 session. Looked up by sid in the `operator_sessions` store on reconnect.
26pub struct WSOperatorSession {
27    sid: SessionId,
28    /// The current mpsc sender on the write path. `None` on disconnect;
29    /// swapped to `Some(new_tx)` on reconnect.
30    tx: Mutex<Option<mpsc::UnboundedSender<ServerMsg>>>,
31    /// `req_id` → pending oneshot. Resolved when `answer` / `hook_ack` /
32    /// `spawn_ack` arrives.
33    pending: Mutex<HashMap<String, oneshot::Sender<PendingReply>>>,
34    /// Public HTTP base URL the server is reachable at (from
35    /// `AppState.base_url`, sourced from the binary at boot time).
36    /// Rendered literally into the Spawn `directive`'s `base_url` line
37    /// when `Some`; `None` falls back to a `mse_doctor`-pointer
38    /// placeholder (issue #8).
39    base_url: Option<std::sync::Arc<str>>,
40}
41
42impl WSOperatorSession {
43    /// `login.rs::handle_operator_socket` is the sole constructor call site.
44    /// Auth (Bearer token match) is checked there against `OperatorSessionEntry.token`
45    /// *before* upgrade — this struct no longer carries its own auth_token copy.
46    ///
47    /// `base_url` is the server's public HTTP root (e.g.
48    /// `"http://127.0.0.1:7777"`), threaded from `AppState.base_url`.
49    /// When `Some`, it is rendered literally into Spawn directives
50    /// (issue #8); `None` falls back to a `mse_doctor`-pointer
51    /// placeholder.
52    pub(super) fn new_with_base_url(
53        sid: SessionId,
54        tx: mpsc::UnboundedSender<ServerMsg>,
55        base_url: Option<std::sync::Arc<str>>,
56    ) -> Self {
57        Self {
58            sid,
59            tx: Mutex::new(Some(tx)),
60            pending: Mutex::new(HashMap::new()),
61            base_url,
62        }
63    }
64
65    /// Swaps in a new tx on reconnect. Expected to be called only from the handler side.
66    pub(super) async fn replace_tx(&self, new_tx: mpsc::UnboundedSender<ServerMsg>) {
67        *self.tx.lock().await = Some(new_tx);
68    }
69
70    /// Clears tx to `None` on disconnect. Expected to be called only from the handler side.
71    pub(crate) async fn clear_tx(&self) {
72        *self.tx.lock().await = None;
73    }
74
75    /// Resolves the pending oneshot when a `ClientMsg` arrives on the handler's
76    /// read task. If `req_id` is not registered, no-op (= silently drops unknown acks).
77    pub(super) async fn resolve_pending(&self, req_id: &str, reply: PendingReply) {
78        if let Some(otx) = self.pending.lock().await.remove(req_id) {
79            let _ = otx.send(reply);
80        }
81    }
82
83    /// Inserts an entry into pending, sends S→C, and waits for the reply. No
84    /// timeout in v1.5 (= an ask during a disconnect immediately returns `Err`
85    /// on send failure; reconnect-wait behavior is v2).
86    async fn send_and_await(&self, req_id: String, msg: ServerMsg) -> Result<PendingReply, String> {
87        let (otx, orx) = oneshot::channel::<PendingReply>();
88        self.pending.lock().await.insert(req_id.clone(), otx);
89
90        // Fetch `tx` and send. When None, we are disconnected — fail fast.
91        let send_result = {
92            let guard = self.tx.lock().await;
93            match guard.as_ref() {
94                Some(tx) => tx
95                    .send(msg)
96                    .map_err(|_| "ws send channel closed".to_string()),
97                None => Err("ws operator disconnected".to_string()),
98            }
99        };
100        if let Err(e) = send_result {
101            self.pending.lock().await.remove(&req_id);
102            return Err(e);
103        }
104
105        orx.await
106            .map_err(|_| "ws operator: oneshot cancelled (= reply path closed)".to_string())
107    }
108
109    /// Fire-and-forget send for `after` (= no reply expected).
110    async fn send_oneway(&self, msg: ServerMsg) -> Result<(), String> {
111        let guard = self.tx.lock().await;
112        match guard.as_ref() {
113            Some(tx) => tx
114                .send(msg)
115                .map_err(|_| "ws send channel closed".to_string()),
116            None => Err("ws operator disconnected".to_string()),
117        }
118    }
119}
120
121#[async_trait]
122impl SeniorBridge for WSOperatorSession {
123    async fn ask(&self, task_id: &StepId, question: Value) -> Result<Value, String> {
124        let req_id = format!("{}-ask-{}", self.sid, uuid::Uuid::new_v4());
125        let msg = ServerMsg::Ask {
126            req_id: req_id.clone(),
127            parent_req_id: current_parent_req_id(),
128            task_id: task_id.clone(),
129            question,
130        };
131        match self.send_and_await(req_id, msg).await? {
132            PendingReply::Answer(v) => Ok(v),
133            PendingReply::HookAck { .. } => {
134                Err("ws operator: unexpected hook_ack reply to ask".into())
135            }
136            PendingReply::SpawnAck { .. } => {
137                Err("ws operator: unexpected spawn_ack reply to ask".into())
138            }
139            PendingReply::SpawnHalt { .. } => {
140                Err("ws operator: unexpected spawn_halt reply to ask".into())
141            }
142        }
143    }
144}
145
146#[async_trait]
147impl SpawnHook for WSOperatorSession {
148    async fn before(&self, ctx: &Ctx) -> Result<(), String> {
149        let req_id = format!("{}-hb-{}", self.sid, uuid::Uuid::new_v4());
150        let msg = ServerMsg::HookBefore {
151            req_id: req_id.clone(),
152            parent_req_id: current_parent_req_id(),
153            task_id: ctx.task_id.clone(),
154            agent: ctx.agent.clone(),
155            attempt: ctx.attempt,
156        };
157        match self.send_and_await(req_id, msg).await? {
158            PendingReply::HookAck { ok: true, .. } => Ok(()),
159            PendingReply::HookAck { ok: false, reason } => {
160                Err(reason.unwrap_or_else(|| "ws operator: spawn rejected".into()))
161            }
162            PendingReply::Answer(_) => {
163                Err("ws operator: unexpected answer reply to hook_before".into())
164            }
165            PendingReply::SpawnAck { .. } => {
166                Err("ws operator: unexpected spawn_ack reply to hook_before".into())
167            }
168            PendingReply::SpawnHalt { .. } => {
169                Err("ws operator: unexpected spawn_halt reply to hook_before".into())
170            }
171        }
172    }
173
174    async fn after(&self, ctx: &Ctx, result: &Value) -> Result<(), String> {
175        let req_id = format!("{}-ha-{}", self.sid, uuid::Uuid::new_v4());
176        let msg = ServerMsg::HookAfter {
177            req_id,
178            parent_req_id: current_parent_req_id(),
179            task_id: ctx.task_id.clone(),
180            agent: ctx.agent.clone(),
181            attempt: ctx.attempt,
182            result: result.clone(),
183        };
184        // `after` is fire-and-forget — swallow send failures.
185        let _ = self.send_oneway(msg).await;
186        Ok(())
187    }
188}
189
190#[async_trait]
191impl Operator for WSOperatorSession {
192    /// Thin control channel impl (the Spawn thin-control axis): `system` / `prompt`
193    /// have already been baked into engine state on the server side
194    /// (= `bake_worker_system_prompt` in `OperatorSpawner.spawn` + the existing
195    /// `fetch_prompt` path). This impl encodes `worker_token` and hands it to
196    /// the MainAI in a single Spawn message; the SubAgent then hits
197    /// `/v1/worker/prompt` + `/v1/worker/result` itself over HTTP. `system` is
198    /// intentionally **not used here** (heavy payloads are not carried on WS —
199    /// thin-path discipline); `prompt` (issue #18) is used only to recover a
200    /// `Value` for the `Spawn.directive` reminder line (see
201    /// `default_spawn_directive_with_task_directive`) — the SubAgent still
202    /// self-fetches the full prompt over HTTP, unchanged.
203    ///
204    /// The SubAgent's result post (= HTTP POST `/v1/worker/result`) appends
205    /// `Final` to `output_tail`; when the MainAI returns `SpawnAck`, this
206    /// `execute` returns `WorkerResult` and control returns to the dispatch path.
207    ///
208    /// `worker` is required (see `requires_worker_binding`) — the compile-time
209    /// gate in `OperatorSpawnerFactory::build` is the primary defense, but a
210    /// `None` can still reach here on paths that bypass compilation (e.g. an
211    /// operator-sid-pin path). This runtime check is the defensive second
212    /// layer: fail the task loud rather than silently degrade to the old
213    /// hardcoded `"mse-worker"` literal.
214    async fn execute(
215        &self,
216        ctx: &Ctx,
217        _system: Option<String>,
218        prompt: Value,
219        worker: Option<WorkerBinding>,
220        worker_token: CapToken,
221    ) -> Result<WorkerResult, WorkerError> {
222        let Some(worker) = worker else {
223            return Err(WorkerError::Failed(format!(
224                "agent '{}' has no worker_binding; WS thin-path requires one \
225                 (Blueprint AgentDef.profile.worker_binding)",
226                ctx.agent
227            )));
228        };
229        let req_id = format!("{}-spawn-{}", self.sid, uuid::Uuid::new_v4());
230        let worker_handle = ctx
231            .meta
232            .runtime
233            .get("worker_handle")
234            .and_then(|v| v.as_str())
235            .map(|s| s.to_string());
236        let data_sink_endpoint = ctx
237            .meta
238            .runtime
239            .get("data_sink_endpoint")
240            .and_then(|v| v.as_str());
241        // issue #13 run_id propagation: `EngineDispatcher::with_run` (when
242        // the launch carries a `RunContext`) inserts this into
243        // `Ctx.meta.runtime["run_id"]`; `None` on launches with no run
244        // tracing (see `Engine::dispatch_attempt_with`'s `run_id` param).
245        let run_id = ctx.meta.runtime.get("run_id").and_then(|v| v.as_str());
246        // GH #20 Contract C: `project_name_alias` / `project_root` /
247        // `work_dir` (previously read individually here) now come off one
248        // materialized `AgentContextView` — reads back the view
249        // `AgentContextMiddleware` stashed into
250        // `ctx.meta.runtime[AGENT_CONTEXT_KEY]`, falling back to a
251        // field-by-field pull off `ctx.meta.runtime` when that middleware
252        // was never layered (backward compat). See the module doc on
253        // `mlua_swarm::core::agent_context` for the full narrative.
254        let view = AgentContextView::materialized_or_from_ctx(ctx);
255        // issue #18: `prompt` is `TaskSpec.initial_directive`, threaded as
256        // `Value` end-to-end through `EngineState.prompts` /
257        // `Engine::fetch_prompt`. The WS Spawn frame text render is the
258        // sole String boundary on this axis — no re-parse round trip,
259        // and Object / Array / Number seeds keep their structural shape
260        // all the way to the render call.
261        let directive = default_spawn_directive_with_task_directive(
262            &ctx.agent,
263            ctx.task_id.as_str(),
264            &worker.variant,
265            &view,
266            data_sink_endpoint,
267            self.base_url.as_deref(),
268            run_id,
269            &prompt,
270        );
271        let msg = ServerMsg::Spawn {
272            req_id: req_id.clone(),
273            parent_req_id: current_parent_req_id(),
274            task_id: ctx.task_id.clone(),
275            agent: ctx.agent.clone(),
276            attempt: ctx.attempt,
277            capability_token: worker_token.encode(),
278            worker_handle,
279            worker: Some(worker),
280            directive,
281        };
282        match self.send_and_await(req_id, msg).await {
283            Ok(PendingReply::SpawnAck {
284                value,
285                ok,
286                error: None,
287            }) => Ok(WorkerResult { value, ok }),
288            Ok(PendingReply::SpawnAck {
289                error: Some(msg), ..
290            }) => Err(WorkerError::Failed(msg)),
291            // `spawn_halt` (issue #7): controlled halt. Return
292            // `Ok(WorkerResult { ok: true, value: halt_marker })` so the
293            // step lands as a normal termination rather than a
294            // `WorkerError::Failed` — log stays `info`, downstream retry
295            // logic doesn't fire. The halt marker carries the caller's
296            // partial value and reason string in a fixed shape.
297            Ok(PendingReply::SpawnHalt { value, reason }) => {
298                let marker = serde_json::json!({
299                    "halted": true,
300                    "reason": reason,
301                    "value": value,
302                });
303                Ok(WorkerResult {
304                    value: marker,
305                    ok: true,
306                })
307            }
308            Ok(_) => Err(WorkerError::Failed(
309                "ws operator: unexpected non-spawn reply".into(),
310            )),
311            Err(e) => Err(WorkerError::Failed(format!("ws operator spawn: {e}"))),
312        }
313    }
314
315    fn requires_worker_binding(&self) -> bool {
316        true
317    }
318}
319
320/// Literal instruction text for the MainAI (= WS Client = Operator role). Fix
321/// for observation #7.
322///
323/// Minimal hand-off form parallel to /orch (agent_primitive): sends an
324/// `[agent_primitive dispatch=@<agent>]` marker + worker endpoint + auth +
325/// task_id in the payload; the MainAI **kicks a SubAgent by specifying AgentId +
326/// Token** and **forwards the return string verbatim into `SpawnAck.value`**.
327///
328/// The detailed instructions for the SubAgent are consolidated into the
329/// agent.md `system` (= the body fetched by `GET /v1/worker/prompt`); the
330/// directive is narrowed to the minimum routing information.
331///
332/// # `project_name_alias` / `project_root` / `work_dir` / `task_metadata`
333/// (GH #20 Contract C — `AgentContextView.to_directive_header`)
334///
335/// These task-level context header lines are no longer read individually
336/// here — they come off one materialized `view: &AgentContextView`
337/// (see `mlua_swarm::core::agent_context` for the full Contract C
338/// narrative) via [`AgentContextView::to_directive_header`], rendered
339/// verbatim at the top of the "worker endpoint" block below. Format is
340/// byte-identical to the pre-#20 individual splices
341/// (`project_name_alias: {a}` / `project_root: {p}` / `work_dir: {w}`,
342/// each independently absent-or-present, no empty-string placeholder) —
343/// the additive change is the new `task_metadata: {compact-json}` line
344/// (closes the F2 gap tracked in the `operator-execution-model` guide)
345/// plus one line per `view.extra` entry.
346///
347/// `project_name_alias` is ALSO used below (via `view.project_name_alias`)
348/// to expand the "LDS Session Alias" mandatory reminder block for the
349/// MainAI — the engine itself performs no other action on the alias; the
350/// expansion here is what the MainAI actually reads.
351///
352/// # `subagent_type` (Blueprint-baked worker binding)
353///
354/// Resolved from `AgentDef.profile.worker_binding` (see `WorkerBinding`) and
355/// literally substituted for the old hardcoded `"mse-worker"` string — the
356/// Blueprint is the single source of truth for which Claude Code SubAgent
357/// definition the MainAI must dispatch. There is deliberately **no fallback**
358/// to another `subagent_type` here: if the named SubAgent definition is not
359/// registered, the MainAI is instructed to fail the SpawnAck loud rather than
360/// silently substitute a different one.
361/// `base_url` is the server's public HTTP root (e.g.
362/// `"http://127.0.0.1:7777"`). When `Some`, it is rendered verbatim into
363/// the SubAgent prompt block so the operator can copy the frame
364/// straight through without a `mse_doctor` lookup (issue #8). When
365/// `None`, a fallback placeholder points the reader at `mse_doctor` —
366/// no fake port number appears in the directive.
367///
368/// `run_id` (issue #13 ID-hierarchy persistence) is `Some` whenever this
369/// dispatch's `Ctx.meta.runtime["run_id"]` is populated (see
370/// `Engine::dispatch_attempt_with`), and is rendered into the observation
371/// route hint below (`GET /v1/runs/{run_id}`) so a MainAI reading the
372/// directive can drill into that specific kick's `RunRecord.step_entries`
373/// trace. `None` falls back to a generic `<run_id>` placeholder. Kept as
374/// its own parameter (not read off `view`) — the directive's observation
375/// route hint is a separate rendering concern from the task-level context
376/// header.
377#[allow(clippy::too_many_arguments)]
378pub(super) fn default_spawn_directive(
379    agent: &str,
380    task_id: &str,
381    subagent_type: &str,
382    view: &AgentContextView,
383    data_sink_endpoint: Option<&str>,
384    base_url: Option<&str>,
385    run_id: Option<&str>,
386) -> String {
387    // GH #20: task-level context header lines (project_name_alias /
388    // project_root / work_dir / task_metadata / extra), rendered by the
389    // materialized view itself. See the doc above.
390    let context_header = view.to_directive_header();
391    // Endpoint hint for the Data path (Big Response routing). Only when
392    // Some, inject a convention line telling the MainAgent to pass the Big
393    // EMIT POST target URL into the SubAgent prompt or environment when it
394    // kicks a SubAgent. Audience: MainAgent (the SubAgent-launcher side).
395    // A single authenticated emit endpoint: the token can be passed as
396    // Bearer or `?token=`; both consume the same CapToken material.
397    let data_endpoint_block = match data_sink_endpoint {
398        Some(base) => format!(
399            "\n\
400             [Data path endpoint — MainAgent reminder]\n\
401             When you kick a SubAgent, inject the following two lines into\n\
402             its prompt / environment so Big Response payloads (4k+ tokens,\n\
403             files, intermediate artifacts) flow directly to the Store owner,\n\
404             bypassing the MainAgent (context stays small; only the out_id\n\
405             ref is passed around).\n  \
406             DATA_EMIT: {base}/v1/data/emit  (POST, auth = Bearer worker_handle or ?token=)\n  \
407             DATA_GET:  {base}/v1/data/<out_id|out_name>  (the next SubAgent fetches from $IN_REFS)\n\
408             When a SubAgent produces a Big Response, POST it to DATA_EMIT\n\
409             and return only the one-line out_id ref (do not mix the body\n\
410             in; the MainAgent must not answer directly).\n\
411             \n"
412        ),
413        None => String::new(),
414    };
415    let main_ai_reminder = match view.project_name_alias.as_deref() {
416        Some(a) => format!(
417            "\n\
418             [LDS Session Alias Reminder — MainAI mandatory]\n\
419             Before kicking the SubAgent below, call:\n  \
420             mcp__lds__session_create(root=<working_dir>, alias=\"{a}\")\n\
421             (= establish a single task-level lds session; reuse on repeated dispatch).\n\
422             Then add this literal line to the SubAgent prompt body below:\n  \
423             LDS Session Alias: {a}\n\
424             The SubAgent will call mcp__lds__session_start(alias=\"{a}\") on init,\n\
425             keeping worktree ownership unified across dispatches.\n\
426             (Full discipline rationale is inlined above; reach is via this directive itself,\n\
427              not via any external doc path. The 2 steps above are the complete contract.)\n\
428             \n"
429        ),
430        None => String::new(),
431    };
432    // Issue #8: render the actual server bind literally when it was
433    // sourced at boot; fall back to a pointer at `mse_doctor` rather
434    // than a fake port number.
435    let base_url_line = match base_url {
436        Some(u) => u.to_string(),
437        None => "<your server's actual bind — check with mse_doctor>".to_string(),
438    };
439    // issue #13: the real drill-down route is `GET /v1/runs/{run_id}` (a
440    // single `RunRecord`, `step_entries` trace included) — `GET
441    // /v1/tasks/{id}` does exist but returns the coarser `TaskRecord` +
442    // every `RunRecord` kicked from it, not this specific kick.
443    let run_route_line = match run_id {
444        Some(rid) => format!("GET <base_url>/v1/runs/{rid}"),
445        None => "GET <base_url>/v1/runs/<run_id>".to_string(),
446    };
447    format!(
448        "[agent_primitive dispatch=@{agent}]\n\
449         worker endpoint:\n  \
450         GET  <base_url>/v1/worker/prompt?task_id={task_id}\n  \
451         POST <base_url>/v1/worker/submit\n\
452         auth: Bearer <worker_handle from THIS Spawn payload (= short `wh-XXXXXXXX` form)>\n\
453         task_id: {task_id}\n\
454         agent_id: {agent}\n\
455         {context_header}\
456         {data_endpoint_block}\
457         {main_ai_reminder}\
458         Kick a SubAgent via Agent tool with subagent_type=\"{subagent_type}\" (= project-local \
459         `.claude/agents/{subagent_type}.md`, this agent's Blueprint-declared worker binding). \
460         The prompt you pass to it MUST be EXACTLY these 4 lines (no preamble, no extra text):\n\
461         \n  \
462         agent_id: {agent}\n  \
463         worker_handle: <THIS Spawn payload's `worker_handle` field (short string `wh-XXXXXXXX`)>\n  \
464         base_url: {base_url_line}\n  \
465         task_id: {task_id}\n\
466         \n\
467         The SubAgent self-fetches system + prompt via GET (Bearer = handle), \
468         executes as agent @{agent}, POSTs raw body to /v1/worker/submit (Bearer = handle, \
469         server resolves task_id from handle), and replies `OUTPUT` 1 word. You then forward \
470         SpawnAck {{req_id, value:{{}}, ok:true}} through your operator client — MCP path: \
471         mse_ack(sid, req_id, kind=\"spawn_ack\", ok=true) (= empty value because canonical \
472         body lives in output_tail via the POST). \
473         Do NOT fetch /v1/worker/prompt yourself. Do NOT wrap, summarize, or field-select \
474         the SubAgent reply. Observation / debug is a separate channel (= agent-inspect MCP / \
475         {run_route_line}), do NOT mix it into the forward path. \
476         If the SubAgent type is not registered, FAIL LOUD: reply SpawnAck ok=false with an \
477         error explaining the missing `.claude/agents/{subagent_type}.md` — do NOT fall back \
478         to another subagent_type."
479    )
480}
481
482/// Wraps [`default_spawn_directive`]'s routing/reminder text as the WS
483/// `Spawn.directive` `Value` (issue #18), additionally splicing in a
484/// `task_directive` line built from `TaskSpec.initial_directive` when the
485/// task was seeded with one.
486///
487/// This is the sole place the render from `Value` (`task_directive`) down
488/// to `String` literal happens for the WS Spawn path — the coercion that
489/// used to sit in `EngineDispatcher::dispatch` moved here. `task_directive
490/// == Value::Null` (no seed, or the caller could not recover one) omits
491/// the line entirely, leaving the output byte-identical to
492/// [`default_spawn_directive`]'s own text — this preserves every existing
493/// [`default_spawn_directive`] test unchanged, since that function's
494/// signature and body are untouched by issue #18.
495#[allow(clippy::too_many_arguments)]
496pub(super) fn default_spawn_directive_with_task_directive(
497    agent: &str,
498    task_id: &str,
499    subagent_type: &str,
500    view: &AgentContextView,
501    data_sink_endpoint: Option<&str>,
502    base_url: Option<&str>,
503    run_id: Option<&str>,
504    task_directive: &Value,
505) -> String {
506    let base = default_spawn_directive(
507        agent,
508        task_id,
509        subagent_type,
510        view,
511        data_sink_endpoint,
512        base_url,
513        run_id,
514    );
515    // Strings pass through verbatim; anything else (Object / Array /
516    // Number / Bool) is serde-stringified — the same coercion pattern
517    // `EngineDispatcher::dispatch` used to apply eagerly, now applied
518    // lazily at this render boundary only.
519    let task_directive_line = match task_directive {
520        Value::Null => String::new(),
521        Value::String(s) => format!("task_directive: {s}\n"),
522        other => format!("task_directive: {other}\n"),
523    };
524    format!("{base}{task_directive_line}")
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use mlua_swarm::core::agent_context::{
531        TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
532    };
533
534    /// Test helper: builds an `AgentContextView` with only
535    /// `project_name_alias` / `project_root` / `work_dir` set (the three
536    /// fields `default_spawn_directive`'s retired individual params used
537    /// to carry) — everything else stays at `Default`. Mirrors the
538    /// pre-#20 call shape so the mechanical rewrite of every existing
539    /// test stays a 1:1 argument swap.
540    fn view_with(
541        alias: Option<&str>,
542        project_root: Option<&str>,
543        work_dir: Option<&str>,
544    ) -> AgentContextView {
545        AgentContextView {
546            project_name_alias: alias.map(String::from),
547            project_root: project_root.map(String::from),
548            work_dir: work_dir.map(String::from),
549            ..AgentContextView::default()
550        }
551    }
552
553    #[test]
554    fn directive_omits_project_name_alias_when_none() {
555        let d = default_spawn_directive(
556            "impl-lead",
557            "task-x",
558            "mse-worker-coder",
559            &view_with(None, None, None),
560            None,
561            None,
562            None,
563        );
564        assert!(!d.contains("project_name_alias:"));
565        assert!(!d.contains("LDS Session Alias"));
566        assert!(!d.contains("session_create"));
567    }
568
569    #[test]
570    fn directive_emits_project_name_alias_when_some() {
571        let d = default_spawn_directive(
572            "impl-lead",
573            "task-x",
574            "mse-worker-coder",
575            &view_with(Some("mse-task-7785"), None, None),
576            None,
577            None,
578            None,
579        );
580        // Header line (expanded verbatim from the value).
581        assert!(
582            d.contains("project_name_alias: mse-task-7785"),
583            "directive missing project_name_alias header: {d}"
584        );
585        // MainAI mandatory reminder (= session_create + SubAgent prompt inject)
586        assert!(
587            d.contains("mcp__lds__session_create(root=<working_dir>, alias=\"mse-task-7785\")"),
588            "directive missing session_create reminder: {d}"
589        );
590        assert!(
591            d.contains("LDS Session Alias: mse-task-7785"),
592            "directive missing SubAgent prompt inject line: {d}"
593        );
594        // Reach discipline: the rationale is inlined into the directive (no external doc path reference).
595        assert!(
596            d.contains("inlined above") || d.contains("complete contract"),
597            "directive should inline rationale rather than point at external doc: {d}"
598        );
599        // The SoT is not pointed at an AI personal memory file (which is
600        // outside the MainAI's reach) — reach-axis consistency. Path
601        // references coming from the subagent registration convention (for
602        // example `agents/mse-worker.md`) are a separate case and are
603        // allowed. The pattern is assembled by string concat so that no
604        // gitignored dir literal remains in the source and the
605        // internal-doc-leak / secret-pre-commit-checker mechanical pattern
606        // match is avoided.
607        let forbidden_doc_ref = format!(".{}/CLAUDE.md", "claude");
608        assert!(
609            !d.contains(&forbidden_doc_ref),
610            "directive must not reference {forbidden_doc_ref} (out of MainAI scope): {d}"
611        );
612    }
613
614    #[test]
615    fn directive_omits_data_endpoint_when_none() {
616        let d = default_spawn_directive(
617            "impl-lead",
618            "task-x",
619            "mse-worker-coder",
620            &view_with(None, None, None),
621            None,
622            None,
623            None,
624        );
625        assert!(!d.contains("[Data path endpoint"));
626        assert!(!d.contains("DATA_EMIT"));
627        assert!(!d.contains("DATA_GET"));
628    }
629
630    #[test]
631    fn directive_emits_data_endpoint_when_some() {
632        let base = "http://127.0.0.1:7785";
633        let d = default_spawn_directive(
634            "impl-lead",
635            "task-x",
636            "mse-worker-coder",
637            &view_with(None, None, None),
638            Some(base),
639            None,
640            None,
641        );
642        assert!(
643            d.contains("[Data path endpoint"),
644            "directive missing data endpoint block header: {d}"
645        );
646        assert!(
647            d.contains(&format!("DATA_EMIT: {base}/v1/data/emit")),
648            "directive missing single-mouth emit line: {d}"
649        );
650        assert!(
651            d.contains("Bearer worker_handle or ?token="),
652            "directive missing auth transport hint: {d}"
653        );
654        assert!(
655            d.contains(&format!("DATA_GET:  {base}/v1/data/<out_id|out_name>")),
656            "directive missing GET line: {d}"
657        );
658        assert!(
659            !d.contains("emit-auth"),
660            "old split endpoint must not leak into directive: {d}"
661        );
662        assert!(
663            d.contains("bypassing the MainAgent") && d.contains("out_id ref"),
664            "directive should carry the ownership + bypass reasoning: {d}"
665        );
666    }
667
668    #[test]
669    fn directive_carries_declared_subagent_type_and_has_no_fallback() {
670        let d = default_spawn_directive(
671            "impl-lead",
672            "task-x",
673            "mse-worker-coder",
674            &view_with(None, None, None),
675            None,
676            None,
677            None,
678        );
679        assert!(
680            d.contains("subagent_type=\"mse-worker-coder\""),
681            "directive must carry the Blueprint-declared subagent_type literally: {d}"
682        );
683        assert!(
684            d.contains(".claude/agents/mse-worker-coder.md"),
685            "directive must reference the declared subagent's own .md path: {d}"
686        );
687        // The old hardcoded default and its silent-fallback text must be gone.
688        assert!(
689            !d.contains("general-purpose"),
690            "directive must not fall back to subagent_type=\"general-purpose\": {d}"
691        );
692        assert!(
693            !d.contains("mse-worker\""),
694            "directive must not carry the old hardcoded \"mse-worker\" literal: {d}"
695        );
696        assert!(
697            d.contains("FAIL LOUD"),
698            "directive must instruct the MainAI to fail loud instead of falling back: {d}"
699        );
700    }
701
702    // ─── Issue #8: base_url rendering + fallback framing ─────────────────
703
704    /// Layer 1: when `base_url` is `Some`, it must land verbatim in the
705    /// SubAgent-prompt block, so the operator can copy the frame
706    /// through without a `mse_doctor` lookup.
707    #[test]
708    fn directive_renders_actual_base_url_when_some() {
709        let d = default_spawn_directive(
710            "impl-lead",
711            "task-x",
712            "mse-worker-coder",
713            &view_with(None, None, None),
714            None,
715            Some("http://127.0.0.1:8888"),
716            None,
717        );
718        assert!(
719            d.contains("base_url: http://127.0.0.1:8888"),
720            "directive must render the actual bind literally: {d}"
721        );
722        assert!(
723            !d.contains("mse_doctor"),
724            "no mse_doctor detour when bind is known: {d}"
725        );
726    }
727
728    /// Layer 3: when `base_url` is `None` (unit tests, mock harnesses,
729    /// pre-serve rendering) the fallback line must point the reader at
730    /// `mse_doctor` — never a fake port number.
731    #[test]
732    fn directive_falls_back_to_mse_doctor_pointer_when_none() {
733        let d = default_spawn_directive(
734            "impl-lead",
735            "task-x",
736            "mse-worker-coder",
737            &view_with(None, None, None),
738            None,
739            None,
740            None,
741        );
742        assert!(
743            d.contains("check with mse_doctor"),
744            "fallback must point at mse_doctor: {d}"
745        );
746    }
747
748    /// Regression guard: the historical `7786` example port (the whole
749    /// origin of issue #8) must not survive in the rendered directive
750    /// under any input combination.
751    #[test]
752    fn directive_never_contains_stale_example_port_7786() {
753        for base in [
754            None,
755            Some("http://127.0.0.1:7777"),
756            Some("http://192.0.2.1:9000"),
757        ] {
758            let d = default_spawn_directive(
759                "impl-lead",
760                "task-x",
761                "mse-worker-coder",
762                &view_with(Some("mse-task-alias"), None, None),
763                Some("http://127.0.0.1:7785"),
764                base,
765                None,
766            );
767            assert!(
768                !d.contains("7786"),
769                "stale example port 7786 leaked: base={base:?}, d={d}"
770            );
771        }
772    }
773
774    // ─── Issue #13: run_id observation route (doc-drift fix) ─────────────
775
776    /// Regression guard: the stale `GET /v1/tasks/{id}` observation hint
777    /// (a route that never returns a single `RunRecord`) must be gone —
778    /// the directive must point at the real drill-down route instead.
779    #[test]
780    fn directive_never_contains_stale_tasks_id_route() {
781        let d = default_spawn_directive(
782            "impl-lead",
783            "task-x",
784            "mse-worker-coder",
785            &view_with(None, None, None),
786            None,
787            None,
788            Some("R-abc123"),
789        );
790        assert!(
791            !d.contains("/v1/tasks/{id}") && !d.contains("/v1/tasks/{{id}}"),
792            "stale /v1/tasks/{{id}} observation hint leaked: {d}"
793        );
794    }
795
796    /// When `run_id` is `Some`, it is rendered literally into the
797    /// observation route hint (`GET /v1/runs/<run_id>`).
798    #[test]
799    fn directive_renders_actual_run_id_when_some() {
800        let d = default_spawn_directive(
801            "impl-lead",
802            "task-x",
803            "mse-worker-coder",
804            &view_with(None, None, None),
805            None,
806            None,
807            Some("R-abc123"),
808        );
809        assert!(
810            d.contains("GET <base_url>/v1/runs/R-abc123"),
811            "directive missing real run_id in observation route: {d}"
812        );
813    }
814
815    /// `run_id: None` (no run tracing for this launch) falls back to a
816    /// generic placeholder route rather than a stale/incorrect one.
817    #[test]
818    fn directive_falls_back_to_run_id_placeholder_when_none() {
819        let d = default_spawn_directive(
820            "impl-lead",
821            "task-x",
822            "mse-worker-coder",
823            &view_with(None, None, None),
824            None,
825            None,
826            None,
827        );
828        assert!(
829            d.contains("GET <base_url>/v1/runs/<run_id>"),
830            "directive missing placeholder observation route: {d}"
831        );
832    }
833
834    // ─── Issue #17: project_root / work_dir header lines ─────────────────
835
836    /// Both absent → neither header line appears (no empty-string
837    /// placeholder either).
838    #[test]
839    fn directive_omits_project_root_and_work_dir_when_both_none() {
840        let d = default_spawn_directive(
841            "impl-lead",
842            "task-x",
843            "mse-worker-coder",
844            &view_with(None, None, None),
845            None,
846            None,
847            None,
848        );
849        assert!(!d.contains("project_root:"));
850        assert!(!d.contains("work_dir:"));
851    }
852
853    /// Both present → both header lines render literally, alongside
854    /// `project_name_alias`'s existing splice.
855    #[test]
856    fn directive_splices_project_root_and_work_dir_when_both_present() {
857        let d = default_spawn_directive(
858            "impl-lead",
859            "task-x",
860            "mse-worker-coder",
861            &view_with(None, Some("/repo"), Some("/repo/work")),
862            None,
863            None,
864            None,
865        );
866        assert!(
867            d.contains("project_root: /repo"),
868            "directive missing project_root header: {d}"
869        );
870        assert!(
871            d.contains("work_dir: /repo/work"),
872            "directive missing work_dir header: {d}"
873        );
874    }
875
876    /// Partial: `project_root` present, `work_dir` absent — each field is
877    /// independent, so only the present one renders.
878    #[test]
879    fn directive_splices_project_root_only_when_work_dir_absent() {
880        let d = default_spawn_directive(
881            "impl-lead",
882            "task-x",
883            "mse-worker-coder",
884            &view_with(None, Some("/repo"), None),
885            None,
886            None,
887            None,
888        );
889        assert!(
890            d.contains("project_root: /repo"),
891            "directive missing project_root header: {d}"
892        );
893        assert!(!d.contains("work_dir:"));
894    }
895
896    // ─── GH #20: task_metadata header line (Contract C, closes the F2 gap) ─
897
898    /// `task_metadata` renders as a new `task_metadata: {compact-json}`
899    /// line — the F2 gap the `operator-execution-model` guide tracked
900    /// (`task_metadata`'s inner keys were never spliced into the
901    /// directive before GH #20).
902    #[test]
903    fn directive_splices_task_metadata_when_some() {
904        let view = AgentContextView {
905            task_metadata: Some(serde_json::json!({"issue": 20})),
906            ..view_with(None, Some("/repo"), None)
907        };
908        let d = default_spawn_directive(
909            "impl-lead",
910            "task-x",
911            "mse-worker-coder",
912            &view,
913            None,
914            None,
915            None,
916        );
917        assert!(
918            d.contains(r#"task_metadata: {"issue":20}"#),
919            "directive missing task_metadata header: {d}"
920        );
921        // Additive-only: the pre-existing project_root line still renders.
922        assert!(d.contains("project_root: /repo"));
923    }
924
925    /// `task_metadata: None` (absent) omits the line entirely — no
926    /// empty-string placeholder, matching every other header line's
927    /// absent-field contract.
928    #[test]
929    fn directive_omits_task_metadata_when_none() {
930        let d = default_spawn_directive(
931            "impl-lead",
932            "task-x",
933            "mse-worker-coder",
934            &view_with(None, None, None),
935            None,
936            None,
937            None,
938        );
939        assert!(!d.contains("task_metadata:"));
940    }
941
942    // ─── Issue #7: spawn_halt handling in Operator::execute ──────────────
943
944    fn test_ctx(task_id: &str) -> mlua_swarm::Ctx {
945        mlua_swarm::Ctx::new(mlua_swarm::StepId::parse(task_id).unwrap(), 1, "a")
946    }
947
948    fn test_worker_binding() -> mlua_swarm::WorkerBinding {
949        mlua_swarm::WorkerBinding {
950            variant: "test-variant".into(),
951            tools: vec![],
952        }
953    }
954
955    fn test_cap_token() -> mlua_swarm::CapToken {
956        mlua_swarm::CapToken {
957            agent_id: "a".into(),
958            role: mlua_swarm::Role::Worker,
959            scopes: vec!["*".into()],
960            issued_at: 0,
961            expire_at: u64::MAX / 2,
962            max_uses: None,
963            nonce: "test-nonce".into(),
964            sig_hex: "".into(),
965        }
966    }
967
968    /// A `PendingReply::SpawnHalt` reply must translate into a
969    /// `Ok(WorkerResult { ok: true, value: <halt marker> })` — a normal
970    /// termination, not a `WorkerError::Failed` (fail-loud). This is
971    /// the whole point of the new verb: distinguishing a controlled
972    /// halt from a real worker error at the log / retry-signal level.
973    #[tokio::test]
974    async fn spawn_halt_reply_lands_as_ok_worker_result_with_marker() {
975        use mlua_swarm::Operator;
976        use tokio::sync::mpsc;
977
978        let (tx, mut rx) = mpsc::unbounded_channel();
979        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
980            SessionId::parse("S-halt").unwrap(),
981            tx,
982            None,
983        ));
984
985        // Kick execute() in a background task so we can grab the
986        // req_id the server assigns and inject a matching SpawnHalt.
987        let session_bg = session.clone();
988        let handle = tokio::spawn(async move {
989            session_bg
990                .execute(
991                    &test_ctx("ST-halt"),
992                    None,
993                    "".into(),
994                    Some(test_worker_binding()),
995                    test_cap_token(),
996                )
997                .await
998        });
999
1000        let sent = rx.recv().await.expect("Spawn sent");
1001        let req_id = match sent {
1002            ServerMsg::Spawn { req_id, .. } => req_id,
1003            other => panic!("expected Spawn, got {other:?}"),
1004        };
1005
1006        session
1007            .resolve_pending(
1008                &req_id,
1009                PendingReply::SpawnHalt {
1010                    value: serde_json::json!({"partial": "abc"}),
1011                    reason: Some("shape verified".into()),
1012                },
1013            )
1014            .await;
1015
1016        let result = handle.await.expect("join").expect("execute Ok");
1017        assert!(
1018            result.ok,
1019            "spawn_halt must land as ok=true (normal termination), got: {result:?}"
1020        );
1021        assert_eq!(result.value["halted"], true);
1022        assert_eq!(result.value["reason"], "shape verified");
1023        assert_eq!(result.value["value"], serde_json::json!({"partial": "abc"}));
1024    }
1025
1026    /// `spawn_ack { ok: false, error: Some(_) }` must retain its
1027    /// current fail-loud behaviour (backward compat guard).
1028    #[tokio::test]
1029    async fn spawn_ack_with_error_still_lands_as_worker_error() {
1030        use mlua_swarm::{Operator, WorkerError};
1031        use tokio::sync::mpsc;
1032
1033        let (tx, mut rx) = mpsc::unbounded_channel();
1034        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1035            SessionId::parse("S-err").unwrap(),
1036            tx,
1037            None,
1038        ));
1039
1040        let session_bg = session.clone();
1041        let handle = tokio::spawn(async move {
1042            session_bg
1043                .execute(
1044                    &test_ctx("ST-err"),
1045                    None,
1046                    "".into(),
1047                    Some(test_worker_binding()),
1048                    test_cap_token(),
1049                )
1050                .await
1051        });
1052
1053        let sent = rx.recv().await.expect("Spawn sent");
1054        let req_id = match sent {
1055            ServerMsg::Spawn { req_id, .. } => req_id,
1056            other => panic!("expected Spawn, got {other:?}"),
1057        };
1058
1059        session
1060            .resolve_pending(
1061                &req_id,
1062                PendingReply::SpawnAck {
1063                    value: serde_json::json!({}),
1064                    ok: false,
1065                    error: Some("real crash".into()),
1066                },
1067            )
1068            .await;
1069
1070        let err = handle.await.expect("join").expect_err("must be error");
1071        assert!(matches!(err, WorkerError::Failed(msg) if msg.contains("real crash")));
1072    }
1073
1074    // ─── Issue #17: end-to-end `execute()` splice (ctx.meta.runtime → Spawn.directive) ───
1075
1076    /// `Ctx.meta.runtime` carrying both `project_root` and `work_dir`
1077    /// (the `TaskInputMiddleware` injection shape) must land in the
1078    /// `ServerMsg::Spawn.directive` actually sent over the wire — not
1079    /// just in the pure `default_spawn_directive` helper.
1080    #[tokio::test]
1081    async fn execute_splices_project_root_and_work_dir_from_ctx_meta_runtime() {
1082        use mlua_swarm::Operator;
1083        use tokio::sync::mpsc;
1084
1085        let (tx, mut rx) = mpsc::unbounded_channel();
1086        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1087            SessionId::parse("S-ctxroot").unwrap(),
1088            tx,
1089            None,
1090        ));
1091
1092        let mut ctx = test_ctx("ST-ctxroot");
1093        ctx.meta.runtime.insert(
1094            TASK_PROJECT_ROOT_KEY.to_string(),
1095            serde_json::json!("/repo"),
1096        );
1097        ctx.meta.runtime.insert(
1098            TASK_WORK_DIR_KEY.to_string(),
1099            serde_json::json!("/repo/work"),
1100        );
1101
1102        let session_bg = session.clone();
1103        let handle = tokio::spawn(async move {
1104            session_bg
1105                .execute(
1106                    &ctx,
1107                    None,
1108                    "".into(),
1109                    Some(test_worker_binding()),
1110                    test_cap_token(),
1111                )
1112                .await
1113        });
1114
1115        let sent = rx.recv().await.expect("Spawn sent");
1116        let req_id = match sent {
1117            ServerMsg::Spawn {
1118                req_id, directive, ..
1119            } => {
1120                // issue #18: `Spawn.directive` is now `Value`; extract the
1121                // `String` it wraps (always a `Value::String` on this
1122                // path — see `default_spawn_directive_with_task_directive`).
1123                let directive = directive.as_str();
1124                assert!(
1125                    directive.contains("project_root: /repo"),
1126                    "directive missing project_root splice: {directive}"
1127                );
1128                assert!(
1129                    directive.contains("work_dir: /repo/work"),
1130                    "directive missing work_dir splice: {directive}"
1131                );
1132                req_id
1133            }
1134            other => panic!("expected Spawn, got {other:?}"),
1135        };
1136
1137        session
1138            .resolve_pending(
1139                &req_id,
1140                PendingReply::SpawnAck {
1141                    value: serde_json::json!({}),
1142                    ok: true,
1143                    error: None,
1144                },
1145            )
1146            .await;
1147        handle.await.expect("join").expect("execute Ok");
1148    }
1149
1150    /// Partial: only `project_root` present in `ctx.meta.runtime` (no
1151    /// `TaskInputMiddleware`-populated `work_dir`) — the splice is
1152    /// per-field independent, matching `TaskInputMiddleware`'s own
1153    /// per-field-optional contract.
1154    #[tokio::test]
1155    async fn execute_splices_project_root_only_when_ctx_meta_runtime_partial() {
1156        use mlua_swarm::Operator;
1157        use tokio::sync::mpsc;
1158
1159        let (tx, mut rx) = mpsc::unbounded_channel();
1160        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1161            SessionId::parse("S-ctxpartial").unwrap(),
1162            tx,
1163            None,
1164        ));
1165
1166        let mut ctx = test_ctx("ST-ctxpartial");
1167        ctx.meta.runtime.insert(
1168            TASK_PROJECT_ROOT_KEY.to_string(),
1169            serde_json::json!("/repo"),
1170        );
1171
1172        let session_bg = session.clone();
1173        let handle = tokio::spawn(async move {
1174            session_bg
1175                .execute(
1176                    &ctx,
1177                    None,
1178                    "".into(),
1179                    Some(test_worker_binding()),
1180                    test_cap_token(),
1181                )
1182                .await
1183        });
1184
1185        let sent = rx.recv().await.expect("Spawn sent");
1186        let req_id = match sent {
1187            ServerMsg::Spawn {
1188                req_id, directive, ..
1189            } => {
1190                let directive = directive.as_str();
1191                assert!(
1192                    directive.contains("project_root: /repo"),
1193                    "directive missing project_root splice: {directive}"
1194                );
1195                assert!(!directive.contains("work_dir:"));
1196                req_id
1197            }
1198            other => panic!("expected Spawn, got {other:?}"),
1199        };
1200
1201        session
1202            .resolve_pending(
1203                &req_id,
1204                PendingReply::SpawnAck {
1205                    value: serde_json::json!({}),
1206                    ok: true,
1207                    error: None,
1208                },
1209            )
1210            .await;
1211        handle.await.expect("join").expect("execute Ok");
1212    }
1213
1214    /// Neither present in `ctx.meta.runtime` (no `TaskInputMiddleware`
1215    /// layered for this launch) — the directive carries neither header
1216    /// line, matching pre-issue-#17 behavior exactly.
1217    #[tokio::test]
1218    async fn execute_omits_project_root_and_work_dir_when_ctx_meta_runtime_absent() {
1219        use mlua_swarm::Operator;
1220        use tokio::sync::mpsc;
1221
1222        let (tx, mut rx) = mpsc::unbounded_channel();
1223        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1224            SessionId::parse("S-ctxabsent").unwrap(),
1225            tx,
1226            None,
1227        ));
1228
1229        let ctx = test_ctx("ST-ctxabsent");
1230
1231        let session_bg = session.clone();
1232        let handle = tokio::spawn(async move {
1233            session_bg
1234                .execute(
1235                    &ctx,
1236                    None,
1237                    "".into(),
1238                    Some(test_worker_binding()),
1239                    test_cap_token(),
1240                )
1241                .await
1242        });
1243
1244        let sent = rx.recv().await.expect("Spawn sent");
1245        let req_id = match sent {
1246            ServerMsg::Spawn {
1247                req_id, directive, ..
1248            } => {
1249                let directive = directive.as_str();
1250                assert!(!directive.contains("project_root:"));
1251                assert!(!directive.contains("work_dir:"));
1252                req_id
1253            }
1254            other => panic!("expected Spawn, got {other:?}"),
1255        };
1256
1257        session
1258            .resolve_pending(
1259                &req_id,
1260                PendingReply::SpawnAck {
1261                    value: serde_json::json!({}),
1262                    ok: true,
1263                    error: None,
1264                },
1265            )
1266            .await;
1267        handle.await.expect("join").expect("execute Ok");
1268    }
1269
1270    /// GH #20 / F2 gap: `task_metadata` in `ctx.meta.runtime` (the
1271    /// `TaskInputMiddleware` injection shape) now reaches the
1272    /// `ServerMsg::Spawn.directive` actually sent over the wire, via
1273    /// `AgentContextView::materialized_or_from_ctx` falling back to
1274    /// `from_ctx` when `AgentContextMiddleware` was not layered.
1275    #[tokio::test]
1276    async fn execute_splices_task_metadata_from_ctx_meta_runtime() {
1277        use mlua_swarm::Operator;
1278        use tokio::sync::mpsc;
1279
1280        let (tx, mut rx) = mpsc::unbounded_channel();
1281        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1282            SessionId::parse("S-ctxmeta").unwrap(),
1283            tx,
1284            None,
1285        ));
1286
1287        let mut ctx = test_ctx("ST-ctxmeta");
1288        ctx.meta.runtime.insert(
1289            TASK_METADATA_KEY.to_string(),
1290            serde_json::json!({"issue": 20}),
1291        );
1292
1293        let session_bg = session.clone();
1294        let handle = tokio::spawn(async move {
1295            session_bg
1296                .execute(
1297                    &ctx,
1298                    None,
1299                    "".into(),
1300                    Some(test_worker_binding()),
1301                    test_cap_token(),
1302                )
1303                .await
1304        });
1305
1306        let sent = rx.recv().await.expect("Spawn sent");
1307        let req_id = match sent {
1308            ServerMsg::Spawn {
1309                req_id, directive, ..
1310            } => {
1311                let directive = directive.as_str();
1312                assert!(
1313                    directive.contains(r#"task_metadata: {"issue":20}"#),
1314                    "directive missing task_metadata splice: {directive}"
1315                );
1316                req_id
1317            }
1318            other => panic!("expected Spawn, got {other:?}"),
1319        };
1320
1321        session
1322            .resolve_pending(
1323                &req_id,
1324                PendingReply::SpawnAck {
1325                    value: serde_json::json!({}),
1326                    ok: true,
1327                    error: None,
1328                },
1329            )
1330            .await;
1331        handle.await.expect("join").expect("execute Ok");
1332    }
1333
1334    // ─── Issue #18: `Value` pass-through render boundary
1335    //     (`default_spawn_directive_with_task_directive`) ───
1336
1337    /// A `String` seed splices in verbatim, unquoted (matching
1338    /// `Value::String(s) => s.clone()` — no JSON-quoting artifact).
1339    #[test]
1340    fn with_task_directive_splices_string_seed_verbatim() {
1341        let directive = default_spawn_directive_with_task_directive(
1342            "impl-lead",
1343            "task-x",
1344            "mse-worker-coder",
1345            &view_with(None, None, None),
1346            None,
1347            None,
1348            None,
1349            &serde_json::json!("do the thing"),
1350        );
1351        let text = directive.as_str();
1352        assert!(
1353            text.contains("task_directive: do the thing"),
1354            "missing task_directive line for a String seed: {text}"
1355        );
1356    }
1357
1358    /// An Object seed renders as its JSON literal (issue #18 Invariant 3 —
1359    /// same shape `Engine::start_task` / `Engine::dispatch_attempt_with`
1360    /// produce for the Worker HTTP path via `render_directive_to_string`).
1361    #[test]
1362    fn with_task_directive_renders_object_seed_as_json_literal() {
1363        let directive = default_spawn_directive_with_task_directive(
1364            "impl-lead",
1365            "task-x",
1366            "mse-worker-coder",
1367            &view_with(None, None, None),
1368            None,
1369            None,
1370            None,
1371            &serde_json::json!({"key": "value"}),
1372        );
1373        let text = directive.as_str();
1374        assert!(
1375            text.contains(r#"task_directive: {"key":"value"}"#),
1376            "missing JSON-literal task_directive line for an Object seed: {text}"
1377        );
1378    }
1379
1380    /// `Value::Null` (no seed recovered) omits the line entirely — the
1381    /// output is byte-identical to `default_spawn_directive`'s own text,
1382    /// preserving every pre-issue-#18 caller unchanged.
1383    #[test]
1384    fn with_task_directive_omits_line_when_null() {
1385        let wrapped = default_spawn_directive_with_task_directive(
1386            "impl-lead",
1387            "task-x",
1388            "mse-worker-coder",
1389            &view_with(None, None, None),
1390            None,
1391            None,
1392            None,
1393            &serde_json::Value::Null,
1394        );
1395        let plain = default_spawn_directive(
1396            "impl-lead",
1397            "task-x",
1398            "mse-worker-coder",
1399            &view_with(None, None, None),
1400            None,
1401            None,
1402            None,
1403        );
1404        assert_eq!(
1405            wrapped,
1406            serde_json::Value::String(plain),
1407            "Value::Null seed must not add a task_directive line"
1408        );
1409    }
1410
1411    /// End-to-end via `execute()`: an Object-shaped `Step.in` seed, once
1412    /// rendered to a JSON-literal `String` by the engine (the Worker HTTP
1413    /// path's `render_directive_to_string`), reaches `ServerMsg::Spawn`
1414    /// with the same JSON literal spliced into `directive` — the WS
1415    /// render layer is the sole `Value → String` coercion point on this
1416    /// path (issue #18).
1417    #[tokio::test]
1418    async fn execute_splices_json_literal_task_directive_for_object_seed() {
1419        use mlua_swarm::Operator;
1420        use tokio::sync::mpsc;
1421
1422        let (tx, mut rx) = mpsc::unbounded_channel();
1423        let session = std::sync::Arc::new(WSOperatorSession::new_with_base_url(
1424            SessionId::parse("S-objseed").unwrap(),
1425            tx,
1426            None,
1427        ));
1428
1429        let ctx = test_ctx("ST-objseed");
1430        // Issue #18: `Value` flows end-to-end from `Step.in` through the
1431        // engine, so the Object seed reaches `execute()` as `Value` — no
1432        // stringification upstream. Only the WS Spawn frame render
1433        // performs the `Value → String` coercion.
1434        let rendered_prompt = serde_json::json!({"key": "value"});
1435
1436        let session_bg = session.clone();
1437        let handle = tokio::spawn(async move {
1438            session_bg
1439                .execute(
1440                    &ctx,
1441                    None,
1442                    rendered_prompt,
1443                    Some(test_worker_binding()),
1444                    test_cap_token(),
1445                )
1446                .await
1447        });
1448
1449        let sent = rx.recv().await.expect("Spawn sent");
1450        let req_id = match sent {
1451            ServerMsg::Spawn {
1452                req_id, directive, ..
1453            } => {
1454                let directive = directive.as_str();
1455                assert!(
1456                    directive.contains(r#"task_directive: {"key":"value"}"#),
1457                    "directive missing JSON-literal task_directive splice: {directive}"
1458                );
1459                req_id
1460            }
1461            other => panic!("expected Spawn, got {other:?}"),
1462        };
1463
1464        session
1465            .resolve_pending(
1466                &req_id,
1467                PendingReply::SpawnAck {
1468                    value: serde_json::json!({}),
1469                    ok: true,
1470                    error: None,
1471                },
1472            )
1473            .await;
1474        handle.await.expect("join").expect("execute Ok");
1475    }
1476}