1use crate::core::agent_context::{
69 AgentContextView, ContextPolicy, AGENT_CONTEXT_KEY, PROJECTION_PLACEMENT_KEY,
70 PROJECT_NAME_ALIAS_KEY, RUN_ID_KEY, STEP_CTX_KEY, TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY,
71 TASK_WORK_DIR_KEY,
72};
73use crate::core::ctx::Ctx;
74use crate::core::engine::Engine;
75use crate::middleware::SpawnerLayer;
76use crate::types::{CapToken, StepId};
77use crate::worker::adapter::{SpawnError, SpawnerAdapter};
78use crate::worker::Worker;
79use async_trait::async_trait;
80use serde_json::Value;
81use std::collections::HashMap;
82use std::sync::Arc;
83
84const NAMED_RUNTIME_KEYS: [&str; 5] = [
89 TASK_PROJECT_ROOT_KEY,
90 TASK_WORK_DIR_KEY,
91 TASK_METADATA_KEY,
92 RUN_ID_KEY,
93 PROJECT_NAME_ALIAS_KEY,
94];
95
96pub struct AgentContextMiddleware {
102 global_ctx: Option<Value>,
105 per_agent_ctx: HashMap<String, Value>,
108 default_policy: Option<ContextPolicy>,
111 per_agent_policy: HashMap<String, ContextPolicy>,
115}
116
117impl AgentContextMiddleware {
118 pub fn new(
122 global_ctx: Option<Value>,
123 per_agent_ctx: HashMap<String, Value>,
124 default_policy: Option<ContextPolicy>,
125 per_agent_policy: HashMap<String, ContextPolicy>,
126 ) -> Self {
127 Self {
128 global_ctx,
129 per_agent_ctx,
130 default_policy,
131 per_agent_policy,
132 }
133 }
134}
135
136impl Default for AgentContextMiddleware {
137 fn default() -> Self {
141 Self::new(None, HashMap::new(), None, HashMap::new())
142 }
143}
144
145impl SpawnerLayer for AgentContextMiddleware {
146 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
147 Arc::new(AgentContextWrapped {
148 inner,
149 global_ctx: self.global_ctx.clone(),
150 per_agent_ctx: self.per_agent_ctx.clone(),
151 default_policy: self.default_policy.clone(),
152 per_agent_policy: self.per_agent_policy.clone(),
153 })
154 }
155}
156
157struct AgentContextWrapped {
158 inner: Arc<dyn SpawnerAdapter>,
159 global_ctx: Option<Value>,
160 per_agent_ctx: HashMap<String, Value>,
161 default_policy: Option<ContextPolicy>,
162 per_agent_policy: HashMap<String, ContextPolicy>,
163}
164
165impl AgentContextWrapped {
166 fn merge_ctx_tiers(&self, agent: &str) -> serde_json::Map<String, Value> {
171 let mut merged = serde_json::Map::new();
172 if let Some(global) = &self.global_ctx {
173 match global.as_object() {
174 Some(obj) => {
175 for (k, v) in obj {
176 merged.insert(k.clone(), v.clone());
177 }
178 }
179 None => tracing::warn!(
180 value = %global,
181 "AgentContextMiddleware: default_agent_ctx is not a JSON object; skipping this tier"
182 ),
183 }
184 }
185 if let Some(per_agent) = self.per_agent_ctx.get(agent) {
186 match per_agent.as_object() {
187 Some(obj) => {
188 for (k, v) in obj {
189 merged.insert(k.clone(), v.clone());
190 }
191 }
192 None => tracing::warn!(
193 agent = %agent,
194 value = %per_agent,
195 "AgentContextMiddleware: AgentMeta.ctx is not a JSON object; skipping this tier"
196 ),
197 }
198 }
199 merged
200 }
201}
202
203#[async_trait]
204impl SpawnerAdapter for AgentContextWrapped {
205 async fn spawn(
206 &self,
207 engine: &Engine,
208 ctx: &Ctx,
209 task_id: StepId,
210 attempt: u32,
211 token: CapToken,
212 ) -> Result<Box<dyn Worker>, SpawnError> {
213 let step_tier: serde_json::Map<String, Value> = match ctx.meta.runtime.get(STEP_CTX_KEY) {
221 Some(Value::Object(obj)) => obj.clone(),
222 Some(other) => {
223 tracing::warn!(
224 value = %other,
225 "AgentContextMiddleware: step_ctx runtime bundle is not a JSON object; skipping the Step tier"
226 );
227 serde_json::Map::new()
228 }
229 None => serde_json::Map::new(),
230 };
231
232 let merged = self.merge_ctx_tiers(&ctx.agent);
236
237 let mut new_ctx = ctx.clone();
244 for (k, v) in &step_tier {
245 new_ctx
246 .meta
247 .runtime
248 .entry(k.clone())
249 .or_insert_with(|| v.clone());
250 }
251 for (k, v) in &merged {
252 new_ctx
253 .meta
254 .runtime
255 .entry(k.clone())
256 .or_insert_with(|| v.clone());
257 }
258
259 let mut view = AgentContextView::from_ctx(&new_ctx);
265 for (k, v) in step_tier.iter().chain(merged.iter()) {
266 if !NAMED_RUNTIME_KEYS.contains(&k.as_str()) {
267 view.extra.entry(k.clone()).or_insert_with(|| v.clone());
268 }
269 }
270
271 let policy = self
274 .per_agent_policy
275 .get(&ctx.agent)
276 .or(self.default_policy.as_ref());
277 let view = match policy {
278 Some(p) => view.apply_policy(p),
279 None => view,
280 };
281
282 let view_for_state = view.clone();
294 let task_id_for_state = task_id.clone();
295 let policy_for_state = policy.cloned().unwrap_or_default();
296 engine
297 .with_state("agent_context.materialize", move |s| {
298 s.agent_ctx.insert(
299 (task_id_for_state, attempt),
300 crate::core::state::AgentCtxEntry {
301 view: view_for_state,
302 policy: policy_for_state,
303 },
304 );
305 })
306 .await
307 .map_err(|e| SpawnError::Internal(format!("agent_context state insert: {e}")))?;
308
309 match serde_json::to_value(&view) {
316 Ok(value) => {
317 new_ctx
318 .meta
319 .runtime
320 .insert(AGENT_CONTEXT_KEY.to_string(), value);
321 }
322 Err(e) => {
323 tracing::warn!(
324 error = %e,
325 task_id = %task_id,
326 "AgentContextView failed to serialize; proceeding without agent_context in ctx.meta.runtime"
327 );
328 }
329 }
330
331 let projection_placement = engine
343 .projection_placement_for(&task_id)
344 .await
345 .unwrap_or_default();
346 match serde_json::to_value(&*projection_placement) {
347 Ok(value) => {
348 new_ctx
349 .meta
350 .runtime
351 .insert(PROJECTION_PLACEMENT_KEY.to_string(), value);
352 }
353 Err(e) => {
354 tracing::warn!(
355 error = %e,
356 task_id = %task_id,
357 "ProjectionPlacement failed to serialize; proceeding without it in ctx.meta.runtime"
358 );
359 }
360 }
361
362 self.inner
363 .spawn(engine, &new_ctx, task_id, attempt, token)
364 .await
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::core::agent_context::{TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
372 use crate::core::config::EngineCfg;
373 use crate::types::Role;
374 use serde_json::Value;
375 use std::sync::Mutex;
376 use std::time::Duration;
377
378 struct CtxProbe {
381 seen: Arc<Mutex<Option<Ctx>>>,
382 }
383
384 #[async_trait]
385 impl SpawnerAdapter for CtxProbe {
386 async fn spawn(
387 &self,
388 _engine: &Engine,
389 ctx: &Ctx,
390 _task_id: StepId,
391 _attempt: u32,
392 _token: CapToken,
393 ) -> Result<Box<dyn Worker>, SpawnError> {
394 *self.seen.lock().unwrap() = Some(ctx.clone());
395 Err(SpawnError::Internal("probe stop".into()))
396 }
397 }
398
399 fn probe_stack(
400 layer: AgentContextMiddleware,
401 ) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
402 let seen = Arc::new(Mutex::new(None));
403 let inner = Arc::new(CtxProbe { seen: seen.clone() });
404 let wrapped = layer.wrap(inner);
405 (wrapped, seen)
406 }
407
408 #[tokio::test]
409 async fn snapshots_view_into_engine_state_agent_ctx() {
410 let (stack, _seen) = probe_stack(AgentContextMiddleware::default());
411 let engine = Engine::new(EngineCfg::default());
412 let task_id = StepId::parse("ST-1").unwrap();
413 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
414 ctx.meta.runtime.insert(
415 TASK_PROJECT_ROOT_KEY.to_string(),
416 Value::String("/repo".to_string()),
417 );
418 let token = engine
419 .attach("ut-op", Role::Operator, Duration::from_secs(30))
420 .await
421 .expect("attach");
422 let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
423
424 let snapshotted = engine
425 .with_state("test.read_agent_ctx", move |s| {
426 s.agent_ctx.get(&(task_id, 1)).cloned()
427 })
428 .await
429 .expect("with_state")
430 .expect("agent_ctx entry present")
431 .view;
432 assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
433 }
434
435 #[tokio::test]
444 async fn no_snapshotted_projection_placement_stashes_the_default() {
445 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
446 let engine = Engine::new(EngineCfg::default());
447 let task_id = StepId::parse("ST-14").unwrap();
448 let ctx = Ctx::new(task_id.clone(), 1, "planner");
449 let token = engine
450 .attach("ut-op", Role::Operator, Duration::from_secs(30))
451 .await
452 .expect("attach");
453 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
454
455 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
456 let raw = observed
457 .meta
458 .runtime
459 .get(PROJECTION_PLACEMENT_KEY)
460 .expect("projection_placement key present");
461 let placement: crate::core::projection_placement::ProjectionPlacement =
462 serde_json::from_value(raw.clone()).expect("round-trip");
463 assert_eq!(
464 placement,
465 crate::core::projection_placement::ProjectionPlacement::default()
466 );
467 }
468
469 #[tokio::test]
473 async fn snapshotted_projection_placement_is_stashed_into_ctx() {
474 use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
475
476 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
477 let engine = Engine::new(EngineCfg::default());
478 let task_id = StepId::parse("ST-15").unwrap();
479 let custom = ProjectionPlacement {
480 root_preference: RootPreference::ProjectRoot,
481 dir_template: "custom/{task_id}/out".to_string(),
482 };
483 let task_id_for_seed = task_id.clone();
484 let custom_for_seed = std::sync::Arc::new(custom.clone());
485 engine
486 .with_state("test.seed_projection_placement", move |s| {
487 s.projection_placements
488 .insert(task_id_for_seed, custom_for_seed);
489 })
490 .await
491 .expect("seed projection_placements");
492
493 let ctx = Ctx::new(task_id.clone(), 1, "planner");
494 let token = engine
495 .attach("ut-op", Role::Operator, Duration::from_secs(30))
496 .await
497 .expect("attach");
498 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
499
500 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
501 let raw = observed
502 .meta
503 .runtime
504 .get(PROJECTION_PLACEMENT_KEY)
505 .expect("projection_placement key present");
506 let placement: ProjectionPlacement =
507 serde_json::from_value(raw.clone()).expect("round-trip");
508 assert_eq!(placement, custom);
509 }
510
511 #[tokio::test]
512 async fn inner_spawner_observes_agent_context_key_in_ctx() {
513 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
514 let engine = Engine::new(EngineCfg::default());
515 let task_id = StepId::parse("ST-2").unwrap();
516 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
517 ctx.meta.runtime.insert(
518 TASK_WORK_DIR_KEY.to_string(),
519 Value::String("/repo/work".to_string()),
520 );
521 let token = engine
522 .attach("ut-op", Role::Operator, Duration::from_secs(30))
523 .await
524 .expect("attach");
525 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
526
527 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
528 let raw = observed
529 .meta
530 .runtime
531 .get(AGENT_CONTEXT_KEY)
532 .expect("agent_context key present");
533 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
534 assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
535 }
536
537 #[tokio::test]
538 async fn policy_exclude_is_reflected_in_both_state_and_ctx() {
539 let policy = ContextPolicy {
540 include: None,
541 exclude: vec!["work_dir".to_string()],
542 ..Default::default()
543 };
544 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
545 None,
546 HashMap::new(),
547 Some(policy),
548 HashMap::new(),
549 ));
550 let engine = Engine::new(EngineCfg::default());
551 let task_id = StepId::parse("ST-3").unwrap();
552 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
553 ctx.meta.runtime.insert(
554 TASK_PROJECT_ROOT_KEY.to_string(),
555 Value::String("/repo".to_string()),
556 );
557 ctx.meta.runtime.insert(
558 TASK_WORK_DIR_KEY.to_string(),
559 Value::String("/repo/work".to_string()),
560 );
561 let token = engine
562 .attach("ut-op", Role::Operator, Duration::from_secs(30))
563 .await
564 .expect("attach");
565 let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
566
567 let snapshotted = engine
568 .with_state("test.read_agent_ctx_excluded", move |s| {
569 s.agent_ctx.get(&(task_id, 1)).cloned()
570 })
571 .await
572 .expect("with_state")
573 .expect("agent_ctx entry present")
574 .view;
575 assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
576 assert!(snapshotted.work_dir.is_none());
577
578 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
579 let raw = observed
580 .meta
581 .runtime
582 .get(AGENT_CONTEXT_KEY)
583 .expect("agent_context key present");
584 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
585 assert_eq!(view.project_root.as_deref(), Some("/repo"));
586 assert!(view.work_dir.is_none());
587 }
588
589 #[tokio::test]
599 async fn precedence_pre_inserted_runtime_key_survives_agent_declared_default() {
600 let mut per_agent_ctx = HashMap::new();
601 per_agent_ctx.insert(
602 "planner".to_string(),
603 serde_json::json!({ "work_dir": "/agent-declared" }),
604 );
605 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
606 None,
607 per_agent_ctx,
608 None,
609 HashMap::new(),
610 ));
611 let engine = Engine::new(EngineCfg::default());
612 let task_id = StepId::parse("ST-4").unwrap();
613 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
614 ctx.meta.runtime.insert(
615 TASK_WORK_DIR_KEY.to_string(),
616 Value::String("/task-declared".to_string()),
617 );
618 let token = engine
619 .attach("ut-op", Role::Operator, Duration::from_secs(30))
620 .await
621 .expect("attach");
622 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
623
624 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
625 let raw = observed
626 .meta
627 .runtime
628 .get(AGENT_CONTEXT_KEY)
629 .expect("agent_context key present");
630 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
631 assert_eq!(
632 view.work_dir.as_deref(),
633 Some("/task-declared"),
634 "an already-present runtime key must win over the agent-declared default"
635 );
636 }
637
638 #[tokio::test]
641 async fn precedence_absent_runtime_key_gets_agent_declared_default() {
642 let mut per_agent_ctx = HashMap::new();
643 per_agent_ctx.insert(
644 "planner".to_string(),
645 serde_json::json!({ "work_dir": "/agent-declared" }),
646 );
647 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
648 None,
649 per_agent_ctx,
650 None,
651 HashMap::new(),
652 ));
653 let engine = Engine::new(EngineCfg::default());
654 let task_id = StepId::parse("ST-5").unwrap();
655 let ctx = Ctx::new(task_id.clone(), 1, "planner");
656 let token = engine
657 .attach("ut-op", Role::Operator, Duration::from_secs(30))
658 .await
659 .expect("attach");
660 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
661
662 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
663 let raw = observed
664 .meta
665 .runtime
666 .get(AGENT_CONTEXT_KEY)
667 .expect("agent_context key present");
668 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
669 assert_eq!(view.work_dir.as_deref(), Some("/agent-declared"));
670 }
671
672 #[tokio::test]
674 async fn agent_ctx_wins_over_global_on_key_collision() {
675 let mut per_agent_ctx = HashMap::new();
676 per_agent_ctx.insert(
677 "planner".to_string(),
678 serde_json::json!({ "work_dir": "/agent" }),
679 );
680 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
681 Some(serde_json::json!({ "work_dir": "/global" })),
682 per_agent_ctx,
683 None,
684 HashMap::new(),
685 ));
686 let engine = Engine::new(EngineCfg::default());
687 let task_id = StepId::parse("ST-6").unwrap();
688 let ctx = Ctx::new(task_id.clone(), 1, "planner");
689 let token = engine
690 .attach("ut-op", Role::Operator, Duration::from_secs(30))
691 .await
692 .expect("attach");
693 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
694
695 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
696 let raw = observed
697 .meta
698 .runtime
699 .get(AGENT_CONTEXT_KEY)
700 .expect("agent_context key present");
701 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
702 assert_eq!(view.work_dir.as_deref(), Some("/agent"));
703 }
704
705 #[tokio::test]
710 async fn unknown_key_reaches_runtime_extra_directive_and_serialized_json() {
711 let mut per_agent_ctx = HashMap::new();
712 per_agent_ctx.insert(
713 "planner".to_string(),
714 serde_json::json!({ "org_conventions": "x" }),
715 );
716 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
717 None,
718 per_agent_ctx,
719 None,
720 HashMap::new(),
721 ));
722 let engine = Engine::new(EngineCfg::default());
723 let task_id = StepId::parse("ST-7").unwrap();
724 let ctx = Ctx::new(task_id.clone(), 1, "planner");
725 let token = engine
726 .attach("ut-op", Role::Operator, Duration::from_secs(30))
727 .await
728 .expect("attach");
729 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
730
731 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
732
733 assert_eq!(
735 observed.meta.runtime.get("org_conventions"),
736 Some(&Value::String("x".to_string())),
737 "unknown key must land in ctx.meta.runtime too (in-process workers read ctx directly)"
738 );
739
740 let raw = observed
741 .meta
742 .runtime
743 .get(AGENT_CONTEXT_KEY)
744 .expect("agent_context key present");
745
746 assert_eq!(
748 raw.get("extra").and_then(|e| e.get("org_conventions")),
749 Some(&Value::String("x".to_string()))
750 );
751
752 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
753
754 assert_eq!(
756 view.extra.get("org_conventions"),
757 Some(&Value::String("x".to_string()))
758 );
759
760 let header = view.to_directive_header();
762 assert!(
763 header.contains("org_conventions: \"x\"\n"),
764 "header must render the unknown extra key: {header}"
765 );
766 }
767
768 #[tokio::test]
772 async fn per_agent_policy_outranks_default_and_other_agents_fall_through() {
773 let default_policy = ContextPolicy {
774 include: None,
775 exclude: vec!["work_dir".to_string()],
776 ..Default::default()
777 };
778 let mut per_agent_policy = HashMap::new();
779 per_agent_policy.insert(
780 "planner".to_string(),
781 ContextPolicy {
782 include: None,
783 exclude: vec![],
784 ..Default::default()
785 },
786 );
787
788 let engine = Engine::new(EngineCfg::default());
789
790 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
793 None,
794 HashMap::new(),
795 Some(default_policy.clone()),
796 per_agent_policy.clone(),
797 ));
798 let task_id = StepId::parse("ST-8a").unwrap();
799 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
800 ctx.meta.runtime.insert(
801 TASK_WORK_DIR_KEY.to_string(),
802 Value::String("/repo/work".to_string()),
803 );
804 let token = engine
805 .attach("ut-op-a", Role::Operator, Duration::from_secs(30))
806 .await
807 .expect("attach");
808 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
809 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
810 let raw = observed
811 .meta
812 .runtime
813 .get(AGENT_CONTEXT_KEY)
814 .expect("agent_context key present");
815 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
816 assert_eq!(
817 view.work_dir.as_deref(),
818 Some("/repo/work"),
819 "per-agent pass-all override must win over the BP-global exclude"
820 );
821
822 let (stack2, seen2) = probe_stack(AgentContextMiddleware::new(
825 None,
826 HashMap::new(),
827 Some(default_policy),
828 per_agent_policy,
829 ));
830 let task_id2 = StepId::parse("ST-8b").unwrap();
831 let mut ctx2 = Ctx::new(task_id2.clone(), 1, "other");
832 ctx2.meta.runtime.insert(
833 TASK_WORK_DIR_KEY.to_string(),
834 Value::String("/repo/work".to_string()),
835 );
836 let token2 = engine
837 .attach("ut-op-b", Role::Operator, Duration::from_secs(30))
838 .await
839 .expect("attach");
840 let _ = stack2.spawn(&engine, &ctx2, task_id2, 1, token2).await;
841 let observed2 = seen2.lock().unwrap().clone().expect("inner ctx captured");
842 let raw2 = observed2
843 .meta
844 .runtime
845 .get(AGENT_CONTEXT_KEY)
846 .expect("agent_context key present");
847 let view2: AgentContextView = serde_json::from_value(raw2.clone()).expect("round-trip");
848 assert!(
849 view2.work_dir.is_none(),
850 "an agent without a per-agent override must fall through to the BP-global exclude"
851 );
852 }
853
854 #[tokio::test]
858 async fn non_object_ctx_tier_value_is_skipped_and_spawn_still_proceeds() {
859 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
860 Some(Value::String("not-an-object".to_string())),
861 HashMap::new(),
862 None,
863 HashMap::new(),
864 ));
865 let engine = Engine::new(EngineCfg::default());
866 let task_id = StepId::parse("ST-9").unwrap();
867 let ctx = Ctx::new(task_id.clone(), 1, "planner");
868 let token = engine
869 .attach("ut-op", Role::Operator, Duration::from_secs(30))
870 .await
871 .expect("attach");
872 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
877
878 let observed = seen.lock().unwrap().clone();
879 assert!(
880 observed.is_some(),
881 "a non-Object tier value must not stop the spawn from reaching inner"
882 );
883 }
884
885 #[tokio::test]
892 async fn step_key_beats_agent_declared_same_key() {
893 let mut per_agent_ctx = HashMap::new();
894 per_agent_ctx.insert(
895 "planner".to_string(),
896 serde_json::json!({ "work_dir": "/agent-declared" }),
897 );
898 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
899 None,
900 per_agent_ctx,
901 None,
902 HashMap::new(),
903 ));
904 let engine = Engine::new(EngineCfg::default());
905 let task_id = StepId::parse("ST-10").unwrap();
906 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
907 ctx.meta.runtime.insert(
908 STEP_CTX_KEY.to_string(),
909 serde_json::json!({ "work_dir": "/step-declared" }),
910 );
911 let token = engine
912 .attach("ut-op", Role::Operator, Duration::from_secs(30))
913 .await
914 .expect("attach");
915 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
916
917 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
918 let raw = observed
919 .meta
920 .runtime
921 .get(AGENT_CONTEXT_KEY)
922 .expect("agent_context key present");
923 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
924 assert_eq!(
925 view.work_dir.as_deref(),
926 Some("/step-declared"),
927 "the Step tier must win over an agent-declared default for the same key"
928 );
929 }
930
931 #[tokio::test]
934 async fn task_preinserted_key_beats_step_tier() {
935 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
936 let engine = Engine::new(EngineCfg::default());
937 let task_id = StepId::parse("ST-11").unwrap();
938 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
939 ctx.meta.runtime.insert(
942 TASK_WORK_DIR_KEY.to_string(),
943 Value::String("/task-declared".to_string()),
944 );
945 ctx.meta.runtime.insert(
946 STEP_CTX_KEY.to_string(),
947 serde_json::json!({ "work_dir": "/step-declared" }),
948 );
949 let token = engine
950 .attach("ut-op", Role::Operator, Duration::from_secs(30))
951 .await
952 .expect("attach");
953 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
954
955 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
956 let raw = observed
957 .meta
958 .runtime
959 .get(AGENT_CONTEXT_KEY)
960 .expect("agent_context key present");
961 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
962 assert_eq!(
963 view.work_dir.as_deref(),
964 Some("/task-declared"),
965 "an already-present (Task-tier) runtime key must win over the Step tier"
966 );
967 }
968
969 #[tokio::test]
974 async fn unknown_step_key_reaches_runtime_extra_directive_and_serialized_json() {
975 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
976 let engine = Engine::new(EngineCfg::default());
977 let task_id = StepId::parse("ST-12").unwrap();
978 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
979 ctx.meta.runtime.insert(
980 STEP_CTX_KEY.to_string(),
981 serde_json::json!({ "loop_idx": 2 }),
982 );
983 let token = engine
984 .attach("ut-op", Role::Operator, Duration::from_secs(30))
985 .await
986 .expect("attach");
987 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
988
989 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
990
991 assert_eq!(
993 observed.meta.runtime.get("loop_idx"),
994 Some(&serde_json::json!(2)),
995 "unknown step key must land in ctx.meta.runtime too"
996 );
997
998 let raw = observed
999 .meta
1000 .runtime
1001 .get(AGENT_CONTEXT_KEY)
1002 .expect("agent_context key present");
1003
1004 assert_eq!(
1006 raw.get("extra").and_then(|e| e.get("loop_idx")),
1007 Some(&serde_json::json!(2))
1008 );
1009
1010 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
1011
1012 assert_eq!(view.extra.get("loop_idx"), Some(&serde_json::json!(2)));
1014
1015 let header = view.to_directive_header();
1017 assert!(
1018 header.contains("loop_idx: 2\n"),
1019 "header must render the unknown Step-tier extra key: {header}"
1020 );
1021
1022 assert!(observed.meta.runtime.contains_key(STEP_CTX_KEY));
1026 assert!(!view.extra.contains_key(STEP_CTX_KEY));
1027 }
1028
1029 #[tokio::test]
1032 async fn no_step_ctx_key_behaves_identically_to_phase_1() {
1033 let mut per_agent_ctx = HashMap::new();
1034 per_agent_ctx.insert(
1035 "planner".to_string(),
1036 serde_json::json!({ "work_dir": "/agent-declared" }),
1037 );
1038 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
1039 None,
1040 per_agent_ctx,
1041 None,
1042 HashMap::new(),
1043 ));
1044 let engine = Engine::new(EngineCfg::default());
1045 let task_id = StepId::parse("ST-13").unwrap();
1046 let ctx = Ctx::new(task_id.clone(), 1, "planner");
1047 let token = engine
1048 .attach("ut-op", Role::Operator, Duration::from_secs(30))
1049 .await
1050 .expect("attach");
1051 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
1052
1053 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
1054 let raw = observed
1055 .meta
1056 .runtime
1057 .get(AGENT_CONTEXT_KEY)
1058 .expect("agent_context key present");
1059 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
1060 assert_eq!(
1061 view.work_dir.as_deref(),
1062 Some("/agent-declared"),
1063 "absent STEP_CTX_KEY must fall straight through to the Agent tier, unchanged from Phase 1"
1064 );
1065 }
1066}