Skip to content
Draft
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
76 changes: 57 additions & 19 deletions cli/src/services/agent_trace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
//! - same hunk slot in `post_commit_patch` but not exact line-by-line match => `mixed`
//! - hunk present in `post_commit_patch` but missing from `intersection_patch` => `unknown`

use std::{error::Error, fmt, io::Cursor, path::Path, sync::OnceLock};
use std::{collections::BTreeSet, error::Error, fmt, io::Cursor, path::Path, sync::OnceLock};

use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset};
Expand All @@ -34,6 +34,7 @@ const RANGE_CONTENT_HASH_PREFIX: &str = "murmur3:";
const RANGE_CONTENT_HASH_INPUT_VERSION: &[u8] = b"sce-agent-trace-range-content-hash-v1\0";
const TOUCHED_LINE_ADDED_TAG: &[u8] = b"added\0";
const TOUCHED_LINE_REMOVED_TAG: &[u8] = b"removed\0";
const SESSION_RELATED_URL_PREFIX: &str = "https://sce.crocoder.dev/sessions/";

fn default_agent_trace_version() -> String {
AGENT_TRACE_VERSION.to_owned()
Expand Down Expand Up @@ -218,6 +219,21 @@ pub struct Conversation {
pub contributor: Contributor,
/// Line ranges in the new file, derived from the `post_commit_patch` hunk metadata.
pub ranges: Vec<LineRange>,
/// Optional related resources for this conversation entry.
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<Vec<ConversationRelated>>,
}

/// A related resource for a conversation entry.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub struct ConversationRelated {
/// Free-form related resource type.
#[serde(rename = "type")]
pub kind: String,
/// Related resource URL.
pub url: String,
}

/// Nested contributor object for a conversation entry.
Expand Down Expand Up @@ -383,7 +399,7 @@ fn parse_embedded_deleted_patch(file: &PatchFileChange) -> Option<ParsedPatch> {
.collect::<Vec<_>>()
.join("\n");

let parsed_patch = parse_patch(&embedded_patch).ok()?;
let parsed_patch = parse_patch(&embedded_patch, None).ok()?;
(!parsed_patch.files.is_empty()).then_some(parsed_patch)
}

Expand All @@ -404,29 +420,51 @@ fn build_trace_file(
.hunks
.iter()
.map(|post_commit_hunk| {
let (contributor_kind, contributor_model_id) = match intersection_file {
Some(ifile) => {
let contributor_kind = classify_hunk(post_commit_hunk, &ifile.hunks);
let matched_intersection_hunk = ifile
.hunks
.iter()
.find(|h| h.old_start == post_commit_hunk.old_start);
let contributor_model_id = match contributor_kind {
HunkContributor::Ai | HunkContributor::Mixed => {
matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone())
}
HunkContributor::Unknown => None,
};
(contributor_kind, contributor_model_id)
}
None => (HunkContributor::Unknown, None),
};
let (contributor_kind, contributor_model_id, matched_intersection_hunk) =
match intersection_file {
Some(ifile) => {
let contributor_kind = classify_hunk(post_commit_hunk, &ifile.hunks);
let matched_intersection_hunk = ifile
.hunks
.iter()
.find(|h| h.old_start == post_commit_hunk.old_start);
let contributor_model_id = match contributor_kind {
HunkContributor::Ai | HunkContributor::Mixed => {
matched_intersection_hunk.and_then(|hunk| hunk.model_id.clone())
}
HunkContributor::Unknown => None,
};
(
contributor_kind,
contributor_model_id,
matched_intersection_hunk,
)
}
None => (HunkContributor::Unknown, None, None),
};
let related_session_ids = matched_intersection_hunk
.into_iter()
.flat_map(|hunk| hunk.lines.iter())
.filter_map(|line| line.session_id.as_deref())
.filter(|session_id| !session_id.is_empty())
.collect::<BTreeSet<_>>();
let related = (!related_session_ids.is_empty()).then(|| {
related_session_ids
.into_iter()
.map(|session_id| ConversationRelated {
kind: String::from("session"),
url: format!("{SESSION_RELATED_URL_PREFIX}{session_id}"),
})
.collect()
});

Conversation {
contributor: Contributor {
kind: contributor_kind,
model_id: contributor_model_id,
},
ranges: vec![line_range_from_hunk(post_commit_file, post_commit_hunk)],
related,
}
})
.collect();
Expand Down
54 changes: 48 additions & 6 deletions cli/src/services/agent_trace/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const TEST_COMMIT_REVISION: &str = "a0b1c2d3e4f5a6b7c8d9e0f11223344556677889";
fn parse_fixtures(fixtures: &[&str]) -> Vec<ParsedPatch> {
fixtures
.iter()
.map(|fixture| parse_patch(fixture).expect("fixture patch should parse"))
.map(|fixture| parse_patch(fixture, None).expect("fixture patch should parse"))
.collect()
}

