diff --git a/code-rs/core/src/client_common.rs b/code-rs/core/src/client_common.rs index f6f09a8a184..b8366ae5e70 100644 --- a/code-rs/core/src/client_common.rs +++ b/code-rs/core/src/client_common.rs @@ -202,7 +202,7 @@ impl Prompt { "prepended developer message", 1, trimmed.len(), - Some(format!("developer:prepend:{:016x}", stable_hash(trimmed))), + duplicate_key_for_prepend_developer_message(trimmed), ); input_with_instructions.push(ResponseItem::Message { id: None, @@ -351,13 +351,23 @@ impl Prompt { } fn classify_prepend_developer_message(text: &str) -> ContextSourceKind { - if text.contains(" Option { + if classify_prepend_developer_message(text) == ContextSourceKind::AutoReviewLedger { + Some("developer:prepend:auto_review_ledger".to_string()) + } else { + Some(format!("developer:prepend:{:016x}", stable_hash(text))) + } +} + fn add_response_item_to_ledger(ledger: &mut ContextLedger, item: &ResponseItem) { if let ResponseItem::Message { role, content, .. } = item { if role == "user" && UserInstructions::is_user_instructions(content) { @@ -488,6 +498,7 @@ fn label_for_input_item(item: &ResponseItem) -> &'static str { ContextSourceKind::ExplicitSkill => "explicit skill", ContextSourceKind::EnvironmentContext => "environment context", ContextSourceKind::BrowserStatus => "browser/status context", + ContextSourceKind::AutoReviewLedger => "auto review ledger", ContextSourceKind::ToolOutput => "tool output", ContextSourceKind::DeveloperInstructions => "developer message", ContextSourceKind::UserInstructions => "user/project instructions", @@ -1228,6 +1239,25 @@ mod tests { } } + #[test] + fn auto_review_ledger_developer_message_has_distinct_context_source() { + let mut prompt = Prompt::default(); + prompt.prepend_developer_messages.push( + "\nrun id=abc status=Reviewing\n" + .to_string(), + ); + + let (_input, ledger) = prompt.get_formatted_input_with_ledger(); + let entry = ledger + .entries() + .iter() + .find(|entry| entry.source == ContextSourceKind::AutoReviewLedger) + .expect("auto review ledger entry"); + assert_eq!(entry.label, "prepended developer message"); + assert_eq!(entry.persistence, ContextPersistence::RequestOnly); + assert_eq!(entry.duplicate_key.as_deref(), Some("developer:prepend:auto_review_ledger")); + } + #[test] fn serializes_text_verbosity_when_set() { let input: Vec = vec![]; diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index ddc6a099a1c..354a37d39f7 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -30,6 +30,7 @@ use crate::protocol::McpListToolsResponseEvent; use crate::protocol::TaskLifecycleEvent; use crate::protocol::TaskLifecyclePhase; use crate::protocol::TaskOriginKind; +use crate::review_store::{AutoReviewLedgerOptions, AutoReviewRunStore}; use code_app_server_protocol::AuthMode as AppAuthMode; use code_protocol::models::ContentItem; use code_protocol::models::ResponseItem; @@ -39,6 +40,7 @@ use code_protocol::models::FunctionCallOutputPayload; use code_protocol::models::ShellCommandToolCallParams; use code_protocol::models::ShellToolCallParams; use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Clone, Debug, Eq, PartialEq)] enum AgentTaskKind { @@ -1538,6 +1540,21 @@ fn build_prepend_developer_messages( messages } +fn build_auto_review_ledger_message(cwd: &Path) -> Option { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + match AutoReviewRunStore::open_existing(cwd) { + Ok(Some(store)) => store.compact_ledger(AutoReviewLedgerOptions::new(now)), + Ok(None) => None, + Err(err) => { + tracing::warn!(?err, "failed to open auto-review run store for request ledger"); + None + } + } +} + fn active_session_model_notice_for_request( sess: &Session, submission_id: &str, @@ -3074,6 +3091,7 @@ async fn run_turn( let mut did_context_model_fallback = false; let mut forced_model_override: Option = None; let mut fallback_metadata_warning_sent = false; + let auto_review_ledger_message = build_auto_review_ledger_message(&tc.cwd); // Attempt input starts as the provided input, and may be augmented with // items from a previous dropped stream attempt so we don't lose progress. let mut attempt_input: Vec = input.clone(); @@ -3106,6 +3124,9 @@ async fn run_turn( prepend_developer_messages.push(memory_prompt); } } + if let Some(auto_review_ledger) = auto_review_ledger_message.clone() { + prepend_developer_messages.push(auto_review_ledger); + } let mut prompt = Prompt { input: attempt_input.clone(), diff --git a/code-rs/core/src/context_ledger.rs b/code-rs/core/src/context_ledger.rs index a207d09cda8..390a34c2ed1 100644 --- a/code-rs/core/src/context_ledger.rs +++ b/code-rs/core/src/context_ledger.rs @@ -19,6 +19,7 @@ pub enum ContextSourceKind { ExplicitSkill, EnvironmentContext, BrowserStatus, + AutoReviewLedger, StatusItem, ConversationHistory, PendingInput, diff --git a/code-rs/core/src/review_store.rs b/code-rs/core/src/review_store.rs index ca9b99f3dc2..f684646f95e 100644 --- a/code-rs/core/src/review_store.rs +++ b/code-rs/core/src/review_store.rs @@ -16,6 +16,11 @@ const SCHEMA_VERSION: u32 = 1; const DEFAULT_MAX_RUNS: usize = 500; const MAX_FINDING_DIGESTS: usize = 25; const MAX_FINDING_DIGEST_TITLE_CHARS: usize = 160; +const DEFAULT_LEDGER_MAX_BYTES: usize = 2_400; +const DEFAULT_LEDGER_MAX_RUNS: usize = 5; +const LEDGER_RECENT_ACTIONABLE_SECS: u64 = 24 * 60 * 60; +const LEDGER_IN_FLIGHT_ACTIVITY_SECS: u64 = 60 * 60; +const LEDGER_MAX_FINDINGS_PER_RUN: usize = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -214,6 +219,23 @@ impl AutoReviewRun { } } +#[derive(Debug, Clone, Copy)] +pub struct AutoReviewLedgerOptions { + pub max_bytes: usize, + pub max_runs: usize, + pub now: u64, +} + +impl AutoReviewLedgerOptions { + pub fn new(now: u64) -> Self { + Self { + max_bytes: DEFAULT_LEDGER_MAX_BYTES, + max_runs: DEFAULT_LEDGER_MAX_RUNS, + now, + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct AutoReviewRunsFile { schema_version: u32, @@ -232,6 +254,14 @@ impl AutoReviewRunStore { Self::open_in_dir(auto_review_dir(scope)?) } + pub fn open_existing(scope: &Path) -> io::Result> { + let root = auto_review_dir(scope)?; + if !root.exists() { + return Ok(None); + } + Self::open_in_dir(root).map(Some) + } + pub fn open_in_dir(root: PathBuf) -> io::Result { fs::create_dir_all(&root)?; let outputs_dir = root.join(OUTPUTS_DIR); @@ -372,6 +402,10 @@ impl AutoReviewRunStore { let text = fs::read_to_string(path)?; serde_json::from_str(&text).map_err(io::Error::other) } + + pub fn compact_ledger(&self, options: AutoReviewLedgerOptions) -> Option { + compact_ledger_from_runs(self.runs.values(), options) + } } pub fn auto_review_dir(scope: &Path) -> io::Result { @@ -469,6 +503,192 @@ fn finding_digests(output: &ReviewOutputEvent) -> Vec { .collect() } +fn compact_ledger_from_runs<'a, I>( + runs: I, + options: AutoReviewLedgerOptions, +) -> Option +where + I: IntoIterator, +{ + let max_bytes = options.max_bytes.max(64); + let mut selected = runs + .into_iter() + .filter(|run| run_is_ledger_actionable(run, options.now)) + .collect::>(); + if selected.is_empty() { + return None; + } + + selected.sort_by(|left, right| { + ledger_priority(right) + .cmp(&ledger_priority(left)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| right.created_at.cmp(&left.created_at)) + .then_with(|| right.run_id.cmp(&left.run_id)) + }); + selected.truncate(options.max_runs.max(1)); + + let mut lines = Vec::new(); + lines.push(format!( + "" + )); + lines.push("Auto Review state for this repo. Details are not included in this request; treat run ids as stable references for future detail lookup.".to_string()); + for run in selected { + append_ledger_run(&mut lines, run, options.now); + } + lines.push("".to_string()); + + let mut ledger = lines.join("\n"); + if ledger.len() > max_bytes { + truncate_ledger_to_bytes(&mut ledger, max_bytes); + } + Some(ledger) +} + +fn run_is_ledger_actionable(run: &AutoReviewRun, now: u64) -> bool { + if run.status.is_in_flight() { + let reference_time = run.last_activity_at.unwrap_or(run.updated_at); + return now.saturating_sub(reference_time) <= LEDGER_IN_FLIGHT_ACTIVITY_SECS + || matches!(run.freshness, AutoReviewFreshness::Current | AutoReviewFreshness::LongRunning); + } + if matches!( + run.status, + AutoReviewRunStatus::Lost | AutoReviewRunStatus::Superseded | AutoReviewRunStatus::Skipped + ) { + return false; + } + let has_error_detail = run.error_summary.is_some() || run.error_class.is_some(); + let terminal_actionable = run.finding_count > 0 + || !run.finding_digests.is_empty() + || has_error_detail + || (matches!(run.status, AutoReviewRunStatus::Failed | AutoReviewRunStatus::Cancelled) + && has_error_detail); + if !terminal_actionable { + return false; + } + let reference_time = run.completed_at.or(run.last_activity_at).unwrap_or(run.updated_at); + now.saturating_sub(reference_time) <= LEDGER_RECENT_ACTIONABLE_SECS +} + +fn ledger_priority(run: &AutoReviewRun) -> u8 { + if run.status.is_in_flight() { + return 5; + } + if run.finding_count > 0 || !run.finding_digests.is_empty() { + return 4; + } + if matches!(run.status, AutoReviewRunStatus::Failed | AutoReviewRunStatus::Cancelled) { + return 3; + } + 1 +} + +fn append_ledger_run(lines: &mut Vec, run: &AutoReviewRun, now: u64) { + let age_secs = now.saturating_sub(run.updated_at); + let activity_age_secs = run + .last_activity_at + .map(|last_activity| now.saturating_sub(last_activity)); + let snapshot = run + .snapshot_commit + .as_deref() + .and_then(short_sha) + .unwrap_or("unknown"); + let branch = run.branch.as_deref().or(run.batch_id.as_deref()).unwrap_or("unknown"); + let mut line = format!( + "run id={} status={:?} freshness={:?} source={:?} branch={} snapshot={} age={}s", + run.run_id, run.status, run.freshness, run.source, branch, snapshot, age_secs + ); + if let Some(activity_age_secs) = activity_age_secs { + line.push_str(&format!(" last_activity={}s", activity_age_secs)); + } + if let Some(agent_id) = run.agent_id.as_deref().and_then(short_agent_id) { + line.push_str(&format!(" agent={agent_id}")); + } + if run.finding_count > 0 { + line.push_str(&format!(" findings={}", run.finding_count)); + } + if run.omitted_finding_digest_count > 0 { + line.push_str(&format!(" omitted_findings={}", run.omitted_finding_digest_count)); + } + if let Some(summary) = run.summary_digest.as_deref() { + line.push_str(" summary="); + line.push_str(&single_line(summary, 180)); + } + if let Some(error) = run.error_summary.as_deref() { + line.push_str(" error="); + line.push_str(&single_line(error, 180)); + } + lines.push(line); + + for finding in run.finding_digests.iter().take(LEDGER_MAX_FINDINGS_PER_RUN) { + let location = finding + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let line_start = finding + .line_start + .map(|line| line.to_string()) + .unwrap_or_else(|| "?".to_string()); + lines.push(format!( + " finding id={} priority={} location={}:{} title={}", + finding.finding_id, + finding.priority, + location, + line_start, + single_line(&finding.title, 160) + )); + } + if run.finding_digests.len() > LEDGER_MAX_FINDINGS_PER_RUN { + lines.push(format!( + " more_findings={} full_output=lazy", + run.finding_digests.len() - LEDGER_MAX_FINDINGS_PER_RUN + )); + } +} + +fn short_sha(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(&value[..value.len().min(12)]) + } +} + +fn short_agent_id(value: &str) -> Option<&str> { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(&value[..value.len().min(8)]) + } +} + +fn single_line(value: &str, max_chars: usize) -> String { + let flattened = value.split_whitespace().collect::>().join(" "); + summarize(&flattened, max_chars).unwrap_or_default() +} + +fn truncate_ledger_to_bytes(ledger: &mut String, max_bytes: usize) { + let marker = "\n\n"; + if max_bytes <= marker.len() { + ledger.clear(); + ledger.push_str(&marker[..max_bytes.min(marker.len())]); + return; + } + let target = max_bytes - marker.len(); + let mut cutoff = 0usize; + for (idx, _) in ledger.char_indices() { + if idx > target { + break; + } + cutoff = idx; + } + ledger.truncate(cutoff); + ledger.push_str(marker); +} + fn summarize(text: &str, max_chars: usize) -> Option { let text = text.trim(); if text.is_empty() { @@ -518,6 +738,25 @@ mod tests { } } + fn finding_digest(idx: usize) -> AutoReviewFindingDigest { + AutoReviewFindingDigest { + finding_id: format!("f{idx}"), + priority: 2, + title: format!("finding title {idx}"), + path: Some(PathBuf::from(format!("/repo/src/file{idx}.rs"))), + line_start: Some(idx as u32), + line_end: Some(idx as u32 + 1), + } + } + + fn ledger_options(now: u64) -> AutoReviewLedgerOptions { + AutoReviewLedgerOptions { + now, + max_bytes: 2_400, + max_runs: 5, + } + } + #[test] #[serial] fn run_store_persists_and_loads_runs() { @@ -682,4 +921,178 @@ mod tests { assert!(loaded.get(middle).is_some()); assert!(loaded.get(newest).is_some()); } + + #[test] + #[serial] + fn compact_ledger_omits_idle_clean_runs() { + let code_home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + set_code_home(code_home.path()); + let run_id = Uuid::new_v4(); + let mut run = AutoReviewRun::new(run_id, AutoReviewRunSource::Tui, 10); + run.mark_status(AutoReviewRunStatus::Completed, 20); + let mut store = AutoReviewRunStore::open(repo.path()).unwrap(); + store.upsert(run).unwrap(); + + assert!(store.compact_ledger(ledger_options(30)).is_none()); + } + + #[test] + #[serial] + fn compact_ledger_includes_active_run_activity_and_snapshot() { + let code_home = TempDir::new().unwrap(); + let repo = TempDir::new().unwrap(); + set_code_home(code_home.path()); + let run_id = Uuid::new_v4(); + let mut run = AutoReviewRun::new(run_id, AutoReviewRunSource::Tui, 100); + run.agent_id = Some("agent-1234567890".to_string()); + run.branch = Some("auto-review".to_string()); + run.snapshot_commit = Some("abcdef1234567890".to_string()); + run.freshness = AutoReviewFreshness::Current; + run.mark_activity(120); + run.mark_status(AutoReviewRunStatus::Reviewing, 130); + let mut store = AutoReviewRunStore::open(repo.path()).unwrap(); + store.upsert(run).unwrap(); + + let ledger = store.compact_ledger(ledger_options(160)).expect("ledger"); + assert!(ledger.contains(" { ContextSourceKind::ExplicitSkill => "explicit skill", ContextSourceKind::EnvironmentContext => "environment", ContextSourceKind::BrowserStatus => "browser status", + ContextSourceKind::AutoReviewLedger => "auto review ledger", ContextSourceKind::StatusItem => "status item", ContextSourceKind::ConversationHistory => "history", ContextSourceKind::PendingInput => "pending input",