Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS mutation_trace_scope_provenance (
scope_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
model_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
17 changes: 16 additions & 1 deletion cli/src/services/agent_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,20 @@ fn classify_hunk_combined(
}
}

fn combined_model_id(
direct_hunk: Option<&PatchHunk>,
mutation_hunk: Option<&PatchHunk>,
) -> Option<String> {
match (direct_hunk, mutation_hunk) {
(Some(direct), Some(mutation)) if direct.model_id == mutation.model_id => {
direct.model_id.clone()
}
(Some(direct), None) => direct.model_id.clone(),
(None, Some(mutation)) => mutation.model_id.clone(),
(None, None) | (Some(_), Some(_)) => None,
}
}

#[allow(dead_code)]
pub(crate) fn patches_have_overlap(
candidate_patch: &ParsedPatch,
Expand Down Expand Up @@ -596,13 +610,14 @@ fn build_trace_file(
);
let contributor_model_id = match contributor_kind {
HunkContributor::Ai | HunkContributor::Mixed => {
matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone())
combined_model_id(matched_intersection_hunk, matched_mutation_hunk)
}
HunkContributor::Unknown => None,
};
record_hunk_line_changes(line_changes, contributor_kind, post_commit_hunk);
let related_session_ids = matched_intersection_hunk
.into_iter()
.chain(matched_mutation_hunk)
.flat_map(|hunk| hunk.lines.iter())
.filter_map(|line| line.session_id.as_deref())
.filter(|session_id| !session_id.is_empty())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
{
"url": "https://sce.crocoder.dev/conversations/PLACEHOLDER",
"contributor": {
"type": "ai",
"model_id": "claude-sonnet-5"
"type": "ai"
},
"ranges": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
{
"url": "https://sce.crocoder.dev/conversations/PLACEHOLDER",
"contributor": {
"type": "mixed",
"model_id": "claude-sonnet-5"
"type": "mixed"
},
"ranges": [
{
Expand Down
151 changes: 151 additions & 0 deletions cli/src/services/agent_trace/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,49 @@ fn parse_fixture(fixture: &str) -> ParsedPatch {
parse_patch(fixture, None).expect("fixture patch should parse")
}

fn build_evidence_trace_with_mutation_provenance(
direct_fixture: &str,
mutation_fixture: &str,
post_commit_fixture: &str,
mutation_model_id: Option<&str>,
mutation_session_ids: &[&str],
) -> super::AgentTrace {
let mut direct_patch = parse_patch(direct_fixture, Some(EVIDENCE_DIRECT_SESSION_ID))
.expect("direct fixture patch should parse");
for file in &mut direct_patch.files {
for hunk in &mut file.hunks {
hunk.model_id = Some(String::from(EVIDENCE_DIRECT_MODEL_ID));
}
}

let mut mutation_ai_patch = parse_fixture(mutation_fixture);
for (line, session_id) in mutation_ai_patch.files[0].hunks[0]
.lines
.iter_mut()
.zip(mutation_session_ids.iter().copied())
{
line.session_id = Some(String::from(session_id));
}
mutation_ai_patch.files[0].hunks[0].model_id = mutation_model_id.map(str::to_owned);

let post_commit_patch = parse_fixture(post_commit_fixture);
build_agent_trace_from_evidence(
AgentTraceEvidence {
direct_patch: &direct_patch,
mutation_ai_patch: &mutation_ai_patch,
},
&post_commit_patch,
AgentTraceMetadataInput {
commit_timestamp: TEST_COMMIT_TIMESTAMP,
commit_revision: TEST_COMMIT_REVISION,
vcs_type: Some(AgentTraceVcsType::Git),
tool_name: Some(EVIDENCE_TOOL_NAME),
tool_version: Some(EVIDENCE_TOOL_VERSION),
},
)
.expect("agent trace should build")
}

const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[
include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_01.patch"),
include_str!("fixtures/text_file_lifecycle_reconstruction/incremental_02.patch"),
Expand Down Expand Up @@ -531,6 +574,114 @@ fn mutation_only_no_provenance_evidence_matches_golden_agent_trace() {
});
}

#[test]
fn mutation_only_evidence_emits_mutation_model_and_session() {
let trace = build_evidence_trace_with_mutation_provenance(
include_str!("fixtures/exclusive_without_direct/direct.patch"),
include_str!("fixtures/exclusive_without_direct/mutation_ai.patch"),
include_str!("fixtures/exclusive_without_direct/post_commit.patch"),
Some("gpt-5.6-sol"),
&[
"cx-session-1",
"cx-session-1",
"cx-session-1",
"cx-session-1",
],
);

let conversation = &trace.files[0].conversations[0];
assert_eq!(conversation.contributor.kind, super::HunkContributor::Ai);
assert_eq!(
conversation.contributor.model_id.as_deref(),
Some("gpt-5.6-sol")
);
assert_eq!(
conversation.related,
Some(vec![super::ConversationRelated {
kind: String::from("session"),
url: String::from("https://sce.crocoder.dev/sessions/cx-session-1"),
}])
);
validate_agent_trace_value(
&serde_json::to_value(&trace).expect("agent trace should serialize"),
)
.expect("mutation-only agent trace should validate against schema");
}

#[test]
fn combined_evidence_unions_sessions_and_requires_model_agreement() {
let matching_trace = build_evidence_trace_with_mutation_provenance(
include_str!("fixtures/direct_plus_mutation/direct.patch"),
include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"),
include_str!("fixtures/direct_plus_mutation/post_commit.patch"),
Some(EVIDENCE_DIRECT_MODEL_ID),
&["sess-a", "sess-z"],
);
let matching_conversation = &matching_trace.files[0].conversations[0];
assert_eq!(
matching_conversation.contributor.model_id.as_deref(),
Some(EVIDENCE_DIRECT_MODEL_ID)
);
assert_eq!(
matching_conversation.related,
Some(vec![
super::ConversationRelated {
kind: String::from("session"),
url: String::from("https://sce.crocoder.dev/sessions/sess-a"),
},
super::ConversationRelated {
kind: String::from("session"),
url: String::from("https://sce.crocoder.dev/sessions/sess-direct"),
},
super::ConversationRelated {
kind: String::from("session"),
url: String::from("https://sce.crocoder.dev/sessions/sess-z"),
},
])
);

let conflicting_trace = build_evidence_trace_with_mutation_provenance(
include_str!("fixtures/direct_plus_mutation/direct.patch"),
include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"),
include_str!("fixtures/direct_plus_mutation/post_commit.patch"),
Some("claude-opus-5"),
&["sess-a", "sess-direct"],
);
assert_eq!(
conflicting_trace.files[0].conversations[0]
.contributor
.model_id,
None
);
assert_eq!(
conflicting_trace.files[0].conversations[0]
.related
.as_ref()
.expect("conflicting evidence should retain related sessions")
.len(),
2
);

let unknown_trace = build_evidence_trace_with_mutation_provenance(
include_str!("fixtures/direct_plus_mutation/direct.patch"),
include_str!("fixtures/direct_plus_mutation/mutation_ai.patch"),
include_str!("fixtures/direct_plus_mutation/post_commit.patch"),
None,
&["sess-a", "sess-z"],
);
assert_eq!(
unknown_trace.files[0].conversations[0].contributor.model_id,
None
);

for trace in [&matching_trace, &conflicting_trace, &unknown_trace] {
validate_agent_trace_value(
&serde_json::to_value(trace).expect("agent trace should serialize"),
)
.expect("combined agent trace should validate against schema");
}
}

#[test]
fn direct_only_evidence_equals_direct_only_build_agent_trace() {
let direct = include_str!("fixtures/direct_only/direct.patch");
Expand Down
14 changes: 10 additions & 4 deletions cli/src/services/agent_trace_db/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ mod tests {
"mutation_trace_processed_events",
"mutation_trace_events",
"mutation_trace_event_active_scopes",
"mutation_trace_scope_provenance",
] {
assert!(
sqlite_object_exists(&db, "table", table),
Expand Down Expand Up @@ -453,10 +454,11 @@ mod tests {
String::from("002_repository_source_instance_id"),
String::from("003_claude_model_state"),
String::from("004_mutation_trace_protocol"),
String::from("005_mutation_scope_provenance"),
],
"repository DBs should be initialized from the baseline schema plus \
its additive source-instance-id, Claude model-state, and \
mutation-trace-protocol migrations"
its additive source-instance-id, Claude model-state, \
mutation-trace-protocol, and mutation-scope-provenance migrations"
);

db.ensure_schema_ready_for_hooks()
Expand Down Expand Up @@ -499,6 +501,7 @@ mod tests {
String::from("002_repository_source_instance_id"),
String::from("003_claude_model_state"),
String::from("004_mutation_trace_protocol"),
String::from("005_mutation_scope_provenance"),
]
);

Expand Down Expand Up @@ -1068,8 +1071,9 @@ mod tests {
String::from("002_repository_source_instance_id"),
String::from("003_claude_model_state"),
String::from("004_mutation_trace_protocol"),
String::from("005_mutation_scope_provenance"),
],
"an existing 001+002 database should get 003 and 004 applied on top through the setup/lifecycle path, without reapplying 001/002"
"an existing 001+002 database should get 003, 004, and 005 applied on top through the setup/lifecycle path, without reapplying 001/002"
);

for table in [
Expand All @@ -1078,6 +1082,7 @@ mod tests {
"mutation_trace_processed_events",
"mutation_trace_events",
"mutation_trace_event_active_scopes",
"mutation_trace_scope_provenance",
] {
assert!(
sqlite_object_exists(&migrated, "table", table),
Expand Down Expand Up @@ -1140,7 +1145,7 @@ mod tests {
String::from("001_repository_schema"),
String::from("002_repository_source_instance_id"),
],
"the no-migration hook-runtime path must never record or apply 003 or 004"
"the no-migration hook-runtime path must never record or apply 003, 004, or 005"
);

for table in [
Expand All @@ -1149,6 +1154,7 @@ mod tests {
"mutation_trace_processed_events",
"mutation_trace_events",
"mutation_trace_event_active_scopes",
"mutation_trace_scope_provenance",
] {
assert!(
!sqlite_object_exists(&db, "table", table),
Expand Down
Loading
Loading