Expand Down Expand Up @@ -53,7 +53,8 @@ const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[

fn assert_builds_expected_agent_trace(scenario: AgentTraceScenario) {
let constructed_patch = combine_patches(&parse_fixtures(scenario.incremental));
let post_commit_patch = parse_patch(scenario.post_commit).expect("fixture patch should parse");
let post_commit_patch =
parse_patch(scenario.post_commit, None).expect("fixture patch should parse");
let golden: Value = serde_json::from_str(scenario.golden).expect("golden json should load");
validate_agent_trace_value(&golden).expect("golden json should validate against schema");
let actual = build_agent_trace(
Expand Down Expand Up @@ -146,15 +147,20 @@ fn poem_edit_reconstruction_matches_golden_agent_trace() {

#[test]
fn poem_edit_reconstruction_maps_each_hunk_to_one_range() {
let constructed_patch = combine_patches(&parse_fixtures(&[
let mut constructed_patch = combine_patches(&parse_fixtures(&[
include_str!("fixtures/poem_edit_reconstruction/incremental_01.patch"),
include_str!("fixtures/poem_edit_reconstruction/incremental_02.patch"),
]));
let post_commit_patch = parse_patch(include_str!(
"fixtures/poem_edit_reconstruction/post_commit.patch"
))
let post_commit_patch = parse_patch(
include_str!("fixtures/poem_edit_reconstruction/post_commit.patch"),
None,
)
.expect("fixture patch should parse");

let first_hunk_lines = &mut constructed_patch.files[0].hunks[0].lines;
first_hunk_lines[0].session_id = Some(String::from("session-z"));
first_hunk_lines[1].session_id = Some(String::from("session-a"));

let agent_trace = build_agent_trace(
&constructed_patch,
&post_commit_patch,
Expand All @@ -174,6 +180,42 @@ fn poem_edit_reconstruction_maps_each_hunk_to_one_range() {
assert_eq!(agent_trace.files.len(), 1);
assert_eq!(agent_trace.files[0].path, "poem.md");
assert_eq!(agent_trace.files[0].conversations.len(), 3);
assert_eq!(
agent_trace.files[0].conversations[0].related,
Some(vec![
super::ConversationRelated {
kind: String::from("session"),
url: String::from("https://sce.crocoder.dev/sessions/session-a"),
},
super::ConversationRelated {
kind: String::from("session"),
url: String::from("https://sce.crocoder.dev/sessions/session-z"),
},
])
);
assert_eq!(agent_trace.files[0].conversations[1].related, None);
assert_eq!(agent_trace.files[0].conversations[2].related, None);
assert_eq!(
actual_json["files"][0]["conversations"][0]["related"],
json!([
{
"type": "session",
"url": "https://sce.crocoder.dev/sessions/session-a"
},
{
"type": "session",
"url": "https://sce.crocoder.dev/sessions/session-z"
}
])
);
assert!(
actual_json["files"][0]["conversations"][1]["related"].is_null(),
"conversations without session-backed lines should omit related"
);
assert!(
actual_json["files"][0]["conversations"][2]["related"].is_null(),
"conversations without session-backed lines should omit related"
);
assert_eq!(
agent_trace.files[0]
.conversations
Expand Down
2 changes: 1 addition & 1 deletion cli/src/services/agent_trace_db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ fn parse_recent_diff_trace_patch_rows(rows: Vec<DiffTracePatchRow>) -> RecentDif
let mut skipped = Vec::new();

for row in rows {
match parse_patch(&row.patch) {
match parse_patch(&row.patch, Some(row.session_id.as_str())) {
Ok(mut patch) => {
for file in &mut patch.files {
for hunk in &mut file.hunks {
Expand Down
6 changes: 3 additions & 3 deletions cli/src/services/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ where
.context("Failed to serialize post-commit Agent Trace payload for persistence.")?
);

let constructed_url = format!("{}{}", AGENT_TRACE_URL_PREFIX, agent_trace.id);
let constructed_url = format!("{AGENT_TRACE_URL_PREFIX}{}", agent_trace.id);

let insert_input = AgentTraceInsert {
commit_id: &flow_result.post_commit_data.commit_oid,
Expand Down Expand Up @@ -982,7 +982,7 @@ pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result<Post
let commit_oid = capture_head_oid_from_git(repository_root)?;
let commit_time_ms = capture_head_timestamp_from_git(repository_root)?;
let patch_text = capture_head_patch_from_git(repository_root)?;
let parsed_patch = parse_patch_from_text(&patch_text).map_err(|e| {
let parsed_patch = parse_patch_from_text(&patch_text, None).map_err(|e| {
anyhow!(post_commit_patch_error(
"failed to parse post-commit patch",
&e.to_string()
Expand Down Expand Up @@ -1062,7 +1062,7 @@ mod tests {
"Index: {path}\n===================================================================\n--- {path}\n+++ {path}\n@@ -0,0 +1,1 @@\n+{content}\n"
);

parse_patch_from_text(&patch_text).expect("test patch should parse")
parse_patch_from_text(&patch_text, None).expect("test patch should parse")
}

#[test]
Expand Down
31 changes: 22 additions & 9 deletions cli/src/services/patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ pub struct TouchedLine {
pub line_number: u64,
/// Content of the line (without the leading `+`/`-` prefix).
pub content: String,
/// Optional session identifier associated with this touched line.
#[serde(default)]
pub session_id: Option<String>,
}

/// Kind of touched line.
Expand Down Expand Up @@ -190,8 +193,8 @@ pub fn load_patch_from_json_bytes(input: &[u8]) -> Result<ParsedPatch, PatchLoad
/// ```
/// use sce::services::patch::{intersect_patches, parse_patch};
///
/// let constructed_patch = parse_patch("...")?;
/// let post_commit_patch = parse_patch("...")?;
/// let constructed_patch = parse_patch("...", None)?;
/// let post_commit_patch = parse_patch("...", None)?;
/// let overlap = intersect_patches(&constructed_patch, &post_commit_patch);
/// ```
#[allow(dead_code)]
Expand Down Expand Up @@ -231,7 +234,7 @@ pub fn intersect_patches(
let overlapping_lines: Vec<TouchedLine> = post_commit_hunk
.lines
.iter()
.filter(|line| {
.filter_map(|line| {
if let Some(index) = find_available_line_match(
&available_lines,
&used_lines,
Expand All @@ -242,7 +245,11 @@ pub fn intersect_patches(
matched_model_id = available_lines[index].model_id.map(str::to_string);
}
used_lines[index] = true;
return true;
let mut overlapping_line = line.clone();
overlapping_line
.session_id
.clone_from(&available_lines[index].line.session_id);
return Some(overlapping_line);
}

if let Some(index) = find_available_line_match(
Expand All @@ -255,12 +262,15 @@ pub fn intersect_patches(
matched_model_id = available_lines[index].model_id.map(str::to_string);
}
used_lines[index] = true;
return true;
let mut overlapping_line = line.clone();
overlapping_line
.session_id
.clone_from(&available_lines[index].line.session_id);
return Some(overlapping_line);
}

false
None
})
.cloned()
.collect();

if overlapping_lines.is_empty() {
Expand Down Expand Up @@ -506,7 +516,7 @@ pub fn combine_patches(patches: &[ParsedPatch]) -> ParsedPatch {
/// Returns `ParseError` with an actionable message when the input is malformed,
/// such as an invalid hunk header or a `---`/`+++` line that cannot be parsed.
#[allow(dead_code)]
pub fn parse_patch(input: &str) -> Result<ParsedPatch, ParseError> {
pub fn parse_patch(input: &str, session_id: Option<&str>) -> Result<ParsedPatch, ParseError> {
let mut files: Vec<PatchFileChange> = Vec::new();
let mut current_file: Option<FileBuilder> = None;

Expand Down Expand Up @@ -591,7 +601,7 @@ pub fn parse_patch(input: &str) -> Result<ParsedPatch, ParseError> {
// Parse hunk header: @@ -old_start[,old_count] +new_start[,new_count] @@
if let Some(rest) = line.strip_prefix("@@ ") {
if let Some(fb) = current_file.as_mut() {
let hunk = parse_hunk_header_and_body(rest, &mut lines)?;
let hunk = parse_hunk_header_and_body(rest, &mut lines, session_id)?;
fb.add_hunk(hunk);
}
}
Expand Down Expand Up @@ -773,6 +783,7 @@ fn parse_diff_path(rest: &str) -> String {
fn parse_hunk_header_and_body<'a, I>(
rest: &str,
lines: &mut std::iter::Peekable<I>,
session_id: Option<&str>,
) -> Result<PatchHunk, ParseError>
where
I: Iterator<Item = &'a str>,
Expand Down Expand Up @@ -837,6 +848,7 @@ where
kind: TouchedLineKind::Added,
line_number: new_line_num,
content: content.to_string(),
session_id: session_id.map(str::to_string),
});
new_line_num += 1;
} else if let Some(content) = line.strip_prefix('-') {
Expand All @@ -845,6 +857,7 @@ where
kind: TouchedLineKind::Removed,
line_number: old_line_num,
content: content.to_string(),
session_id: session_id.map(str::to_string),
});
old_line_num += 1;
} else if line.starts_with(' ') || line.starts_with('\t') {
Expand Down
4 changes: 2 additions & 2 deletions cli/src/services/patch/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct PatchScenario {
fn parse_fixtures(fixtures: &[&str]) -> Vec<ParsedPatch> {
fixtures
.iter()
.map(|fixture| parse_patch(fixture).expect("fixture patch should parse"))
.map(|fixture| parse_patch(fixture, None).expect("fixture patch should parse"))
.collect()
}

Expand Down Expand Up @@ -45,7 +45,7 @@ const TEXT_FILE_LIFECYCLE_RECONSTRUCTION_INCREMENTALS: &[&str] = &[

fn assert_reconstructs_post_commit(scenario: PatchScenario) {
let combined = combine_patches(&parse_fixtures(scenario.incremental));
let post_commit = parse_patch(scenario.post_commit).expect("fixture patch should parse");
let post_commit = parse_patch(scenario.post_commit, None).expect("fixture patch should parse");
let golden: ParsedPatch =
serde_json::from_str(scenario.golden).expect("golden json should load");

Expand Down
6 changes: 3 additions & 3 deletions context/cli/patch-service.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading