Skip to main content

mlua_swarm/core/
agent_context.rs

1//! `AgentContextView` — Contract C: the task-level context that must reach
2//! the LLM/Agent boundary, materialized once and consumed by every axis.
3//!
4//! # The gap this closes (GH #20)
5//!
6//! Task-level context lives in `Ctx.meta.runtime` (`project_root` /
7//! `work_dir` / `task_metadata` / `run_id` / `project_name_alias`, …) and
8//! has to reach the executing Agent through two independent renderers:
9//!
10//! - **Spawner axis** — the WS thin-path
11//!   (`crates/mlua-swarm-server/src/operator_ws/session.rs`) splices
12//!   individual `Ctx.meta.runtime` keys into the `Spawn.directive` text a
13//!   MainAI reads.
14//! - **Worker axis** — `Engine::fetch_worker_payload{,_trusted}`
15//!   (`crate::core::engine`) builds a [`crate::types::WorkerPayload`] a
16//!   SubAgent pulls over `GET /v1/worker/prompt`; it never carried
17//!   task-level meta at all (only `task_id` / `attempt` / `agent` /
18//!   `system` / `prompt`).
19//!
20//! Before this module, each axis field-by-field-pulled the keys it needed
21//! straight out of `ctx.meta.runtime`, so a new field (e.g.
22//! `task_metadata`) had to be wired into every consumer by hand — and
23//! `task_metadata` never made it into the directive text at all (the F2
24//! gap tracked in the `operator-execution-model` guide).
25//!
26//! # The fix: materialize once, consume everywhere
27//!
28//! [`AgentContextView`] formalizes Contract C as one view struct. A new
29//! innermost `SpawnerLayer` (`crate::middleware::agent_context`) builds it
30//! from `Ctx` exactly once per spawn — after the outer
31//! `TaskInputMiddleware` / `ProjectNameAliasMiddleware` /
32//! `WorkerBindingMiddleware` layers have inserted their runtime keys, and
33//! before the base spawner stack (WS session / in-process AgentBlock) runs
34//! — and fans the materialized view out on two independent rails:
35//!
36//! ```text
37//!  TaskInput/Alias/Binding middlewares (outer, existing)  — insert runtime keys
38//!         │ ctx (keys present)
39//!         ▼
40//!  AgentContextMiddleware (innermost layer)
41//!    view = AgentContextView::from_ctx(ctx).apply_policy(&policy)
42//!    (a) EngineState.agent_contexts[(task_id, attempt)] = view     ← Worker axis source
43//!    (b) ctx.meta.runtime[AGENT_CONTEXT_KEY] = json(view)          ← Spawner axis source
44//!         │ inner.spawn(new_ctx)
45//!         ▼
46//!  base stack (OperatorDelegate → WS session.rs | in-proc AgentBlock runtime.rs)
47//! ```
48//!
49//! (a) is read back by `Engine::fetch_worker_payload{,_trusted}` (keyed by
50//! `(task_id, attempt)`, mirroring `EngineState.prompts` / `.systems` —
51//! `Ctx` itself is not stored, so the view has to be snapshotted at
52//! dispatch time to still be servable when the Worker axis fetches it
53//! later). (b) is read back via [`AgentContextView::materialized_or_from_ctx`]
54//! by both the Spawner axis (WS `session.rs`) and the in-process
55//! AgentBlock axis (`crate::worker::agent_block::runtime`) — falling back
56//! to [`AgentContextView::from_ctx`] when the middleware was never layered
57//! (backward compat).
58//!
59//! A field added to [`AgentContextView`] (either a named field, or an
60//! `extra` entry) reaches both axes automatically — no per-consumer wiring
61//! required. [`ContextPolicy`] filters the materialized view before it is
62//! snapshotted / stashed; GH #21 Phase 1 wires it to Blueprint schema
63//! fields (`Blueprint.default_agent_ctx` / `default_context_policy`,
64//! `AgentMeta.ctx` / `context_policy`, resolved by
65//! `crate::service::task_launch::derive_agent_ctx` /
66//! `derive_context_policies` and consumed by
67//! `crate::middleware::agent_context::AgentContextMiddleware`).
68
69use crate::core::ctx::Ctx;
70use serde::{Deserialize, Serialize};
71use serde_json::Value;
72
73/// `ctx.meta.runtime` key under which the materialized
74/// [`AgentContextView`] (JSON-serialized) is stashed by
75/// `crate::middleware::agent_context::AgentContextMiddleware` — the
76/// Spawner axis's read-back source (see the module doc).
77pub const AGENT_CONTEXT_KEY: &str = "agent_context";
78
79/// `ctx.meta.runtime` key that carries the issue #13 run-id propagation
80/// value. Canonical home as of GH #20 (previously a bare literal at
81/// `Engine::dispatch_attempt_with`).
82pub const RUN_ID_KEY: &str = "run_id";
83
84/// `ctx.meta.runtime` key that carries the GH #21 Phase 2 Step tier's
85/// resolved context bundle (`TaskSpec.step_ctx`, threaded through by
86/// `Engine::dispatch_attempt_with` on every attempt — same insertion
87/// site as [`RUN_ID_KEY`]). Consumed by
88/// `crate::middleware::agent_context::AgentContextMiddleware`, which
89/// unpacks the bundle's keys and applies them with the same
90/// only-if-absent mechanics as the Agent / BP-global tiers, ordered
91/// FIRST (Step outranks Agent and BP-global — see that middleware's
92/// module doc for the full precedence narrative). The bundle itself
93/// (this key's raw value) stays in the runtime bag verbatim — only its
94/// individual keys are folded into [`AgentContextView::extra`].
95pub const STEP_CTX_KEY: &str = "step_ctx";
96
97/// `ctx.meta.runtime` key that carries `Blueprint.metadata.project_name_alias`.
98/// Canonical home as of GH #20; re-exported from
99/// [`crate::middleware::project_name_alias`] for API compatibility (same
100/// string value the existing `ctx.meta.runtime.get("project_name_alias")`
101/// consumers keyed off).
102pub const PROJECT_NAME_ALIAS_KEY: &str = "project_name_alias";
103
104/// `ctx.meta.runtime` key that carries the Task-level project root path.
105/// Canonical home as of GH #20; re-exported from
106/// [`crate::middleware::task_input`] for API compatibility.
107pub const TASK_PROJECT_ROOT_KEY: &str = "project_root";
108
109/// `ctx.meta.runtime` key that carries the Task-level work dir path.
110/// Canonical home as of GH #20; re-exported from
111/// [`crate::middleware::task_input`] for API compatibility.
112pub const TASK_WORK_DIR_KEY: &str = "work_dir";
113
114/// `ctx.meta.runtime` key that carries the Task-level free-form metadata
115/// object. Canonical home as of GH #20; re-exported from
116/// [`crate::middleware::task_input`] for API compatibility.
117pub const TASK_METADATA_KEY: &str = "task_metadata";
118
119/// Contract C's view struct — the task-level context that must reach the
120/// LLM/Agent boundary, materialized once per spawn (see the module doc).
121///
122/// `task_id` / `agent` / `attempt` are Ctx-immediate identity fields,
123/// always present. Every other named field is independently optional,
124/// mirroring the "absent key = no value, not an empty placeholder"
125/// contract the individual `ctx.meta.runtime` injectors already follow
126/// (`TaskInputMiddleware`, `ProjectNameAliasMiddleware`, …). `extra` is
127/// the injectable surface for future supply-axis fields (FlowIr ctx /
128/// StepMeta) — empty in [`Self::from_ctx`] today, but a value dropped into
129/// it reaches every consumer of this view (directive header text, the
130/// Worker axis's [`crate::types::WorkerPayload::context`], and the
131/// serialized JSON) with no further wiring.
132///
133/// `deny_unknown_fields` is deliberately NOT set: this view is meant to
134/// grow additively (new named fields, new `extra` entries) without
135/// breaking deserialization of a view a slightly older engine build
136/// produced — forward tolerance for additive fields.
137#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
138pub struct AgentContextView {
139    /// The task this view was materialized for (`Ctx.task_id`, rendered
140    /// via its `Display` — the same string form `StepId` serializes as
141    /// elsewhere).
142    pub task_id: String,
143    /// The agent this dispatch is targeting (`Ctx.agent`).
144    pub agent: String,
145    /// 1-based attempt counter for `task_id` (`Ctx.attempt`).
146    pub attempt: u32,
147    /// Task-level project root path, from
148    /// `ctx.meta.runtime[TASK_PROJECT_ROOT_KEY]`.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub project_root: Option<String>,
151    /// Task-level working directory, from
152    /// `ctx.meta.runtime[TASK_WORK_DIR_KEY]`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub work_dir: Option<String>,
155    /// Task-level free-form metadata bag, from
156    /// `ctx.meta.runtime[TASK_METADATA_KEY]`.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    #[schemars(with = "Option<serde_json::Value>")]
159    pub task_metadata: Option<Value>,
160    /// Issue #13 run-id propagation value, from
161    /// `ctx.meta.runtime[RUN_ID_KEY]`.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub run_id: Option<String>,
164    /// `Blueprint.metadata.project_name_alias`, from
165    /// `ctx.meta.runtime[PROJECT_NAME_ALIAS_KEY]`.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub project_name_alias: Option<String>,
168    /// Injectable surface for future supply-axis fields not yet promoted
169    /// to a named field (e.g. FlowIr ctx / StepMeta). Empty in
170    /// [`Self::from_ctx`] today — a future `ContextPolicy`-aware
171    /// materializer may populate it without a breaking change to this
172    /// struct.
173    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
174    #[schemars(with = "serde_json::Value")]
175    pub extra: serde_json::Map<String, Value>,
176}
177
178impl AgentContextView {
179    /// Builds a view directly off `ctx` — the Ctx-immediate identity
180    /// fields plus whatever the canonical keys above currently hold in
181    /// `ctx.meta.runtime`. `extra` is always empty here (see the field
182    /// doc); a caller that wants to inject additional fields should build
183    /// on top of the result.
184    pub fn from_ctx(ctx: &Ctx) -> Self {
185        let runtime = &ctx.meta.runtime;
186        Self {
187            task_id: ctx.task_id.to_string(),
188            agent: ctx.agent.clone(),
189            attempt: ctx.attempt,
190            project_root: runtime
191                .get(TASK_PROJECT_ROOT_KEY)
192                .and_then(Value::as_str)
193                .map(String::from),
194            work_dir: runtime
195                .get(TASK_WORK_DIR_KEY)
196                .and_then(Value::as_str)
197                .map(String::from),
198            task_metadata: runtime.get(TASK_METADATA_KEY).cloned(),
199            run_id: runtime
200                .get(RUN_ID_KEY)
201                .and_then(Value::as_str)
202                .map(String::from),
203            project_name_alias: runtime
204                .get(PROJECT_NAME_ALIAS_KEY)
205                .and_then(Value::as_str)
206                .map(String::from),
207            extra: serde_json::Map::new(),
208        }
209    }
210
211    /// Reads the canonical, policy-applied view back out of
212    /// `ctx.meta.runtime[AGENT_CONTEXT_KEY]` (materialized by
213    /// `AgentContextMiddleware`); falls back to [`Self::from_ctx`] when
214    /// the key is absent or fails to deserialize (middleware not yet
215    /// layered onto this spawner stack — backward compat).
216    pub fn materialized_or_from_ctx(ctx: &Ctx) -> Self {
217        ctx.meta
218            .runtime
219            .get(AGENT_CONTEXT_KEY)
220            .and_then(|v| serde_json::from_value::<Self>(v.clone()).ok())
221            .unwrap_or_else(|| Self::from_ctx(ctx))
222    }
223
224    /// Renders ONLY the task-level context lines for the Spawn directive
225    /// header — `project_name_alias:` / `project_root:` / `work_dir:` in
226    /// that literal order (matching the pre-existing splice order in
227    /// `crates/mlua-swarm-server/src/operator_ws/session.rs`), then a new
228    /// `task_metadata: {compact-json}` line when `Some`, then one
229    /// `{key}: {compact-json}` line per `extra` entry. Absent fields
230    /// render as nothing (no empty-string placeholder) — same contract as
231    /// the individual `ctx.meta.runtime` injectors. Does NOT render
232    /// `task_id` / `agent` / `attempt` / `run_id` — those already appear
233    /// elsewhere in the directive template.
234    pub fn to_directive_header(&self) -> String {
235        let mut out = String::new();
236        if let Some(alias) = &self.project_name_alias {
237            out.push_str(&format!("project_name_alias: {alias}\n"));
238        }
239        if let Some(root) = &self.project_root {
240            out.push_str(&format!("project_root: {root}\n"));
241        }
242        if let Some(dir) = &self.work_dir {
243            out.push_str(&format!("work_dir: {dir}\n"));
244        }
245        if let Some(meta) = &self.task_metadata {
246            out.push_str(&format!("task_metadata: {meta}\n"));
247        }
248        for (key, value) in &self.extra {
249            out.push_str(&format!("{key}: {value}\n"));
250        }
251        out
252    }
253
254    /// Applies `policy` to `self`, returning a filtered copy. Filtered
255    /// `Option` fields become `None`; filtered `extra` keys are removed.
256    /// Identity fields (`task_id` / `agent` / `attempt`) are never
257    /// filtered. A pass-all policy (the `ContextPolicy` default) returns
258    /// `self` unchanged. Moved here from the former
259    /// `ContextPolicy::apply(&self, view)` (GH #21 Phase 1 — the filter
260    /// data shape now lives in `mlua_swarm_schema::ContextPolicy`; the
261    /// application logic stays on the view it filters).
262    pub fn apply_policy(mut self, policy: &ContextPolicy) -> Self {
263        if !policy.allows("project_root") {
264            self.project_root = None;
265        }
266        if !policy.allows("work_dir") {
267            self.work_dir = None;
268        }
269        if !policy.allows("task_metadata") {
270            self.task_metadata = None;
271        }
272        if !policy.allows("run_id") {
273            self.run_id = None;
274        }
275        if !policy.allows("project_name_alias") {
276            self.project_name_alias = None;
277        }
278        self.extra.retain(|key, _| policy.allows(key));
279
280        self
281    }
282}
283
284/// Filter over [`AgentContextView`] fields (GH #20/#21). Relocated to the
285/// schema crate in GH #21 Phase 1 so a Blueprint author can declare one via
286/// `Blueprint.default_context_policy` / `AgentMeta.context_policy` — this
287/// re-export keeps `mlua_swarm::core::agent_context::ContextPolicy`
288/// resolving unchanged for every existing caller (path-compat; see the
289/// `context_policy_path_compat_reexport` test below). The filter
290/// *application* logic lives on the view side, at
291/// [`AgentContextView::apply_policy`].
292pub use mlua_swarm_schema::ContextPolicy;
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::types::StepId;
298
299    fn ctx_with_runtime(pairs: &[(&str, Value)]) -> Ctx {
300        let mut ctx = Ctx::new(StepId::parse("ST-1").unwrap(), 3, "planner");
301        for (k, v) in pairs {
302            ctx.meta.runtime.insert((*k).to_string(), v.clone());
303        }
304        ctx
305    }
306
307    #[test]
308    fn from_ctx_extracts_identity_and_all_runtime_keys() {
309        let ctx = ctx_with_runtime(&[
310            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
311            (TASK_WORK_DIR_KEY, Value::String("/repo/work".into())),
312            (TASK_METADATA_KEY, serde_json::json!({"issue": 20})),
313            (RUN_ID_KEY, Value::String("R-abc".into())),
314            (PROJECT_NAME_ALIAS_KEY, Value::String("alias-x".into())),
315        ]);
316        let view = AgentContextView::from_ctx(&ctx);
317        assert_eq!(view.task_id, "ST-1");
318        assert_eq!(view.agent, "planner");
319        assert_eq!(view.attempt, 3);
320        assert_eq!(view.project_root.as_deref(), Some("/repo"));
321        assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
322        assert_eq!(view.task_metadata, Some(serde_json::json!({"issue": 20})));
323        assert_eq!(view.run_id.as_deref(), Some("R-abc"));
324        assert_eq!(view.project_name_alias.as_deref(), Some("alias-x"));
325        assert!(view.extra.is_empty());
326    }
327
328    #[test]
329    fn from_ctx_absent_runtime_keys_stay_none() {
330        let ctx = ctx_with_runtime(&[]);
331        let view = AgentContextView::from_ctx(&ctx);
332        assert!(view.project_root.is_none());
333        assert!(view.work_dir.is_none());
334        assert!(view.task_metadata.is_none());
335        assert!(view.run_id.is_none());
336        assert!(view.project_name_alias.is_none());
337    }
338
339    #[test]
340    fn materialized_or_from_ctx_prefers_materialized_view() {
341        let mut view = AgentContextView::from_ctx(&ctx_with_runtime(&[]));
342        view.task_id = "ST-materialized".into();
343        view.extra
344            .insert("custom".into(), Value::String("value".into()));
345        let mut ctx = ctx_with_runtime(&[]);
346        ctx.meta.runtime.insert(
347            AGENT_CONTEXT_KEY.to_string(),
348            serde_json::to_value(&view).unwrap(),
349        );
350
351        let resolved = AgentContextView::materialized_or_from_ctx(&ctx);
352        assert_eq!(resolved.task_id, "ST-materialized");
353        assert_eq!(
354            resolved.extra.get("custom"),
355            Some(&Value::String("value".into()))
356        );
357    }
358
359    #[test]
360    fn materialized_or_from_ctx_falls_back_when_key_absent() {
361        let ctx = ctx_with_runtime(&[(TASK_PROJECT_ROOT_KEY, Value::String("/repo".into()))]);
362        let resolved = AgentContextView::materialized_or_from_ctx(&ctx);
363        assert_eq!(resolved.project_root.as_deref(), Some("/repo"));
364    }
365
366    #[test]
367    fn materialized_or_from_ctx_falls_back_when_value_malformed() {
368        let mut ctx = ctx_with_runtime(&[]);
369        ctx.meta.runtime.insert(
370            AGENT_CONTEXT_KEY.to_string(),
371            Value::String("not an object".into()),
372        );
373        // Malformed AGENT_CONTEXT_KEY value must not panic or propagate an
374        // error — fall back to from_ctx (identity fields still resolve).
375        let resolved = AgentContextView::materialized_or_from_ctx(&ctx);
376        assert_eq!(resolved.task_id, "ST-1");
377    }
378
379    #[test]
380    fn serde_round_trip_skips_absent_optionals_and_empty_extra() {
381        let view = AgentContextView {
382            task_id: "ST-1".into(),
383            agent: "planner".into(),
384            attempt: 1,
385            ..Default::default()
386        };
387        let json = serde_json::to_value(&view).unwrap();
388        let obj = json.as_object().unwrap();
389        assert_eq!(
390            obj.len(),
391            3,
392            "only identity fields should serialize: {obj:?}"
393        );
394        assert!(obj.contains_key("task_id"));
395        assert!(obj.contains_key("agent"));
396        assert!(obj.contains_key("attempt"));
397
398        let round_tripped: AgentContextView = serde_json::from_value(json).unwrap();
399        assert_eq!(round_tripped, view);
400    }
401
402    #[test]
403    fn serde_round_trip_full_view() {
404        let mut view = AgentContextView::from_ctx(&ctx_with_runtime(&[
405            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
406            (RUN_ID_KEY, Value::String("R-1".into())),
407        ]));
408        view.extra
409            .insert("flow_ir_ctx".into(), serde_json::json!({"k": "v"}));
410        let json = serde_json::to_value(&view).unwrap();
411        let round_tripped: AgentContextView = serde_json::from_value(json).unwrap();
412        assert_eq!(round_tripped, view);
413    }
414
415    #[test]
416    fn json_schema_export_contains_all_fields() {
417        let schema = schemars::schema_for!(AgentContextView);
418        let json = serde_json::to_value(&schema).unwrap();
419        let props = json["properties"].as_object().expect("properties object");
420        for field in [
421            "task_id",
422            "agent",
423            "attempt",
424            "project_root",
425            "work_dir",
426            "task_metadata",
427            "run_id",
428            "project_name_alias",
429            "extra",
430        ] {
431            assert!(props.contains_key(field), "schema missing field {field}");
432        }
433    }
434
435    #[test]
436    fn context_policy_default_is_pass_all() {
437        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[
438            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
439            (RUN_ID_KEY, Value::String("R-1".into())),
440        ]));
441        let filtered = view.clone().apply_policy(&ContextPolicy::default());
442        assert_eq!(filtered, view);
443    }
444
445    #[test]
446    fn context_policy_include_only_keeps_listed_fields() {
447        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[
448            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
449            (TASK_WORK_DIR_KEY, Value::String("/repo/work".into())),
450            (RUN_ID_KEY, Value::String("R-1".into())),
451        ]));
452        let policy = ContextPolicy {
453            include: Some(vec!["project_root".to_string()]),
454            exclude: Vec::new(),
455        };
456        let filtered = view.apply_policy(&policy);
457        assert_eq!(filtered.project_root.as_deref(), Some("/repo"));
458        assert!(filtered.work_dir.is_none());
459        assert!(filtered.run_id.is_none());
460    }
461
462    #[test]
463    fn context_policy_exclude_drops_listed_fields() {
464        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[
465            (TASK_PROJECT_ROOT_KEY, Value::String("/repo".into())),
466            (TASK_WORK_DIR_KEY, Value::String("/repo/work".into())),
467        ]));
468        let policy = ContextPolicy {
469            include: None,
470            exclude: vec!["work_dir".to_string()],
471        };
472        let filtered = view.apply_policy(&policy);
473        assert_eq!(filtered.project_root.as_deref(), Some("/repo"));
474        assert!(filtered.work_dir.is_none());
475    }
476
477    #[test]
478    fn context_policy_exclude_beats_include() {
479        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[(
480            TASK_PROJECT_ROOT_KEY,
481            Value::String("/repo".into()),
482        )]));
483        let policy = ContextPolicy {
484            include: Some(vec!["project_root".to_string()]),
485            exclude: vec!["project_root".to_string()],
486        };
487        let filtered = view.apply_policy(&policy);
488        assert!(filtered.project_root.is_none());
489    }
490
491    #[test]
492    fn context_policy_never_filters_identity_fields() {
493        let view = AgentContextView::from_ctx(&ctx_with_runtime(&[]));
494        let policy = ContextPolicy {
495            include: Some(vec![]),
496            exclude: vec![
497                "task_id".to_string(),
498                "agent".to_string(),
499                "attempt".to_string(),
500            ],
501        };
502        let filtered = view.clone().apply_policy(&policy);
503        assert_eq!(filtered.task_id, view.task_id);
504        assert_eq!(filtered.agent, view.agent);
505        assert_eq!(filtered.attempt, view.attempt);
506    }
507
508    #[test]
509    fn context_policy_exclude_removes_extra_key() {
510        let mut view = AgentContextView::from_ctx(&ctx_with_runtime(&[]));
511        view.extra
512            .insert("secret".into(), Value::String("x".into()));
513        view.extra.insert("kept".into(), Value::String("y".into()));
514        let policy = ContextPolicy {
515            include: None,
516            exclude: vec!["secret".to_string()],
517        };
518        let filtered = view.apply_policy(&policy);
519        assert!(!filtered.extra.contains_key("secret"));
520        assert!(filtered.extra.contains_key("kept"));
521    }
522
523    #[test]
524    fn to_directive_header_renders_task_metadata_and_omits_absent_fields() {
525        let view = AgentContextView {
526            task_id: "ST-1".into(),
527            agent: "planner".into(),
528            attempt: 1,
529            project_root: Some("/repo".into()),
530            work_dir: None,
531            task_metadata: Some(serde_json::json!({"issue": 20})),
532            run_id: None,
533            project_name_alias: None,
534            extra: serde_json::Map::new(),
535        };
536        let header = view.to_directive_header();
537        assert_eq!(
538            header,
539            "project_root: /repo\ntask_metadata: {\"issue\":20}\n"
540        );
541        assert!(!header.contains("work_dir:"));
542        assert!(!header.contains("project_name_alias:"));
543    }
544
545    #[test]
546    fn to_directive_header_renders_alias_root_work_dir_in_existing_order() {
547        let view = AgentContextView {
548            task_id: "ST-1".into(),
549            agent: "planner".into(),
550            attempt: 1,
551            project_root: Some("/repo".into()),
552            work_dir: Some("/repo/work".into()),
553            task_metadata: None,
554            run_id: None,
555            project_name_alias: Some("alias-x".into()),
556            extra: serde_json::Map::new(),
557        };
558        let header = view.to_directive_header();
559        assert_eq!(
560            header,
561            "project_name_alias: alias-x\nproject_root: /repo\nwork_dir: /repo/work\n"
562        );
563    }
564
565    #[test]
566    fn to_directive_header_empty_view_renders_empty_string() {
567        let view = AgentContextView {
568            task_id: "ST-1".into(),
569            agent: "planner".into(),
570            attempt: 1,
571            ..Default::default()
572        };
573        assert_eq!(view.to_directive_header(), "");
574    }
575
576    #[test]
577    fn extra_field_propagates_to_directive_header_and_serialized_json() {
578        let mut view = AgentContextView {
579            task_id: "ST-1".into(),
580            agent: "planner".into(),
581            attempt: 1,
582            ..Default::default()
583        };
584        view.extra
585            .insert("step_meta".into(), serde_json::json!({"loop_idx": 2}));
586
587        let header = view.to_directive_header();
588        assert_eq!(header, "step_meta: {\"loop_idx\":2}\n");
589
590        let json = serde_json::to_value(&view).unwrap();
591        assert_eq!(
592            json.get("extra").and_then(|e| e.get("step_meta")),
593            Some(&serde_json::json!({"loop_idx": 2}))
594        );
595    }
596
597    /// GH #21 Phase 1 path-compat guard: `ContextPolicy` moved to the
598    /// schema crate, but every caller importing it as
599    /// `crate::core::agent_context::ContextPolicy` (this module's `pub
600    /// use` re-export) must keep resolving unchanged.
601    #[test]
602    fn context_policy_path_compat_reexport() {
603        let policy: crate::core::agent_context::ContextPolicy =
604            crate::core::agent_context::ContextPolicy::default();
605        assert_eq!(policy, mlua_swarm_schema::ContextPolicy::default());
606    }
607}