diff --git a/src/authorship/authorship_log.rs b/src/authorship/authorship_log.rs index 496d5433b..5a0aa42b1 100644 --- a/src/authorship/authorship_log.rs +++ b/src/authorship/authorship_log.rs @@ -203,6 +203,9 @@ pub struct PromptRecord { /// Full URL to CAS-stored messages (format: {api_base_url}/cas/{hash}) #[serde(default, skip_serializing_if = "Option::is_none")] pub messages_url: Option, + /// Hash of the parent prompt record (for subagent transcripts) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, } impl Eq for PromptRecord {} @@ -249,6 +252,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, } } diff --git a/src/authorship/authorship_log_serialization.rs b/src/authorship/authorship_log_serialization.rs index dd7c69c51..b38db4649 100644 --- a/src/authorship/authorship_log_serialization.rs +++ b/src/authorship/authorship_log_serialization.rs @@ -775,6 +775,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -842,6 +843,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -891,6 +893,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1070,6 +1073,7 @@ mod tests { accepted_lines: 11, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1241,6 +1245,7 @@ mod tests { accepted_lines: 10, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1265,6 +1270,7 @@ mod tests { accepted_lines: 20, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/src/authorship/internal_db.rs b/src/authorship/internal_db.rs index 6eb160a06..632fe0a15 100644 --- a/src/authorship/internal_db.rs +++ b/src/authorship/internal_db.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must match MIGRATIONS.len()) -const SCHEMA_VERSION: usize = 3; +const SCHEMA_VERSION: usize = 4; /// Database migrations - each migration upgrades the schema by one version /// Migration at index N upgrades from version N to version N+1 @@ -77,6 +77,10 @@ const MIGRATIONS: &[&str] = &[ cached_at INTEGER NOT NULL ); "#, + // Migration 3 -> 4: Add parent_id for subagent prompt records + r#" + ALTER TABLE prompts ADD COLUMN parent_id TEXT; + "#, ]; /// Global database singleton @@ -98,8 +102,9 @@ pub struct PromptDbRecord { pub total_deletions: Option, // Line deletions from checkpoint stats pub accepted_lines: Option, // Lines accepted in commit (future) pub overridden_lines: Option, // Lines overridden in commit (future) - pub created_at: i64, // Unix timestamp - pub updated_at: i64, // Unix timestamp + pub parent_id: Option, // Parent prompt hash (for subagent records) + pub created_at: i64, // Unix timestamp + pub updated_at: i64, // Unix timestamp } impl PromptDbRecord { @@ -138,6 +143,7 @@ impl PromptDbRecord { total_deletions: Some(checkpoint.line_stats.deletions), accepted_lines: None, // Not yet calculated overridden_lines: None, // Not yet calculated + parent_id: None, created_at, updated_at, }) @@ -161,6 +167,7 @@ impl PromptDbRecord { accepted_lines: self.accepted_lines.unwrap_or(0), overriden_lines: self.overridden_lines.unwrap_or(0), messages_url: None, + parent_id: self.parent_id.clone(), } } @@ -509,8 +516,8 @@ impl InternalDatabase { id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + overridden_lines, created_at, updated_at, parent_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) ON CONFLICT(id) DO UPDATE SET workdir = excluded.workdir, model = excluded.model, @@ -522,7 +529,8 @@ impl InternalDatabase { total_deletions = excluded.total_deletions, accepted_lines = excluded.accepted_lines, overridden_lines = excluded.overridden_lines, - updated_at = excluded.updated_at + updated_at = excluded.updated_at, + parent_id = excluded.parent_id "#, params![ record.id, @@ -540,6 +548,7 @@ impl InternalDatabase { record.overridden_lines, record.created_at, record.updated_at, + record.parent_id, ], )?; @@ -562,8 +571,8 @@ impl InternalDatabase { id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + overridden_lines, created_at, updated_at, parent_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) ON CONFLICT(id) DO UPDATE SET workdir = excluded.workdir, model = excluded.model, @@ -575,7 +584,8 @@ impl InternalDatabase { total_deletions = excluded.total_deletions, accepted_lines = excluded.accepted_lines, overridden_lines = excluded.overridden_lines, - updated_at = excluded.updated_at + updated_at = excluded.updated_at, + parent_id = excluded.parent_id "#, )?; @@ -602,6 +612,7 @@ impl InternalDatabase { record.overridden_lines, record.created_at, record.updated_at, + record.parent_id, ])?; } } @@ -616,7 +627,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE id = ?1", )?; @@ -648,6 +659,7 @@ impl InternalDatabase { total_deletions: row.get(10)?, accepted_lines: row.get(11)?, overridden_lines: row.get(12)?, + parent_id: row.get(15)?, created_at: row.get(13)?, updated_at: row.get(14)?, }) @@ -670,7 +682,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE commit_sha = ?1", )?; @@ -702,6 +714,7 @@ impl InternalDatabase { total_deletions: row.get(10)?, accepted_lines: row.get(11)?, overridden_lines: row.get(12)?, + parent_id: row.get(15)?, created_at: row.get(13)?, updated_at: row.get(14)?, }) @@ -728,7 +741,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE workdir = ?1 AND updated_at >= ?2 ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4".to_string(), vec![Box::new(wd.to_string()), Box::new(ts), Box::new(limit as i64), Box::new(offset as i64)], ), @@ -736,7 +749,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE workdir = ?1 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3".to_string(), vec![Box::new(wd.to_string()), Box::new(limit as i64), Box::new(offset as i64)], ), @@ -744,7 +757,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE updated_at >= ?1 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3".to_string(), vec![Box::new(ts), Box::new(limit as i64), Box::new(offset as i64)], ), @@ -752,7 +765,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2".to_string(), vec![Box::new(limit as i64), Box::new(offset as i64)], ), @@ -789,6 +802,7 @@ impl InternalDatabase { total_deletions: row.get(10)?, accepted_lines: row.get(11)?, overridden_lines: row.get(12)?, + parent_id: row.get(15)?, created_at: row.get(13)?, updated_at: row.get(14)?, }) @@ -817,7 +831,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE messages LIKE ?1 AND workdir = ?2 ORDER BY updated_at DESC LIMIT ?3 OFFSET ?4".to_string(), vec![Box::new(search_pattern), Box::new(wd.to_string()), Box::new(limit as i64), Box::new(offset as i64)], ), @@ -825,7 +839,7 @@ impl InternalDatabase { "SELECT id, workdir, tool, model, external_thread_id, messages, commit_sha, agent_metadata, human_author, total_additions, total_deletions, accepted_lines, - overridden_lines, created_at, updated_at + overridden_lines, created_at, updated_at, parent_id FROM prompts WHERE messages LIKE ?1 ORDER BY updated_at DESC LIMIT ?2 OFFSET ?3".to_string(), vec![Box::new(search_pattern), Box::new(limit as i64), Box::new(offset as i64)], ), @@ -862,6 +876,7 @@ impl InternalDatabase { total_deletions: row.get(10)?, accepted_lines: row.get(11)?, overridden_lines: row.get(12)?, + parent_id: row.get(15)?, created_at: row.get(13)?, updated_at: row.get(14)?, }) @@ -1106,6 +1121,7 @@ mod tests { total_deletions: Some(5), accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: 1234567890, updated_at: 1234567890, } @@ -1135,7 +1151,7 @@ mod tests { |row| row.get(0), ) .unwrap(); - assert_eq!(version, "3"); + assert_eq!(version, "4"); } #[test] diff --git a/src/authorship/post_commit.rs b/src/authorship/post_commit.rs index 6c18c8ce4..241b3ee44 100644 --- a/src/authorship/post_commit.rs +++ b/src/authorship/post_commit.rs @@ -6,6 +6,7 @@ use crate::authorship::ignore::{ use crate::authorship::prompt_utils::{PromptUpdateResult, update_prompt_from_tool}; use crate::authorship::secrets::{redact_secrets_from_prompts, strip_prompt_messages}; use crate::authorship::stats::{stats_for_commit_stats, write_stats_to_terminal}; +use crate::authorship::transcript::AiTranscript; use crate::authorship::virtual_attribution::VirtualAttributions; use crate::authorship::working_log::{Checkpoint, CheckpointKind, WorkingLogEntry}; use crate::config::{Config, PromptStorageMode}; @@ -395,12 +396,31 @@ fn update_prompts_to_latest(checkpoints: &mut [Checkpoint]) -> Result<(), GitAiE // Apply the update to the last checkpoint only match result { - PromptUpdateResult::Updated(latest_transcript, latest_model) => { + PromptUpdateResult::Updated(latest_transcript, latest_model, subagents) => { let checkpoint = &mut checkpoints[last_idx]; checkpoint.transcript = Some(latest_transcript); if let Some(agent_id) = &mut checkpoint.agent_id { agent_id.model = latest_model; } + // Store subagent info in agent_metadata for downstream expansion + if !subagents.is_empty() { + let checkpoint = &mut checkpoints[last_idx]; + let metadata = checkpoint.agent_metadata.get_or_insert_with(HashMap::new); + if let Ok(subagents_json) = serde_json::to_string( + &subagents + .iter() + .map(|s| { + serde_json::json!({ + "agent_id": s.agent_id, + "transcript": s.transcript, + "model": s.model, + }) + }) + .collect::>(), + ) { + metadata.insert("__subagents".to_string(), subagents_json); + } + } } PromptUpdateResult::Unchanged => { // No update available, keep existing transcript @@ -455,6 +475,61 @@ fn batch_upsert_prompts_to_db( ) { records.push(record); } + + // Check for subagent data in agent_metadata and expand into separate records + if let Some(metadata) = &checkpoint.agent_metadata + && let Some(subagents_json) = metadata.get("__subagents") + && let Ok(subagents) = serde_json::from_str::>(subagents_json) + { + let parent_hash = checkpoint.agent_id.as_ref().map(|aid| { + crate::authorship::authorship_log_serialization::generate_short_hash( + &aid.id, &aid.tool, + ) + }); + for subagent in subagents { + if let (Some(agent_id_str), Some(transcript_value)) = ( + subagent.get("agent_id").and_then(|v| v.as_str()), + subagent.get("transcript"), + ) { + let subagent_hash = + crate::authorship::authorship_log_serialization::generate_short_hash( + agent_id_str, + "claude", + ); + if let Ok(transcript) = + serde_json::from_value::(transcript_value.clone()) + { + let model = subagent + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + records.push(PromptDbRecord { + id: subagent_hash, + workdir: Some(workdir.clone()), + tool: "claude".to_string(), + model, + external_thread_id: agent_id_str.to_string(), + messages: transcript, + commit_sha: Some(commit_sha.to_string()), + agent_metadata: None, + human_author: Some(checkpoint.author.clone()), + total_additions: None, + total_deletions: None, + accepted_lines: None, + overridden_lines: None, + parent_id: parent_hash.clone(), + created_at: now, + updated_at: now, + }); + } + } + } + } } if records.is_empty() { diff --git a/src/authorship/prompt_utils.rs b/src/authorship/prompt_utils.rs index f1c079774..e79347749 100644 --- a/src/authorship/prompt_utils.rs +++ b/src/authorship/prompt_utils.rs @@ -3,7 +3,7 @@ use crate::authorship::internal_db::InternalDatabase; use crate::authorship::transcript::AiTranscript; use crate::commands::checkpoint_agent::agent_presets::{ ClaudePreset, CodexPreset, ContinueCliPreset, CursorPreset, DroidPreset, GeminiPreset, - GithubCopilotPreset, + GithubCopilotPreset, SubagentInfo, }; use crate::commands::checkpoint_agent::opencode_preset::OpenCodePreset; use crate::error::GitAiError; @@ -152,9 +152,9 @@ pub fn find_prompt_with_db_fallback( /// Result of attempting to update a prompt from a tool pub enum PromptUpdateResult { - Updated(AiTranscript, String), // (new_transcript, new_model) - Unchanged, // No update available or needed - Failed(GitAiError), // Error occurred but not fatal + Updated(AiTranscript, String, Vec), // (new_transcript, new_model, subagents) + Unchanged, // No update available or needed + Failed(GitAiError), // Error occurred but not fatal } /// Update a prompt by fetching latest transcript from the tool @@ -194,6 +194,7 @@ fn update_codex_prompt( Ok((transcript, model)) => PromptUpdateResult::Updated( transcript, model.unwrap_or_else(|| current_model.to_string()), + vec![], ), Err(e) => { debug_log(&format!( @@ -247,7 +248,7 @@ fn update_cursor_prompt( Ok(Some((latest_transcript, _db_model))) => { // For Cursor, preserve the model from the checkpoint (which came from hook input) // rather than using the database model - PromptUpdateResult::Updated(latest_transcript, current_model.to_string()) + PromptUpdateResult::Updated(latest_transcript, current_model.to_string(), vec![]) } Ok(None) => PromptUpdateResult::Unchanged, Err(e) => { @@ -277,12 +278,13 @@ fn update_claude_prompt( if let Some(transcript_path) = metadata.get("transcript_path") { // Try to read and parse the transcript JSONL match ClaudePreset::transcript_and_model_from_claude_code_jsonl(transcript_path) { - Ok((transcript, model)) => { + Ok((transcript, model, subagents)) => { // Update to the latest transcript (similar to Cursor behavior) // This handles both cases: initial load failure and getting latest version PromptUpdateResult::Updated( transcript, model.unwrap_or_else(|| current_model.to_string()), + subagents, ) } Err(e) => { @@ -326,6 +328,7 @@ fn update_gemini_prompt( PromptUpdateResult::Updated( transcript, model.unwrap_or_else(|| current_model.to_string()), + vec![], ) } Err(e) => { @@ -371,6 +374,7 @@ fn update_github_copilot_prompt( PromptUpdateResult::Updated( transcript, model.unwrap_or_else(|| current_model.to_string()), + vec![], ) } Err(e) => { @@ -412,7 +416,7 @@ fn update_continue_cli_prompt( // Update to the latest transcript (similar to Cursor behavior) // This handles both cases: initial load failure and getting latest version // IMPORTANT: Always preserve the original model from agent_id (don't overwrite) - PromptUpdateResult::Updated(transcript, current_model.to_string()) + PromptUpdateResult::Updated(transcript, current_model.to_string(), vec![]) } Err(e) => { debug_log(&format!( @@ -483,7 +487,7 @@ fn update_droid_prompt( current_model.to_string() }; - PromptUpdateResult::Updated(transcript, model) + PromptUpdateResult::Updated(transcript, model, vec![]) } else { // No transcript_path in metadata PromptUpdateResult::Unchanged @@ -519,6 +523,7 @@ fn update_opencode_prompt( Ok((transcript, model)) => PromptUpdateResult::Updated( transcript, model.unwrap_or_else(|| current_model.to_string()), + vec![], ), Err(e) => { debug_log(&format!( @@ -636,6 +641,7 @@ mod tests { accepted_lines: 8, overriden_lines: 2, messages_url: None, + parent_id: None, } } @@ -1146,7 +1152,7 @@ mod tests { match result { PromptUpdateResult::Unchanged | PromptUpdateResult::Failed(_) - | PromptUpdateResult::Updated(_, _) => {} + | PromptUpdateResult::Updated(_, _, _) => {} } } diff --git a/src/authorship/rebase_authorship.rs b/src/authorship/rebase_authorship.rs index 30c3d234b..bb30bfc43 100644 --- a/src/authorship/rebase_authorship.rs +++ b/src/authorship/rebase_authorship.rs @@ -3380,6 +3380,7 @@ mod tests { accepted_lines: 5, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -3568,6 +3569,7 @@ mod tests { accepted_lines: 13, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); prompts.insert( @@ -3585,6 +3587,7 @@ mod tests { accepted_lines: 6, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -3691,6 +3694,7 @@ mod tests { accepted_lines: 3, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -3830,6 +3834,7 @@ mod tests { accepted_lines: 4, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); let old_wl = repo @@ -3952,6 +3957,7 @@ mod tests { accepted_lines: 8, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); let v1_wl = repo @@ -4119,6 +4125,7 @@ mod tests { accepted_lines: 13, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); prompts.insert( @@ -4136,6 +4143,7 @@ mod tests { accepted_lines: 16, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__file_names_with_spaces-2.snap b/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__file_names_with_spaces-2.snap index 1e66b7dc1..6bb3bb22e 100644 --- a/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__file_names_with_spaces-2.snap +++ b/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__file_names_with_spaces-2.snap @@ -1,5 +1,6 @@ --- source: src/authorship/authorship_log_serialization.rs +assertion_line: 814 expression: log --- AuthorshipLogV3 { @@ -66,6 +67,7 @@ AuthorshipLogV3 { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, }, }, diff --git a/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__serialize_deserialize_no_attestations-2.snap b/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__serialize_deserialize_no_attestations-2.snap index 531015fb7..12533959b 100644 --- a/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__serialize_deserialize_no_attestations-2.snap +++ b/src/authorship/snapshots/git_ai__authorship__authorship_log_serialization__tests__serialize_deserialize_no_attestations-2.snap @@ -1,5 +1,6 @@ --- source: src/authorship/authorship_log_serialization.rs +assertion_line: 906 expression: deserialized --- AuthorshipLogV3 { @@ -24,6 +25,7 @@ AuthorshipLogV3 { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, }, }, diff --git a/src/authorship/stats.rs b/src/authorship/stats.rs index c818ff1dc..8ad7f98d6 100644 --- a/src/authorship/stats.rs +++ b/src/authorship/stats.rs @@ -1310,6 +1310,7 @@ mod tests { accepted_lines: 5, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1355,6 +1356,7 @@ mod tests { accepted_lines: 3, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1402,6 +1404,7 @@ mod tests { accepted_lines: 3, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1770,6 +1773,7 @@ mod tests { accepted_lines: 0, overriden_lines: 100, // Unrealistically high messages_url: None, + parent_id: None, }, ); diff --git a/src/authorship/virtual_attribution.rs b/src/authorship/virtual_attribution.rs index 0c0cae370..2993926e4 100644 --- a/src/authorship/virtual_attribution.rs +++ b/src/authorship/virtual_attribution.rs @@ -362,6 +362,7 @@ impl VirtualAttributions { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }; prompts @@ -369,6 +370,59 @@ impl VirtualAttributions { .or_insert_with(BTreeMap::new) .insert(String::new(), prompt_record); + // Expand subagent metadata into separate prompt entries + if let Some(metadata) = &checkpoint.agent_metadata + && let Some(subagents_json) = metadata.get("__subagents") + && let Ok(subagents) = + serde_json::from_str::>(subagents_json) + { + for subagent in subagents { + if let (Some(agent_id_str), Some(transcript_value)) = ( + subagent.get("agent_id").and_then(|v| v.as_str()), + subagent.get("transcript"), + ) { + let subagent_hash = crate::authorship::authorship_log_serialization::generate_short_hash( + agent_id_str, "claude", + ); + let subagent_model = subagent + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let subagent_messages = transcript_value + .get("messages") + .and_then(|v| { + serde_json::from_value::< + Vec, + >(v.clone()) + .ok() + }) + .unwrap_or_default(); + + let subagent_prompt = crate::authorship::authorship_log::PromptRecord { + agent_id: crate::authorship::working_log::AgentId { + tool: "claude".to_string(), + id: agent_id_str.to_string(), + model: subagent_model, + }, + human_author: human_author.clone(), + messages: subagent_messages, + total_additions: 0, + total_deletions: 0, + accepted_lines: 0, + overriden_lines: 0, + messages_url: None, + parent_id: Some(author_id.clone()), + }; + + prompts + .entry(subagent_hash) + .or_insert_with(BTreeMap::new) + .insert(String::new(), subagent_prompt); + } + } + } + // Track additions and deletions from checkpoint line_stats *session_additions.entry(author_id.clone()).or_insert(0) += checkpoint.line_stats.additions; diff --git a/src/commands/checkpoint_agent/agent_presets.rs b/src/commands/checkpoint_agent/agent_presets.rs index 4f7e7e977..c1e9daeaa 100644 --- a/src/commands/checkpoint_agent/agent_presets.rs +++ b/src/commands/checkpoint_agent/agent_presets.rs @@ -31,6 +31,13 @@ pub struct AgentRunResult { pub dirty_files: Option>, } +#[derive(Clone, Debug)] +pub struct SubagentInfo { + pub agent_id: String, + pub transcript: AiTranscript, + pub model: Option, +} + pub trait AgentCheckpointPreset { fn run(&self, flags: AgentCheckpointFlags) -> Result; } @@ -85,7 +92,7 @@ impl AgentCheckpointPreset for ClaudePreset { // Parse into transcript and extract model let (transcript, model) = match ClaudePreset::transcript_and_model_from_claude_code_jsonl(transcript_path) { - Ok((transcript, model)) => (transcript, model), + Ok((transcript, model, _subagents)) => (transcript, model), Err(e) => { eprintln!("[Warning] Failed to parse Claude JSONL: {e}"); log_error( @@ -205,20 +212,97 @@ impl ClaudePreset { false } - /// Parse a Claude Code JSONL file into a transcript and extract model info + /// Parse a Claude Code JSONL file into a transcript and extract model info. + /// Also discovers subagent transcripts from the sibling subagents directory + /// and returns them as separate SubagentInfo entries. pub fn transcript_and_model_from_claude_code_jsonl( transcript_path: &str, - ) -> Result<(AiTranscript, Option), GitAiError> { + ) -> Result<(AiTranscript, Option, Vec), GitAiError> { let jsonl_content = std::fs::read_to_string(transcript_path).map_err(GitAiError::IoError)?; let mut transcript = AiTranscript::new(); let mut model = None; let mut plan_states = std::collections::HashMap::new(); + Self::parse_claude_jsonl_content( + &jsonl_content, + &mut transcript, + &mut model, + &mut plan_states, + ); + + // Discover and parse subagent transcripts. + // Claude Code stores subagent JSONL files at: + // /subagents/agent-.jsonl + // relative to the main transcript at .jsonl + let mut subagents = Vec::new(); + let transcript_path_buf = Path::new(transcript_path); + if let Some(stem) = transcript_path_buf.file_stem().and_then(|s| s.to_str()) { + let subagents_dir = transcript_path_buf + .parent() + .unwrap_or(Path::new(".")) + .join(stem) + .join("subagents"); + + if subagents_dir.is_dir() + && let Ok(entries) = std::fs::read_dir(&subagents_dir) + { + let mut subagent_files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.extension().and_then(|ext| ext.to_str()) == Some("jsonl") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("agent-")) + }) + .collect(); + + // Sort for deterministic ordering + subagent_files.sort(); + + for subagent_path in subagent_files { + if let Ok(subagent_content) = std::fs::read_to_string(&subagent_path) { + let mut subagent_transcript = AiTranscript::new(); + let mut subagent_model = None; + let mut subagent_plan_states = std::collections::HashMap::new(); + Self::parse_claude_jsonl_content( + &subagent_content, + &mut subagent_transcript, + &mut subagent_model, + &mut subagent_plan_states, + ); + // Extract agent ID from filename (e.g., "agent-test-sub-1" from "agent-test-sub-1.jsonl") + if let Some(agent_id) = subagent_path.file_stem().and_then(|s| s.to_str()) { + subagents.push(SubagentInfo { + agent_id: agent_id.to_string(), + transcript: subagent_transcript, + model: subagent_model, + }); + } + } + } + } + } + + Ok((transcript, model, subagents)) + } + + /// Parse Claude Code JSONL content and append messages to a transcript. + /// Extracts model info into `model` if not already set. + fn parse_claude_jsonl_content( + jsonl_content: &str, + transcript: &mut AiTranscript, + model: &mut Option, + plan_states: &mut std::collections::HashMap, + ) { for line in jsonl_content.lines() { if !line.trim().is_empty() { // Parse the raw JSONL entry - let raw_entry: serde_json::Value = serde_json::from_str(line)?; + let raw_entry: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; let timestamp = raw_entry["timestamp"].as_str().map(|s| s.to_string()); // Extract model from assistant messages if we haven't found it yet @@ -226,7 +310,7 @@ impl ClaudePreset { && raw_entry["type"].as_str() == Some("assistant") && let Some(model_str) = raw_entry["message"]["model"].as_str() { - model = Some(model_str.to_string()); + *model = Some(model_str.to_string()); } // Extract messages based on the type @@ -295,7 +379,7 @@ impl ClaudePreset { if let Some(plan_text) = extract_plan_from_tool_use( name, &item["input"], - &mut plan_states, + plan_states, ) { transcript.add_message(Message::Plan { text: plan_text, @@ -319,8 +403,6 @@ impl ClaudePreset { } } } - - Ok((transcript, model)) } } diff --git a/src/commands/continue_session.rs b/src/commands/continue_session.rs index 854cccad9..519b3b7ac 100644 --- a/src/commands/continue_session.rs +++ b/src/commands/continue_session.rs @@ -1386,6 +1386,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, } } diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 99c153e94..bacbb6c28 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -1124,7 +1124,7 @@ fn handle_show_transcript(args: &[String]) { crate::error::GitAiError, > = match agent_name.as_str() { "claude" => match ClaudePreset::transcript_and_model_from_claude_code_jsonl(path_or_id) { - Ok((transcript, model)) => Ok((transcript, model)), + Ok((transcript, model, _subagents)) => Ok((transcript, model)), Err(e) => { eprintln!("Error loading Claude transcript: {}", e); std::process::exit(1); diff --git a/src/commands/search.rs b/src/commands/search.rs index 90649d8f9..238258677 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -1440,6 +1440,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, } } diff --git a/src/commands/status.rs b/src/commands/status.rs index e0653ee33..1c2717029 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -516,6 +516,7 @@ mod tests { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/src/commands/sync_prompts.rs b/src/commands/sync_prompts.rs index e55800cf9..7f8c52e0f 100644 --- a/src/commands/sync_prompts.rs +++ b/src/commands/sync_prompts.rs @@ -203,7 +203,7 @@ fn update_prompt_record(record: &PromptDbRecord) -> Result { + PromptUpdateResult::Updated(new_transcript, new_model, _subagents) => { // Check if transcript actually changed if new_transcript == record.messages { return Ok(None); // No actual change diff --git a/tests/agent_presets_comprehensive.rs b/tests/agent_presets_comprehensive.rs index db741c91c..e4c2e028b 100644 --- a/tests/agent_presets_comprehensive.rs +++ b/tests/agent_presets_comprehensive.rs @@ -147,7 +147,7 @@ fn test_claude_transcript_parsing_empty_file() { ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_file.to_str().unwrap()); assert!(result.is_ok()); - let (transcript, model) = result.unwrap(); + let (transcript, model, _subagents) = result.unwrap(); assert!(transcript.messages().is_empty()); assert!(model.is_none()); @@ -162,7 +162,11 @@ fn test_claude_transcript_parsing_malformed_json() { let result = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_file.to_str().unwrap()); - assert!(result.is_err()); + // Malformed JSON lines are silently skipped (not fatal), so we get Ok with empty transcript + let (transcript, model, subagents) = result.expect("File read should succeed"); + assert!(transcript.messages().is_empty()); + assert!(model.is_none()); + assert!(subagents.is_empty()); fs::remove_file(temp_file).ok(); } @@ -180,7 +184,7 @@ fn test_claude_transcript_parsing_with_empty_lines() { ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_file.to_str().unwrap()); assert!(result.is_ok()); - let (transcript, model) = result.unwrap(); + let (transcript, model, _subagents) = result.unwrap(); assert_eq!(transcript.messages().len(), 2); assert_eq!(model, Some("claude-3".to_string())); @@ -1144,7 +1148,7 @@ fn test_claude_transcript_with_tool_result_in_user_content() { ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_file.to_str().unwrap()) .expect("Should parse successfully"); - let (transcript, _) = result; + let (transcript, _, _subagents) = result; // Should skip tool_result but include the text content let user_messages: Vec<_> = transcript .messages() diff --git a/tests/blame_comprehensive.rs b/tests/blame_comprehensive.rs index 6aef83a1b..57437f13c 100644 --- a/tests/blame_comprehensive.rs +++ b/tests/blame_comprehensive.rs @@ -632,6 +632,7 @@ fn test_blame_ai_authorship_hunk_splitting() { accepted_lines: 1, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -652,6 +653,7 @@ fn test_blame_ai_authorship_hunk_splitting() { accepted_lines: 1, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -712,6 +714,7 @@ fn test_blame_ai_authorship_no_splitting() { accepted_lines: 2, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/tests/blame_flags.rs b/tests/blame_flags.rs index 10599af52..eb33a471a 100644 --- a/tests/blame_flags.rs +++ b/tests/blame_flags.rs @@ -1124,6 +1124,7 @@ fn test_blame_ai_human_author() { accepted_lines: 1, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -1145,6 +1146,7 @@ fn test_blame_ai_human_author() { accepted_lines: 1, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/tests/cherry_pick.rs b/tests/cherry_pick.rs index 778c7b5f8..01de11dcb 100644 --- a/tests/cherry_pick.rs +++ b/tests/cherry_pick.rs @@ -180,6 +180,7 @@ fn test_cherry_pick_preserves_prompt_only_commit_note_metadata() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/tests/claude_code.rs b/tests/claude_code.rs index 46d17c0f2..0dc471729 100644 --- a/tests/claude_code.rs +++ b/tests/claude_code.rs @@ -16,7 +16,7 @@ use test_utils::fixture_path; #[test] fn test_parse_example_claude_code_jsonl_with_model() { let fixture = fixture_path("example-claude-code.jsonl"); - let (transcript, model) = + let (transcript, model, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(fixture.to_str().unwrap()) .expect("Failed to parse JSONL"); @@ -246,7 +246,7 @@ fn test_claude_e2e_prefers_latest_checkpoint_for_prompts() { #[test] fn test_parse_claude_code_jsonl_with_thinking() { let fixture = fixture_path("claude-code-with-thinking.jsonl"); - let (transcript, model) = + let (transcript, model, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(fixture.to_str().unwrap()) .expect("Failed to parse JSONL"); @@ -375,8 +375,9 @@ fn test_tool_results_are_not_parsed_as_user_messages() { temp_file.write_all(jsonl_content.as_bytes()).unwrap(); let temp_path = temp_file.path().to_str().unwrap(); - let (transcript, _model) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path) - .expect("Failed to parse JSONL"); + let (transcript, _model, _subagents) = + ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path) + .expect("Failed to parse JSONL"); // Should only have 1 message (the assistant response) // The tool_result should be skipped entirely @@ -412,8 +413,9 @@ fn test_user_text_content_blocks_are_parsed_correctly() { temp_file.write_all(jsonl_content.as_bytes()).unwrap(); let temp_path = temp_file.path().to_str().unwrap(); - let (transcript, _model) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path) - .expect("Failed to parse JSONL"); + let (transcript, _model, _subagents) = + ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path) + .expect("Failed to parse JSONL"); // Should have 2 messages (user + assistant) assert_eq!( @@ -581,7 +583,7 @@ fn test_extract_plan_returns_none_for_empty_content() { #[test] fn test_parse_claude_code_jsonl_with_plan() { let fixture = fixture_path("claude-code-with-plan.jsonl"); - let (transcript, model) = + let (transcript, model, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(fixture.to_str().unwrap()) .expect("Failed to parse JSONL"); @@ -730,7 +732,7 @@ fn test_plan_write_with_inline_jsonl() { temp_file.write_all(jsonl_content.as_bytes()).unwrap(); let temp_path = temp_file.path().to_str().unwrap(); - let (transcript, _) = + let (transcript, _, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path).unwrap(); assert_eq!(transcript.messages().len(), 1); @@ -754,7 +756,7 @@ fn test_plan_edit_with_inline_jsonl() { temp_file.write_all(jsonl_content.as_bytes()).unwrap(); let temp_path = temp_file.path().to_str().unwrap(); - let (transcript, _) = + let (transcript, _, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path).unwrap(); assert_eq!(transcript.messages().len(), 1); @@ -779,7 +781,7 @@ fn test_non_plan_edit_remains_tool_use() { temp_file.write_all(jsonl_content.as_bytes()).unwrap(); let temp_path = temp_file.path().to_str().unwrap(); - let (transcript, _) = + let (transcript, _, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path).unwrap(); assert_eq!(transcript.messages().len(), 1); @@ -818,7 +820,7 @@ fn test_mixed_plan_and_code_edits_in_single_assistant_message() { temp_file.write_all(jsonl_content.as_bytes()).unwrap(); let temp_path = temp_file.path().to_str().unwrap(); - let (transcript, _) = + let (transcript, _, _subagents) = ClaudePreset::transcript_and_model_from_claude_code_jsonl(temp_path).unwrap(); assert_eq!(transcript.messages().len(), 2); @@ -835,3 +837,131 @@ fn test_mixed_plan_and_code_edits_in_single_assistant_message() { "Second tool_use should remain ToolUse" ); } + +// ===== Subagent transcript tests ===== + +#[test] +fn test_parse_claude_code_jsonl_with_subagents() { + let fixture = fixture_path("claude-code-with-subagents.jsonl"); + let (transcript, model, subagents) = + ClaudePreset::transcript_and_model_from_claude_code_jsonl(fixture.to_str().unwrap()) + .expect("Failed to parse JSONL"); + + // Verify model is extracted from the main transcript + assert_eq!( + model.as_deref(), + Some("claude-sonnet-4-20250514"), + "Model should be extracted from main transcript" + ); + + // Count messages by type in the MAIN transcript only (subagent messages are separate) + let user_messages: Vec<_> = transcript + .messages() + .iter() + .filter(|m| matches!(m, Message::User { .. })) + .collect(); + let assistant_messages: Vec<_> = transcript + .messages() + .iter() + .filter(|m| matches!(m, Message::Assistant { .. })) + .collect(); + let tool_use_messages: Vec<_> = transcript + .messages() + .iter() + .filter(|m| matches!(m, Message::ToolUse { .. })) + .collect(); + + // Main transcript only: 1 user + 3 assistant text + 2 tool_use (Task, Edit) + // Subagent messages are NOT merged into the main transcript + assert_eq!( + user_messages.len(), + 1, + "Expected 1 user message (main only)" + ); + assert_eq!( + assistant_messages.len(), + 3, + "Expected 3 assistant messages (main only)" + ); + assert_eq!( + tool_use_messages.len(), + 2, + "Expected 2 tool_use messages (main only)" + ); + + // Verify subagent messages are NOT in the main transcript + let has_subagent_text = transcript.messages().iter().any(|m| { + if let Message::Assistant { text, .. } = m { + text.contains("search for auth-related files") + } else { + false + } + }); + assert!( + !has_subagent_text, + "Subagent assistant messages should NOT be in the main transcript" + ); + + // Verify subagents were collected separately + assert_eq!(subagents.len(), 1, "Expected 1 subagent entry"); + assert_eq!( + subagents[0].agent_id, "agent-test-sub-1", + "Subagent ID should be extracted from filename" + ); + + // Verify the subagent transcript has the expected messages + let sub_user: Vec<_> = subagents[0] + .transcript + .messages() + .iter() + .filter(|m| matches!(m, Message::User { .. })) + .collect(); + let sub_assistant: Vec<_> = subagents[0] + .transcript + .messages() + .iter() + .filter(|m| matches!(m, Message::Assistant { .. })) + .collect(); + let sub_tool: Vec<_> = subagents[0] + .transcript + .messages() + .iter() + .filter(|m| matches!(m, Message::ToolUse { .. })) + .collect(); + + assert_eq!(sub_user.len(), 1, "Subagent should have 1 user message"); + assert_eq!( + sub_assistant.len(), + 2, + "Subagent should have 2 assistant messages" + ); + assert_eq!(sub_tool.len(), 1, "Subagent should have 1 tool_use message"); + + // Verify subagent has the expected content + let has_sub_text = subagents[0].transcript.messages().iter().any(|m| { + if let Message::Assistant { text, .. } = m { + text.contains("search for auth-related files") + } else { + false + } + }); + assert!( + has_sub_text, + "Subagent transcript should contain its specific content" + ); +} + +#[test] +fn test_parse_claude_code_jsonl_without_subagents_dir() { + // Existing fixture has no subagents directory - should work fine + let fixture = fixture_path("example-claude-code.jsonl"); + let (transcript, model, subagents) = + ClaudePreset::transcript_and_model_from_claude_code_jsonl(fixture.to_str().unwrap()) + .expect("Failed to parse JSONL"); + + assert!(!transcript.messages().is_empty()); + assert!(model.is_some()); + // Should parse exactly as before (no subagent messages added) + assert_eq!(model.unwrap(), "claude-sonnet-4-20250514"); + assert!(subagents.is_empty(), "Should have no subagents"); +} diff --git a/tests/fixtures/claude-code-with-subagents.jsonl b/tests/fixtures/claude-code-with-subagents.jsonl new file mode 100644 index 000000000..7fb7fe0f2 --- /dev/null +++ b/tests/fixtures/claude-code-with-subagents.jsonl @@ -0,0 +1,6 @@ +{"type":"user","message":{"role":"user","content":"Help me refactor the auth module"},"timestamp":"2025-06-01T10:00:00Z"} +{"type":"assistant","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[{"type":"text","text":"I'll analyze the auth module and create a plan."},{"type":"tool_use","id":"toolu_01ABC","name":"Task","input":{"description":"Explore auth module","prompt":"Find all auth-related files","subagent_type":"Explore"}}]},"timestamp":"2025-06-01T10:00:01Z"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":"Found auth files in src/auth/\nagentId: test-sub-1"}]},"timestamp":"2025-06-01T10:00:10Z"} +{"type":"assistant","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[{"type":"text","text":"Based on the analysis, I'll now refactor the auth module."},{"type":"tool_use","id":"toolu_02DEF","name":"Edit","input":{"file_path":"src/auth/mod.rs","old_string":"old code","new_string":"new code"}}]},"timestamp":"2025-06-01T10:00:11Z"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02DEF","content":"File edited successfully"}]},"timestamp":"2025-06-01T10:00:12Z"} +{"type":"assistant","message":{"model":"claude-sonnet-4-20250514","role":"assistant","content":[{"type":"text","text":"The auth module has been refactored successfully."}]},"timestamp":"2025-06-01T10:00:13Z"} diff --git a/tests/fixtures/claude-code-with-subagents/subagents/agent-test-sub-1.jsonl b/tests/fixtures/claude-code-with-subagents/subagents/agent-test-sub-1.jsonl new file mode 100644 index 000000000..9042450d6 --- /dev/null +++ b/tests/fixtures/claude-code-with-subagents/subagents/agent-test-sub-1.jsonl @@ -0,0 +1,4 @@ +{"type":"user","message":{"role":"user","content":"Find all auth-related files"},"sessionId":"main-session","agentId":"test-sub-1","isSidechain":true,"timestamp":"2025-06-01T10:00:02Z"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"I'll search for auth-related files in the codebase."},{"type":"tool_use","id":"toolu_sub_01","name":"Glob","input":{"pattern":"**/auth/**"}}]},"sessionId":"main-session","agentId":"test-sub-1","isSidechain":true,"timestamp":"2025-06-01T10:00:03Z"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_sub_01","content":"src/auth/mod.rs\nsrc/auth/jwt.rs\nsrc/auth/middleware.rs"}]},"sessionId":"main-session","agentId":"test-sub-1","isSidechain":true,"timestamp":"2025-06-01T10:00:04Z"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"Found auth files in src/auth/"}]},"sessionId":"main-session","agentId":"test-sub-1","isSidechain":true,"timestamp":"2025-06-01T10:00:05Z"} diff --git a/tests/initial_attributions.rs b/tests/initial_attributions.rs index c1e1daf7d..05b13443b 100644 --- a/tests/initial_attributions.rs +++ b/tests/initial_attributions.rs @@ -63,6 +63,7 @@ fn test_initial_only_no_blame_data() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -151,6 +152,7 @@ fn test_initial_wins_overlaps() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -228,6 +230,7 @@ fn test_initial_and_blame_merge() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); prompts.insert( @@ -245,6 +248,7 @@ fn test_initial_and_blame_merge() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -315,6 +319,7 @@ fn test_partial_file_coverage() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); @@ -403,6 +408,7 @@ fn test_initial_attributions_in_subsequent_checkpoint() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, ); diff --git a/tests/prompt_picker_test.rs b/tests/prompt_picker_test.rs index 4d46fbc05..aa76da640 100644 --- a/tests/prompt_picker_test.rs +++ b/tests/prompt_picker_test.rs @@ -64,6 +64,7 @@ fn create_test_prompt( total_deletions: Some(5), accepted_lines: Some(8), overridden_lines: Some(2), + parent_id: None, created_at: now - 3600, // 1 hour ago updated_at: now - 1800, // 30 minutes ago } @@ -162,6 +163,7 @@ fn test_prompt_record_first_message_snippet_no_user_message() { total_deletions: None, accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: now, updated_at: now, }; @@ -193,6 +195,7 @@ fn test_prompt_record_first_message_snippet_empty_transcript() { total_deletions: None, accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: now, updated_at: now, }; @@ -237,6 +240,7 @@ fn test_prompt_record_message_count_empty() { total_deletions: None, accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: now, updated_at: now, }; @@ -815,6 +819,7 @@ fn test_prompt_record_with_all_message_types() { total_deletions: None, accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: now, updated_at: now, }; @@ -852,6 +857,7 @@ fn test_prompt_record_snippet_prefers_user_over_assistant() { total_deletions: None, accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: now, updated_at: now, }; @@ -918,6 +924,7 @@ fn test_prompt_record_optional_fields_none() { total_deletions: None, accepted_lines: None, overridden_lines: None, + parent_id: None, created_at: now, updated_at: now, }; diff --git a/tests/rebase.rs b/tests/rebase.rs index a6585c83a..d846d2032 100644 --- a/tests/rebase.rs +++ b/tests/rebase.rs @@ -399,6 +399,7 @@ fn test_rebase_preserves_prompt_only_commit_note_metadata() { accepted_lines: 0, overriden_lines: 0, messages_url: None, + parent_id: None, }, );