From 7de4fdb7db284af63e08c3f11f8c87beed54fc7e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Sep 2026 14:00:12 -0400 Subject: [PATCH 1/2] fix(codex): align root lifecycle source metadata Set root metadata.source from the same native SessionStart source as input.source, including resume and compaction events that produce no subsequent turn context. Add regression coverage for startup, resume, compact, and imported sessions. --- bt-daemon/src/translate/codex.rs | 58 ++++++++++++++++++++------ bt-daemon/tests/codex_translator.rs | 64 ++++++++++++++++++++++++++++- bt-daemon/tests/replay.rs | 2 +- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index adc93c9..15b28bc 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -280,6 +280,7 @@ impl AgentTranslator for CodexTranslator { if let Some(hook) = decode::(payload) { self.session_source = hook.source; self.permission_mode = hook.permission_mode; + self.update_open_main_root_source(&mut ops); } } "SubagentStart" => { @@ -430,6 +431,34 @@ impl CodexTranslator { ); } + fn update_open_main_root_source(&self, ops: &mut Vec) { + let Some(source) = &self.session_source else { + return; + }; + if !self.root_opened { + return; + } + let model = self + .main_path + .as_ref() + .and_then(|path| self.scopes.get(path)) + .and_then(|scope| scope.model.clone()); + ops.push(SpanOp::Merge(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + input: Some(json!({ + "model": model, + "cwd": self.root_cwd, + "source": source, + })), + metadata: Some(json!({ + "source": source, + "session_source": source, + })), + ..Default::default() + })); + } + fn record_compaction_trigger(&mut self, hook: CompactHook, ops: &mut Vec) { let turn_id = hook.turn_id; let trigger = hook.trigger.unwrap_or_else(|| "manual".to_string()); @@ -594,20 +623,26 @@ impl CodexTranslator { let model_turn_id = context.turn_id; scope.model = Some(m.clone()); if scope.root_created { - let input = if scope.kind == ScopeKind::Main { - json!({ - "model": m, - "cwd": self.root_cwd, - "source": self.session_source, - }) + let (input, metadata) = if scope.kind == ScopeKind::Main { + ( + json!({ + "model": m, + "cwd": self.root_cwd, + "source": self.session_source, + }), + json!({ + "model": m, + "source": self.session_source, + }), + ) } else { - json!({ "model": m }) + (json!({ "model": m }), json!({ "model": m })) }; ops.push(SpanOp::Merge(SpanRow { span_id: scope.turn_parent_span_id.clone(), root_span_id: self.root_span_id.clone(), input: Some(input), - metadata: Some(json!({ "model": m })), + metadata: Some(metadata), ..Default::default() })); } @@ -685,10 +720,9 @@ impl CodexTranslator { ); } } - // `source` identifies the agent consistently across all coding-agent - // integrations. Codex's SessionStart source (for example, startup or - // resume) describes how this session began, so keep it separately. - md.insert("source".into(), json!("codex")); + // Keep lifecycle cohorts consistent: both root input.source and + // metadata.source come from the same native SessionStart event. + md.insert("source".into(), json!(self.session_source)); if let Some(session_source) = &self.session_source { md.insert("session_source".into(), json!(session_source)); } diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index 6d63d9c..d2f977c 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -194,7 +194,7 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { json!("gpt-5.5"), "model backfilled from turn_context" ); - assert_eq!(md["source"], json!("codex")); + assert_eq!(md["source"], json!("startup")); assert_eq!(md["session_source"], json!("startup")); assert_eq!(md["permission_mode"], json!("auto")); assert_eq!(md["username"], json!(expected_username())); @@ -267,6 +267,68 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { ); } +#[test] +fn codex_root_metadata_source_matches_native_lifecycle_source() { + let tmp = tempfile::tempdir().unwrap(); + + for source in ["startup", "resume", "compact"] { + let transcript = tmp.path().join(format!("{source}.jsonl")); + append( + &transcript, + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "session-1", "cwd": "/x/app" } }), + ); + append( + &transcript, + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + ); + let session_id = format!("lifecycle-{source}"); + let registry = Registry::default_agents(); + let mut translator = registry.create("codex", &session_id); + let ctx = SessionCtx { + session_id: session_id.clone(), + config: None, + }; + let mut ops = translator + .handle( + &envelope( + &session_id, + "SessionStart", + transcript.to_str().unwrap(), + json!({ "source": "startup" }), + ), + &ctx, + ) + .unwrap(); + + if source != "startup" { + // A resumed or compacted session can exit before writing another + // model-bearing turn_context record. + ops.extend( + translator + .handle( + &envelope( + &session_id, + "SessionStart", + transcript.to_str().unwrap(), + json!({ "source": source }), + ), + &ctx, + ) + .unwrap(), + ); + } + + let rows = reduce(ops); + let root = find(&rows, SpanType::Task, "codex: app"); + assert_eq!(root.input.as_ref().unwrap()["source"], json!(source)); + assert_eq!(root.input.as_ref().unwrap()["model"], json!("gpt-5.5")); + assert_eq!(root.input.as_ref().unwrap()["cwd"], json!("/x/app")); + assert_eq!(root.metadata.as_ref().unwrap()["source"], json!(source)); + } +} + #[test] fn codex_trailing_injected_user_row_does_not_replace_hook_prompt() { let tmp = tempfile::tempdir().unwrap(); diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs index 6549bcd..5cadd82 100644 --- a/bt-daemon/tests/replay.rs +++ b/bt-daemon/tests/replay.rs @@ -446,7 +446,7 @@ async fn imports_native_codex_rollout_through_codex_translator() { assert!(rows.iter().any(|row| { row.pointer("/Insert/metadata/source") .and_then(Value::as_str) - == Some("codex") + == Some("import") && row .pointer("/Insert/metadata/session_source") .and_then(Value::as_str) From 441574ecab4bedff78a999a6ac38b72a0280eed0 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Sep 2026 15:59:14 -0400 Subject: [PATCH 2/2] fix regression --- bt-daemon/src/translate/codex.rs | 10 +++++----- bt-daemon/tests/codex_translator.rs | 10 +++++++--- bt-daemon/tests/replay.rs | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index 15b28bc..a496b57 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -452,7 +452,7 @@ impl CodexTranslator { "source": source, })), metadata: Some(json!({ - "source": source, + "source": "codex", "session_source": source, })), ..Default::default() @@ -632,7 +632,7 @@ impl CodexTranslator { }), json!({ "model": m, - "source": self.session_source, + "source": "codex", }), ) } else { @@ -720,9 +720,9 @@ impl CodexTranslator { ); } } - // Keep lifecycle cohorts consistent: both root input.source and - // metadata.source come from the same native SessionStart event. - md.insert("source".into(), json!(self.session_source)); + // `source` is the stable integration identity used to classify + // traces. The native SessionStart lifecycle source is separate. + md.insert("source".into(), json!("codex")); if let Some(session_source) = &self.session_source { md.insert("session_source".into(), json!(session_source)); } diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index d2f977c..c094db2 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -194,7 +194,7 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { json!("gpt-5.5"), "model backfilled from turn_context" ); - assert_eq!(md["source"], json!("startup")); + assert_eq!(md["source"], json!("codex")); assert_eq!(md["session_source"], json!("startup")); assert_eq!(md["permission_mode"], json!("auto")); assert_eq!(md["username"], json!(expected_username())); @@ -268,7 +268,7 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { } #[test] -fn codex_root_metadata_source_matches_native_lifecycle_source() { +fn codex_root_preserves_canonical_source_across_lifecycle_events() { let tmp = tempfile::tempdir().unwrap(); for source in ["startup", "resume", "compact"] { @@ -325,7 +325,11 @@ fn codex_root_metadata_source_matches_native_lifecycle_source() { assert_eq!(root.input.as_ref().unwrap()["source"], json!(source)); assert_eq!(root.input.as_ref().unwrap()["model"], json!("gpt-5.5")); assert_eq!(root.input.as_ref().unwrap()["cwd"], json!("/x/app")); - assert_eq!(root.metadata.as_ref().unwrap()["source"], json!(source)); + assert_eq!(root.metadata.as_ref().unwrap()["source"], json!("codex")); + assert_eq!( + root.metadata.as_ref().unwrap()["session_source"], + json!(source) + ); } } diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs index 5cadd82..6549bcd 100644 --- a/bt-daemon/tests/replay.rs +++ b/bt-daemon/tests/replay.rs @@ -446,7 +446,7 @@ async fn imports_native_codex_rollout_through_codex_translator() { assert!(rows.iter().any(|row| { row.pointer("/Insert/metadata/source") .and_then(Value::as_str) - == Some("import") + == Some("codex") && row .pointer("/Insert/metadata/session_source") .and_then(Value::as_str)