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();
286 let task_id_for_state = task_id.clone();
287 engine
288 .with_state("agent_context.materialize", move |s| {
289 s.agent_contexts
290 .insert((task_id_for_state, attempt), view_for_state);
291 })
292 .await
293 .map_err(|e| SpawnError::Internal(format!("agent_context state insert: {e}")))?;
294
295 match serde_json::to_value(&view) {
302 Ok(value) => {
303 new_ctx
304 .meta
305 .runtime
306 .insert(AGENT_CONTEXT_KEY.to_string(), value);
307 }
308 Err(e) => {
309 tracing::warn!(
310 error = %e,
311 task_id = %task_id,
312 "AgentContextView failed to serialize; proceeding without agent_context in ctx.meta.runtime"
313 );
314 }
315 }
316
317 self.inner
318 .spawn(engine, &new_ctx, task_id, attempt, token)
319 .await
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::core::agent_context::{TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
327 use crate::core::config::EngineCfg;
328 use crate::types::Role;
329 use serde_json::Value;
330 use std::sync::Mutex;
331 use std::time::Duration;
332
333 struct CtxProbe {
336 seen: Arc<Mutex<Option<Ctx>>>,
337 }
338
339 #[async_trait]
340 impl SpawnerAdapter for CtxProbe {
341 async fn spawn(
342 &self,
343 _engine: &Engine,
344 ctx: &Ctx,
345 _task_id: StepId,
346 _attempt: u32,
347 _token: CapToken,
348 ) -> Result<Box<dyn Worker>, SpawnError> {
349 *self.seen.lock().unwrap() = Some(ctx.clone());
350 Err(SpawnError::Internal("probe stop".into()))
351 }
352 }
353
354 fn probe_stack(
355 layer: AgentContextMiddleware,
356 ) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
357 let seen = Arc::new(Mutex::new(None));
358 let inner = Arc::new(CtxProbe { seen: seen.clone() });
359 let wrapped = layer.wrap(inner);
360 (wrapped, seen)
361 }
362
363 #[tokio::test]
364 async fn snapshots_view_into_engine_state_agent_contexts() {
365 let (stack, _seen) = probe_stack(AgentContextMiddleware::default());
366 let engine = Engine::new(EngineCfg::default());
367 let task_id = StepId::parse("ST-1").unwrap();
368 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
369 ctx.meta.runtime.insert(
370 TASK_PROJECT_ROOT_KEY.to_string(),
371 Value::String("/repo".to_string()),
372 );
373 let token = engine
374 .attach("ut-op", Role::Operator, Duration::from_secs(30))
375 .await
376 .expect("attach");
377 let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
378
379 let snapshotted = engine
380 .with_state("test.read_agent_contexts", move |s| {
381 s.agent_contexts.get(&(task_id, 1)).cloned()
382 })
383 .await
384 .expect("with_state")
385 .expect("agent_contexts entry present");
386 assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
387 }
388
389 #[tokio::test]
390 async fn inner_spawner_observes_agent_context_key_in_ctx() {
391 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
392 let engine = Engine::new(EngineCfg::default());
393 let task_id = StepId::parse("ST-2").unwrap();
394 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
395 ctx.meta.runtime.insert(
396 TASK_WORK_DIR_KEY.to_string(),
397 Value::String("/repo/work".to_string()),
398 );
399 let token = engine
400 .attach("ut-op", Role::Operator, Duration::from_secs(30))
401 .await
402 .expect("attach");
403 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
404
405 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
406 let raw = observed
407 .meta
408 .runtime
409 .get(AGENT_CONTEXT_KEY)
410 .expect("agent_context key present");
411 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
412 assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
413 }
414
415 #[tokio::test]
416 async fn policy_exclude_is_reflected_in_both_state_and_ctx() {
417 let policy = ContextPolicy {
418 include: None,
419 exclude: vec!["work_dir".to_string()],
420 };
421 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
422 None,
423 HashMap::new(),
424 Some(policy),
425 HashMap::new(),
426 ));
427 let engine = Engine::new(EngineCfg::default());
428 let task_id = StepId::parse("ST-3").unwrap();
429 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
430 ctx.meta.runtime.insert(
431 TASK_PROJECT_ROOT_KEY.to_string(),
432 Value::String("/repo".to_string()),
433 );
434 ctx.meta.runtime.insert(
435 TASK_WORK_DIR_KEY.to_string(),
436 Value::String("/repo/work".to_string()),
437 );
438 let token = engine
439 .attach("ut-op", Role::Operator, Duration::from_secs(30))
440 .await
441 .expect("attach");
442 let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
443
444 let snapshotted = engine
445 .with_state("test.read_agent_contexts_excluded", move |s| {
446 s.agent_contexts.get(&(task_id, 1)).cloned()
447 })
448 .await
449 .expect("with_state")
450 .expect("agent_contexts entry present");
451 assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
452 assert!(snapshotted.work_dir.is_none());
453
454 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
455 let raw = observed
456 .meta
457 .runtime
458 .get(AGENT_CONTEXT_KEY)
459 .expect("agent_context key present");
460 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
461 assert_eq!(view.project_root.as_deref(), Some("/repo"));
462 assert!(view.work_dir.is_none());
463 }
464
465 #[tokio::test]
475 async fn precedence_pre_inserted_runtime_key_survives_agent_declared_default() {
476 let mut per_agent_ctx = HashMap::new();
477 per_agent_ctx.insert(
478 "planner".to_string(),
479 serde_json::json!({ "work_dir": "/agent-declared" }),
480 );
481 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
482 None,
483 per_agent_ctx,
484 None,
485 HashMap::new(),
486 ));
487 let engine = Engine::new(EngineCfg::default());
488 let task_id = StepId::parse("ST-4").unwrap();
489 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
490 ctx.meta.runtime.insert(
491 TASK_WORK_DIR_KEY.to_string(),
492 Value::String("/task-declared".to_string()),
493 );
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(AGENT_CONTEXT_KEY)
505 .expect("agent_context key present");
506 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
507 assert_eq!(
508 view.work_dir.as_deref(),
509 Some("/task-declared"),
510 "an already-present runtime key must win over the agent-declared default"
511 );
512 }
513
514 #[tokio::test]
517 async fn precedence_absent_runtime_key_gets_agent_declared_default() {
518 let mut per_agent_ctx = HashMap::new();
519 per_agent_ctx.insert(
520 "planner".to_string(),
521 serde_json::json!({ "work_dir": "/agent-declared" }),
522 );
523 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
524 None,
525 per_agent_ctx,
526 None,
527 HashMap::new(),
528 ));
529 let engine = Engine::new(EngineCfg::default());
530 let task_id = StepId::parse("ST-5").unwrap();
531 let ctx = Ctx::new(task_id.clone(), 1, "planner");
532 let token = engine
533 .attach("ut-op", Role::Operator, Duration::from_secs(30))
534 .await
535 .expect("attach");
536 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
537
538 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
539 let raw = observed
540 .meta
541 .runtime
542 .get(AGENT_CONTEXT_KEY)
543 .expect("agent_context key present");
544 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
545 assert_eq!(view.work_dir.as_deref(), Some("/agent-declared"));
546 }
547
548 #[tokio::test]
550 async fn agent_ctx_wins_over_global_on_key_collision() {
551 let mut per_agent_ctx = HashMap::new();
552 per_agent_ctx.insert(
553 "planner".to_string(),
554 serde_json::json!({ "work_dir": "/agent" }),
555 );
556 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
557 Some(serde_json::json!({ "work_dir": "/global" })),
558 per_agent_ctx,
559 None,
560 HashMap::new(),
561 ));
562 let engine = Engine::new(EngineCfg::default());
563 let task_id = StepId::parse("ST-6").unwrap();
564 let ctx = Ctx::new(task_id.clone(), 1, "planner");
565 let token = engine
566 .attach("ut-op", Role::Operator, Duration::from_secs(30))
567 .await
568 .expect("attach");
569 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
570
571 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
572 let raw = observed
573 .meta
574 .runtime
575 .get(AGENT_CONTEXT_KEY)
576 .expect("agent_context key present");
577 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
578 assert_eq!(view.work_dir.as_deref(), Some("/agent"));
579 }
580
581 #[tokio::test]
586 async fn unknown_key_reaches_runtime_extra_directive_and_serialized_json() {
587 let mut per_agent_ctx = HashMap::new();
588 per_agent_ctx.insert(
589 "planner".to_string(),
590 serde_json::json!({ "org_conventions": "x" }),
591 );
592 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
593 None,
594 per_agent_ctx,
595 None,
596 HashMap::new(),
597 ));
598 let engine = Engine::new(EngineCfg::default());
599 let task_id = StepId::parse("ST-7").unwrap();
600 let ctx = Ctx::new(task_id.clone(), 1, "planner");
601 let token = engine
602 .attach("ut-op", Role::Operator, Duration::from_secs(30))
603 .await
604 .expect("attach");
605 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
606
607 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
608
609 assert_eq!(
611 observed.meta.runtime.get("org_conventions"),
612 Some(&Value::String("x".to_string())),
613 "unknown key must land in ctx.meta.runtime too (in-process workers read ctx directly)"
614 );
615
616 let raw = observed
617 .meta
618 .runtime
619 .get(AGENT_CONTEXT_KEY)
620 .expect("agent_context key present");
621
622 assert_eq!(
624 raw.get("extra").and_then(|e| e.get("org_conventions")),
625 Some(&Value::String("x".to_string()))
626 );
627
628 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
629
630 assert_eq!(
632 view.extra.get("org_conventions"),
633 Some(&Value::String("x".to_string()))
634 );
635
636 let header = view.to_directive_header();
638 assert!(
639 header.contains("org_conventions: \"x\"\n"),
640 "header must render the unknown extra key: {header}"
641 );
642 }
643
644 #[tokio::test]
648 async fn per_agent_policy_outranks_default_and_other_agents_fall_through() {
649 let default_policy = ContextPolicy {
650 include: None,
651 exclude: vec!["work_dir".to_string()],
652 };
653 let mut per_agent_policy = HashMap::new();
654 per_agent_policy.insert(
655 "planner".to_string(),
656 ContextPolicy {
657 include: None,
658 exclude: vec![],
659 },
660 );
661
662 let engine = Engine::new(EngineCfg::default());
663
664 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
667 None,
668 HashMap::new(),
669 Some(default_policy.clone()),
670 per_agent_policy.clone(),
671 ));
672 let task_id = StepId::parse("ST-8a").unwrap();
673 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
674 ctx.meta.runtime.insert(
675 TASK_WORK_DIR_KEY.to_string(),
676 Value::String("/repo/work".to_string()),
677 );
678 let token = engine
679 .attach("ut-op-a", Role::Operator, Duration::from_secs(30))
680 .await
681 .expect("attach");
682 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
683 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
684 let raw = observed
685 .meta
686 .runtime
687 .get(AGENT_CONTEXT_KEY)
688 .expect("agent_context key present");
689 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
690 assert_eq!(
691 view.work_dir.as_deref(),
692 Some("/repo/work"),
693 "per-agent pass-all override must win over the BP-global exclude"
694 );
695
696 let (stack2, seen2) = probe_stack(AgentContextMiddleware::new(
699 None,
700 HashMap::new(),
701 Some(default_policy),
702 per_agent_policy,
703 ));
704 let task_id2 = StepId::parse("ST-8b").unwrap();
705 let mut ctx2 = Ctx::new(task_id2.clone(), 1, "other");
706 ctx2.meta.runtime.insert(
707 TASK_WORK_DIR_KEY.to_string(),
708 Value::String("/repo/work".to_string()),
709 );
710 let token2 = engine
711 .attach("ut-op-b", Role::Operator, Duration::from_secs(30))
712 .await
713 .expect("attach");
714 let _ = stack2.spawn(&engine, &ctx2, task_id2, 1, token2).await;
715 let observed2 = seen2.lock().unwrap().clone().expect("inner ctx captured");
716 let raw2 = observed2
717 .meta
718 .runtime
719 .get(AGENT_CONTEXT_KEY)
720 .expect("agent_context key present");
721 let view2: AgentContextView = serde_json::from_value(raw2.clone()).expect("round-trip");
722 assert!(
723 view2.work_dir.is_none(),
724 "an agent without a per-agent override must fall through to the BP-global exclude"
725 );
726 }
727
728 #[tokio::test]
732 async fn non_object_ctx_tier_value_is_skipped_and_spawn_still_proceeds() {
733 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
734 Some(Value::String("not-an-object".to_string())),
735 HashMap::new(),
736 None,
737 HashMap::new(),
738 ));
739 let engine = Engine::new(EngineCfg::default());
740 let task_id = StepId::parse("ST-9").unwrap();
741 let ctx = Ctx::new(task_id.clone(), 1, "planner");
742 let token = engine
743 .attach("ut-op", Role::Operator, Duration::from_secs(30))
744 .await
745 .expect("attach");
746 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
751
752 let observed = seen.lock().unwrap().clone();
753 assert!(
754 observed.is_some(),
755 "a non-Object tier value must not stop the spawn from reaching inner"
756 );
757 }
758
759 #[tokio::test]
766 async fn step_key_beats_agent_declared_same_key() {
767 let mut per_agent_ctx = HashMap::new();
768 per_agent_ctx.insert(
769 "planner".to_string(),
770 serde_json::json!({ "work_dir": "/agent-declared" }),
771 );
772 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
773 None,
774 per_agent_ctx,
775 None,
776 HashMap::new(),
777 ));
778 let engine = Engine::new(EngineCfg::default());
779 let task_id = StepId::parse("ST-10").unwrap();
780 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
781 ctx.meta.runtime.insert(
782 STEP_CTX_KEY.to_string(),
783 serde_json::json!({ "work_dir": "/step-declared" }),
784 );
785 let token = engine
786 .attach("ut-op", Role::Operator, Duration::from_secs(30))
787 .await
788 .expect("attach");
789 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
790
791 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
792 let raw = observed
793 .meta
794 .runtime
795 .get(AGENT_CONTEXT_KEY)
796 .expect("agent_context key present");
797 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
798 assert_eq!(
799 view.work_dir.as_deref(),
800 Some("/step-declared"),
801 "the Step tier must win over an agent-declared default for the same key"
802 );
803 }
804
805 #[tokio::test]
808 async fn task_preinserted_key_beats_step_tier() {
809 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
810 let engine = Engine::new(EngineCfg::default());
811 let task_id = StepId::parse("ST-11").unwrap();
812 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
813 ctx.meta.runtime.insert(
816 TASK_WORK_DIR_KEY.to_string(),
817 Value::String("/task-declared".to_string()),
818 );
819 ctx.meta.runtime.insert(
820 STEP_CTX_KEY.to_string(),
821 serde_json::json!({ "work_dir": "/step-declared" }),
822 );
823 let token = engine
824 .attach("ut-op", Role::Operator, Duration::from_secs(30))
825 .await
826 .expect("attach");
827 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
828
829 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
830 let raw = observed
831 .meta
832 .runtime
833 .get(AGENT_CONTEXT_KEY)
834 .expect("agent_context key present");
835 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
836 assert_eq!(
837 view.work_dir.as_deref(),
838 Some("/task-declared"),
839 "an already-present (Task-tier) runtime key must win over the Step tier"
840 );
841 }
842
843 #[tokio::test]
848 async fn unknown_step_key_reaches_runtime_extra_directive_and_serialized_json() {
849 let (stack, seen) = probe_stack(AgentContextMiddleware::default());
850 let engine = Engine::new(EngineCfg::default());
851 let task_id = StepId::parse("ST-12").unwrap();
852 let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
853 ctx.meta.runtime.insert(
854 STEP_CTX_KEY.to_string(),
855 serde_json::json!({ "loop_idx": 2 }),
856 );
857 let token = engine
858 .attach("ut-op", Role::Operator, Duration::from_secs(30))
859 .await
860 .expect("attach");
861 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
862
863 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
864
865 assert_eq!(
867 observed.meta.runtime.get("loop_idx"),
868 Some(&serde_json::json!(2)),
869 "unknown step key must land in ctx.meta.runtime too"
870 );
871
872 let raw = observed
873 .meta
874 .runtime
875 .get(AGENT_CONTEXT_KEY)
876 .expect("agent_context key present");
877
878 assert_eq!(
880 raw.get("extra").and_then(|e| e.get("loop_idx")),
881 Some(&serde_json::json!(2))
882 );
883
884 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
885
886 assert_eq!(view.extra.get("loop_idx"), Some(&serde_json::json!(2)));
888
889 let header = view.to_directive_header();
891 assert!(
892 header.contains("loop_idx: 2\n"),
893 "header must render the unknown Step-tier extra key: {header}"
894 );
895
896 assert!(observed.meta.runtime.contains_key(STEP_CTX_KEY));
900 assert!(!view.extra.contains_key(STEP_CTX_KEY));
901 }
902
903 #[tokio::test]
906 async fn no_step_ctx_key_behaves_identically_to_phase_1() {
907 let mut per_agent_ctx = HashMap::new();
908 per_agent_ctx.insert(
909 "planner".to_string(),
910 serde_json::json!({ "work_dir": "/agent-declared" }),
911 );
912 let (stack, seen) = probe_stack(AgentContextMiddleware::new(
913 None,
914 per_agent_ctx,
915 None,
916 HashMap::new(),
917 ));
918 let engine = Engine::new(EngineCfg::default());
919 let task_id = StepId::parse("ST-13").unwrap();
920 let ctx = Ctx::new(task_id.clone(), 1, "planner");
921 let token = engine
922 .attach("ut-op", Role::Operator, Duration::from_secs(30))
923 .await
924 .expect("attach");
925 let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
926
927 let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
928 let raw = observed
929 .meta
930 .runtime
931 .get(AGENT_CONTEXT_KEY)
932 .expect("agent_context key present");
933 let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
934 assert_eq!(
935 view.work_dir.as_deref(),
936 Some("/agent-declared"),
937 "absent STEP_CTX_KEY must fall straight through to the Agent tier, unchanged from Phase 1"
938 );
939 }
940}