1use crate::core::agent_context::{
68 AgentContextView, ContextPolicy, AGENT_CONTEXT_KEY, PROJECT_NAME_ALIAS_KEY, RUN_ID_KEY,
69 STEP_CTX_KEY, TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY,
70};
71use crate::core::ctx::Ctx;
72use crate::core::engine::Engine;
73use crate::middleware::SpawnerLayer;
74use crate::types::{CapToken, StepId};
75use crate::worker::adapter::{SpawnError, SpawnerAdapter};
76use crate::worker::Worker;
77use async_trait::async_trait;
78use serde_json::Value;
79use std::collections::HashMap;
80use std::sync::Arc;
81
82const NAMED_RUNTIME_KEYS: [&str; 5] = [
87 TASK_PROJECT_ROOT_KEY,
88 TASK_WORK_DIR_KEY,
89 TASK_METADATA_KEY,
90 RUN_ID_KEY,
91 PROJECT_NAME_ALIAS_KEY,
92];
93
94pub struct AgentContextMiddleware {
100 global_ctx: Option<Value>,
103 per_agent_ctx: HashMap<String, Value>,
106 default_policy: Option<ContextPolicy>,
109 per_agent_policy: HashMap<String, ContextPolicy>,
113}
114
115impl AgentContextMiddleware {
116 pub fn new(
120 global_ctx: Option<Value>,
121 per_agent_ctx: HashMap<String, Value>,
122 default_policy: Option<ContextPolicy>,
123 per_agent_policy: HashMap<String, ContextPolicy>,
124 ) -> Self {
125 Self {
126 global_ctx,
127 per_agent_ctx,
128 default_policy,
129 per_agent_policy,
130 }
131 }
132}
133
134impl Default for AgentContextMiddleware {
135 fn default() -> Self {
139 Self::new(None, HashMap::new(), None, HashMap::new())
140 }
141}
142
143impl SpawnerLayer for AgentContextMiddleware {
144 fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
145 Arc::new(AgentContextWrapped {
146 inner,
147 global_ctx: self.global_ctx.clone(),
148 per_agent_ctx: self.per_agent_ctx.clone(),
149 default_policy: self.default_policy.clone(),
150 per_agent_policy: self.per_agent_policy.clone(),
151 })
152 }
153}
154
155struct AgentContextWrapped {
156 inner: Arc<dyn SpawnerAdapter>,
157 global_ctx: Option<Value>,
158 per_agent_ctx: HashMap<String, Value>,
159 default_policy: Option<ContextPolicy>,
160 per_agent_policy: HashMap<String, ContextPolicy>,
161}
162
163impl AgentContextWrapped {
164 fn merge_ctx_tiers(&self, agent: &str) -> serde_json::Map<String, Value> {
169 let mut merged = serde_json::Map::new();
170 if let Some(global) = &self.global_ctx {
171 match global.as_object() {
172 Some(obj) => {
173 for (k, v) in obj {
174 merged.insert(k.clone(), v.clone());
175 }
176 }
177 None => tracing::warn!(
178 value = %global,
179 "AgentContextMiddleware: default_agent_ctx is not a JSON object; skipping this tier"
180 ),
181 }
182 }
183 if let Some(per_agent) = self.per_agent_ctx.get(agent) {
184 match per_agent.as_object() {
185 Some(obj) => {
186 for (k, v) in obj {
187 merged.insert(k.clone(), v.clone());
188 }
189 }
190 None => tracing::warn!(
191 agent = %agent,
192 value = %per_agent,
193 "AgentContextMiddleware: AgentMeta.ctx is not a JSON object; skipping this tier"
194 ),
195 }
196 }
197 merged
198 }
199}
200
201#[async_trait]
202impl SpawnerAdapter for AgentContextWrapped {
203 async fn spawn(
204 &self,
205 engine: &Engine,
206 ctx: &Ctx,
207 task_id: StepId,
208 attempt: u32,
209 token: CapToken,
210 ) -> Result<Box<dyn Worker>, SpawnError> {
211 let step_tier: serde_json::Map<String, Value> = match ctx.meta.runtime.get(STEP_CTX_KEY) {
219 Some(Value::Object(obj)) => obj.clone(),
220 Some(other) => {
221 tracing::warn!(
222 value = %other,
223 "AgentContextMiddleware: step_ctx runtime bundle is not a JSON object; skipping the Step tier"
224 );
225 serde_json::Map::new()
226 }
227 None => serde_json::Map::new(),
228 };
229
230 let merged = self.merge_ctx_tiers(&ctx.agent);
234
235 let mut new_ctx = ctx.clone();
242 for (k, v) in &step_tier {
243 new_ctx
244 .meta
245 .runtime
246 .entry(k.clone())
247 .or_insert_with(|| v.clone());
248 }
249 for (k, v) in &merged {
250 new_ctx
251 .meta
252 .runtime
253 .entry(k.clone())
254 .or_insert_with(|| v.clone());
255 }
256
257 let mut view = AgentContextView::from_ctx(&new_ctx);
263 for (k, v) in step_tier.iter().chain(merged.iter()) {
264 if !NAMED_RUNTIME_KEYS.contains(&k.as_str()) {
265 view.extra.entry(k.clone()).or_insert_with(|| v.clone());
266 }
267 }
268
269 let policy = self
272 .per_agent_policy
273 .get(&ctx.agent)
274 .or(self.default_policy.as_ref());
275 let view = match policy {
276 Some(p) => view.apply_policy(p),
277 None => view,
278 };
279
280 let view_for_state = view.clone();
290 let task_id_for_state = task_id.clone();
291 let policy_for_state = policy.cloned().unwrap_or_default();
292 engine
293 .with_state("agent_context.materialize", move |s| {
294 s.agent_contexts
295 .insert((task_id_for_state.clone(), attempt), view_for_state);
296 s.context_policies
297 .insert((task_id_for_state, attempt), policy_for_state);
298 })
299 .await
300 .map_err(|e| SpawnError::Internal(format!("agent_context state insert: {e}")))?;
301
302 match serde_json::to_value(&view) {
309 Ok(value) => {
310 new_ctx
311 .meta
312 .runtime
313 .insert(AGENT_CONTEXT_KEY.to_string(), value);
314 }
315 Err(e) => {
316 tracing::warn!(
317 error = %e,
318 task_id = %task_id,
319 "AgentContextView failed to serialize; proceeding without agent_context in ctx.meta.runtime"
320 );
321 }
322 }
323
324 self.inner
325 .spawn(engine, &new_ctx, task_id, attempt, token)
326 .await
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::core::agent_context::{TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
334 use crate::core::config::EngineCfg;
335 use crate::types::Role;
336 use serde_json::Value;
337 use std::sync::Mutex;
338 use std::time::Duration;
339
340 struct CtxProbe {
343 seen: Arc<Mutex<Option<Ctx>>>,
344 }
345
346 #[async_trait]
347 impl SpawnerAdapter for CtxProbe {
348 async fn spawn(
349 &self,
350 _engine: &Engine,
351 ctx: &Ctx,
352 _task_id: StepId,
353 _attempt: u32,
354 _token: CapToken,
355 ) -> Result<Box<dyn Worker>, SpawnError> {
356 *self.seen.lock().unwrap() = Some(ctx.clone());
357 Err(SpawnError::Internal("probe stop".into()))
358 }
359 }
360
361 fn probe_stack(
362 layer: AgentContextMiddleware,
363 ) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
364 let seen = Arc::new(Mutex::new(None));
365 let inner = Arc::new(CtxProbe { seen: seen.clone() });
366 let wrapped = layer.wrap(inner);
367 (wrapped, seen)
368 }
369
370 #[tokio::test]
371 async fn snapshots_view_into_engine_state_agent_contexts() {
372 let (stack, _seen) = probe_stack(AgentContextMiddleware::default());
373 let engine = Engine::new(EngineCfg::default());
374 let task_id = StepId::parse("ST-1").unwrap();
375 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
376 ctx.meta.runtime.insert(
377 TASK_PROJECT_ROOT_KEY.to_string(),
378 Value::String("/repo".to_string()),
379 );
380 let token = engine
381 .attach("ut-op", Role::Operator, Duration::from_secs(30))
382 .await
383 .expect("attach");
384 let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
385
386 let snapshotted = engine
387 .with_state("test.read_agent_contexts", move |s| {
388 s.agent_contexts.get(&(task_id, 1)).cloned()
389 })
390 .await
391 .expect("with_state")
392 .expect("agent_contexts entry present");
393 assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
394 }
395
396 #[tokio::test]
397 async fn inner_spawner_observes_agent_context_key_in_ctx() {
398 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
399 let engine = Engine::new(EngineCfg::default());
400 let task_id = StepId::parse("ST-2").unwrap();
401 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
402 ctx.meta.runtime.insert(
403 TASK_WORK_DIR_KEY.to_string(),
404 Value::String("/repo/work".to_string()),
405 );
406 let token = engine
407 .attach("ut-op", Role::Operator, Duration::from_secs(30))
408 .await
409 .expect("attach");
410 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
411
412 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
413 let raw = observed
414 .meta
415 .runtime
416 .get(AGENT_CONTEXT_KEY)
417 .expect("agent_context key present");
418 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
419 assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
420 }
421
422 #[tokio::test]
423 async fn policy_exclude_is_reflected_in_both_state_and_ctx() {
424 let policy = ContextPolicy {
425 include: None,
426 exclude: vec!["work_dir".to_string()],
427 ..Default::default()
428 };
429 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
430 None,
431 HashMap::new(),
432 Some(policy),
433 HashMap::new(),
434 ));
435 let engine = Engine::new(EngineCfg::default());
436 let task_id = StepId::parse("ST-3").unwrap();
437 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
438 ctx.meta.runtime.insert(
439 TASK_PROJECT_ROOT_KEY.to_string(),
440 Value::String("/repo".to_string()),
441 );
442 ctx.meta.runtime.insert(
443 TASK_WORK_DIR_KEY.to_string(),
444 Value::String("/repo/work".to_string()),
445 );
446 let token = engine
447 .attach("ut-op", Role::Operator, Duration::from_secs(30))
448 .await
449 .expect("attach");
450 let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
451
452 let snapshotted = engine
453 .with_state("test.read_agent_contexts_excluded", move |s| {
454 s.agent_contexts.get(&(task_id, 1)).cloned()
455 })
456 .await
457 .expect("with_state")
458 .expect("agent_contexts entry present");
459 assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
460 assert!(snapshotted.work_dir.is_none());
461
462 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
463 let raw = observed
464 .meta
465 .runtime
466 .get(AGENT_CONTEXT_KEY)
467 .expect("agent_context key present");
468 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
469 assert_eq!(view.project_root.as_deref(), Some("/repo"));
470 assert!(view.work_dir.is_none());
471 }
472
473 #[tokio::test]
483 async fn precedence_pre_inserted_runtime_key_survives_agent_declared_default() {
484 let mut per_agent_ctx = HashMap::new();
485 per_agent_ctx.insert(
486 "planner".to_string(),
487 serde_json::json!({ "work_dir": "/agent-declared" }),
488 );
489 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
490 None,
491 per_agent_ctx,
492 None,
493 HashMap::new(),
494 ));
495 let engine = Engine::new(EngineCfg::default());
496 let task_id = StepId::parse("ST-4").unwrap();
497 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
498 ctx.meta.runtime.insert(
499 TASK_WORK_DIR_KEY.to_string(),
500 Value::String("/task-declared".to_string()),
501 );
502 let token = engine
503 .attach("ut-op", Role::Operator, Duration::from_secs(30))
504 .await
505 .expect("attach");
506 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
507
508 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
509 let raw = observed
510 .meta
511 .runtime
512 .get(AGENT_CONTEXT_KEY)
513 .expect("agent_context key present");
514 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
515 assert_eq!(
516 view.work_dir.as_deref(),
517 Some("/task-declared"),
518 "an already-present runtime key must win over the agent-declared default"
519 );
520 }
521
522 #[tokio::test]
525 async fn precedence_absent_runtime_key_gets_agent_declared_default() {
526 let mut per_agent_ctx = HashMap::new();
527 per_agent_ctx.insert(
528 "planner".to_string(),
529 serde_json::json!({ "work_dir": "/agent-declared" }),
530 );
531 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
532 None,
533 per_agent_ctx,
534 None,
535 HashMap::new(),
536 ));
537 let engine = Engine::new(EngineCfg::default());
538 let task_id = StepId::parse("ST-5").unwrap();
539 let ctx = Ctx::new(task_id.clone(), 1, "planner");
540 let token = engine
541 .attach("ut-op", Role::Operator, Duration::from_secs(30))
542 .await
543 .expect("attach");
544 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
545
546 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
547 let raw = observed
548 .meta
549 .runtime
550 .get(AGENT_CONTEXT_KEY)
551 .expect("agent_context key present");
552 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
553 assert_eq!(view.work_dir.as_deref(), Some("/agent-declared"));
554 }
555
556 #[tokio::test]
558 async fn agent_ctx_wins_over_global_on_key_collision() {
559 let mut per_agent_ctx = HashMap::new();
560 per_agent_ctx.insert(
561 "planner".to_string(),
562 serde_json::json!({ "work_dir": "/agent" }),
563 );
564 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
565 Some(serde_json::json!({ "work_dir": "/global" })),
566 per_agent_ctx,
567 None,
568 HashMap::new(),
569 ));
570 let engine = Engine::new(EngineCfg::default());
571 let task_id = StepId::parse("ST-6").unwrap();
572 let ctx = Ctx::new(task_id.clone(), 1, "planner");
573 let token = engine
574 .attach("ut-op", Role::Operator, Duration::from_secs(30))
575 .await
576 .expect("attach");
577 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
578
579 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
580 let raw = observed
581 .meta
582 .runtime
583 .get(AGENT_CONTEXT_KEY)
584 .expect("agent_context key present");
585 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
586 assert_eq!(view.work_dir.as_deref(), Some("/agent"));
587 }
588
589 #[tokio::test]
594 async fn unknown_key_reaches_runtime_extra_directive_and_serialized_json() {
595 let mut per_agent_ctx = HashMap::new();
596 per_agent_ctx.insert(
597 "planner".to_string(),
598 serde_json::json!({ "org_conventions": "x" }),
599 );
600 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
601 None,
602 per_agent_ctx,
603 None,
604 HashMap::new(),
605 ));
606 let engine = Engine::new(EngineCfg::default());
607 let task_id = StepId::parse("ST-7").unwrap();
608 let ctx = Ctx::new(task_id.clone(), 1, "planner");
609 let token = engine
610 .attach("ut-op", Role::Operator, Duration::from_secs(30))
611 .await
612 .expect("attach");
613 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
614
615 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
616
617 assert_eq!(
619 observed.meta.runtime.get("org_conventions"),
620 Some(&Value::String("x".to_string())),
621 "unknown key must land in ctx.meta.runtime too (in-process workers read ctx directly)"
622 );
623
624 let raw = observed
625 .meta
626 .runtime
627 .get(AGENT_CONTEXT_KEY)
628 .expect("agent_context key present");
629
630 assert_eq!(
632 raw.get("extra").and_then(|e| e.get("org_conventions")),
633 Some(&Value::String("x".to_string()))
634 );
635
636 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
637
638 assert_eq!(
640 view.extra.get("org_conventions"),
641 Some(&Value::String("x".to_string()))
642 );
643
644 let header = view.to_directive_header();
646 assert!(
647 header.contains("org_conventions: \"x\"\n"),
648 "header must render the unknown extra key: {header}"
649 );
650 }
651
652 #[tokio::test]
656 async fn per_agent_policy_outranks_default_and_other_agents_fall_through() {
657 let default_policy = ContextPolicy {
658 include: None,
659 exclude: vec!["work_dir".to_string()],
660 ..Default::default()
661 };
662 let mut per_agent_policy = HashMap::new();
663 per_agent_policy.insert(
664 "planner".to_string(),
665 ContextPolicy {
666 include: None,
667 exclude: vec![],
668 ..Default::default()
669 },
670 );
671
672 let engine = Engine::new(EngineCfg::default());
673
674 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
677 None,
678 HashMap::new(),
679 Some(default_policy.clone()),
680 per_agent_policy.clone(),
681 ));
682 let task_id = StepId::parse("ST-8a").unwrap();
683 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
684 ctx.meta.runtime.insert(
685 TASK_WORK_DIR_KEY.to_string(),
686 Value::String("/repo/work".to_string()),
687 );
688 let token = engine
689 .attach("ut-op-a", Role::Operator, Duration::from_secs(30))
690 .await
691 .expect("attach");
692 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
693 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
694 let raw = observed
695 .meta
696 .runtime
697 .get(AGENT_CONTEXT_KEY)
698 .expect("agent_context key present");
699 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
700 assert_eq!(
701 view.work_dir.as_deref(),
702 Some("/repo/work"),
703 "per-agent pass-all override must win over the BP-global exclude"
704 );
705
706 let (stack2, seen2) = probe_stack(AgentContextMiddleware::new(
709 None,
710 HashMap::new(),
711 Some(default_policy),
712 per_agent_policy,
713 ));
714 let task_id2 = StepId::parse("ST-8b").unwrap();
715 let mut ctx2 = Ctx::new(task_id2.clone(), 1, "other");
716 ctx2.meta.runtime.insert(
717 TASK_WORK_DIR_KEY.to_string(),
718 Value::String("/repo/work".to_string()),
719 );
720 let token2 = engine
721 .attach("ut-op-b", Role::Operator, Duration::from_secs(30))
722 .await
723 .expect("attach");
724 let _ = stack2.spawn(&engine, &ctx2, task_id2, 1, token2).await;
725 let observed2 = seen2.lock().unwrap().clone().expect("inner ctx captured");
726 let raw2 = observed2
727 .meta
728 .runtime
729 .get(AGENT_CONTEXT_KEY)
730 .expect("agent_context key present");
731 let view2: AgentContextView = serde_json::from_value(raw2.clone()).expect("round-trip");
732 assert!(
733 view2.work_dir.is_none(),
734 "an agent without a per-agent override must fall through to the BP-global exclude"
735 );
736 }
737
738 #[tokio::test]
742 async fn non_object_ctx_tier_value_is_skipped_and_spawn_still_proceeds() {
743 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
744 Some(Value::String("not-an-object".to_string())),
745 HashMap::new(),
746 None,
747 HashMap::new(),
748 ));
749 let engine = Engine::new(EngineCfg::default());
750 let task_id = StepId::parse("ST-9").unwrap();
751 let ctx = Ctx::new(task_id.clone(), 1, "planner");
752 let token = engine
753 .attach("ut-op", Role::Operator, Duration::from_secs(30))
754 .await
755 .expect("attach");
756 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
761
762 let observed = seen.lock().unwrap().clone();
763 assert!(
764 observed.is_some(),
765 "a non-Object tier value must not stop the spawn from reaching inner"
766 );
767 }
768
769 #[tokio::test]
776 async fn step_key_beats_agent_declared_same_key() {
777 let mut per_agent_ctx = HashMap::new();
778 per_agent_ctx.insert(
779 "planner".to_string(),
780 serde_json::json!({ "work_dir": "/agent-declared" }),
781 );
782 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
783 None,
784 per_agent_ctx,
785 None,
786 HashMap::new(),
787 ));
788 let engine = Engine::new(EngineCfg::default());
789 let task_id = StepId::parse("ST-10").unwrap();
790 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
791 ctx.meta.runtime.insert(
792 STEP_CTX_KEY.to_string(),
793 serde_json::json!({ "work_dir": "/step-declared" }),
794 );
795 let token = engine
796 .attach("ut-op", Role::Operator, Duration::from_secs(30))
797 .await
798 .expect("attach");
799 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
800
801 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
802 let raw = observed
803 .meta
804 .runtime
805 .get(AGENT_CONTEXT_KEY)
806 .expect("agent_context key present");
807 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
808 assert_eq!(
809 view.work_dir.as_deref(),
810 Some("/step-declared"),
811 "the Step tier must win over an agent-declared default for the same key"
812 );
813 }
814
815 #[tokio::test]
818 async fn task_preinserted_key_beats_step_tier() {
819 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
820 let engine = Engine::new(EngineCfg::default());
821 let task_id = StepId::parse("ST-11").unwrap();
822 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
823 ctx.meta.runtime.insert(
826 TASK_WORK_DIR_KEY.to_string(),
827 Value::String("/task-declared".to_string()),
828 );
829 ctx.meta.runtime.insert(
830 STEP_CTX_KEY.to_string(),
831 serde_json::json!({ "work_dir": "/step-declared" }),
832 );
833 let token = engine
834 .attach("ut-op", Role::Operator, Duration::from_secs(30))
835 .await
836 .expect("attach");
837 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
838
839 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
840 let raw = observed
841 .meta
842 .runtime
843 .get(AGENT_CONTEXT_KEY)
844 .expect("agent_context key present");
845 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
846 assert_eq!(
847 view.work_dir.as_deref(),
848 Some("/task-declared"),
849 "an already-present (Task-tier) runtime key must win over the Step tier"
850 );
851 }
852
853 #[tokio::test]
858 async fn unknown_step_key_reaches_runtime_extra_directive_and_serialized_json() {
859 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
860 let engine = Engine::new(EngineCfg::default());
861 let task_id = StepId::parse("ST-12").unwrap();
862 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
863 ctx.meta.runtime.insert(
864 STEP_CTX_KEY.to_string(),
865 serde_json::json!({ "loop_idx": 2 }),
866 );
867 let token = engine
868 .attach("ut-op", Role::Operator, Duration::from_secs(30))
869 .await
870 .expect("attach");
871 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
872
873 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
874
875 assert_eq!(
877 observed.meta.runtime.get("loop_idx"),
878 Some(&serde_json::json!(2)),
879 "unknown step key must land in ctx.meta.runtime too"
880 );
881
882 let raw = observed
883 .meta
884 .runtime
885 .get(AGENT_CONTEXT_KEY)
886 .expect("agent_context key present");
887
888 assert_eq!(
890 raw.get("extra").and_then(|e| e.get("loop_idx")),
891 Some(&serde_json::json!(2))
892 );
893
894 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
895
896 assert_eq!(view.extra.get("loop_idx"), Some(&serde_json::json!(2)));
898
899 let header = view.to_directive_header();
901 assert!(
902 header.contains("loop_idx: 2\n"),
903 "header must render the unknown Step-tier extra key: {header}"
904 );
905
906 assert!(observed.meta.runtime.contains_key(STEP_CTX_KEY));
910 assert!(!view.extra.contains_key(STEP_CTX_KEY));
911 }
912
913 #[tokio::test]
916 async fn no_step_ctx_key_behaves_identically_to_phase_1() {
917 let mut per_agent_ctx = HashMap::new();
918 per_agent_ctx.insert(
919 "planner".to_string(),
920 serde_json::json!({ "work_dir": "/agent-declared" }),
921 );
922 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
923 None,
924 per_agent_ctx,
925 None,
926 HashMap::new(),
927 ));
928 let engine = Engine::new(EngineCfg::default());
929 let task_id = StepId::parse("ST-13").unwrap();
930 let ctx = Ctx::new(task_id.clone(), 1, "planner");
931 let token = engine
932 .attach("ut-op", Role::Operator, Duration::from_secs(30))
933 .await
934 .expect("attach");
935 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
936
937 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
938 let raw = observed
939 .meta
940 .runtime
941 .get(AGENT_CONTEXT_KEY)
942 .expect("agent_context key present");
943 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
944 assert_eq!(
945 view.work_dir.as_deref(),
946 Some("/agent-declared"),
947 "absent STEP_CTX_KEY must fall straight through to the Agent tier, unchanged from Phase 1"
948 );
949 }
950}