use crate::core::agent_context::{
AgentContextView, ContextPolicy, AGENT_CONTEXT_KEY, PROJECTION_PLACEMENT_KEY,
PROJECT_NAME_ALIAS_KEY, RUN_ID_KEY, STEP_CTX_KEY, TASK_METADATA_KEY, TASK_PROJECT_ROOT_KEY,
TASK_WORK_DIR_KEY,
};
use crate::core::ctx::Ctx;
use crate::core::engine::Engine;
use crate::middleware::SpawnerLayer;
use crate::types::{CapToken, StepId};
use crate::worker::adapter::{SpawnError, SpawnerAdapter};
use crate::worker::Worker;
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
const NAMED_RUNTIME_KEYS: [&str; 5] = [
TASK_PROJECT_ROOT_KEY,
TASK_WORK_DIR_KEY,
TASK_METADATA_KEY,
RUN_ID_KEY,
PROJECT_NAME_ALIAS_KEY,
];
pub struct AgentContextMiddleware {
global_ctx: Option<Value>,
per_agent_ctx: HashMap<String, Value>,
default_policy: Option<ContextPolicy>,
per_agent_policy: HashMap<String, ContextPolicy>,
}
impl AgentContextMiddleware {
pub fn new(
global_ctx: Option<Value>,
per_agent_ctx: HashMap<String, Value>,
default_policy: Option<ContextPolicy>,
per_agent_policy: HashMap<String, ContextPolicy>,
) -> Self {
Self {
global_ctx,
per_agent_ctx,
default_policy,
per_agent_policy,
}
}
}
impl Default for AgentContextMiddleware {
fn default() -> Self {
Self::new(None, HashMap::new(), None, HashMap::new())
}
}
impl SpawnerLayer for AgentContextMiddleware {
fn wrap(&self, inner: Arc<dyn SpawnerAdapter>) -> Arc<dyn SpawnerAdapter> {
Arc::new(AgentContextWrapped {
inner,
global_ctx: self.global_ctx.clone(),
per_agent_ctx: self.per_agent_ctx.clone(),
default_policy: self.default_policy.clone(),
per_agent_policy: self.per_agent_policy.clone(),
})
}
}
struct AgentContextWrapped {
inner: Arc<dyn SpawnerAdapter>,
global_ctx: Option<Value>,
per_agent_ctx: HashMap<String, Value>,
default_policy: Option<ContextPolicy>,
per_agent_policy: HashMap<String, ContextPolicy>,
}
impl AgentContextWrapped {
fn merge_ctx_tiers(&self, agent: &str) -> serde_json::Map<String, Value> {
let mut merged = serde_json::Map::new();
if let Some(global) = &self.global_ctx {
match global.as_object() {
Some(obj) => {
for (k, v) in obj {
merged.insert(k.clone(), v.clone());
}
}
None => tracing::warn!(
value = %global,
"AgentContextMiddleware: default_agent_ctx is not a JSON object; skipping this tier"
),
}
}
if let Some(per_agent) = self.per_agent_ctx.get(agent) {
match per_agent.as_object() {
Some(obj) => {
for (k, v) in obj {
merged.insert(k.clone(), v.clone());
}
}
None => tracing::warn!(
agent = %agent,
value = %per_agent,
"AgentContextMiddleware: AgentMeta.ctx is not a JSON object; skipping this tier"
),
}
}
merged
}
}
#[async_trait]
impl SpawnerAdapter for AgentContextWrapped {
async fn spawn(
&self,
engine: &Engine,
ctx: &Ctx,
task_id: StepId,
attempt: u32,
token: CapToken,
) -> Result<Box<dyn Worker>, SpawnError> {
let step_tier: serde_json::Map<String, Value> = match ctx.meta.runtime.get(STEP_CTX_KEY) {
Some(Value::Object(obj)) => obj.clone(),
Some(other) => {
tracing::warn!(
value = %other,
"AgentContextMiddleware: step_ctx runtime bundle is not a JSON object; skipping the Step tier"
);
serde_json::Map::new()
}
None => serde_json::Map::new(),
};
let merged = self.merge_ctx_tiers(&ctx.agent);
let mut new_ctx = ctx.clone();
for (k, v) in &step_tier {
new_ctx
.meta
.runtime
.entry(k.clone())
.or_insert_with(|| v.clone());
}
for (k, v) in &merged {
new_ctx
.meta
.runtime
.entry(k.clone())
.or_insert_with(|| v.clone());
}
let mut view = AgentContextView::from_ctx(&new_ctx);
for (k, v) in step_tier.iter().chain(merged.iter()) {
if !NAMED_RUNTIME_KEYS.contains(&k.as_str()) {
view.extra.entry(k.clone()).or_insert_with(|| v.clone());
}
}
let policy = self
.per_agent_policy
.get(&ctx.agent)
.or(self.default_policy.as_ref());
let view = match policy {
Some(p) => view.apply_policy(p),
None => view,
};
let view_for_state = view.clone();
let task_id_for_state = task_id.clone();
let policy_for_state = policy.cloned().unwrap_or_default();
engine
.with_state("agent_context.materialize", move |s| {
s.agent_ctx.insert(
(task_id_for_state, attempt),
crate::core::state::AgentCtxEntry {
view: view_for_state,
policy: policy_for_state,
},
);
})
.await
.map_err(|e| SpawnError::Internal(format!("agent_context state insert: {e}")))?;
match serde_json::to_value(&view) {
Ok(value) => {
new_ctx
.meta
.runtime
.insert(AGENT_CONTEXT_KEY.to_string(), value);
}
Err(e) => {
tracing::warn!(
error = %e,
task_id = %task_id,
"AgentContextView failed to serialize; proceeding without agent_context in ctx.meta.runtime"
);
}
}
let projection_placement = engine
.projection_placement_for(&task_id)
.await
.unwrap_or_default();
match serde_json::to_value(&*projection_placement) {
Ok(value) => {
new_ctx
.meta
.runtime
.insert(PROJECTION_PLACEMENT_KEY.to_string(), value);
}
Err(e) => {
tracing::warn!(
error = %e,
task_id = %task_id,
"ProjectionPlacement failed to serialize; proceeding without it in ctx.meta.runtime"
);
}
}
self.inner
.spawn(engine, &new_ctx, task_id, attempt, token)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::agent_context::{TASK_PROJECT_ROOT_KEY, TASK_WORK_DIR_KEY};
use crate::core::config::EngineCfg;
use crate::types::Role;
use serde_json::Value;
use std::sync::Mutex;
use std::time::Duration;
struct CtxProbe {
seen: Arc<Mutex<Option<Ctx>>>,
}
#[async_trait]
impl SpawnerAdapter for CtxProbe {
async fn spawn(
&self,
_engine: &Engine,
ctx: &Ctx,
_task_id: StepId,
_attempt: u32,
_token: CapToken,
) -> Result<Box<dyn Worker>, SpawnError> {
*self.seen.lock().unwrap() = Some(ctx.clone());
Err(SpawnError::Internal("probe stop".into()))
}
}
fn probe_stack(
layer: AgentContextMiddleware,
) -> (Arc<dyn SpawnerAdapter>, Arc<Mutex<Option<Ctx>>>) {
let seen = Arc::new(Mutex::new(None));
let inner = Arc::new(CtxProbe { seen: seen.clone() });
let wrapped = layer.wrap(inner);
(wrapped, seen)
}
#[tokio::test]
async fn snapshots_view_into_engine_state_agent_ctx() {
let (stack, _seen) = probe_stack(AgentContextMiddleware::default());
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-1").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
TASK_PROJECT_ROOT_KEY.to_string(),
Value::String("/repo".to_string()),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
let snapshotted = engine
.with_state("test.read_agent_ctx", move |s| {
s.agent_ctx.get(&(task_id, 1)).cloned()
})
.await
.expect("with_state")
.expect("agent_ctx entry present")
.view;
assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
}
#[tokio::test]
async fn no_snapshotted_projection_placement_stashes_the_default() {
let (stack, seen) = probe_stack(AgentContextMiddleware::default());
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-14").unwrap();
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(PROJECTION_PLACEMENT_KEY)
.expect("projection_placement key present");
let placement: crate::core::projection_placement::ProjectionPlacement =
serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
placement,
crate::core::projection_placement::ProjectionPlacement::default()
);
}
#[tokio::test]
async fn snapshotted_projection_placement_is_stashed_into_ctx() {
use crate::core::projection_placement::{ProjectionPlacement, RootPreference};
let (stack, seen) = probe_stack(AgentContextMiddleware::default());
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-15").unwrap();
let custom = ProjectionPlacement {
root_preference: RootPreference::ProjectRoot,
dir_template: "custom/{task_id}/out".to_string(),
};
let task_id_for_seed = task_id.clone();
let custom_for_seed = std::sync::Arc::new(custom.clone());
engine
.with_state("test.seed_projection_placement", move |s| {
s.projection_placements
.insert(task_id_for_seed, custom_for_seed);
})
.await
.expect("seed projection_placements");
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(PROJECTION_PLACEMENT_KEY)
.expect("projection_placement key present");
let placement: ProjectionPlacement =
serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(placement, custom);
}
#[tokio::test]
async fn inner_spawner_observes_agent_context_key_in_ctx() {
let (stack, seen) = probe_stack(AgentContextMiddleware::default());
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-2").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
TASK_WORK_DIR_KEY.to_string(),
Value::String("/repo/work".to_string()),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(view.work_dir.as_deref(), Some("/repo/work"));
}
#[tokio::test]
async fn policy_exclude_is_reflected_in_both_state_and_ctx() {
let policy = ContextPolicy {
include: None,
exclude: vec!["work_dir".to_string()],
..Default::default()
};
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
HashMap::new(),
Some(policy),
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-3").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
TASK_PROJECT_ROOT_KEY.to_string(),
Value::String("/repo".to_string()),
);
ctx.meta.runtime.insert(
TASK_WORK_DIR_KEY.to_string(),
Value::String("/repo/work".to_string()),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id.clone(), 1, token).await;
let snapshotted = engine
.with_state("test.read_agent_ctx_excluded", move |s| {
s.agent_ctx.get(&(task_id, 1)).cloned()
})
.await
.expect("with_state")
.expect("agent_ctx entry present")
.view;
assert_eq!(snapshotted.project_root.as_deref(), Some("/repo"));
assert!(snapshotted.work_dir.is_none());
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(view.project_root.as_deref(), Some("/repo"));
assert!(view.work_dir.is_none());
}
#[tokio::test]
async fn precedence_pre_inserted_runtime_key_survives_agent_declared_default() {
let mut per_agent_ctx = HashMap::new();
per_agent_ctx.insert(
"planner".to_string(),
serde_json::json!({ "work_dir": "/agent-declared" }),
);
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
per_agent_ctx,
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-4").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
TASK_WORK_DIR_KEY.to_string(),
Value::String("/task-declared".to_string()),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
view.work_dir.as_deref(),
Some("/task-declared"),
"an already-present runtime key must win over the agent-declared default"
);
}
#[tokio::test]
async fn precedence_absent_runtime_key_gets_agent_declared_default() {
let mut per_agent_ctx = HashMap::new();
per_agent_ctx.insert(
"planner".to_string(),
serde_json::json!({ "work_dir": "/agent-declared" }),
);
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
per_agent_ctx,
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-5").unwrap();
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(view.work_dir.as_deref(), Some("/agent-declared"));
}
#[tokio::test]
async fn agent_ctx_wins_over_global_on_key_collision() {
let mut per_agent_ctx = HashMap::new();
per_agent_ctx.insert(
"planner".to_string(),
serde_json::json!({ "work_dir": "/agent" }),
);
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
Some(serde_json::json!({ "work_dir": "/global" })),
per_agent_ctx,
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-6").unwrap();
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(view.work_dir.as_deref(), Some("/agent"));
}
#[tokio::test]
async fn unknown_key_reaches_runtime_extra_directive_and_serialized_json() {
let mut per_agent_ctx = HashMap::new();
per_agent_ctx.insert(
"planner".to_string(),
serde_json::json!({ "org_conventions": "x" }),
);
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
per_agent_ctx,
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-7").unwrap();
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
assert_eq!(
observed.meta.runtime.get("org_conventions"),
Some(&Value::String("x".to_string())),
"unknown key must land in ctx.meta.runtime too (in-process workers read ctx directly)"
);
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
assert_eq!(
raw.get("extra").and_then(|e| e.get("org_conventions")),
Some(&Value::String("x".to_string()))
);
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
view.extra.get("org_conventions"),
Some(&Value::String("x".to_string()))
);
let header = view.to_directive_header();
assert!(
header.contains("org_conventions: \"x\"\n"),
"header must render the unknown extra key: {header}"
);
}
#[tokio::test]
async fn per_agent_policy_outranks_default_and_other_agents_fall_through() {
let default_policy = ContextPolicy {
include: None,
exclude: vec!["work_dir".to_string()],
..Default::default()
};
let mut per_agent_policy = HashMap::new();
per_agent_policy.insert(
"planner".to_string(),
ContextPolicy {
include: None,
exclude: vec![],
..Default::default()
},
);
let engine = Engine::new(EngineCfg::default());
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
HashMap::new(),
Some(default_policy.clone()),
per_agent_policy.clone(),
));
let task_id = StepId::parse("ST-8a").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
TASK_WORK_DIR_KEY.to_string(),
Value::String("/repo/work".to_string()),
);
let token = engine
.attach("ut-op-a", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
view.work_dir.as_deref(),
Some("/repo/work"),
"per-agent pass-all override must win over the BP-global exclude"
);
let (stack2, seen2) = probe_stack(AgentContextMiddleware::new(
None,
HashMap::new(),
Some(default_policy),
per_agent_policy,
));
let task_id2 = StepId::parse("ST-8b").unwrap();
let mut ctx2 = Ctx::new(task_id2.clone(), 1, "other");
ctx2.meta.runtime.insert(
TASK_WORK_DIR_KEY.to_string(),
Value::String("/repo/work".to_string()),
);
let token2 = engine
.attach("ut-op-b", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack2.spawn(&engine, &ctx2, task_id2, 1, token2).await;
let observed2 = seen2.lock().unwrap().clone().expect("inner ctx captured");
let raw2 = observed2
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view2: AgentContextView = serde_json::from_value(raw2.clone()).expect("round-trip");
assert!(
view2.work_dir.is_none(),
"an agent without a per-agent override must fall through to the BP-global exclude"
);
}
#[tokio::test]
async fn non_object_ctx_tier_value_is_skipped_and_spawn_still_proceeds() {
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
Some(Value::String("not-an-object".to_string())),
HashMap::new(),
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-9").unwrap();
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone();
assert!(
observed.is_some(),
"a non-Object tier value must not stop the spawn from reaching inner"
);
}
#[tokio::test]
async fn step_key_beats_agent_declared_same_key() {
let mut per_agent_ctx = HashMap::new();
per_agent_ctx.insert(
"planner".to_string(),
serde_json::json!({ "work_dir": "/agent-declared" }),
);
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
per_agent_ctx,
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-10").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
STEP_CTX_KEY.to_string(),
serde_json::json!({ "work_dir": "/step-declared" }),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
view.work_dir.as_deref(),
Some("/step-declared"),
"the Step tier must win over an agent-declared default for the same key"
);
}
#[tokio::test]
async fn task_preinserted_key_beats_step_tier() {
let (stack, seen) = probe_stack(AgentContextMiddleware::default());
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-11").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
TASK_WORK_DIR_KEY.to_string(),
Value::String("/task-declared".to_string()),
);
ctx.meta.runtime.insert(
STEP_CTX_KEY.to_string(),
serde_json::json!({ "work_dir": "/step-declared" }),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
view.work_dir.as_deref(),
Some("/task-declared"),
"an already-present (Task-tier) runtime key must win over the Step tier"
);
}
#[tokio::test]
async fn unknown_step_key_reaches_runtime_extra_directive_and_serialized_json() {
let (stack, seen) = probe_stack(AgentContextMiddleware::default());
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-12").unwrap();
let mut ctx = Ctx::new(task_id.clone(), 1, "planner");
ctx.meta.runtime.insert(
STEP_CTX_KEY.to_string(),
serde_json::json!({ "loop_idx": 2 }),
);
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
assert_eq!(
observed.meta.runtime.get("loop_idx"),
Some(&serde_json::json!(2)),
"unknown step key must land in ctx.meta.runtime too"
);
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
assert_eq!(
raw.get("extra").and_then(|e| e.get("loop_idx")),
Some(&serde_json::json!(2))
);
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(view.extra.get("loop_idx"), Some(&serde_json::json!(2)));
let header = view.to_directive_header();
assert!(
header.contains("loop_idx: 2\n"),
"header must render the unknown Step-tier extra key: {header}"
);
assert!(observed.meta.runtime.contains_key(STEP_CTX_KEY));
assert!(!view.extra.contains_key(STEP_CTX_KEY));
}
#[tokio::test]
async fn no_step_ctx_key_behaves_identically_to_phase_1() {
let mut per_agent_ctx = HashMap::new();
per_agent_ctx.insert(
"planner".to_string(),
serde_json::json!({ "work_dir": "/agent-declared" }),
);
let (stack, seen) = probe_stack(AgentContextMiddleware::new(
None,
per_agent_ctx,
None,
HashMap::new(),
));
let engine = Engine::new(EngineCfg::default());
let task_id = StepId::parse("ST-13").unwrap();
let ctx = Ctx::new(task_id.clone(), 1, "planner");
let token = engine
.attach("ut-op", Role::Operator, Duration::from_secs(30))
.await
.expect("attach");
let _ = stack.spawn(&engine, &ctx, task_id, 1, token).await;
let observed = seen.lock().unwrap().clone().expect("inner ctx captured");
let raw = observed
.meta
.runtime
.get(AGENT_CONTEXT_KEY)
.expect("agent_context key present");
let view: AgentContextView = serde_json::from_value(raw.clone()).expect("round-trip");
assert_eq!(
view.work_dir.as_deref(),
Some("/agent-declared"),
"absent STEP_CTX_KEY must fall straight through to the Agent tier, unchanged from Phase 1"
);
}
}