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