diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index adc93c9..a496b57 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": "codex", + "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": "codex", + }), + ) } 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,9 +720,8 @@ 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. + // `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 6d63d9c..c094db2 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -267,6 +267,72 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { ); } +#[test] +fn codex_root_preserves_canonical_source_across_lifecycle_events() { + 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!("codex")); + assert_eq!( + root.metadata.as_ref().unwrap()["session_source"], + json!(source) + ); + } +} + #[test] fn codex_trailing_injected_user_row_does_not_replace_hook_prompt() { let tmp = tempfile::tempdir().unwrap();