Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions cli/src/services/agent_trace_db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec<DiffTracePatchRow>) -> RecentDif
let mut skipped = Vec::new();

for row in rows {
let is_structured = row.payload_type == PAYLOAD_TYPE_STRUCTURED;
let parse_result = match row.payload_type.as_str() {
PAYLOAD_TYPE_PATCH => parse_patch(&row.patch, Some(row.session_id.as_str()))
.map_err(|error| skipped_diff_trace_patch_reason(&error)),
Expand All @@ -402,6 +403,11 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec<DiffTracePatchRow>) -> RecentDif
for file in &mut patch.files {
for hunk in &mut file.hunks {
hunk.model_id.clone_from(&row.model_id);
if is_structured {
for line in &mut hunk.lines {
line.session_id = Some(row.session_id.clone());
}
}
}
}

Expand Down Expand Up @@ -441,6 +447,7 @@ mod tests {

use super::repository::RepositoryAgentTraceDb;
use super::*;
use crate::services::agent_trace::{build_agent_trace, AgentTraceMetadataInput};

fn unique_test_db_path() -> PathBuf {
let nonce = SystemTime::now()
Expand Down Expand Up @@ -479,6 +486,119 @@ mod tests {
.expect("diff trace insert should succeed");
}

#[test]
fn structured_diff_trace_reconstruction_uses_persisted_model_and_session_provenance() {
let db_path = unique_test_db_path();
let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open");
let payload =
include_str!("../structured_patch/fixtures/edit_multi_hunk/claude-post-tool-use.json");

db.insert_diff_trace(DiffTraceInsert {
time_ms: 1_000,
session_id: "cc_session-123",
patch: payload,
model_id: Some("claude/claude-sonnet-4-5"),
tool_name: "claude",
tool_version: Some("1.0.0"),
payload_type: PAYLOAD_TYPE_STRUCTURED,
})
.expect("structured diff trace insert should succeed");

let result = db
.recent_diff_trace_patches(0, 2_000)
.expect("structured diff trace should load");
assert_eq!(result.loaded_count(), 1);
assert_eq!(result.skipped_count(), 0);

let patch = &result.patches[0].patch;
assert!(
patch
.files
.iter()
.flat_map(|file| &file.hunks)
.all(|hunk| hunk.model_id.as_deref() == Some("claude/claude-sonnet-4-5")),
"every reconstructed hunk should use the persisted row model"
);
assert!(
patch
.files
.iter()
.flat_map(|file| &file.hunks)
.flat_map(|hunk| &hunk.lines)
.all(|line| line.session_id.as_deref() == Some("cc_session-123")),
"every reconstructed touched line should use the persisted canonical row session"
);

drop(db);
if let Some(parent) = db_path.parent() {
fs::remove_dir_all(parent).expect("test DB directory should be removed");
}
}

#[test]
fn claude_model_attribution_flows_from_persisted_structured_row_to_agent_trace() {
let db_path = unique_test_db_path();
let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open");
let payload =
include_str!("../structured_patch/fixtures/edit_single_hunk/claude-post-tool-use.json");
let post_commit_patch = parse_patch(
include_str!("../structured_patch/fixtures/edit_single_hunk/expected.patch"),
None,
)
.expect("post-commit fixture should parse");

db.insert_diff_trace(DiffTraceInsert {
time_ms: 1_000,
session_id: "cc_session-123",
patch: payload,
model_id: Some("claude/claude-sonnet-4-5"),
tool_name: "claude",
tool_version: Some("1.0.0"),
payload_type: PAYLOAD_TYPE_STRUCTURED,
})
.expect("structured diff trace insert should succeed");

let mut result = db
.recent_diff_trace_patches(0, 2_000)
.expect("structured diff trace should load");
let constructed_patch = result
.patches
.pop()
.expect("one structured diff trace should load")
.patch;
let agent_trace = build_agent_trace(
&constructed_patch,
&post_commit_patch,
AgentTraceMetadataInput {
commit_timestamp: "2026-04-23T10:20:30Z",
commit_revision: "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889",
vcs_type: None,
tool_name: Some("claude"),
tool_version: Some("1.0.0"),
},
)
.expect("Agent Trace should build");
let agent_trace_json =
serde_json::to_value(agent_trace).expect("Agent Trace should serialize");

assert_eq!(
agent_trace_json["files"][0]["conversations"][0]["contributor"]["model_id"],
"claude/claude-sonnet-4-5"
);
assert_eq!(
agent_trace_json["files"][0]["conversations"][0]["related"],
serde_json::json!([{
"type": "session",
"url": "https://sce.crocoder.dev/sessions/cc_session-123"
}])
);

drop(db);
if let Some(parent) = db_path.parent() {
fs::remove_dir_all(parent).expect("test DB directory should be removed");
}
}

#[test]
fn recent_diff_trace_patches_applies_bounded_window_ordering_and_parse_accounting() {
let db_path = unique_test_db_path();
Expand Down
157 changes: 157 additions & 0 deletions cli/src/services/hooks/claude_transcript.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;

use serde_json::Value;

/// Extract the model identity from a Claude JSONL transcript by matching an
/// assistant message whose `tool_use` content block has the given ID.
///
/// Transcript access and parsing are fail-open. Unreadable files, unreadable
/// lines, missing fields, and unmatched tool calls return `None`; malformed
/// unrelated JSONL records are skipped so later valid records can still match.
pub fn extract_claude_transcript_model(
transcript_path: &Path,
tool_use_id: &str,
) -> Option<String> {
extract_claude_transcript_model_from_reader(
File::open(transcript_path).map(BufReader::new),
tool_use_id,
)
}

fn extract_claude_transcript_model_from_reader<R: BufRead>(
reader: io::Result<R>,
tool_use_id: &str,
) -> Option<String> {
let reader = reader.ok()?;

for line in reader.lines() {
let line = line.ok()?;
if line.trim().is_empty() {
continue;
}

let Ok(parsed) = serde_json::from_str::<Value>(&line) else {
continue;
};
let Some(record) = parsed.as_object() else {
continue;
};

// Current Claude transcripts wrap the assistant message in `message`.
// Keep support for the earlier flat assistant-message shape as well.
let message = if let Some(message) = record.get("message").and_then(Value::as_object) {
let is_assistant = record
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "assistant")
|| message
.get("role")
.and_then(Value::as_str)
.is_some_and(|value| value == "assistant");
if !is_assistant {
continue;
}
message
} else {
if record.get("role").and_then(Value::as_str) != Some("assistant") {
continue;
}
record
};

let Some(content) = message.get("content").and_then(Value::as_array) else {
continue;
};
let has_matching_tool_use = content.iter().any(|block| {
block.as_object().is_some_and(|block| {
block
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "tool_use")
&& block
.get("id")
.and_then(Value::as_str)
.is_some_and(|value| value == tool_use_id)
})
});

if has_matching_tool_use {
return message
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|model| !model.is_empty())
.map(str::to_string);
}
}

None
}

#[cfg(test)]
mod tests {
use std::io::{Cursor, Error, ErrorKind};

use super::*;

fn transcript_reader(content: &str) -> Cursor<&[u8]> {
Cursor::new(content.as_bytes())
}

#[test]
fn claude_transcript_reads_real_assistant_envelope_and_skips_malformed_records() {
let transcript = concat!(
"{malformed unrelated record}\n",
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_use","id":"tool-123"}]}}"#,
"\n",
r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-4-1","content":[{"type":"text","text":"working"},{"type":"tool_use","id":"tool-123","name":"Write"}]}}"#,
"\n"
);

let model = extract_claude_transcript_model_from_reader(
Ok(transcript_reader(transcript)),
"tool-123",
);

assert_eq!(model.as_deref(), Some("claude-opus-4-1"));
}

#[test]
fn claude_transcript_returns_none_when_transcript_cannot_be_read() {
let unavailable = Err(Error::new(ErrorKind::NotFound, "transcript unavailable"));

assert_eq!(
extract_claude_transcript_model_from_reader::<Cursor<&[u8]>>(unavailable, "tool-123"),
None
);
}

#[test]
fn claude_transcript_returns_none_for_unmatched_tool_use_or_missing_model() {
let unmatched = concat!(
r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-4-1","content":[{"type":"tool_use","id":"other-tool"}]}}"#,
"\n"
);
let missing_model = concat!(
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-123"}]}}"#,
"\n"
);

assert_eq!(
extract_claude_transcript_model_from_reader(
Ok(transcript_reader(unmatched)),
"tool-123"
),
None
);
assert_eq!(
extract_claude_transcript_model_from_reader(
Ok(transcript_reader(missing_model)),
"tool-123"
),
None
);
}
}
Loading
Loading