diff --git a/bt-daemon/src/delivery_ledger.rs b/bt-daemon/src/delivery_ledger.rs index 6fe64d4..4eb9f32 100644 --- a/bt-daemon/src/delivery_ledger.rs +++ b/bt-daemon/src/delivery_ledger.rs @@ -268,6 +268,16 @@ mod tests { }) } + fn late_terminal_merge(span_id: &str, key: &str, end_ms: i64) -> SpanOp { + SpanOp::Merge(SpanRow { + span_id: span_id.into(), + root_span_id: "root".into(), + end_ms: Some(end_ms), + late_merge_key: Some(key.into()), + ..Default::default() + }) + } + #[tokio::test] async fn a_destination_receives_a_terminal_span_only_once_across_sink_instances() { let temp = tempfile::tempdir().unwrap(); @@ -370,6 +380,53 @@ mod tests { ); } + #[tokio::test] + async fn a_resumed_root_delivers_each_distinct_terminal_refresh_once() { + let temp = tempfile::tempdir().unwrap(); + let first = RecordingSink::default(); + let mut first = LedgerSink::new( + Box::new(first), + temp.path(), + "codex", + "session-1", + Some(&config("project-a")), + ) + .await; + assert_eq!( + first + .emit(&[late_terminal_merge("root", "session:stop:3", 3)]) + .await + .unwrap(), + 1 + ); + first.flush().await.unwrap(); + + let resumed = RecordingSink::default(); + let mut resumed = LedgerSink::new( + Box::new(resumed), + temp.path(), + "codex", + "session-1", + Some(&config("project-a")), + ) + .await; + assert_eq!( + resumed + .emit(&[late_terminal_merge("root", "session:stop:3", 3)]) + .await + .unwrap(), + 0 + ); + assert_eq!( + resumed + .emit(&[late_terminal_merge("root", "session:stop:5", 5)]) + .await + .unwrap(), + 1 + ); + resumed.flush().await.unwrap(); + } + #[tokio::test] async fn a_different_destination_replays_the_same_terminal_span() { let temp = tempfile::tempdir().unwrap(); diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs index 4700464..ac4a452 100644 --- a/bt-daemon/src/translate/codex.rs +++ b/bt-daemon/src/translate/codex.rs @@ -69,7 +69,11 @@ struct SubagentStartHook { /// handlers preserve those native objects in span input/output. #[derive(Deserialize)] struct RolloutRecord { - #[serde(default, deserialize_with = "deserialize_timestamp")] + #[serde( + rename = "timestamp", + default, + deserialize_with = "deserialize_timestamp" + )] timestamp_ms: Option, #[serde(rename = "type", default)] kind: Option, @@ -122,7 +126,6 @@ impl TranslatorFactory for CodexTranslatorFactory { root_span_id: ids::span_id(session_id, "root"), external_parent_span_id: None, root_opened: false, - root_ended: false, session_source: None, permission_mode: None, root_cwd: None, @@ -155,6 +158,13 @@ struct OpenTurn { last_child_end_ms: Option, llm_seq: u32, explicit_skill_names: Vec, + input_source: Option, +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum TurnInputSource { + Native, + Authoritative, } struct OpenLlm { @@ -227,7 +237,6 @@ struct CodexTranslator { root_span_id: String, external_parent_span_id: Option, root_opened: bool, - root_ended: bool, session_source: Option, permission_mode: Option, root_cwd: Option, @@ -769,6 +778,7 @@ impl CodexTranslator { last_child_end_ms: None, llm_seq: 0, explicit_skill_names: Vec::new(), + input_source: None, }); } @@ -777,12 +787,39 @@ impl CodexTranslator { .or_else(|| str_field(payload, "text")) .or_else(|| str_field(payload, "prompt")); let Some(text) = text else { return }; - // Explicit skill mentions in the prompt (e.g. "$skill", "/skills name"). - let names = explicit_skill_names(&text); + self.set_turn_input_text(scope, text, TurnInputSource::Authoritative, ops); + } + + fn set_turn_input_text( + &mut self, + scope: &mut Scope, + text: String, + source: TurnInputSource, + ops: &mut Vec, + ) { if let Some(turn) = scope.open_turns.last_mut() { - for n in names { - if !turn.explicit_skill_names.contains(&n) { - turn.explicit_skill_names.push(n); + match source { + TurnInputSource::Authoritative => { + turn.input_source = Some(TurnInputSource::Authoritative); + } + TurnInputSource::Native => { + // A rollout can contain several user-role rows for one turn: + // injected runtime context, the initiating prompt, and later + // steering/context rows. In the absence of the authoritative + // event_msg, retain the first non-injected native prompt. + if turn.input_source.is_some() || is_injected_user_context(&text) { + return; + } + turn.input_source = Some(TurnInputSource::Native); + } + } + // Explicit skill mentions in the selected prompt (e.g. `$skill`, + // `/skills name`) are user intent. Native injected context is + // filtered before reaching this point, so it cannot contribute + // attribution when the legacy authoritative event is absent. + for name in explicit_skill_names(&text) { + if !turn.explicit_skill_names.contains(&name) { + turn.explicit_skill_names.push(name); } } ops.push(SpanOp::Merge(SpanRow { @@ -858,22 +895,10 @@ impl CodexTranslator { llm.last_output_ms = llm.last_output_ms.max(ts); } } else if role == "user" { - let names = explicit_skill_names(&text); - if let Some(turn) = scope.open_turns.last_mut() { - for name in names { - if !turn.explicit_skill_names.contains(&name) { - turn.explicit_skill_names.push(name); - } - } - if let Some(metadata) = explicit_skill_metadata(&turn.explicit_skill_names) { - ops.push(SpanOp::Merge(SpanRow { - span_id: turn.span_id.clone(), - root_span_id: self.root_span_id.clone(), - metadata: Some(metadata), - ..Default::default() - })); - } - } + // Newer rollouts carry the initiating prompt solely as a native + // response item, without the older `event_msg.user_message` row. + // The task span must remain readable independently of its LLM input. + self.set_turn_input_text(scope, text.clone(), TurnInputSource::Native, ops); } scope.message_history.push(msg); } @@ -1264,10 +1289,9 @@ impl CodexTranslator { } fn end_main_root(&mut self, fallback_ts: i64, ops: &mut Vec) { - if self.root_ended || !self.root_opened { + if !self.root_opened { return; } - self.root_ended = true; let end_ms = self .main_path .as_ref() @@ -1278,6 +1302,7 @@ impl CodexTranslator { span_id: self.root_span_id.clone(), root_span_id: self.root_span_id.clone(), end_ms: Some(end_ms), + late_merge_key: Some(format!("session:stop:{end_ms}")), ..Default::default() })); } @@ -1361,6 +1386,26 @@ impl CodexTranslator { } } +fn is_injected_user_context(text: &str) -> bool { + let text = text.trim_start(); + [ + "# AGENTS.md instructions", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ] + .iter() + .any(|prefix| text.starts_with(prefix)) +} + fn effective_transcript_path(event: &Envelope) -> Option { event .payload diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs index d2874e2..19234d4 100644 --- a/bt-daemon/tests/codex_translator.rs +++ b/bt-daemon/tests/codex_translator.rs @@ -27,8 +27,12 @@ fn write_transcript(path: &std::path::Path) { "payload": { "type": "task_started", "turn_id": "t1" } }), json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "turn_context", "payload": { "turn_id": "t1", "model": "gpt-5.5" } }), - json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", - "payload": { "type": "user_message", "message": "list the files" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "# AGENTS.md instructions for /test/project\nRead $review" }] } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "list the files" }] } }), json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "response_item", "payload": { "type": "reasoning", "summary": [{ "type": "summary_text", "text": "I'll run ls" }], @@ -199,6 +203,13 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { let turn = find(&rows, SpanType::Task, "turn: t1"); assert_eq!(turn.parent_span_ids, vec![root.span_id.clone()]); assert_eq!(turn.input, Some(json!("list the files"))); + assert!( + turn.metadata + .as_ref() + .and_then(|metadata| metadata.get("loaded_skill_names")) + .is_none(), + "injected context must not be attributed as an explicitly requested skill" + ); assert_eq!(turn.output, Some(json!("Here are the files."))); assert_eq!( turn.metadata.as_ref().unwrap()["model"], @@ -214,6 +225,7 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { let llm = find(&rows, SpanType::Llm, "gpt-5.5"); assert_eq!(llm.parent_span_ids, vec![turn.span_id.clone()]); assert!(llm.end_ms.is_some(), "llm closed by token_count"); + assert_eq!(llm.start_ms, Some(1_767_225_602_000)); let m = llm.metrics.as_ref().unwrap(); assert_eq!(m["prompt_tokens"], json!(100.0)); assert_eq!(m["completion_tokens"], json!(20.0)); @@ -223,6 +235,10 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { json!({ "type": "summary_text", "text": "I'll run ls" }) ); + let tool = find(&rows, SpanType::Tool, "shell"); + assert_eq!(tool.start_ms, Some(1_767_225_607_000)); + assert_eq!(tool.end_ms, Some(1_767_225_609_000)); + // Tool span under the turn. let tool = find(&rows, SpanType::Tool, "shell"); assert_eq!(tool.parent_span_ids, vec![turn.span_id.clone()]); @@ -251,6 +267,97 @@ fn codex_happy_path_builds_session_turn_llm_tool_tree() { ); } +#[test] +fn codex_trailing_injected_user_row_does_not_replace_hook_prompt() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let records = [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "session-1", "cwd": "/test/project" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "fix the timestamps" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "$review injected context" }] } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1" } }), + ]; + let mut file = std::fs::File::create(&transcript).unwrap(); + for record in records { + writeln!(file, "{}", line(record)).unwrap(); + } + + let path = transcript.to_str().unwrap(); + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle(&envelope("s", "SessionStart", path, json!({})), &ctx) + .unwrap(), + ); + + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.input, Some(json!("fix the timestamps"))); + assert!(turn + .metadata + .as_ref() + .and_then(|metadata| metadata.get("loaded_skill_names")) + .is_none()); +} + +#[test] +fn codex_native_prompt_ignores_surrounding_injected_user_rows() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let records = [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "session-1", "cwd": "/test/project" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "injected before" }] } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "$review fix the timestamps" }] } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "$review injected after" }] } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1" } }), + ]; + let mut file = std::fs::File::create(&transcript).unwrap(); + for record in records { + writeln!(file, "{}", line(record)).unwrap(); + } + + let path = transcript.to_str().unwrap(); + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle(&envelope("s", "SessionStart", path, json!({})), &ctx) + .unwrap(), + ); + + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.input, Some(json!("$review fix the timestamps"))); + assert_eq!( + turn.metadata.as_ref().unwrap()["loaded_skill_names"], + json!(["review"]) + ); +} + #[test] fn codex_incremental_reads_advance_offset() { // Two reads: the second only sees records appended after the first. @@ -478,6 +585,63 @@ fn codex_stop_closes_turn_before_late_task_complete() { assert_eq!(turn.output, Some(json!("done"))); } +#[test] +fn codex_later_stop_extends_session_root_through_resumed_turn() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let path = transcript.to_str().unwrap(); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let mut ops = translator + .handle(&envelope("s", "Stop", path, json!({})), &ctx) + .unwrap(); + + for record in [ + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t2" } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t2" } }), + ] { + append(&transcript, record); + } + ops.extend( + translator + .handle(&envelope("s", "Stop", path, json!({})), &ctx) + .unwrap(), + ); + ops.extend(translator.flush(&ctx).unwrap()); + + let root_refresh_keys = ops + .iter() + .filter_map(|op| match op { + SpanOp::Insert(row) | SpanOp::Merge(row) => row.late_merge_key.as_deref(), + }) + .collect::>(); + assert_eq!( + root_refresh_keys, + ["session:stop:1767225603000", "session:stop:1767225605000"] + ); + + let rows = reduce(ops); + let root = find(&rows, SpanType::Task, "codex: app"); + assert_eq!(root.end_ms, Some(1_767_225_605_000)); +} + // ---- compaction & subagent coverage -------------------------------------- fn append(path: &std::path::Path, v: Value) {