diff --git a/crates/tracedecay-capture/src/claude/canonical.rs b/crates/tracedecay-capture/src/claude/canonical.rs index a0a36864da..0b3569227b 100644 --- a/crates/tracedecay-capture/src/claude/canonical.rs +++ b/crates/tracedecay-capture/src/claude/canonical.rs @@ -42,10 +42,18 @@ pub struct ClaudeSpawnParent<'a> { pub fn normalize( native: &Value, session_id: &str, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { - normalize_spawned(native, session_id, None, stable_record_id, range) + normalize_spawned( + native, + session_id, + None, + transcript_path, + stable_record_id, + range, + ) } /// [`normalize`] for a record of a subagent transcript spawned by `parent`. @@ -53,10 +61,18 @@ pub fn normalize_spawned( native: &Value, session_id: &str, parent: Option>, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { - normalize_record(native, session_id, parent, stable_record_id, range) + normalize_record( + native, + session_id, + parent, + transcript_path, + stable_record_id, + range, + ) } /// One source-record canonicalization, not a per-block walk. @@ -65,6 +81,7 @@ fn normalize_record( native: &Value, session_id: &str, parent: Option>, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { @@ -80,7 +97,7 @@ fn normalize_record( .and_then(parse_rfc3339_timestamp); let mut facts = Vec::new(); - append_session_location_fact(&mut facts, native); + append_session_location_fact(&mut facts, native, transcript_path); if matches!(record_kind, "user" | "assistant") { let message = native.get("message").unwrap_or(native); // Message facts carry only provider-authored visible text. Thinking, @@ -261,19 +278,23 @@ fn tool_result_only_message_content(message: &Value) -> Option { (!parts.is_empty()).then(|| Value::String(parts.join("\n\n"))) } -fn append_session_location_fact(facts: &mut Vec, native: &Value) { - let Some(cwd) = native +fn append_session_location_fact( + facts: &mut Vec, + native: &Value, + transcript_path: Option<&str>, +) { + let cwd = native .get("cwd") .and_then(Value::as_str) .filter(|cwd| !cwd.is_empty()) - .map(str::to_owned) - else { + .map(str::to_owned); + if cwd.is_none() && transcript_path.is_none() { return; - }; + } facts.push(CanonicalObservationFactV1::Session { - project_path: Some(cwd.clone()), - location_path: Some(cwd), - transcript_path: None, + project_path: cwd.clone(), + location_path: cwd, + transcript_path: transcript_path.map(str::to_owned), title: None, started_at: None, ended_at: None, @@ -721,7 +742,7 @@ mod provider_usage_tests { ObservationSourceRangeV1::new(20, 30).unwrap(), ), ] { - let envelope = normalize(native, "session.fixture", stable_id, range).unwrap(); + let envelope = normalize(native, "session.fixture", None, stable_id, range).unwrap(); assert_eq!( envelope.relations().message_id().map(ObservationId::as_str), Some("message.shared") @@ -747,6 +768,7 @@ mod provider_usage_tests { let envelope = normalize( &native, "session.fixture", + None, ObservationId::new("message.fixture").unwrap(), ObservationSourceRangeV1::new(10, 20).unwrap(), ) @@ -781,6 +803,7 @@ mod provider_usage_tests { let envelope = normalize( &native, "session.fixture", + None, ObservationId::new("message.user-fixture").unwrap(), ObservationSourceRangeV1::new(20, 30).unwrap(), ) diff --git a/crates/tracedecay-capture/src/codex.rs b/crates/tracedecay-capture/src/codex.rs index 4790696b88..2c5056d85f 100644 --- a/crates/tracedecay-capture/src/codex.rs +++ b/crates/tracedecay-capture/src/codex.rs @@ -125,19 +125,23 @@ pub fn normalize_codex_observation( ) } +/// The rollout context in effect for one record: where the session projects, +/// which file it was read from, and the model its turn runs under. #[derive(Clone, Copy)] -pub struct CodexObservationLocation<'a> { +pub struct CodexObservationContext<'a> { pub project_path: Option<&'a Path>, pub location_path: Option<&'a Path>, + pub transcript_path: Option<&'a Path>, + pub model: Option<&'a str>, } -pub fn normalize_codex_observation_with_location( +pub fn normalize_codex_observation_with_context( native: &Value, session_id: &str, native_thread_id: Option<&str>, stable_record_id: ObservationId, range: tracedecay_domain::ObservationSourceRangeV1, - location: CodexObservationLocation<'_>, + context: CodexObservationContext<'_>, ) -> Result { normalize_codex_observation_inner( native, @@ -145,7 +149,7 @@ pub fn normalize_codex_observation_with_location( native_thread_id, stable_record_id, range, - Some(location), + Some(context), ) } @@ -158,7 +162,7 @@ fn normalize_codex_observation_inner( native_thread_id: Option<&str>, stable_record_id: ObservationId, range: tracedecay_domain::ObservationSourceRangeV1, - location: Option>, + context: Option>, ) -> Result { normalize_codex_record( native, @@ -166,7 +170,7 @@ fn normalize_codex_observation_inner( native_thread_id, stable_record_id, range, - location, + context, ) } @@ -178,7 +182,7 @@ fn normalize_codex_record( native_thread_id: Option<&str>, stable_record_id: ObservationId, range: tracedecay_domain::ObservationSourceRangeV1, - location: Option>, + context: Option>, ) -> Result { let native_kind = native .get("type") @@ -218,15 +222,17 @@ fn normalize_codex_record( relations = append_codex_session_meta_agent_relations(relations, payload, native_thread_id); } let mut facts = Vec::new(); - if let Some(location) = location { + if let Some(context) = context { facts.push(CanonicalObservationFactV1::Session { - project_path: location + project_path: context .project_path .map(|path| path.to_string_lossy().into_owned()), - location_path: location + location_path: context .location_path .map(|path| path.to_string_lossy().into_owned()), - transcript_path: None, + transcript_path: context + .transcript_path + .map(|path| path.to_string_lossy().into_owned()), title: None, started_at: None, ended_at: None, @@ -256,9 +262,19 @@ fn normalize_codex_record( native_kind: "turn_context".to_string(), state: CanonicalUnknownStateV1::Unsupported, }), - "event_msg" => append_codex_event_facts(payload, timestamp, &mut facts), + "event_msg" => append_codex_event_facts( + payload, + timestamp, + context.and_then(|context| context.model), + &mut facts, + ), "response_item" => { - append_codex_response_item_facts(payload, timestamp, &mut facts); + append_codex_response_item_facts( + payload, + timestamp, + context.and_then(|context| context.model), + &mut facts, + ); } "compacted" => { facts.push(CanonicalObservationFactV1::Compaction { @@ -359,6 +375,7 @@ fn append_codex_session_meta_agent_relations( fn append_codex_event_facts( payload: &Value, timestamp: Option, + context_model: Option<&str>, facts: &mut Vec, ) { match payload.get("type").and_then(Value::as_str) { @@ -375,7 +392,8 @@ fn append_codex_event_facts( model: payload .get("model") .and_then(Value::as_str) - .map(str::to_string), + .map(str::to_string) + .or_else(|| context_model.map(str::to_string)), timestamp, }); } @@ -419,7 +437,8 @@ fn append_codex_event_facts( .get("model") .or_else(|| payload.get("model")) .and_then(Value::as_str) - .map(str::to_string), + .map(str::to_string) + .or_else(|| context_model.map(str::to_string)), timestamp, }); } @@ -809,6 +828,7 @@ fn append_codex_update_plan_lifecycle_fact( fn append_codex_response_item_facts( payload: &Value, timestamp: Option, + context_model: Option<&str>, facts: &mut Vec, ) { let Some(item_kind) = payload.get("type").and_then(Value::as_str) else { @@ -842,7 +862,8 @@ fn append_codex_response_item_facts( model: payload .get("model") .and_then(Value::as_str) - .map(str::to_string), + .map(str::to_string) + .or_else(|| context_model.map(str::to_string)), timestamp, }); } @@ -1028,7 +1049,7 @@ mod provider_usage_tests { ProviderUsageModelV1, ProviderUsageScopeV1, }; - use super::{CodexObservationLocation, normalize_codex_observation_with_location}; + use super::{CodexObservationContext, normalize_codex_observation_with_context}; #[test] fn canonical_payload_uses_only_bound_source_location() { @@ -1045,21 +1066,26 @@ mod provider_usage_tests { } } }); - let canonical = normalize_codex_observation_with_location( + let canonical = normalize_codex_observation_with_context( &native, "session.fixture", Some("thread.fixture"), ObservationId::new("record.fixture").unwrap(), ObservationSourceRangeV1::new(10, 20).unwrap(), - CodexObservationLocation { + CodexObservationContext { project_path: Some(Path::new("/redacted/project")), location_path: Some(Path::new("/redacted/project")), + transcript_path: Some(Path::new("/redacted/project/rollout.jsonl")), + model: Some("gpt-5.5"), }, ) .unwrap(); let encoded = serde_json::to_value(canonical).unwrap(); - assert!(encoded["facts"][0].get("transcript_path").is_none()); + assert_eq!( + encoded["facts"][0]["transcript_path"].as_str(), + Some("/redacted/project/rollout.jsonl") + ); assert!(encoded["relations"].get("turn_id").is_none()); assert_eq!(encoded["facts"][1]["model"]["state"], "unknown"); } @@ -1089,15 +1115,17 @@ mod provider_usage_tests { } } }); - let envelope = normalize_codex_observation_with_location( + let envelope = normalize_codex_observation_with_context( &native, "session.fixture", Some("thread.fixture"), ObservationId::new("record.fixture").unwrap(), ObservationSourceRangeV1::new(10, 20).unwrap(), - CodexObservationLocation { + CodexObservationContext { project_path: None, location_path: None, + transcript_path: None, + model: None, }, ) .unwrap(); @@ -1151,15 +1179,17 @@ mod provider_usage_tests { } } }); - let envelope = normalize_codex_observation_with_location( + let envelope = normalize_codex_observation_with_context( &native, "session.fixture", Some("thread.fixture"), ObservationId::new("record.fixture").unwrap(), ObservationSourceRangeV1::new(10, 20).unwrap(), - CodexObservationLocation { + CodexObservationContext { project_path: None, location_path: None, + transcript_path: None, + model: None, }, ) .unwrap(); @@ -1211,15 +1241,17 @@ mod provider_usage_tests { } } }); - let envelope = normalize_codex_observation_with_location( + let envelope = normalize_codex_observation_with_context( &native, "session.fixture", Some("thread-1"), ObservationId::new("record.fixture").unwrap(), ObservationSourceRangeV1::new(10, 20).unwrap(), - CodexObservationLocation { + CodexObservationContext { project_path: None, location_path: None, + transcript_path: None, + model: Some("gpt-ctx"), }, ) .unwrap(); diff --git a/crates/tracedecay-capture/src/kimi.rs b/crates/tracedecay-capture/src/kimi.rs index 0c2b64a5e9..b7c4eb23f6 100644 --- a/crates/tracedecay-capture/src/kimi.rs +++ b/crates/tracedecay-capture/src/kimi.rs @@ -28,10 +28,11 @@ pub fn native_record_id( pub fn normalize_observation( native: &Value, session_id: &str, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { - normalize_kimi_record(native, session_id, stable_record_id, range) + normalize_kimi_record(native, session_id, transcript_path, stable_record_id, range) } /// One source-record canonicalization, not a per-item walk. @@ -39,6 +40,7 @@ pub fn normalize_observation( fn normalize_kimi_record( native: &Value, session_id: &str, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { @@ -111,6 +113,26 @@ fn normalize_kimi_record( }), } + // A session's transcript is its main agent's wire: only main-agent wires + // pass a path, and it rides every durable record of that wire. + if let Some(transcript_path) = transcript_path { + facts.insert( + 0, + CanonicalObservationFactV1::Session { + project_path: None, + location_path: None, + transcript_path: Some(transcript_path.to_owned()), + title: None, + started_at: None, + ended_at: None, + source: Some("kimi_wire".to_owned()), + native_source: Some(PROVIDER.to_owned()), + profile: None, + location_provenance: None, + }, + ); + } + let timestamp = record.timestamp.and_then(timestamp_secs); let mut evidence = CanonicalObservationEvidenceV1::new(ObservationOrderingDomainV1::FileBytes, range); @@ -389,6 +411,7 @@ mod tests { normalize_observation( &native, "session-current", + None, native_record_id("session-current", range).unwrap(), range, ) diff --git a/crates/tracedecay-capture/src/kiro.rs b/crates/tracedecay-capture/src/kiro.rs index 9258744100..3934c32411 100644 --- a/crates/tracedecay-capture/src/kiro.rs +++ b/crates/tracedecay-capture/src/kiro.rs @@ -17,6 +17,7 @@ pub struct KiroSnapshotMessage<'a> { pub text: &'a str, pub kind: Option<&'a str>, pub model: Option<&'a str>, + pub transcript_path: Option<&'a str>, } /// Shapes only the Kiro fields evidenced by checked-in transcript fixtures. @@ -53,6 +54,12 @@ fn snapshot_kiro_record(message: KiroSnapshotMessage<'_>) -> Value { if let Some(model) = message.model { fields.insert("model".to_string(), Value::String(model.to_string())); } + if let Some(transcript_path) = message.transcript_path { + fields.insert( + "transcript_path".to_string(), + Value::String(transcript_path.to_string()), + ); + } Value::Object(fields) } diff --git a/crates/tracedecay-capture/src/opencode.rs b/crates/tracedecay-capture/src/opencode.rs index 5593b70f6c..297688e7f7 100644 --- a/crates/tracedecay-capture/src/opencode.rs +++ b/crates/tracedecay-capture/src/opencode.rs @@ -88,6 +88,27 @@ fn normalize_opencode_record( if facts.is_empty() { return Err(ObservationRecordParseErrorV1::Empty); } + if let Some(transcript_path) = native + .get("transcript_path") + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + { + facts.insert( + 0, + CanonicalObservationFactV1::Session { + project_path: None, + location_path: None, + transcript_path: Some(transcript_path.to_owned()), + title: None, + started_at: None, + ended_at: None, + source: Some("opencode_state_db".to_owned()), + native_source: Some(PROVIDER.to_owned()), + profile: None, + location_provenance: None, + }, + ); + } let timestamp = created_at; let mut evidence = diff --git a/crates/tracedecay-capture/src/pi.rs b/crates/tracedecay-capture/src/pi.rs index eb71c5682f..78dca7d0e9 100644 --- a/crates/tracedecay-capture/src/pi.rs +++ b/crates/tracedecay-capture/src/pi.rs @@ -70,15 +70,17 @@ pub fn native_record_id( pub fn normalize_observation( native: &Value, session_id: &str, + transcript_path: Option<&str>, range: ObservationSourceRangeV1, ) -> Result { - normalize_pi_entry(native, session_id, range) + normalize_pi_entry(native, session_id, transcript_path, range) } #[tracing::instrument(name = "capture.pi.normalize", level = "trace", skip_all)] fn normalize_pi_entry( native: &Value, session_id: &str, + transcript_path: Option<&str>, range: ObservationSourceRangeV1, ) -> Result { let kind = native @@ -102,7 +104,12 @@ fn normalize_pi_entry( relations = relations.with_parent_session_id(SessionId::new(parent).map_err(|_| invalid())?); } - facts.push(session_fact(Some(header.cwd), None, timestamp)); + facts.push(session_fact( + Some(header.cwd), + transcript_path.map(str::to_owned), + None, + timestamp, + )); ( native_record_id(session_id, HEADER_RECORD_ID)?, kind.to_owned(), @@ -143,7 +150,12 @@ fn normalize_pi_entry( } "session_info" => { let title = non_empty_str(native, "name").map(str::to_owned); - facts.push(session_fact(None, title, None)); + facts.push(session_fact( + None, + transcript_path.map(str::to_owned), + title, + None, + )); kind.to_owned() } other => { @@ -151,6 +163,19 @@ fn normalize_pi_entry( other.to_owned() } }; + // The `session` header and `session_info` records carry richer Session + // facts but are skipped as non-conversational; the transcript path must + // ride the conversational records or it never reaches the row. It goes + // in after content normalization so an empty record still refuses with + // `Empty` instead of becoming a durable session-only observation. + if kind != "session_info" + && let Some(transcript_path) = transcript_path + { + facts.insert( + 0, + session_fact(None, Some(transcript_path.to_owned()), None, None), + ); + } (stable_record_id, native_kind) }; @@ -172,13 +197,14 @@ fn normalize_pi_entry( fn session_fact( project_path: Option, + transcript_path: Option, title: Option, started_at: Option, ) -> CanonicalObservationFactV1 { CanonicalObservationFactV1::Session { project_path, location_path: None, - transcript_path: None, + transcript_path, title, started_at, ended_at: None, diff --git a/crates/tracedecay-capture/src/vibe.rs b/crates/tracedecay-capture/src/vibe.rs index fcf5dca9df..9ad55c0c77 100644 --- a/crates/tracedecay-capture/src/vibe.rs +++ b/crates/tracedecay-capture/src/vibe.rs @@ -27,10 +27,19 @@ pub fn normalize_observation( session_id: &str, model: Option<&str>, location: Option<&str>, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { - normalize_vibe_record(native, session_id, model, location, stable_record_id, range) + normalize_vibe_record( + native, + session_id, + model, + location, + transcript_path, + stable_record_id, + range, + ) } /// One source-record canonicalization, not a per-call walk. @@ -40,6 +49,7 @@ fn normalize_vibe_record( session_id: &str, model: Option<&str>, location: Option<&str>, + transcript_path: Option<&str>, stable_record_id: ObservationId, range: ObservationSourceRangeV1, ) -> Result { @@ -65,13 +75,14 @@ fn normalize_vibe_record( let relations = CanonicalObservationRelationsV1::new(session_id).with_message_id(stable_record_id.clone()); let mut facts = Vec::new(); - if let Some(location) = location.filter(|path| !path.is_empty()) { + let location = location.filter(|path| !path.is_empty()); + if location.is_some() || transcript_path.is_some() { // `project_path` stays unset on purpose: session routing keeps its // scope-derived fallback, only the location metadata is projected. facts.push(CanonicalObservationFactV1::Session { project_path: None, - location_path: Some(location.to_owned()), - transcript_path: None, + location_path: location.map(str::to_owned), + transcript_path: transcript_path.map(str::to_owned), title: None, started_at: None, ended_at: None, diff --git a/crates/tracedecay-capture/tests/capture_suite/kiro.rs b/crates/tracedecay-capture/tests/capture_suite/kiro.rs index 6d246af3b8..36ffbcdb8d 100644 --- a/crates/tracedecay-capture/tests/capture_suite/kiro.rs +++ b/crates/tracedecay-capture/tests/capture_suite/kiro.rs @@ -70,6 +70,7 @@ fn checked_in_workspace_fixture_preserves_private_kernel_payload() { text, kind: Some("message"), model: input["modelId"].as_str(), + transcript_path: None, }); assert_eq!( diff --git a/crates/tracedecay-capture/tests/capture_suite/provider_identity.rs b/crates/tracedecay-capture/tests/capture_suite/provider_identity.rs index 83846086c5..5a91254664 100644 --- a/crates/tracedecay-capture/tests/capture_suite/provider_identity.rs +++ b/crates/tracedecay-capture/tests/capture_suite/provider_identity.rs @@ -354,6 +354,7 @@ fn vibe_checked_in_message_preserves_projection_without_workflow_inference() { "vibe-lookalike", Some("vibe-model"), None, + None, record_id.clone(), range, ) diff --git a/crates/tracedecay-capture/tests/capture_suite/provider_usage_capture.rs b/crates/tracedecay-capture/tests/capture_suite/provider_usage_capture.rs index af0f4b2a86..082a0284f2 100644 --- a/crates/tracedecay-capture/tests/capture_suite/provider_usage_capture.rs +++ b/crates/tracedecay-capture/tests/capture_suite/provider_usage_capture.rs @@ -243,6 +243,7 @@ fn claude_real_usage_shape_records_true_counts_and_model() { let envelope = claude::normalize( &native, "claude-real-session", + None, record_id, ObservationSourceRangeV1::new(0, 128).unwrap(), ) @@ -323,6 +324,7 @@ fn claude_api_error_placeholder_records_no_provider_usage() { let envelope = claude::normalize( &native, "claude-error-session", + None, record_id, ObservationSourceRangeV1::new(0, 128).unwrap(), ) @@ -350,6 +352,7 @@ fn claude_api_error_placeholder_records_no_provider_usage() { let envelope = claude::normalize( &billed, "claude-error-session", + None, record_id, ObservationSourceRangeV1::new(0, 128).unwrap(), ) diff --git a/crates/tracedecay-mcp/src/handlers/edit_check.rs b/crates/tracedecay-mcp/src/handlers/edit_check.rs index 29e3bbf185..62266d8845 100644 --- a/crates/tracedecay-mcp/src/handlers/edit_check.rs +++ b/crates/tracedecay-mcp/src/handlers/edit_check.rs @@ -335,8 +335,14 @@ mod tests { file: "caller.rs".to_owned(), line: 0, }; - let error = signature_edits(root.path(), &[target.clone()], &[target, caller], &[]) - .unwrap_err(); + let symbols = [target, caller]; + let error = signature_edits( + root.path(), + std::slice::from_ref(&symbols[0]), + &symbols, + &[], + ) + .unwrap_err(); assert!( matches!(error, TraceDecayError::Io(ref error) if error.kind() == std::io::ErrorKind::NotFound), "{missing}: {error}" diff --git a/crates/tracedecay-sessions/src/runtime/hosts/claude/frames.rs b/crates/tracedecay-sessions/src/runtime/hosts/claude/frames.rs index 204be27108..56cf771158 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/claude/frames.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/claude/frames.rs @@ -188,6 +188,7 @@ pub fn try_scan_claude_source_frames_with_resume( session_id, tool_use_id: tool_use_id.as_deref(), }); + let transcript_path = identity.source_path.to_string_lossy(); for frame in raw.frames.drain(..) { let Ok(range) = ObservationSourceRangeV1::new(frame.offset, frame.end_offset) else { return Ok(None); @@ -208,6 +209,7 @@ pub fn try_scan_claude_source_frames_with_resume( &native, &identity.session_id, spawn, + Some(transcript_path.as_ref()), stable_record_id, range, ) diff --git a/crates/tracedecay-sessions/src/runtime/hosts/claude/tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/claude/tests.rs index 33f28b0a08..cdd06f8147 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/claude/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/claude/tests.rs @@ -225,7 +225,7 @@ fn claude_checked_in_assistant_fixture_crosses_the_canonical_boundary() { tracedecay_domain::ObservationOrderingDomainV1::FileBytes, |native| { let stable = canonical::stable_record_id(&native, "claude-golden-session", 0)?; - canonical::normalize(&native, "claude-golden-session", stable, range) + canonical::normalize(&native, "claude-golden-session", None, stable, range) }, ) .unwrap(); @@ -282,7 +282,7 @@ fn claude_checked_in_mixed_blocks_keep_authored_message_and_typed_order() { tracedecay_domain::ObservationOrderingDomainV1::FileBytes, |native| { let stable = canonical::stable_record_id(&native, "claude-mixed-session", 0)?; - canonical::normalize(&native, "claude-mixed-session", stable, range) + canonical::normalize(&native, "claude-mixed-session", None, stable, range) }, ) .unwrap(); @@ -365,7 +365,7 @@ fn claude_workflow_lookalike_emits_no_workflow_lifecycle() { tracedecay_domain::ObservationOrderingDomainV1::FileBytes, |native| { let stable = canonical::stable_record_id(&native, "claude-workflow-lookalike", 0)?; - canonical::normalize(&native, "claude-workflow-lookalike", stable, range) + canonical::normalize(&native, "claude-workflow-lookalike", None, stable, range) }, ) .expect("Claude workflow lookalike must still normalize as an assistant message"); @@ -449,7 +449,7 @@ fn claude_task_create_and_update_emit_workflow_lifecycle_facts() { tracedecay_domain::ObservationOrderingDomainV1::FileBytes, |native| { let stable = canonical::stable_record_id(&native, "claude-task-session", 0)?; - canonical::normalize(&native, "claude-task-session", stable, range) + canonical::normalize(&native, "claude-task-session", None, stable, range) }, ) .unwrap(); diff --git a/crates/tracedecay-sessions/src/runtime/hosts/claude_observation/tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/claude_observation/tests.rs index 26211363e8..e3093704a7 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/claude_observation/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/claude_observation/tests.rs @@ -247,12 +247,13 @@ async fn assert_invalid_suffix_preserves_valid_prefix( #[tokio::test] async fn production_vertical_persists_only_sanitized_payload_and_searchable_v1_row() { - let fixture = Fixture::new("production-session"); + let session_id = "7f3e2a1b-9c4d-4e5f-a6b7-c8d9e0f1a2b3"; + let fixture = Fixture::new(session_id); fixture.write_record( "production vertical searchable", "never-persist-this-secret", ); - let source = fixture.source("production-session"); + let source = fixture.source(session_id); assert_eq!( source .discover_transcript_paths(TranscriptDiscoveryBounds::default_walk()) @@ -312,7 +313,7 @@ async fn production_vertical_persists_only_sanitized_payload_and_searchable_v1_r assert_eq!( normalized, format!( - r#"{{"evidence":{{"native_timestamp":1784073600,"ordering_domain":"file_bytes","range":{{"end":{end},"start":0}}}},"facts":[{{"kind":"session","location_path":"/fixture","location_provenance":"transcript_record","project_path":"/fixture","source":"claude_transcript"}},{{"content":"production vertical searchable","kind":"message","role":"user","timestamp":1784073600}}],"native_record_kind":"user","provider":"claude","relations":{{"message_id":"message-production-vertical","session_id":"production-session"}},"stable_record_id":"message-production-vertical","version":1}}"# + r#"{{"evidence":{{"native_timestamp":1784073600,"ordering_domain":"file_bytes","range":{{"end":{end},"start":0}}}},"facts":[{{"kind":"session","location_path":"/fixture","location_provenance":"transcript_record","project_path":"/fixture","source":"claude_transcript","transcript_path":"/fixture/home/.[TraceDecay redacted: high-entropy token].jsonl"}},{{"content":"production vertical searchable","kind":"message","role":"user","timestamp":1784073600}}],"native_record_kind":"user","provider":"claude","relations":{{"message_id":"message-production-vertical","session_id":"7f3e2a1b-9c4d-4e5f-a6b7-c8d9e0f1a2b3"}},"stable_record_id":"message-production-vertical","version":1}}"# ) ); assert!(!payload.contains("never-persist-this-secret")); diff --git a/crates/tracedecay-sessions/src/runtime/hosts/cline_like.rs b/crates/tracedecay-sessions/src/runtime/hosts/cline_like.rs index f526231ebe..69fecac459 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/cline_like.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/cline_like.rs @@ -866,6 +866,14 @@ fn snapshot_native_payload( payload.remove("model"); } else { insert_snapshot_location(&mut payload, metadata, CLINE_LIKE_LOCATION_KEYS); + // Only the api-history stream is the session transcript: usage rows + // replay the ui_messages file, which holds no conversation of its own. + if let Some(source_path) = &message.source_path { + payload.insert( + "transcript_path".to_string(), + Value::String(source_path.clone()), + ); + } if let Some(tool_names) = &message.tool_names { payload.insert("tool_names".to_string(), Value::String(tool_names.clone())); } diff --git a/crates/tracedecay-sessions/src/runtime/hosts/codex/context.rs b/crates/tracedecay-sessions/src/runtime/hosts/codex/context.rs index c3883cf4f5..09d71176d2 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/codex/context.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/codex/context.rs @@ -14,26 +14,39 @@ use crate::runtime::source::MAX_JSONL_RECORD_BYTES; const PRIOR_CONTEXT_CHUNK_BYTES: u64 = 8 * 1024; /// The rollout context a resumed record inherits from the records before it. +/// The model may be absent: real rollouts carry it on `turn_context` records, +/// which not every rollout contains. #[derive(Clone)] pub(super) struct CodexContextState { pub(super) cwd: PathBuf, + pub(super) model: Option, +} + +/// What a context record updates; each field stays put when the record does +/// not carry it. +#[derive(Default)] +struct ContextValues { + cwd: Option, + model: Option, } impl CodexContextState { pub(super) fn from_meta(meta: &CodexMeta) -> Self { Self { cwd: meta.cwd.clone(), + model: meta.model.clone(), } } /// Context in effect at `before_offset`, with the bytes read to rebuild it. /// - /// The cwd is set by the last context record before the cursor, normally - /// the current turn's, so the walk runs backwards from the cursor and stops - /// there instead of replaying the prefix. A cached context for an earlier - /// offset of the same JSONL `generation` also stops it at that offset: - /// one generation is one byte stream, so the bytes before that offset are - /// the ones the cached context was read from. + /// Each context field comes from the last record before the cursor that + /// sets it, normally the current turn's, so the walk runs backwards from + /// the cursor and stops once every field is found instead of replaying + /// the prefix. A cached context for an earlier offset of the same JSONL + /// `generation` also stops it at that offset: one generation is one byte + /// stream, so the bytes before that offset are the ones the cached + /// context was read from. #[tracing::instrument(name = "sessions.hosts.codex.scan_prior", level = "trace", skip_all)] pub(super) fn scan_prior( path: &Path, @@ -53,24 +66,33 @@ impl CodexContextState { |(state, offset)| (offset, state), ); let (state, read) = - match Self::last_context_cwd(&mut file, floor, before_offset, path, meta) { - Ok((cwd, read)) => (cwd.map_or(floor_state, |cwd| Self { cwd }), read), + match Self::last_context_values(&mut file, floor, before_offset, path, meta) { + Ok((values, read)) => ( + Self { + cwd: values.cwd.unwrap_or(floor_state.cwd), + model: values.model.or(floor_state.model), + }, + read, + ), Err(read) => return (Self::from_meta(meta), read), }; store_prior_context(path, generation, before_offset, state.clone()); (state, read) } - /// The cwd the last context record of `[floor, before_offset)` sets, with - /// the bytes read to find it. `floor` is a record boundary. - fn last_context_cwd( + /// The values the last context records of `[floor, before_offset)` set, + /// with the bytes read to find them. `floor` is a record boundary. The + /// walk stops once every field resolves; a field found nowhere before the + /// floor stays `None` for the caller to merge with the floor state. + fn last_context_values( file: &mut File, floor: u64, before_offset: u64, path: &Path, meta: &CodexMeta, - ) -> Result<(Option, u64), u64> { + ) -> Result<(ContextValues, u64), u64> { let mut read = 0_u64; + let mut found = ContextValues::default(); let mut chunk = Vec::new(); // Pieces of the line whose start the walk has not reached, last first. let mut pieces: Vec> = Vec::new(); @@ -79,7 +101,7 @@ impl CodexContextState { // inside, which no prior context includes. let mut in_cursor_record = true; let mut end = before_offset; - while end > floor { + while end > floor && (found.cwd.is_none() || found.model.is_none()) { let start = end.saturating_sub(PRIOR_CONTEXT_CHUNK_BYTES).max(floor); chunk.resize(usize::try_from(end - start).map_err(|_| read)?, 0); file.seek(SeekFrom::Start(start)).map_err(|_| read)?; @@ -87,11 +109,18 @@ impl CodexContextState { read += end - start; let mut rest = &chunk[..]; while let Some(newline) = rest.iter().rposition(|byte| *byte == b'\n') { - if !in_cursor_record - && let Some(cwd) = - line_context_cwd(&rest[newline + 1..], &pieces, pieces_len, path, meta) - { - return Ok((Some(cwd), read)); + if !in_cursor_record { + let values = + line_context_values(&rest[newline + 1..], &pieces, pieces_len, path, meta); + if found.cwd.is_none() { + found.cwd = values.cwd; + } + if found.model.is_none() { + found.model = values.model; + } + if found.cwd.is_some() && found.model.is_some() { + return Ok((found, read)); + } } in_cursor_record = false; pieces.clear(); @@ -107,10 +136,17 @@ impl CodexContextState { } end = start; } - let first = (!in_cursor_record) - .then(|| line_context_cwd(&[], &pieces, pieces_len, path, meta)) - .flatten(); - Ok((first, read)) + if in_cursor_record { + return Ok((found, read)); + } + let first = line_context_values(&[], &pieces, pieces_len, path, meta); + if found.cwd.is_none() { + found.cwd = first.cwd; + } + if found.model.is_none() { + found.model = first.model; + } + Ok((found, read)) } pub(super) fn observe_context_record( @@ -119,11 +155,14 @@ impl CodexContextState { path: &Path, meta: &CodexMeta, ) -> bool { - match context_cwd(record, path, meta) { - Some(cwd) => { - if let Some(cwd) = cwd { + match context_values(record, path, meta) { + Some(values) => { + if let Some(cwd) = values.cwd { self.cwd = cwd; } + if let Some(model) = values.model { + self.model = Some(model); + } true } None => false, @@ -131,26 +170,33 @@ impl CodexContextState { } } -/// What `record` does to the rollout cwd: `None` when it is no context record, -/// `Some(None)` when it is one that leaves the cwd as it was. -fn context_cwd(record: &Value, path: &Path, meta: &CodexMeta) -> Option> { +/// What `record` does to the rollout context: `None` when it is no context +/// record, `Some` of the fields it sets when it is one. +fn context_values(record: &Value, path: &Path, meta: &CodexMeta) -> Option { if let Some(updated) = session_meta_from_record(record, path) { - return Some((updated.session_id == meta.session_id).then_some(updated.cwd)); + let same_session = updated.session_id == meta.session_id; + return Some(ContextValues { + cwd: same_session.then_some(updated.cwd), + model: same_session.then_some(updated.model).flatten(), + }); } - turn_context_from_record(record).map(|context| context.cwd) + turn_context_from_record(record).map(|context| ContextValues { + cwd: context.cwd, + model: context.model, + }) } -/// The cwd the line `head` + `pieces` (stored last first) sets, if it is a -/// context record that sets one. -fn line_context_cwd( +/// The values the line `head` + `pieces` (stored last first) sets, if it is a +/// context record. +fn line_context_values( head: &[u8], pieces: &[Vec], pieces_len: usize, path: &Path, meta: &CodexMeta, -) -> Option { +) -> ContextValues { if head.len() + pieces_len >= MAX_JSONL_RECORD_BYTES { - return None; + return ContextValues::default(); } let assembled; let line = if pieces.is_empty() { @@ -166,10 +212,12 @@ fn line_context_cwd( &assembled[..] }; if !jsonl_frame_hints(line).may_change_codex_context { - return None; + return ContextValues::default(); } - let record = serde_json::from_slice::(line).ok()?; - context_cwd(&record, path, meta).flatten() + let Some(record) = serde_json::from_slice::(line).ok() else { + return ContextValues::default(); + }; + context_values(&record, path, meta).unwrap_or_default() } /// Bounded cache of resumed prior-context state keyed by rollout path, so the diff --git a/crates/tracedecay-sessions/src/runtime/hosts/codex/context/tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/codex/context/tests.rs index fa2c3747be..969f0d0948 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/codex/context/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/codex/context/tests.rs @@ -35,7 +35,7 @@ fn message(index: usize) -> String { } /// A rollout whose session meta sets `/a`, followed by a long first turn, -/// a turn context setting `/b`, records that leave the cwd alone, and a +/// a turn context setting `/b`, records that leave the context alone, and a /// final turn context setting `/d`. Returns the offsets of the `/b` turn /// context and the start and end of the `/d` one. fn rollout(path: &Path) -> (u64, u64, u64) { @@ -45,15 +45,21 @@ fn rollout(path: &Path) -> (u64, u64, u64) { })); contents.extend((0..300).map(message)); let set_b = contents.len() as u64; - contents += &line(json!({"type": "turn_context", "payload": {"cwd": "/b"}})); + contents += &line(json!({ + "type": "turn_context", + "payload": {"cwd": "/b", "model": "m-b"} + })); contents += &line(json!({"type": "turn_context", "payload": {"turn_id": "turn.2"}})); contents += &line(json!({ "type": "session_meta", - "payload": {"id": "session.other", "cwd": "/c"} + "payload": {"id": "session.other", "cwd": "/c", "model": "m-c"} })); contents.extend((300..310).map(message)); let set_d = contents.len() as u64; - contents += &line(json!({"type": "turn_context", "payload": {"cwd": "/d"}})); + contents += &line(json!({ + "type": "turn_context", + "payload": {"cwd": "/d", "model": "m-d"} + })); let after_d = contents.len() as u64; contents += &message(310); std::fs::write(path, contents).unwrap(); @@ -69,6 +75,7 @@ fn prior_context_is_the_last_cwd_set_before_the_cursor() { let (state, read) = CodexContextState::scan_prior(&path, GENERATION, set_d, &meta); assert_eq!(state.cwd, Path::new("/b")); + assert_eq!(state.model.as_deref(), Some("m-b")); assert_eq!( read, PRIOR_CONTEXT_CHUNK_BYTES, "the walk stops at the current turn instead of reading the {set_d}-byte prefix" @@ -81,10 +88,12 @@ fn prior_context_is_the_last_cwd_set_before_the_cursor() { Path::new("/b"), "the record the cursor sits inside is not prior context" ); + assert_eq!(inside_record.model.as_deref(), Some("m-b")); evict_prior_context_for_test(&path); let (first_turn, read) = CodexContextState::scan_prior(&path, GENERATION, set_b, &meta); assert_eq!(first_turn.cwd, Path::new("/a")); + assert_eq!(first_turn.model, None); assert_eq!( read, set_b, "without a turn context the walk reaches the session meta" @@ -92,6 +101,7 @@ fn prior_context_is_the_last_cwd_set_before_the_cursor() { let (replayed, read) = CodexContextState::scan_prior(&path, GENERATION, after_d, &meta); assert_eq!(replayed.cwd, Path::new("/d")); + assert_eq!(replayed.model.as_deref(), Some("m-d")); assert_eq!( read, after_d - set_b, @@ -119,6 +129,7 @@ fn another_generation_does_not_resume_the_cached_context() { Path::new("/e"), "a rewrite's generation reads the cwd from its own bytes, not the cached one" ); + assert_eq!(rewound.model.as_deref(), Some("m-b")); assert_eq!(read, PRIOR_CONTEXT_CHUNK_BYTES); } @@ -131,13 +142,18 @@ fn turn_context_without_cwd_keeps_the_current_one() { Path::new("/tmp/rollout.jsonl"), &meta, )); + assert!(state.observe_context_record( + &json!({"type": "turn_context", "payload": {"model": "m-new"}}), + Path::new("/tmp/rollout.jsonl"), + &meta, + )); assert!(state.observe_context_record( &json!({"type": "turn_context", "payload": {}}), Path::new("/tmp/rollout.jsonl"), &meta, )); assert!(state.observe_context_record( - &json!({"type": "session_meta", "payload": {"id": "session.other", "cwd": "/c"}}), + &json!({"type": "session_meta", "payload": {"id": "session.other", "cwd": "/c", "model": "m-c"}}), Path::new("/tmp/rollout.jsonl"), &meta, )); @@ -147,4 +163,5 @@ fn turn_context_without_cwd_keeps_the_current_one() { &meta, )); assert_eq!(state.cwd, Path::new("/b")); + assert_eq!(state.model.as_deref(), Some("m-new")); } diff --git a/crates/tracedecay-sessions/src/runtime/hosts/codex/observation.rs b/crates/tracedecay-sessions/src/runtime/hosts/codex/observation.rs index b16b9ae09e..d15f48f97e 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/codex/observation.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/codex/observation.rs @@ -11,8 +11,8 @@ pub(super) use tracedecay_capture::codex::codex_native_record_id; #[cfg(test)] pub use tracedecay_capture::codex::normalize_codex_observation; use tracedecay_capture::codex::{ - CodexObservationLocation, codex_observation_record_supported, - normalize_codex_observation_with_location, + CodexObservationContext, codex_observation_record_supported, + normalize_codex_observation_with_context, }; use tracedecay_domain::canonical_text::encode_lowercase_hex; use tracedecay_domain::{ @@ -443,19 +443,24 @@ impl CodexObservationAdmission<'_> { /// profile rollout's on its session cwd, while each record's location is /// the cwd in effect when it was written. That is the linked worktree the /// record ran in whenever it differs from the root, and a `turn_context` - /// can move it mid-rollout. - fn projection_location<'b>( + /// can move it mid-rollout. The source file the record was read from and + /// the turn's model complete the projection context. + fn observation_context<'b>( &'b self, session_cwd: &'b Path, record_cwd: &'b Path, - ) -> CodexObservationLocation<'b> { + transcript_path: &'b Path, + model: Option<&'b str>, + ) -> CodexObservationContext<'b> { let project_path = match self { Self::Project { root, .. } => *root, Self::Profile { .. } => session_cwd, }; - CodexObservationLocation { + CodexObservationContext { project_path: Some(project_path), location_path: Some(record_cwd), + transcript_path: Some(transcript_path), + model, } } } @@ -719,14 +724,18 @@ async fn admit_codex_jsonl_page( } let record_id = codex_native_record_id(&meta.session_id, native) .map_err(|_| ObservationRecordParseErrorV1::NormalizationFailed)?; - let envelope = normalize_codex_observation_with_location( + let envelope = normalize_codex_observation_with_context( native, &meta.session_id, native_thread_id, record_id.clone(), range, - admission_scope - .projection_location(meta.cwd.as_path(), state.context.cwd.as_path()), + admission_scope.observation_context( + meta.cwd.as_path(), + state.context.cwd.as_path(), + path, + state.context.model.as_deref(), + ), )?; stable_record_id = Some(record_id); Ok(envelope) diff --git a/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs b/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs index 411483a483..4228428b6b 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/hermes/observation.rs @@ -38,6 +38,7 @@ pub struct HermesProjectionMetadata { pub location_path: Option, pub profile: Option, pub location_provenance: Option<&'static str>, + pub transcript_path: Option, } pub(super) fn project_projection_metadata( @@ -57,6 +58,7 @@ pub(super) fn project_projection_metadata( location_path: Some(presentation_path.to_string_lossy().into_owned()), profile: source.profile.clone(), location_provenance: Some(location_provenance), + transcript_path: Some(source.state_db.to_string_lossy().into_owned()), } } @@ -100,6 +102,7 @@ struct HermesNativeObservation { source: Option, profile: Option, location_provenance: Option, + transcript_path: Option, } #[derive(serde::Deserialize)] @@ -159,6 +162,7 @@ pub fn native_observation_record( "source": row.session_source, "profile": projection.profile, "location_provenance": projection.location_provenance, + "transcript_path": projection.transcript_path, "usage": include_session_usage.then(|| json!({ "input_tokens": row.session_input_tokens, "output_tokens": row.session_output_tokens, @@ -242,7 +246,7 @@ pub fn normalize_native_observation( let mut facts = vec![CanonicalObservationFactV1::Session { project_path: native.project_path, location_path: native.location_path, - transcript_path: None, + transcript_path: native.transcript_path, title: native.title, started_at: native.started_at.map(|value| value as i64), ended_at: native.ended_at.map(|value| value as i64), diff --git a/crates/tracedecay-sessions/src/runtime/hosts/hermes/state_db.rs b/crates/tracedecay-sessions/src/runtime/hosts/hermes/state_db.rs index ff52d6368f..934d1e9729 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/hermes/state_db.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/hermes/state_db.rs @@ -657,6 +657,7 @@ pub(super) async fn try_ingest_user_state_db_bounded_with_admission( |bounded| { let locations = user_turn_locations(bounded, source); let profile = source.profile.clone(); + let transcript_path = source.state_db.to_string_lossy().into_owned(); let fallback_provenance = "session_cwd"; move |row: &HermesRow| { locations @@ -666,6 +667,7 @@ pub(super) async fn try_ingest_user_state_db_bounded_with_admission( location_path: None, profile: profile.clone(), location_provenance: Some(fallback_provenance), + transcript_path: Some(transcript_path.clone()), }) } }, diff --git a/crates/tracedecay-sessions/src/runtime/hosts/hermes/tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/hermes/tests.rs index fb57cd6d81..fdb1ed4f2b 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/hermes/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/hermes/tests.rs @@ -146,6 +146,7 @@ fn fixture_projection() -> HermesProjectionMetadata { location_path: None, profile: Some("fixture".to_string()), location_provenance: None, + transcript_path: None, } } diff --git a/crates/tracedecay-sessions/src/runtime/hosts/kimi.rs b/crates/tracedecay-sessions/src/runtime/hosts/kimi.rs index 4decad93a8..4d9974993d 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/kimi.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/kimi.rs @@ -683,7 +683,7 @@ pub async fn capture_kimi_observations( } Err(error) => return Err(error), }; - let (session_id, agent_id) = match kimi_session_identity(&path) { + let (session_id, agent_id, is_main_agent) = match kimi_session_identity(&path) { Ok(identity) => identity, Err(_) => { warn_isolated_source(&path, "invalid_source_identity"); @@ -728,6 +728,7 @@ pub async fn capture_kimi_observations( let retention = RetentionClass::new("transcript.kimi.v1").map_err(|_| invalid_frame())?; let native_record_prefix = format!("{session_id}:{agent_id}"); + let transcript_path = is_main_agent.then(|| path.to_string_lossy().into_owned()); let request = JsonlObservationAdmissionRequest::new( PROVIDER, &path, @@ -753,6 +754,7 @@ pub async fn capture_kimi_observations( kimi_capture::normalize_observation( &native, &canonical_session_id, + transcript_path.as_deref(), native_id.clone(), range, ) @@ -907,7 +909,9 @@ fn warn_isolated_source(path: &Path, failure_kind: &'static str) { ); } -fn kimi_session_identity(path: &Path) -> TranscriptIngestResult<(String, String)> { +/// A wire's session identity: its session, its agent, and whether that agent +/// is the session's main wire, the one file that is the session transcript. +fn kimi_session_identity(path: &Path) -> TranscriptIngestResult<(String, String, bool)> { let session_dir = path .parent() .and_then(Path::parent) @@ -960,13 +964,16 @@ fn kimi_session_identity(path: &Path) -> TranscriptIngestResult<(String, String) path: path.to_path_buf(), } })?; - if !state.agents.contains_key(agent_id) { - return Err(TranscriptIngestError::InvalidSourceIdentity { - provider: PROVIDER, - path: path.to_path_buf(), - }); - } - Ok((session_id, agent_id.to_owned())) + let is_main = match state.agents.get(agent_id) { + Some(agent) => agent.kind == "main", + None => { + return Err(TranscriptIngestError::InvalidSourceIdentity { + provider: PROVIDER, + path: path.to_path_buf(), + }); + } + }; + Ok((session_id, agent_id.to_owned(), is_main)) } const fn invalid_frame() -> TranscriptIngestError { diff --git a/crates/tracedecay-sessions/src/runtime/hosts/kiro.rs b/crates/tracedecay-sessions/src/runtime/hosts/kiro.rs index 3133ec2887..c736ff0a70 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/kiro.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/kiro.rs @@ -883,6 +883,7 @@ pub fn normalize_kiro_snapshot_observations( text: &message.text, kind: message.kind.as_deref(), model: message.model.as_deref(), + transcript_path: message.source_path.as_deref(), }); if let Some(map) = payload.as_object_mut() { insert_snapshot_location(map, metadata.as_ref(), KIRO_LOCATION_KEYS); diff --git a/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs b/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs index 273fb047d7..c0da5b4562 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/opencode.rs @@ -868,6 +868,7 @@ fn load_record( let mut record = serde_json::json!({ "message": message, "parts": parts.values, + "transcript_path": database_path.to_string_lossy(), }); if let Some(parent_session_id) = parent_session_id { record["session"] = serde_json::json!({ "parentID": parent_session_id }); diff --git a/crates/tracedecay-sessions/src/runtime/hosts/opencode_tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/opencode_tests.rs index c71940c415..bc22ece14f 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/opencode_tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/opencode_tests.rs @@ -1178,7 +1178,7 @@ async fn wal_reuse_updates_existing_parts_without_changing_file_headers() { ); } -const RETAINED_PROJECT_PAYLOAD: &str = r#"{"message":{"id":"msg_ses_project","role":"user","sessionID":"ses_project","time":{"created":1}},"parts":[{"id":"part_ses_project","text":"secret-ses_project","type":"text"}]}"#; +const RETAINED_PROJECT_PAYLOAD: &str = r#"{"message":{"id":"msg_ses_project","role":"user","sessionID":"ses_project","time":{"created":1}},"parts":[{"id":"part_ses_project","text":"secret-ses_project","type":"text"}],"transcript_path":"{transcript}"}"#; #[tokio::test] async fn retained_read_snapshot_keeps_reference_scope_and_payload_together() { @@ -1236,7 +1236,10 @@ async fn retained_read_snapshot_keeps_reference_scope_and_payload_together() { .unwrap(); assert_eq!(page.records.len(), 1); let payload = String::from_utf8(page.records[0].payload.clone()).unwrap(); - assert_eq!(payload, RETAINED_PROJECT_PAYLOAD); + let transcript = serde_json::to_string(&source.source_path).unwrap(); + let expected = + RETAINED_PROJECT_PAYLOAD.replace("{transcript}", &transcript[1..transcript.len() - 1]); + assert_eq!(payload, expected); assert!(!payload.contains("foreign-secret") && page.records[0].session_id == "ses_project"); } diff --git a/crates/tracedecay-sessions/src/runtime/hosts/pi.rs b/crates/tracedecay-sessions/src/runtime/hosts/pi.rs index 0a2415f6ab..17290c7f08 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/pi.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/pi.rs @@ -834,8 +834,13 @@ async fn admit_session_file( range, ObservationOrderingDomainV1::FileBytes, |native| { - let envelope = - pi_capture::normalize_observation(&native, &canonical_session_id, range)?; + let transcript_path = path.to_string_lossy(); + let envelope = pi_capture::normalize_observation( + &native, + &canonical_session_id, + Some(transcript_path.as_ref()), + range, + )?; native_record_id = Some(envelope.stable_record_id().clone()); Ok(envelope) }, diff --git a/crates/tracedecay-sessions/src/runtime/hosts/pi_tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/pi_tests.rs index 8faf964d66..33451fb601 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/pi_tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/pi_tests.rs @@ -105,7 +105,11 @@ async fn fixture_session_lands_with_stable_entry_identities_and_messages() { assert!(!outcome.deferred, "{outcome:?}"); assert_eq!(outcome.discovery_failures, 0); let stored = admission.observations(); - assert_eq!(stored.len(), 8, "every fixture line is one observation"); + assert_eq!( + stored.len(), + 8, + "the contentless message line is refused and every other fixture line is one observation" + ); for observation in &stored { assert_eq!( observation.observation().source().session_id().as_str(), @@ -185,7 +189,7 @@ async fn malformed_session_file_is_refused_with_a_typed_failure() { let session = std::fs::read(layout.write_fixture()).unwrap(); // The header embeds the project path, so each line's byte span depends on // the temp directory's length. - let [b0, b1, b2, b3, b4, b5, b6, b7, b8]: [usize; 9] = std::iter::once(0) + let [b0, b1, b2, b3, b4, b5, b6, b7, b8, b9]: [usize; 10] = std::iter::once(0) .chain( session .split_inclusive(|byte| *byte == b'\n') @@ -196,7 +200,7 @@ async fn malformed_session_file_is_refused_with_a_typed_failure() { ) .collect::>() .try_into() - .expect("the Pi fixture has eight lines"); + .expect("the Pi fixture has nine lines"); let malformed = layout .session_dir() .join("2026-09-25T17-00-00-000Z_0d7f6c1b-2e4a-4b8d-8c3f-1a2b3c4d5e6f.jsonl"); @@ -221,10 +225,13 @@ async fn malformed_session_file_is_refused_with_a_typed_failure() { // which doubles the `\\` separators on Windows. let escaped = serde_json::to_string(&project).unwrap(); let normalized = payloads.replace(&escaped[1..escaped.len() - 1], "/project"); + let temp_root = layout._temp.path().to_string_lossy().into_owned(); + let escaped_temp = serde_json::to_string(&temp_root).unwrap(); + let normalized = normalized.replace(&escaped_temp[1..escaped_temp.len() - 1], "/tmp-root"); assert_eq!( normalized, format!( - r#"{{"evidence":{{"native_timestamp":1790352000,"ordering_domain":"file_bytes","range":{{"end":{b1},"start":{b0}}}}},"facts":[{{"kind":"session","native_source":"pi","project_path":"/project","source":"pi_session","started_at":1790352000}}],"native_record_kind":"session","provider":"pi","relations":{{"session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:session","version":1}}{{"evidence":{{"native_timestamp":1790352000,"ordering_domain":"file_bytes","range":{{"end":{b2},"start":{b1}}}}},"facts":[{{"kind":"unknown","native_kind":"message.system","state":"unsupported"}}],"native_record_kind":"message.system","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a0b1c2d3","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a0b1c2d3","version":1}}{{"evidence":{{"native_timestamp":1790352000,"ordering_domain":"file_bytes","range":{{"end":{b3},"start":{b2}}}}},"facts":[{{"kind":"unknown","native_kind":"model_change","state":"unsupported"}}],"native_record_kind":"model_change","provider":"pi","relations":{{"parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a0b1c2d3","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:b1c2d3e4","version":1}}{{"evidence":{{"native_timestamp":1790352001,"ordering_domain":"file_bytes","range":{{"end":{b4},"start":{b3}}}}},"facts":[{{"content":"Fix the flaky billing retry test","kind":"message","role":"user","timestamp":1790352001}}],"native_record_kind":"message.user","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:c2d3e4f5","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:b1c2d3e4","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:c2d3e4f5","version":1}}{{"evidence":{{"native_timestamp":1790352002,"ordering_domain":"file_bytes","range":{{"end":{b5},"start":{b4}}}}},"facts":[{{"content":"The retry loop reads the clock twice.","kind":"reasoning","visibility":"visible"}},{{"content":{{"change_type":"edit","edited_at_micros":1790352002000000,"id":"toolu_01PiEdit","type":"toolCall"}},"evidence_kind":"file_edit","kind":"git","reference":"src/billing/retry.rs"}},{{"arguments":{{"newText":"","oldText":"","path":"src/billing/retry.rs"}},"invocation_id":"toolu_01PiEdit","kind":"tool_invocation","name":"edit"}},{{"content":[{{"text":"I will pin the retry clock in the billing test.","type":"text"}}],"kind":"message","model":"claude-sonnet-4-5","role":"assistant","timestamp":1790352002}}],"native_record_kind":"message.assistant","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:d3e4f5a6","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:c2d3e4f5","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:d3e4f5a6","version":1}}{{"evidence":{{"native_timestamp":1790352003,"ordering_domain":"file_bytes","range":{{"end":{b6},"start":{b5}}}}},"facts":[{{"content":[{{"text":"","type":"text"}}],"invocation_id":"toolu_01PiEdit","kind":"tool_result","success":true}}],"native_record_kind":"message.toolResult","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:e4f5a6b7","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:d3e4f5a6","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:e4f5a6b7","version":1}}{{"evidence":{{"native_timestamp":1790352004,"ordering_domain":"file_bytes","range":{{"end":{b7},"start":{b6}}}}},"facts":[{{"content":[{{"text":"The billing retry test now pins its clock.","type":"text"}}],"kind":"message","model":"claude-sonnet-4-5","role":"assistant","timestamp":1790352004}}],"native_record_kind":"message.assistant","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:f5a6b7c8","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:e4f5a6b7","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:f5a6b7c8","version":1}}{{"evidence":{{"native_timestamp":1790352005,"ordering_domain":"file_bytes","range":{{"end":{b8},"start":{b7}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","title":"Billing retry clock"}}],"native_record_kind":"session_info","provider":"pi","relations":{{"parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:f5a6b7c8","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a6b7c8d9","version":1}}"# + r#"{{"evidence":{{"native_timestamp":1790352000,"ordering_domain":"file_bytes","range":{{"end":{b1},"start":{b0}}}}},"facts":[{{"kind":"session","native_source":"pi","project_path":"/project","source":"pi_session","started_at":1790352000,"transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}}],"native_record_kind":"session","provider":"pi","relations":{{"session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:session","version":1}}{{"evidence":{{"native_timestamp":1790352000,"ordering_domain":"file_bytes","range":{{"end":{b2},"start":{b1}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}},{{"kind":"unknown","native_kind":"message.system","state":"unsupported"}}],"native_record_kind":"message.system","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a0b1c2d3","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a0b1c2d3","version":1}}{{"evidence":{{"native_timestamp":1790352000,"ordering_domain":"file_bytes","range":{{"end":{b3},"start":{b2}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}},{{"kind":"unknown","native_kind":"model_change","state":"unsupported"}}],"native_record_kind":"model_change","provider":"pi","relations":{{"parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a0b1c2d3","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:b1c2d3e4","version":1}}{{"evidence":{{"native_timestamp":1790352001,"ordering_domain":"file_bytes","range":{{"end":{b4},"start":{b3}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}},{{"content":"Fix the flaky billing retry test","kind":"message","role":"user","timestamp":1790352001}}],"native_record_kind":"message.user","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:c2d3e4f5","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:b1c2d3e4","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:c2d3e4f5","version":1}}{{"evidence":{{"native_timestamp":1790352002,"ordering_domain":"file_bytes","range":{{"end":{b5},"start":{b4}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}},{{"content":"The retry loop reads the clock twice.","kind":"reasoning","visibility":"visible"}},{{"content":{{"change_type":"edit","edited_at_micros":1790352002000000,"id":"toolu_01PiEdit","type":"toolCall"}},"evidence_kind":"file_edit","kind":"git","reference":"src/billing/retry.rs"}},{{"arguments":{{"newText":"","oldText":"","path":"src/billing/retry.rs"}},"invocation_id":"toolu_01PiEdit","kind":"tool_invocation","name":"edit"}},{{"content":[{{"text":"I will pin the retry clock in the billing test.","type":"text"}}],"kind":"message","model":"claude-sonnet-4-5","role":"assistant","timestamp":1790352002}}],"native_record_kind":"message.assistant","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:d3e4f5a6","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:c2d3e4f5","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:d3e4f5a6","version":1}}{{"evidence":{{"native_timestamp":1790352003,"ordering_domain":"file_bytes","range":{{"end":{b6},"start":{b5}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}},{{"content":[{{"text":"","type":"text"}}],"invocation_id":"toolu_01PiEdit","kind":"tool_result","success":true}}],"native_record_kind":"message.toolResult","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:e4f5a6b7","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:d3e4f5a6","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:e4f5a6b7","version":1}}{{"evidence":{{"native_timestamp":1790352004,"ordering_domain":"file_bytes","range":{{"end":{b7},"start":{b6}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}},{{"content":[{{"text":"The billing retry test now pins its clock.","type":"text"}}],"kind":"message","model":"claude-sonnet-4-5","role":"assistant","timestamp":1790352004}}],"native_record_kind":"message.assistant","provider":"pi","relations":{{"message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:f5a6b7c8","parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:e4f5a6b7","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:f5a6b7c8","version":1}}{{"evidence":{{"native_timestamp":1790352005,"ordering_domain":"file_bytes","range":{{"end":{b9},"start":{b8}}}}},"facts":[{{"kind":"session","native_source":"pi","source":"pi_session","title":"Billing retry clock","transcript_path":"/tmp-root/.pi/agent/sessions/--tmp-.[TraceDecay redacted: high-entropy token].jsonl"}}],"native_record_kind":"session_info","provider":"pi","relations":{{"parent_message_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:f5a6b7c8","session_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"}},"stable_record_id":"5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90:a6b7c8d9","version":1}}"# ) ); assert!(!payloads.contains("orphan entry without a header")); @@ -364,7 +371,7 @@ fn system_prompt_replay_is_not_projected_as_conversation() { let system: Value = serde_json::from_str(FIXTURE.lines().nth(1).unwrap()).unwrap(); let range = tracedecay_domain::ObservationSourceRangeV1::new(0, 10).unwrap(); let envelope = - tracedecay_capture::pi::normalize_observation(&system, SESSION_ID, range).unwrap(); + tracedecay_capture::pi::normalize_observation(&system, SESSION_ID, None, range).unwrap(); assert!( envelope .facts() diff --git a/crates/tracedecay-sessions/src/runtime/hosts/vibe.rs b/crates/tracedecay-sessions/src/runtime/hosts/vibe.rs index 2da2246af2..57de24f214 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/vibe.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/vibe.rs @@ -291,11 +291,13 @@ async fn capture_vibe_path( range, ObservationOrderingDomainV1::FileBytes, |native| { + let transcript_path = path.to_string_lossy(); vibe_capture::normalize_observation( &native, &canonical_session_id, model.as_deref(), Some(location.as_str()), + Some(transcript_path.as_ref()), native_record_id.clone(), range, ) diff --git a/crates/tracedecay-sessions/src/runtime/observation/jsonl_observation_admission/tests.rs b/crates/tracedecay-sessions/src/runtime/observation/jsonl_observation_admission/tests.rs index c7b4e8446e..18aebcd597 100644 --- a/crates/tracedecay-sessions/src/runtime/observation/jsonl_observation_admission/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/observation/jsonl_observation_admission/tests.rs @@ -1544,7 +1544,7 @@ fn catch_up_rollout_line(index: usize, cwd: &Path, word: &str) -> String { _ if index % CATCH_UP_TURN_RECORDS == 1 => json!({ "timestamp": "2026-01-01T00:00:01.000Z", "type": "turn_context", - "payload": {"turn_id": format!("turn-{index:05}"), "cwd": cwd, "pad": pad} + "payload": {"turn_id": format!("turn-{index:05}"), "cwd": cwd, "model": "gpt-5.5", "pad": pad} }), _ => json!({ "timestamp": "2026-01-01T00:00:01.000Z", diff --git a/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs b/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs index af1932369c..525e697df7 100644 --- a/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs +++ b/crates/tracedecay-sessions/src/runtime/observation/snapshot_observation.rs @@ -257,31 +257,40 @@ pub fn canonical_snapshot_envelope( .unwrap_or(CanonicalMessageRoleV1::Unknown); let timestamp = native.get("timestamp").and_then(Value::as_i64); let mut facts = Vec::new(); - if let Some(location) = native.get("location").filter(|location| { + let location = native.get("location").filter(|location| { location .get("path") .and_then(Value::as_str) .is_some_and(|path| !path.is_empty()) - }) { + }); + let transcript_path = native + .get("transcript_path") + .and_then(Value::as_str) + .filter(|path| !path.is_empty()); + if location.is_some() || transcript_path.is_some() { // `project_path` stays unset on purpose: session routing keeps its // scope-derived fallback, only the location metadata is projected. facts.push(CanonicalObservationFactV1::Session { project_path: None, - location_path: location - .get("path") - .and_then(Value::as_str) - .map(str::to_string), - transcript_path: None, + location_path: location.and_then(|location| { + location + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + }), + transcript_path: transcript_path.map(str::to_string), title: None, started_at: None, ended_at: None, source: None, native_source: None, profile: None, - location_provenance: location - .get("provenance") - .and_then(Value::as_str) - .map(str::to_string), + location_provenance: location.and_then(|location| { + location + .get("provenance") + .and_then(Value::as_str) + .map(str::to_string) + }), }); } if let Some(text) = native.get("text").cloned() { diff --git a/crates/tracedecay-store/src/canonical_projection.rs b/crates/tracedecay-store/src/canonical_projection.rs index 9de5aba5f2..a82cadd248 100644 --- a/crates/tracedecay-store/src/canonical_projection.rs +++ b/crates/tracedecay-store/src/canonical_projection.rs @@ -116,11 +116,19 @@ fn derive_canonical_projection_for( } let mut projected = canonical_message_fields_for(rendering, &envelope)?; - let session_fields = if envelope.provider().as_str() == "claude" { - None - } else { - canonical_session_fields(&envelope) - }; + // A Claude row keeps its project id as its path, so the Session fact's + // other fields stay masked off the row; only the transcript it was read + // from is evidence the row should answer. + let session_fields = canonical_session_fields(&envelope).map(|fields| { + if envelope.provider().as_str() == "claude" { + CanonicalSessionFields { + transcript_path: fields.transcript_path, + ..Default::default() + } + } else { + fields + } + }); let primary_message_id = canonical_compatibility_message_fields(&envelope, session_fields.as_ref(), &mut projected)?; let workflow_facts = canonical_workflow_facts(&envelope)?; @@ -282,6 +290,7 @@ fn canonical_session_message_record( } } +#[derive(Default)] struct CanonicalSessionFields { project_path: Option, location_path: Option, diff --git a/crates/tracedecay/tests/runtime_acceptance_suite/canonical_git_observation_correlation.rs b/crates/tracedecay/tests/runtime_acceptance_suite/canonical_git_observation_correlation.rs index 97ffa99df7..7509f53410 100644 --- a/crates/tracedecay/tests/runtime_acceptance_suite/canonical_git_observation_correlation.rs +++ b/crates/tracedecay/tests/runtime_acceptance_suite/canonical_git_observation_correlation.rs @@ -4,7 +4,7 @@ use std::process::Command; use serde_json::json; use tempfile::TempDir; use tracedecay_capture::codex::{ - CodexObservationLocation, codex_native_record_id, normalize_codex_observation_with_location, + CodexObservationContext, codex_native_record_id, normalize_codex_observation_with_context, }; use tracedecay_domain::{ ObservationIdentityMaterialV1, ObservationOrderingDomainV1, ObservationScopeV1, @@ -108,15 +108,17 @@ async fn canonical_codex_capture_publishes_admitted_git_evidence_for_sessions_fo let record_id = record_id.clone(); let project = project.clone(); move |value| { - normalize_codex_observation_with_location( + normalize_codex_observation_with_context( &value, session_id.as_str(), Some(session_id.as_str()), record_id.clone(), range, - CodexObservationLocation { + CodexObservationContext { project_path: Some(&project), location_path: Some(&project), + transcript_path: None, + model: None, }, ) } diff --git a/crates/tracedecay/tests/session_suite/claude_records.rs b/crates/tracedecay/tests/session_suite/claude_records.rs index 088d359a4a..f334a8fee6 100644 --- a/crates/tracedecay/tests/session_suite/claude_records.rs +++ b/crates/tracedecay/tests/session_suite/claude_records.rs @@ -36,6 +36,7 @@ pub(crate) fn canonical_envelope(native: &Value, session_id: &str, start: u64, e let envelope = normalize( native, session_id, + None, record_id(native, session_id, start), range, ) diff --git a/crates/tracedecay/tests/session_suite/observation_application.rs b/crates/tracedecay/tests/session_suite/observation_application.rs index 38d5c0a520..0c40f8f5b7 100644 --- a/crates/tracedecay/tests/session_suite/observation_application.rs +++ b/crates/tracedecay/tests/session_suite/observation_application.rs @@ -61,7 +61,7 @@ fn request( &encoded_frame, range, ObservationOrderingDomainV1::FileBytes, - |native| claude_normalize(&native, session_id, record_id.clone(), range), + |native| claude_normalize(&native, session_id, None, record_id.clone(), range), ) .unwrap(); CaptureObservationRequest::new( diff --git a/crates/tracedecay/tests/transcript_ingest_suite/claude.rs b/crates/tracedecay/tests/transcript_ingest_suite/claude.rs index f94f6a4d74..19f9b18cd4 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/claude.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/claude.rs @@ -497,6 +497,47 @@ fn write_claude_transcript_with_thinking( path } +/// The session row records which source file its canonical projection was +/// read from. +#[tokio::test] +async fn claude_transcript_projects_session_transcript_path() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + init_git_repo(&project); + let transcript = write_claude_transcript(&home, &project, "claude-transcript-path"); + + let db = open_project_session_db(&project).await.unwrap(); + let source = ClaudeSource::with_home(&home); + try_ingest_claude_source(&db, &source, &project) + .await + .unwrap(); + + let session = db + .get_session("claude", "claude-transcript-path") + .await + .expect("claude session should be stored"); + crate::support::assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &transcript, + ); + let hit = db + .search_session_messages("claude", None, "billing pipeline", 10) + .await + .into_iter() + .next() + .expect("session message should be searchable"); + crate::support::assert_path_text_eq( + hit.session + .transcript_path + .as_deref() + .expect("search-hit transcript path"), + &transcript, + ); +} + #[tokio::test] async fn claude_thinking_blocks_do_not_project_as_ordinary_messages() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tracedecay/tests/transcript_ingest_suite/cline_like.rs b/crates/tracedecay/tests/transcript_ingest_suite/cline_like.rs index 9bcc1f3610..0a2b148f18 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/cline_like.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/cline_like.rs @@ -19,7 +19,8 @@ use crate::restart_atomicity::{ open_project_session_db, set_projection_failure, }; use crate::support::{ - assert_metadata_path_eq, create_git_repo_with_linked_worktree, init_git_repo, setup, + assert_metadata_path_eq, assert_path_text_eq, create_git_repo_with_linked_worktree, + init_git_repo, setup, }; pub(super) fn vscode_storage_root( @@ -218,7 +219,7 @@ async fn cline_like_task_location_projects_session_metadata() { ] { let tmp = TempDir::new().unwrap(); let (home, project) = setup(&tmp); - write_task( + let api_history = write_task( &vscode_storage_root(&home, extension_id), &project, &format!("{provider}-location"), @@ -235,6 +236,13 @@ async fn cline_like_task_location_projects_session_metadata() { .get_session(provider, &format!("{provider}-location")) .await .unwrap(); + assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &api_history, + ); let metadata: serde_json::Value = serde_json::from_str(session.metadata_json.as_deref().unwrap()).unwrap(); assert_metadata_path_eq(&metadata[cwd_key.as_str()], &project); diff --git a/crates/tracedecay/tests/transcript_ingest_suite/codex.rs b/crates/tracedecay/tests/transcript_ingest_suite/codex.rs index 212a3fc3cc..779b271d48 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/codex.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/codex.rs @@ -169,6 +169,78 @@ async fn project_scopes_split_codex_turns_when_cwd_changes() { ); } +/// The `session_meta` model is the rollout's starting model; `turn_context` +/// records replace it for the turns that follow, and the session row records +/// which rollout file the projection was read from. +#[tokio::test] +async fn codex_rollout_projects_turn_context_model_and_transcript_path() { + let tmp = TempDir::new().unwrap(); + let (home, project) = crate::support::setup(&tmp); + let path = write_codex_rollout(&home, &project, "codex-model-session"); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": "2026-01-01T00:00:03.000Z", + "type": "turn_context", + "payload": {"turn_id": "turn-2", "cwd": project.to_string_lossy(), "model": "gpt-5.6"} + }) + ) + .unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "timestamp": "2026-01-01T00:00:04.000Z", + "type": "event_msg", + "payload": {"type": "agent_message", "message": "billing pipeline follow-up verified"} + }) + ) + .unwrap(); + drop(file); + + let db = open_project_session_db(&project).await.unwrap(); + ingest_global_sources_for_provider(&home, &db, &project, Some(SessionProvider::Codex)).await; + + let session = db + .get_session("codex", "codex-model-session") + .await + .expect("codex session should be stored"); + crate::support::assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &path, + ); + + let hits = db + .search_session_messages("codex", None, "billing pipeline", 10) + .await; + let model_for = |needle: &str| { + hits.iter() + .find(|hit| hit.message.text.contains(needle)) + .unwrap_or_else(|| panic!("{needle}: message should be searchable")) + .message + .model + .clone() + }; + assert_eq!( + model_for("The billing pipeline regression is fixed."), + Some("gpt-5.5".to_owned()), + "session_meta model is the starting model" + ); + assert_eq!( + model_for("billing pipeline follow-up verified"), + Some("gpt-5.6".to_owned()), + "turn_context replaces the model for the turns that follow" + ); +} + #[tokio::test] async fn user_scope_ingests_codex_turns_after_leaving_a_registered_project() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tracedecay/tests/transcript_ingest_suite/hermes.rs b/crates/tracedecay/tests/transcript_ingest_suite/hermes.rs index bf21aac804..29daa2adef 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/hermes.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/hermes.rs @@ -34,8 +34,8 @@ use crate::restart_atomicity::{ open_sibling_project_session_db, set_projection_failure, }; use crate::support::{ - assert_metadata_path_eq, create_git_repo_with_linked_worktree, init_git_repo, - normalize_path_text, + assert_metadata_path_eq, assert_path_text_eq, create_git_repo_with_linked_worktree, + init_git_repo, normalize_path_text, }; const SESSION_ID: &str = "20260101_000000_abc123"; @@ -272,7 +272,7 @@ async fn hermes_state_db_populates_projection_for_session_cwd_project() { let (hermes_home, project) = setup(&tmp); let linked_worktree = tmp.path().join("linked-worktree"); create_git_repo_with_linked_worktree(&project, &linked_worktree); - write_hermes_profile(&hermes_home, "test", Some(&linked_worktree)).await; + let state_db = write_hermes_profile(&hermes_home, "test", Some(&linked_worktree)).await; let db = open_project_session_db(&project).await.unwrap(); let stats = ingest_homes(&db, std::slice::from_ref(&hermes_home), &project).await; @@ -285,6 +285,13 @@ async fn hermes_state_db_populates_projection_for_session_cwd_project() { .get_session("hermes", SESSION_ID) .await .expect("hermes session should be stored"); + assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &state_db, + ); let project_path = normalize_path_text(&project.to_string_lossy()); let results = db .search_session_messages("hermes", Some(&project_path), "billing pipeline", 10) diff --git a/crates/tracedecay/tests/transcript_ingest_suite/kimi.rs b/crates/tracedecay/tests/transcript_ingest_suite/kimi.rs new file mode 100644 index 0000000000..2a564eadfc --- /dev/null +++ b/crates/tracedecay/tests/transcript_ingest_suite/kimi.rs @@ -0,0 +1,94 @@ +use tempfile::TempDir; +use tracedecay_sessions::runtime::SessionProvider; + +use crate::restart_atomicity::{ingest_global_sources_for_provider, open_project_session_db}; +use crate::support::{assert_path_text_eq, setup}; + +fn wire_message(agent: &str, role: &str, text: &str) -> String { + serde_json::json!({ + "type": "context.append_message", + "agentId": agent, + "message": { + "role": role, + "content": [{"type": "text", "text": text}], + "toolCalls": [] + }, + "time": 1_789_228_081_157_i64 + }) + .to_string() + + "\n" +} + +/// A Kimi session feeds one canonical session from every agent wire in its +/// directory; the main agent's `wire.jsonl` is the session's source +/// transcript. +#[tokio::test] +async fn kimi_main_agent_wire_is_the_session_transcript_path() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let session_dir = home.join(".kimi-code/sessions/wd_project/session-kimi"); + let main_wire = session_dir.join("agents/main/wire.jsonl"); + let sub_wire = session_dir.join("agents/sub-1/wire.jsonl"); + std::fs::create_dir_all(main_wire.parent().unwrap()).unwrap(); + std::fs::create_dir_all(sub_wire.parent().unwrap()).unwrap(); + std::fs::write( + session_dir.join("state.json"), + serde_json::json!({ + "id": "session-kimi", + "cwd": project, + "agents": { + "main": {"type": "main"}, + "sub-1": {"type": "sub"} + } + }) + .to_string(), + ) + .unwrap(); + std::fs::write( + &main_wire, + format!( + "{}{}", + wire_message( + "main", + "user", + "Investigate the billing pipeline regression" + ), + wire_message( + "main", + "assistant", + "The billing pipeline regression is fixed." + ) + ), + ) + .unwrap(); + std::fs::write( + &sub_wire, + wire_message( + "sub-1", + "assistant", + "sub-agent verified the billing pipeline fix", + ), + ) + .unwrap(); + + let db = open_project_session_db(&project).await.unwrap(); + ingest_global_sources_for_provider(&home, &db, &project, Some(SessionProvider::Kimi)).await; + + let session = db + .get_session("kimi", "session-kimi") + .await + .expect("kimi session should be stored"); + assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &main_wire, + ); + assert_eq!( + db.search_session_messages("kimi", None, "billing pipeline", 10) + .await + .len(), + 3 + ); +} diff --git a/crates/tracedecay/tests/transcript_ingest_suite/kiro.rs b/crates/tracedecay/tests/transcript_ingest_suite/kiro.rs index 513f45ef69..a61f0885cc 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/kiro.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/kiro.rs @@ -13,7 +13,8 @@ use crate::restart_atomicity::{ observation_source_cursor, open_project_session_db, set_projection_failure, }; use crate::support::{ - assert_metadata_path_eq, create_git_repo_with_linked_worktree, init_git_repo, setup, + assert_metadata_path_eq, assert_sanitized_path_text_eq, create_git_repo_with_linked_worktree, + init_git_repo, setup, }; fn encode_workspace_path(path: &std::path::Path) -> String { @@ -284,12 +285,19 @@ async fn kiro_secret_is_sanitized_before_observation_and_projection() { async fn kiro_workspace_location_projects_session_metadata() { let tmp = TempDir::new().unwrap(); let (home, project) = setup(&tmp); - write_workspace_session_json(&home, &project, "sess-location"); + let transcript = write_workspace_session_json(&home, &project, "sess-location"); let db = open_project_session_db(&project).await.unwrap(); ingest_global_sources_for_provider(&home, &db, &project, Some(SessionProvider::Kiro)).await; let session = db.get_session("kiro", "sess-location").await.unwrap(); + assert_sanitized_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &transcript, + ); let metadata: serde_json::Value = serde_json::from_str(session.metadata_json.as_deref().unwrap()).unwrap(); assert_metadata_path_eq(&metadata["kiro_session_cwd"], &project); diff --git a/crates/tracedecay/tests/transcript_ingest_suite/main.rs b/crates/tracedecay/tests/transcript_ingest_suite/main.rs index b7ee428c68..fbe15891e7 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/main.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/main.rs @@ -23,8 +23,10 @@ mod cursor; mod cursor_composer; mod git_correlation; mod hermes; +mod kimi; mod kiro; mod linked_worktree_sessions; +mod opencode; mod pi; mod projector_upgrade; mod provider_contract; diff --git a/crates/tracedecay/tests/transcript_ingest_suite/opencode.rs b/crates/tracedecay/tests/transcript_ingest_suite/opencode.rs new file mode 100644 index 0000000000..612c685708 --- /dev/null +++ b/crates/tracedecay/tests/transcript_ingest_suite/opencode.rs @@ -0,0 +1,94 @@ +use tempfile::TempDir; +use tracedecay_sessions::runtime::SessionProvider; + +use crate::restart_atomicity::{ingest_global_sources_for_provider, open_project_session_db}; +use crate::support::{assert_path_text_eq, setup}; + +/// An opencode session's durable source is the profile `opencode.db` its +/// message rows were read from. +#[tokio::test] +async fn opencode_state_db_is_the_session_transcript_path() { + let tmp = TempDir::new().unwrap(); + let (home, project) = setup(&tmp); + let data_dir = home.join(".local/share/opencode"); + std::fs::create_dir_all(&data_dir).unwrap(); + let database = data_dir.join("opencode.db"); + let connection = rusqlite::Connection::open(&database).unwrap(); + connection + .execute_batch( + "CREATE TABLE session ( + id TEXT PRIMARY KEY, + parent_id TEXT, + directory TEXT NOT NULL + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + data BLOB NOT NULL + ); + CREATE INDEX message_session_time_created_id_idx + ON message(session_id, time_created, id); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data BLOB NOT NULL + ); + CREATE INDEX part_message_id_id_idx ON part(message_id, id);", + ) + .unwrap(); + connection + .execute( + "INSERT INTO session(id, directory) VALUES ('ses_project', ?1)", + rusqlite::params![project.to_string_lossy()], + ) + .unwrap(); + connection + .execute( + "INSERT INTO message(id, session_id, time_created, data) + VALUES ('msg_1', 'ses_project', 1, ?1)", + rusqlite::params![ + serde_json::json!({ + "role": "user", "time": {"created": 1} + }) + .to_string() + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO part(id, message_id, session_id, data) + VALUES ('part_1', 'msg_1', 'ses_project', ?1)", + rusqlite::params![ + serde_json::json!({ + "type": "text", + "text": "Investigate the billing pipeline regression" + }) + .to_string() + ], + ) + .unwrap(); + drop(connection); + + let db = open_project_session_db(&project).await.unwrap(); + ingest_global_sources_for_provider(&home, &db, &project, Some(SessionProvider::OpenCode)).await; + + let session = db + .get_session("opencode", "ses_project") + .await + .expect("opencode session should be stored"); + assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &database, + ); + assert_eq!( + db.search_session_messages("opencode", None, "billing pipeline", 10) + .await + .len(), + 1 + ); +} diff --git a/crates/tracedecay/tests/transcript_ingest_suite/pi.rs b/crates/tracedecay/tests/transcript_ingest_suite/pi.rs index 353dbc5d04..478d3456aa 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/pi.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/pi.rs @@ -6,7 +6,7 @@ use tracedecay_sessions::runtime::SessionProvider; use crate::restart_atomicity::{ ingest_global_sources_for_provider, mark_test_project, open_project_session_db, }; -use crate::support::{init_git_repo, setup}; +use crate::support::{assert_sanitized_path_text_eq, init_git_repo, setup}; const SESSION_ID: &str = "5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90"; const FIXTURE_NAME: &str = "2026-09-25T16-00-00-000Z_5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90.jsonl"; @@ -35,7 +35,7 @@ fn install_fixture(agent_dir: &Path, project: &Path) -> PathBuf { path } -async fn assert_fixture_session_landed(home: &Path, project: &Path) { +async fn assert_fixture_session_landed(home: &Path, project: &Path, transcript: &Path) { let db = open_project_session_db(project).await.unwrap(); let stats = ingest_global_sources_for_provider(home, &db, project, Some(SessionProvider::Pi)).await; @@ -47,6 +47,15 @@ async fn assert_fixture_session_landed(home: &Path, project: &Path) { Some(1_790_352_001), "first conversational entry" ); + // The rollout filename embeds the session UUID, so the sanitizer redacts + // it before persistence; the stored path names the file modulo that span. + assert_sanitized_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + transcript, + ); let metadata: serde_json::Value = serde_json::from_str(session.metadata_json.as_deref().unwrap()).unwrap(); assert!( @@ -83,9 +92,9 @@ async fn pi_fixture_session_lands_in_the_project_store() { let (home, project) = setup(&tmp); init_git_repo(&project); mark_test_project(&project); - install_fixture(&home.join(".pi/agent"), &project); + let transcript = install_fixture(&home.join(".pi/agent"), &project); - assert_fixture_session_landed(&home, &project).await; + assert_fixture_session_landed(&home, &project, &transcript).await; } #[tokio::test] @@ -112,7 +121,7 @@ async fn pi_agent_dir_override_relocates_the_session_source_inside_the_home() { crate::support::init_project_at(&project); init_git_repo(&project); mark_test_project(&project); - install_fixture(&relocated, &project); + let transcript = install_fixture(&relocated, &project); - assert_fixture_session_landed(&home, &project).await; + assert_fixture_session_landed(&home, &project, &transcript).await; } diff --git a/crates/tracedecay/tests/transcript_ingest_suite/support.rs b/crates/tracedecay/tests/transcript_ingest_suite/support.rs index 1bac342693..5cd111a6f1 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/support.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/support.rs @@ -41,6 +41,27 @@ pub fn assert_metadata_path_eq(actual: &serde_json::Value, expected: &Path) { assert_path_text_eq(actual, expected); } +/// The observation sanitizer redacts high-entropy spans (session UUIDs live in +/// real transcript filenames) before persistence, so a stored path names its +/// file modulo those spans: every surviving span must appear in `expected` in +/// order. +pub fn assert_sanitized_path_text_eq(actual: &str, expected: &Path) { + const REDACTED: &str = "[TraceDecay redacted: high-entropy token]"; + let expected_text = normalize_path_text(&expected.to_string_lossy()); + let mut cursor = 0; + for part in actual.split(REDACTED).filter(|part| !part.is_empty()) { + let found = expected_text[cursor..] + .find(part) + .map(|index| cursor + index); + assert!( + found.is_some(), + "stored path {actual:?} does not name {} (span {part:?} absent)", + expected.display() + ); + cursor = found.unwrap() + part.len(); + } +} + /// Initializes `project` as a tracedecay project the ingest resolvers accept: /// a git worktree carrying a repository identity marker unique to its path. /// Runs the literal `git` binary so tests that point `GIT` at a stalling fake diff --git a/crates/tracedecay/tests/transcript_ingest_suite/vibe.rs b/crates/tracedecay/tests/transcript_ingest_suite/vibe.rs index 5bd9dd9eef..ae5071d341 100644 --- a/crates/tracedecay/tests/transcript_ingest_suite/vibe.rs +++ b/crates/tracedecay/tests/transcript_ingest_suite/vibe.rs @@ -6,7 +6,7 @@ use tracedecay_sessions::runtime::source::{ }; use crate::restart_atomicity::{ingest_global_sources_for_provider, open_project_session_db}; -use crate::support::{assert_metadata_path_eq, setup}; +use crate::support::{assert_metadata_path_eq, assert_path_text_eq, setup}; #[cfg(unix)] use tracedecay_runtime_core::test_executable::write_executable_script; @@ -168,8 +168,9 @@ async fn vibe_session_location_projects_session_metadata() { .to_string(), ) .unwrap(); + let transcript = session_dir.join("messages.jsonl"); std::fs::write( - session_dir.join("messages.jsonl"), + &transcript, concat!( "{\"role\":\"user\",\"content\":\"Investigate the billing pipeline regression\"}\n", "{\"role\":\"assistant\",\"content\":\"The billing pipeline regression is fixed.\"}\n" @@ -181,6 +182,13 @@ async fn vibe_session_location_projects_session_metadata() { ingest_global_sources_for_provider(&home, &db, &project, Some(SessionProvider::Vibe)).await; let session = db.get_session("vibe", "vibe-location").await.unwrap(); + assert_path_text_eq( + session + .transcript_path + .as_deref() + .expect("session transcript path"), + &transcript, + ); let metadata: serde_json::Value = serde_json::from_str(session.metadata_json.as_deref().unwrap()).unwrap(); assert_metadata_path_eq(&metadata["vibe_session_cwd"], &project); diff --git a/tests/fixtures/transcript_golden/pi/2026-09-25T16-00-00-000Z_5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90.jsonl b/tests/fixtures/transcript_golden/pi/2026-09-25T16-00-00-000Z_5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90.jsonl index d80658b972..b26d5ffb83 100644 --- a/tests/fixtures/transcript_golden/pi/2026-09-25T16-00-00-000Z_5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90.jsonl +++ b/tests/fixtures/transcript_golden/pi/2026-09-25T16-00-00-000Z_5f0c2a8e-3b1d-4c7e-9a2f-6d8e1b4c7a90.jsonl @@ -5,4 +5,5 @@ {"type":"message","id":"d3e4f5a6","parentId":"c2d3e4f5","timestamp":"2026-09-25T16:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The retry loop reads the clock twice."},{"type":"text","text":"I will pin the retry clock in the billing test."},{"type":"toolCall","id":"toolu_01PiEdit","name":"edit","arguments":{"path":"src/billing/retry.rs","oldText":"","newText":""}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":120,"output":40,"cacheRead":0,"cacheWrite":0,"totalTokens":160,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1790352002000}} {"type":"message","id":"e4f5a6b7","parentId":"d3e4f5a6","timestamp":"2026-09-25T16:00:03.000Z","message":{"role":"toolResult","toolCallId":"toolu_01PiEdit","toolName":"edit","content":[{"type":"text","text":""}],"isError":false,"timestamp":1790352003000}} {"type":"message","id":"f5a6b7c8","parentId":"e4f5a6b7","timestamp":"2026-09-25T16:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"The billing retry test now pins its clock."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":120,"output":40,"cacheRead":0,"cacheWrite":0,"totalTokens":160,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1790352004000}} +{"type":"message","id":"b7c8d9e0","parentId":"f5a6b7c8","timestamp":"2026-09-25T16:00:05.000Z","message":{"role":"user","content":"","timestamp":1790352005000}} {"type":"session_info","id":"a6b7c8d9","parentId":"f5a6b7c8","timestamp":"2026-09-25T16:00:05.000Z","name":"Billing retry clock"}