Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/goal_classifier/evidence.rs
T
雷电芽衣 d331b0b2a3 fix(goal-classifier): git diff headers keep a/ b/ prefixes under any host gitconfig
`extract_changed_files` parses `diff --git a/<old> b/<new>` headers.
A host `diff.mnemonicprefix=true` or `diff.noprefix=true` rewrote them
(`c/ w/`, or none), so every tracked change vanished from `changed_files`
and the two evidence git-diff tests failed on such hosts. Both capture
paths now pass `--src-prefix=a/ --dst-prefix=b/`, which override both
config keys. Regression test sets each key in the temp repo.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 13:36:58 -04:00

2237 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Evidence-packet construction for the goal-verification stage.
//!
//! The packet is a strictly-formatted block that each adversarial
//! skeptic subagent receives as its user prompt:
//!
//! ```text
//! OBJECTIVE:
//! <objective>
//!
//! CHANGES_FILE: <patch path or `(unavailable)`>
//!
//! CHANGED_FILES:
//! - <path>
//! ...
//!
//! PLAN_FILE: <plan path or `(unavailable)`>
//!
//! PLAN_CHANGES: <unified diff of plan edits, or `(none)`>
//!
//! FINAL_RESPONSE:
//! <last assistant text, sanitized>
//! ```
//!
//! `PLAN_CHANGES` is the baseline→current diff of the plan file (the
//! agent may edit `plan.md` mid-run); `(none)` when there is no baseline,
//! no edits, or the diff could not be captured.
//!
//! `CHANGES_FILE` is a unified-diff *changelog* (a scope pointer, and
//! the anchor for the claim↔diff honesty check) — it may be truncated.
//! `CHANGED_FILES` is the *complete* list of touched paths the skeptic
//! reads in their current state; verification rests on the live files
//! and on running the code, not on the diff alone. The section names
//! are consumed verbatim by `templates/goal_verifier_prompt.md`, so the
//! format constants here are load-bearing and must not change without
//! updating the template (and bumping any prompt-eval baselines).
use super::GOAL_CLASSIFIER_DIFF_MAX_BYTES;
use kigi_sampling_types::ConversationItem;
use std::borrow::Cow;
use std::io;
use std::io::Read;
use std::path::Path;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::process::Command;
/// Max wall-clock for git commands during evidence capture.
const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Resolve the `git` binary path. Honours `GIT_BIN_PATH` (Bazel's
/// hermetic-git data dep) and falls back to bare `git` on `PATH`.
/// Bazel passes the env var as a runfiles-relative path; resolve it
/// against the current working directory so spawn doesn't see a
/// non-existent relative path.
pub(crate) fn git_bin() -> std::ffi::OsString {
let Some(raw) = std::env::var_os("GIT_BIN_PATH") else {
return std::ffi::OsString::from("git");
};
let path = std::path::PathBuf::from(&raw);
if path.is_relative() {
match std::env::current_dir() {
Ok(cwd) => cwd.join(&path).into_os_string(),
Err(_) => raw,
}
} else {
raw
}
}
/// Build a `tokio::process::Command` for `git` with `kill_on_drop(true)`
/// so a `tokio::time::timeout` firing reaps the child instead of
/// orphaning it.
fn git_command(cwd: &Path) -> Command {
let mut cmd = Command::new(git_bin());
cmd.current_dir(cwd).kill_on_drop(true);
cmd
}
/// SHA-1 hash of the empty tree object. Used as a synthetic parent
/// for the initial-commit case (`git diff --root HEAD` does NOT
/// include the initial commit's additions). SHA-256 repos fall back
/// to this constant if `git hash-object` fails.
const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
/// Cached empty-tree hash. Only SUCCESSFUL derivations populate the
/// cache so a transient git failure does not poison subsequent calls.
static EMPTY_TREE_HASH_CACHE: OnceLock<String> = OnceLock::new();
/// Per-file cap when synthesising a walkdir-based diff; protects the
/// total diff budget from being dominated by a single generated file.
const WALKDIR_PER_FILE_MAX_BYTES: usize = 64 * 1024;
/// Sentinel rendered as the `CHANGES_FILE:` value when the harness
/// could not capture a diff. The verifier prompt's rule 5 keys on
/// this literal — keep in sync.
pub(super) const CHANGES_UNAVAILABLE: &str = "(unavailable)";
/// `PLAN_FILE:` value when `plan_file == None` (planner disabled or never
/// ran). A recorded plan still renders its path even if the file was later
/// deleted — only `None` selects this sentinel. The verifier prompt's
/// rule 2 keys on this literal to fall through to rule 1; keep in sync.
pub(super) const PLAN_UNAVAILABLE: &str = "(unavailable)";
/// `PLAN_CHANGES:` value when there is no baseline, the plan was not
/// edited, or the diff could not be captured. Distinct literal from
/// `PLAN_UNAVAILABLE` — `(unavailable)` means "no plan at all", `(none)`
/// means "a plan exists but nothing changed in it".
pub(super) const PLAN_CHANGES_NONE: &str = "(none)";
/// Reference to the captured changes for the evidence packet. `Copy`
/// so the orchestrator can fan-out the same reference to N parallel
/// skeptic spawns without cloning the (already-borrowed) string.
#[derive(Debug, Clone, Copy)]
pub(crate) enum ChangesRef<'a> {
/// Absolute path to a unified-diff patch file on disk.
File(&'a str),
/// Capture failed — verifier prompt rule 5 takes over.
Unavailable,
}
/// Errors capturing the workspace diff against the goal baseline.
/// All variants degrade to `(unavailable)` at the call site; the
/// distinction is kept so dashboards can tell apart the failure modes.
#[derive(Debug)]
pub(crate) enum ChangesCaptureError {
/// `create_goal` did not record a baseline commit AND the lazy
/// `git rev-parse HEAD` retry did not find one (typically a
/// non-git workspace).
NoBaseline,
/// `git diff` exited non-zero, timed out, or could not be spawned.
DiffCommandFailed(String),
/// The walkdir-based fallback returned no candidate files — the
/// workspace had no modifications since `goal_created_at`. Distinct
/// from `NoBaseline` so the caller can surface "nothing changed"
/// rather than "couldn't tell".
WalkdirEmpty,
/// The walkdir fallback hit an I/O error before producing any
/// output. Different from `WalkdirEmpty` (a legitimate no-op) so
/// the failure is observable in dashboards.
WalkdirFailed(io::Error),
}
impl std::fmt::Display for ChangesCaptureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoBaseline => write!(f, "no git baseline commit recorded for this goal"),
Self::DiffCommandFailed(detail) => write!(f, "git diff failed: {detail}"),
Self::WalkdirEmpty => write!(f, "walkdir found no modified files in workspace"),
Self::WalkdirFailed(err) => write!(f, "walkdir fallback failed: {err}"),
}
}
}
/// Max paths rendered into the `CHANGED_FILES` section. A sprawling
/// diff can't blow up the packet; the overflow is surfaced to the
/// skeptic as a `(… and N more)` note so it knows the list was capped.
const CHANGED_FILES_MAX: usize = 300;
/// Parse the changed-file paths from a unified diff's
/// `diff --git a/<old> b/<new>` headers (the new path). Deduplicated
/// and sorted. [`capture_changes_diff`] feeds it the FULL
/// pre-truncation diff, so the list stays complete even when the
/// rendered patch is byte-capped.
pub(crate) fn extract_changed_files(diff: &str) -> Vec<String> {
let mut files: Vec<String> = diff
.lines()
.filter_map(|l| l.strip_prefix("diff --git "))
.filter_map(|rest| rest.rsplit_once(" b/").map(|(_, p)| p.to_string()))
.filter(|p| !p.is_empty())
.collect();
files.sort();
files.dedup();
files
}
/// Build the evidence packet. `CHANGES_FILE` / `PLAN_FILE` carry an
/// absolute path or the `(unavailable)` sentinel — each skeptic reads
/// them with its own `read_file` tool. `changed_files` is the complete
/// list of touched paths (verification's primary anchor; the skeptic
/// reads their current contents). `plan_file` is borrowed (never
/// cloned); `None` renders [`PLAN_UNAVAILABLE`]. `plan_changes` is the
/// borrowed baseline→current plan diff (already sanitized + truncated by
/// the caller); `None` renders [`PLAN_CHANGES_NONE`].
pub(crate) fn build_classifier_evidence_packet(
objective: &str,
changes: ChangesRef<'_>,
changed_files: &[String],
plan_file: Option<&Path>,
plan_changes: Option<&str>,
final_response: &str,
) -> String {
let changes_value: &str = match changes {
ChangesRef::File(p) => p,
ChangesRef::Unavailable => CHANGES_UNAVAILABLE,
};
let plan_value: Cow<'_, str> = match plan_file {
Some(p) => p.to_string_lossy(),
None => Cow::Borrowed(PLAN_UNAVAILABLE),
};
let mut out = String::with_capacity(
objective.len()
+ changes_value.len()
+ plan_value.len()
+ plan_changes.map_or(0, str::len)
+ final_response.len()
+ 128,
);
// `objective` is the user's own trusted instruction (the goal they
// typed), so it is embedded verbatim — only model-/workspace-derived
// text (FINAL_RESPONSE, skeptic evidence) is control-token sanitized.
out.push_str("OBJECTIVE:\n");
out.push_str(objective);
out.push_str("\n\nCHANGES_FILE: ");
out.push_str(changes_value);
out.push_str("\n\nCHANGED_FILES:\n");
if changed_files.is_empty() {
out.push_str("(none captured)\n");
} else {
for path in changed_files.iter().take(CHANGED_FILES_MAX) {
out.push_str("- ");
// Paths are workspace-derived (the agent can `touch` arbitrary
// names): escape frame-closing tags like FINAL_RESPONSE and
// replace line-breaking chars, which `-z` delivers raw.
out.push_str(&sanitize_final_response(&sanitize_path_control_chars(path)));
out.push('\n');
}
if changed_files.len() > CHANGED_FILES_MAX {
out.push_str(&format!(
"(… and {} more)\n",
changed_files.len() - CHANGED_FILES_MAX
));
}
}
out.push_str("\nPLAN_FILE: ");
out.push_str(&plan_value);
// PLAN_CHANGES is model-/workspace-derived (the agent authored the
// plan), so the caller sanitizes it for control tokens exactly like
// FINAL_RESPONSE before passing it here.
out.push_str("\n\nPLAN_CHANGES:");
match plan_changes {
Some(diff) => {
out.push('\n');
out.push_str(diff);
if !diff.ends_with('\n') {
out.push('\n');
}
}
None => {
out.push(' ');
out.push_str(PLAN_CHANGES_NONE);
out.push('\n');
}
}
out.push_str("\nFINAL_RESPONSE:\n");
out.push_str(final_response);
out.push('\n');
out
}
/// Replace line-breaking chars (ASCII controls + U+2028/U+2029, which
/// some renderers treat as newlines) with `U+FFFD`: filenames may legally
/// contain them and `-z` delivers them raw, letting a path inject
/// free-standing packet lines. Borrows when clean (the common case).
fn sanitize_path_control_chars(path: &str) -> Cow<'_, str> {
fn line_breaking(c: char) -> bool {
c.is_control() || c == '\u{2028}' || c == '\u{2029}'
}
if path.chars().any(line_breaking) {
Cow::Owned(
path.chars()
.map(|c| if line_breaking(c) { '\u{FFFD}' } else { c })
.collect(),
)
} else {
Cow::Borrowed(path)
}
}
/// Truncate `raw` to at most `GOAL_CLASSIFIER_DIFF_MAX_BYTES`, adding
/// an explicit truncation marker so each skeptic knows the diff was
/// elided rather than the agent actually changing little.
fn truncate_diff(raw: String) -> String {
if raw.len() <= GOAL_CLASSIFIER_DIFF_MAX_BYTES {
return raw;
}
let elided = raw.len().saturating_sub(GOAL_CLASSIFIER_DIFF_MAX_BYTES);
// Truncate at a UTF-8 boundary at or below the budget; `floor_char_boundary`
// is stable as of 1.79 but we use a manual scan to stay on the
// crate's MSRV path.
let mut cut = GOAL_CLASSIFIER_DIFF_MAX_BYTES;
while cut > 0 && !raw.is_char_boundary(cut) {
cut -= 1;
}
let mut out = String::with_capacity(cut + 64);
out.push_str(&raw[..cut]);
out.push_str(&format!(
"\n... (diff truncated, {elided} bytes elided) ...\n"
));
out
}
/// Captured workspace changes for the evidence packet: the (truncated)
/// unified diff destined for the patch file plus the COMPLETE changed-file
/// list, extracted from the full pre-truncation diff (and including
/// untracked files the git layers cannot show).
#[derive(Debug)]
pub(crate) struct CapturedChanges {
pub diff: String,
pub changed_files: Vec<String>,
}
/// Capture the workspace diff that each verifier skeptic will reason over.
///
/// The capture strategy walks three layers of fallback, top down, and
/// returns the first that yields a usable diff:
///
/// 1. **Recorded baseline.** If `baseline_commit` is `Some` (the
/// `setup_goal` baseline-capture path succeeded at goal-creation),
/// run `git diff <baseline>`. This is the happy path.
/// 2. **Lazy baseline.** If `baseline_commit` is `None` but the
/// workspace is a git repo *now* (the agent ran `git init` and
/// committed during the goal's lifespan), re-run `git rev-parse
/// HEAD`, find the OLDEST commit since `goal_created_at`, and emit
/// the cumulative diff from its parent (or `--root` if the oldest
/// is the very first commit in the repo).
/// 3. **Walkdir + mtime.** If git is unavailable even after the lazy
/// retry, recursively walk `workspace_root` for files with mtime
/// newer than `goal_created_at` and synthesise a unified-diff-like
/// blob (`--- /dev/null` / `+++ b/<relpath>` followed by `+`
/// prefixed contents, per-file capped at
/// [`WALKDIR_PER_FILE_MAX_BYTES`]). Common vendor / build dirs are
/// skipped.
///
/// `goal_created_at` is the unix-seconds timestamp recorded on
/// `GoalOrchestration.created_at`. Used to bound the lookback for
/// `git log --since` and to gate the walkdir mtime filter.
///
/// The changed-file list comes from the FULL pre-truncation diff (an
/// over-cap diff never drops tail files); the git layers also append
/// untracked paths, which `git diff` omits, while the walkdir layer
/// already covers them via mtime.
pub(crate) async fn capture_changes_diff(
baseline_commit: Option<&str>,
workspace_root: &Path,
goal_created_at: i64,
) -> Result<CapturedChanges, ChangesCaptureError> {
// Layer 1 — recorded baseline. On `DiffCommandFailed` (stale SHA,
// workspace was `git reset --hard`'d, etc.) fall through to
// Layer 2/3 instead of propagating immediately.
//
// LOG-WIRE: the `"goal classifier: …"` prefix on tracing
// messages in this file (and in `goal_classifier.rs`) is the
// stable string dashboards / log-grep tooling matches on;
// renaming it silently breaks those consumers.
if let Some(baseline) = baseline_commit {
match run_git_diff_against_baseline(baseline, workspace_root).await {
Ok(raw) => return Ok(finish_git_capture(raw, workspace_root).await),
Err(e @ ChangesCaptureError::DiffCommandFailed(_)) => {
tracing::debug!(
error = %e,
"goal classifier: layer 1 diff failed; falling through",
);
}
Err(other) => return Err(other),
}
}
// Layer 2 — lazy `git rev-parse HEAD`. If the agent initialised
// the repo during the goal, emit the cumulative diff from the
// oldest-in-window commit's parent.
match lazy_git_baseline_diff(workspace_root, goal_created_at).await {
Ok(raw) => return Ok(finish_git_capture(raw, workspace_root).await),
Err(ChangesCaptureError::NoBaseline) => {}
Err(other) => {
tracing::debug!(
error = %other,
"goal classifier: lazy git baseline diff failed; falling back to walkdir",
);
}
}
// Layer 3 — walkdir + mtime. Untracked files are already covered by
// the mtime filter, so no separate untracked merge.
let raw = walkdir_changes_since(workspace_root, goal_created_at).await?;
let changed_files = extract_changed_files(&raw);
Ok(CapturedChanges {
diff: truncate_diff(raw),
changed_files,
})
}
/// Finish a git-layer capture: extract the file list from the FULL diff,
/// merge in untracked paths (invisible to `git diff`), then truncate the
/// patch body. A note about untracked files is appended AFTER truncation
/// so the cap can never elide it.
async fn finish_git_capture(raw: String, workspace_root: &Path) -> CapturedChanges {
let mut changed_files = extract_changed_files(&raw);
let mut diff = truncate_diff(raw);
let untracked = git_untracked_files(workspace_root).await;
if !untracked.is_empty() {
diff.push_str(&format!(
"\n# Note: {} untracked file(s) exist but are not shown in this diff; \
they are listed in CHANGED_FILES (capped at {CHANGED_FILES_MAX} entries).\n",
untracked.len()
));
changed_files.extend(untracked);
changed_files.sort();
changed_files.dedup();
}
CapturedChanges {
diff,
changed_files,
}
}
/// Untracked, non-ignored paths via `git ls-files --others
/// --exclude-standard -z`. NUL-separated output so non-ASCII / special
/// filenames arrive verbatim instead of octal-escaped-and-quoted.
/// Best-effort: any failure returns an empty list (the diff itself is
/// still usable).
async fn git_untracked_files(workspace_root: &Path) -> Vec<String> {
let mut cmd = git_command(workspace_root);
cmd.arg("ls-files")
.arg("--others")
.arg("--exclude-standard")
.arg("-z");
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(o)) if o.status.success() => o,
Ok(Ok(o)) => {
tracing::debug!(
exit = ?o.status.code(),
"goal classifier: git ls-files non-zero exit; skipping untracked list",
);
return Vec::new();
}
Ok(Err(err)) => {
tracing::debug!(error = %err, "goal classifier: git ls-files spawn failed");
return Vec::new();
}
Err(_) => {
tracing::debug!("goal classifier: git ls-files timed out");
return Vec::new();
}
};
String::from_utf8_lossy(&output.stdout)
.split('\0')
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect()
}
/// Unified diff of the plan baseline → current plan for the
/// `PLAN_CHANGES:` evidence section. The plan lives OUTSIDE the workspace
/// repo, so this uses `git diff --no-index` (which works on arbitrary files;
/// exit-code handling is at the match arms below). Returns `None` — rendering
/// [`PLAN_CHANGES_NONE`] — when there is no baseline, either file is missing,
/// the plan is unchanged, or git failed. Output is capped via [`truncate_diff`];
/// diff headers carry basenames, not the absolute session path, when both
/// files share a parent dir.
pub(crate) async fn capture_plan_changes(
baseline_path: &Path,
current_path: &Path,
) -> Option<String> {
if !baseline_path.is_file() || !current_path.is_file() {
return None;
}
// Run from the shared parent dir and pass BASENAMES so the diff
// headers read `plan.baseline.md` / `plan.md` instead of leaking the
// absolute session-dir path (which embeds the session UUID) to the
// skeptic. Both files are session-goal siblings; if they somehow are
// not, fall back to the absolute paths rather than break.
let (cmd_cwd, baseline_arg, current_arg) = match (
baseline_path.parent(),
current_path.parent(),
baseline_path.file_name(),
current_path.file_name(),
) {
(Some(bp), Some(cp), Some(bn), Some(cn)) if bp == cp => (bp, Path::new(bn), Path::new(cn)),
_ => (
baseline_path.parent().unwrap_or(baseline_path),
baseline_path,
current_path,
),
};
let mut cmd = git_command(cmd_cwd);
cmd.arg("diff")
.arg("--no-index")
.arg("--no-prefix")
.arg(baseline_arg)
.arg(current_arg);
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(err)) => {
tracing::debug!(error = %err, "goal classifier: plan diff spawn failed");
return None;
}
Err(_) => {
tracing::debug!("goal classifier: plan diff timed out");
return None;
}
};
match output.status.code() {
// Identical files — git prints nothing and exits 0.
Some(0) => None,
// Files differ — the diff is on stdout. Empty stdout here would be
// anomalous, so guard against rendering an empty section.
Some(1) => {
let diff = String::from_utf8_lossy(&output.stdout).into_owned();
(!diff.trim().is_empty()).then(|| truncate_diff(diff))
}
other => {
tracing::debug!(
exit = ?other,
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"goal classifier: plan diff returned a non-0/1 exit; rendering (none)",
);
None
}
}
}
/// Pin the `a/` `b/` header prefixes [`extract_changed_files`] parses.
const DIFF_PREFIX_ARGS: [&str; 2] = ["--src-prefix=a/", "--dst-prefix=b/"];
/// Run `git diff <baseline>` and return the FULL (untruncated) stdout;
/// the caller extracts the changed-file list before truncating.
async fn run_git_diff_against_baseline(
baseline: &str,
workspace_root: &Path,
) -> Result<String, ChangesCaptureError> {
let mut cmd = git_command(workspace_root);
cmd.arg("diff").args(DIFF_PREFIX_ARGS).arg(baseline);
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())),
Err(_) => {
return Err(ChangesCaptureError::DiffCommandFailed(
"timed out waiting for git diff".to_string(),
));
}
};
if !output.status.success() {
return Err(ChangesCaptureError::DiffCommandFailed(format!(
"exit={:?} stderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
/// Try to find a git baseline AFTER goal creation — the agent may
/// have `git init`'d during the goal's lifespan. Returns the diff
/// from the oldest in-window commit's parent (or `--root` when the
/// oldest IS the initial commit) up to `HEAD`. Returns `NoBaseline`
/// if the workspace is still not a git repo.
async fn lazy_git_baseline_diff(
workspace_root: &Path,
goal_created_at: i64,
) -> Result<String, ChangesCaptureError> {
// First: does the workspace have a HEAD now? If `git rev-parse
// HEAD` fails we're still in the no-git case.
let head = git_rev_parse_head(workspace_root)
.await
.ok_or(ChangesCaptureError::NoBaseline)?;
// Find the oldest commit since `goal_created_at`. `git log --since`
// accepts a unix-timestamp string. `--reverse --format=%H` lists
// matches oldest-first; we take the first line.
let oldest = git_oldest_commit_since(workspace_root, goal_created_at).await;
let Some(oldest) = oldest else {
// Repo exists but no commits land within the goal's lifespan
// — fall back to walkdir (the agent may have edits not yet
// committed). Caller treats `NoBaseline` as "drop through".
return Err(ChangesCaptureError::NoBaseline);
};
// If `oldest` has a parent, diff `parent..HEAD`. Otherwise the
// oldest IS the initial commit; diff against the empty-tree SHA
// as a synthetic parent (`git diff --root HEAD` does not include
// the initial commit's additions). Hash derived dynamically to
// support SHA-256 repos.
let mut cmd = git_command(workspace_root);
if git_has_parent(workspace_root, &oldest).await {
cmd.arg("diff")
.args(DIFF_PREFIX_ARGS)
.arg(format!("{oldest}^..{head}"));
} else {
let empty_tree = derive_empty_tree_sha(workspace_root).await;
cmd.arg("diff")
.args(DIFF_PREFIX_ARGS)
.arg(format!("{empty_tree}..{head}"));
}
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => return Err(ChangesCaptureError::DiffCommandFailed(err.to_string())),
Err(_) => {
return Err(ChangesCaptureError::DiffCommandFailed(
"timed out waiting for lazy git diff".to_string(),
));
}
};
if !output.status.success() {
return Err(ChangesCaptureError::DiffCommandFailed(format!(
"lazy diff exit={:?} stderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
/// Empty-tree object hash for the calling workspace, derived via
/// `git hash-object -t tree --stdin </dev/null` and cached on success
/// only. Falls back to [`EMPTY_TREE_SHA1`] (with a `warn!`) on failure.
async fn derive_empty_tree_sha(workspace_root: &Path) -> &'static str {
if let Some(cached) = EMPTY_TREE_HASH_CACHE.get() {
return cached.as_str();
}
match run_hash_object_tree(workspace_root).await {
Some(sha) => {
// First-writer wins on the OnceLock.
let _ = EMPTY_TREE_HASH_CACHE.set(sha);
EMPTY_TREE_HASH_CACHE
.get()
.map(String::as_str)
.unwrap_or(EMPTY_TREE_SHA1)
}
None => EMPTY_TREE_SHA1,
}
}
/// Spawn `git hash-object -t tree --stdin` with empty stdin and return
/// the hash. Returns `None` on any failure so the caller falls back to
/// the SHA-1 constant.
async fn run_hash_object_tree(workspace_root: &Path) -> Option<String> {
use std::process::Stdio;
let mut cmd = git_command(workspace_root);
cmd.arg("hash-object")
.arg("-t")
.arg("tree")
.arg("--stdin")
.stdin(Stdio::null());
let output = match tokio::time::timeout(Duration::from_secs(1), cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(err)) => {
tracing::warn!(
error = %err,
"goal classifier: `git hash-object` failed; using SHA-1 empty-tree fallback",
);
return None;
}
Err(_) => {
tracing::warn!(
"goal classifier: `git hash-object` timed out; using SHA-1 empty-tree fallback",
);
return None;
}
};
if !output.status.success() {
tracing::warn!(
exit = ?output.status.code(),
"goal classifier: `git hash-object` non-zero exit; using SHA-1 empty-tree fallback",
);
return None;
}
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
/// Best-effort `git rev-parse HEAD`. Returns `None` for any failure
/// (workspace is not a git repo, git unavailable, timeout). Mirrors
/// `goal_classifier::capture_git_baseline` but with a shorter budget
/// so the lazy retry adds at most ~1s to the runner's wall clock.
async fn git_rev_parse_head(workspace_root: &Path) -> Option<String> {
let mut cmd = git_command(workspace_root);
cmd.arg("rev-parse").arg("HEAD");
let output = match tokio::time::timeout(Duration::from_secs(1), cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(err)) => {
tracing::debug!(error = %err, "goal classifier: git rev-parse HEAD spawn failed");
return None;
}
Err(_) => {
tracing::debug!("goal classifier: git rev-parse HEAD timed out");
return None;
}
};
if !output.status.success() {
tracing::debug!(
exit = ?output.status.code(),
stderr = %String::from_utf8_lossy(&output.stderr).trim(),
"goal classifier: git rev-parse HEAD non-zero exit (likely not a git repo)",
);
return None;
}
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
/// `git log --since=<ts> --reverse --format=%H` — first line if any.
async fn git_oldest_commit_since(workspace_root: &Path, since_unix: i64) -> Option<String> {
let mut cmd = git_command(workspace_root);
cmd.arg("log")
.arg(format!("--since={since_unix}"))
.arg("--reverse")
.arg("--format=%H");
let output = match tokio::time::timeout(DIFF_COMMAND_TIMEOUT, cmd.output()).await {
Ok(Ok(o)) => o,
Ok(Err(err)) => {
tracing::debug!(error = %err, "goal classifier: git log spawn failed");
return None;
}
Err(_) => {
tracing::debug!("goal classifier: git log timed out");
return None;
}
};
if !output.status.success() {
tracing::debug!(
exit = ?output.status.code(),
"goal classifier: git log non-zero exit",
);
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.next()
.map(str::to_string)
.filter(|s| !s.is_empty())
}
/// `true` if `commit` has at least one parent. Used to choose between
/// `parent..HEAD` and `--root HEAD` for the lazy-baseline diff.
async fn git_has_parent(workspace_root: &Path, commit: &str) -> bool {
let mut cmd = git_command(workspace_root);
cmd.arg("rev-parse").arg(format!("{commit}^"));
match tokio::time::timeout(Duration::from_secs(1), cmd.output()).await {
Ok(Ok(o)) => o.status.success(),
Ok(Err(err)) => {
tracing::debug!(error = %err, "goal classifier: git_has_parent spawn failed");
false
}
Err(_) => {
tracing::debug!("goal classifier: git_has_parent timed out");
false
}
}
}
/// Walkdir-based fallback. Walks `workspace_root` for files with
/// mtime > `goal_created_at` and synthesises a unified-diff-like
/// payload. Skips `.git/`, `target/`, `node_modules/`, etc. so a
/// build cache does not dominate the diff budget. Per-file output is
/// capped at [`WALKDIR_PER_FILE_MAX_BYTES`]; the walk stops shortly past
/// [`GOAL_CLASSIFIER_DIFF_MAX_BYTES`] and the caller applies the exact
/// [`truncate_diff`] cap.
async fn walkdir_changes_since(
workspace_root: &Path,
goal_created_at: i64,
) -> Result<String, ChangesCaptureError> {
let root = workspace_root.to_path_buf();
let threshold_unix = goal_created_at;
// Walk + I/O is sync — offload onto the blocking pool so we don't
// wedge the runtime on a large workspace.
let result =
tokio::task::spawn_blocking(move || walkdir_changes_blocking(&root, threshold_unix))
.await
.map_err(|join_err| {
ChangesCaptureError::WalkdirFailed(io::Error::other(format!(
"walkdir join error: {join_err}"
)))
})?;
let buf = result?;
if buf.is_empty() {
return Err(ChangesCaptureError::WalkdirEmpty);
}
Ok(buf)
}
fn walkdir_changes_blocking(
workspace_root: &Path,
threshold_unix: i64,
) -> Result<String, ChangesCaptureError> {
use ignore::WalkBuilder;
let mut builder = WalkBuilder::new(workspace_root);
builder
.standard_filters(true)
.git_ignore(true)
.git_global(false)
.git_exclude(true)
.hidden(false)
// Symlinks never followed: a symlink-into-skipped-dir cannot
// smuggle target bytes into the diff.
.follow_links(false)
.filter_entry(|entry| {
if entry.depth() == 0 {
return true;
}
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
if !is_dir {
return true;
}
entry
.file_name()
.to_str()
.map(|name| {
name != ".git"
&& !kigi_file_utils::skip_dir_set().contains(name.to_lowercase().as_str())
})
.unwrap_or(true)
});
let mut out = String::new();
let mut walked_at_least_one = false;
let mut walk_error: Option<io::Error> = None;
for dent in builder.build() {
let dent = match dent {
Ok(d) => d,
Err(err) => {
walked_at_least_one = true;
// Capture the first hard I/O error; continue walking
// so one bad symlink doesn't abort the rest.
if walk_error.is_none()
&& let Some(io_err) = err.io_error()
{
walk_error = Some(io::Error::new(io_err.kind(), err.to_string()));
}
continue;
}
};
walked_at_least_one = true;
// Files only — directories are not part of the diff payload.
let Some(ft) = dent.file_type() else { continue };
if !ft.is_file() {
continue;
}
let path = dent.path();
let meta = match path.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let modified = meta.modified().ok().and_then(|m| {
m.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
.or_else(|| {
UNIX_EPOCH
.duration_since(m)
.ok()
.map(|d| -(d.as_secs() as i64))
})
});
let Some(mtime) = modified else { continue };
// `<` not `<=`: include files modified in the same second as
// `goal_created_at` (some filesystems have 1s mtime granularity).
if mtime < threshold_unix {
continue;
}
let rel = path
.strip_prefix(workspace_root)
.unwrap_or(path)
.display()
.to_string();
// Early-exit: don't even stat the file if the global budget
// is already exhausted.
if out.len() >= GOAL_CLASSIFIER_DIFF_MAX_BYTES {
break;
}
let mut head_buf = Vec::with_capacity(meta.len().min(8192) as usize);
let exceeded_per_file_cap;
match std::fs::File::open(path) {
Ok(file) => {
// Read at most cap+1 bytes — the extra byte signals
// truncation without slurping multi-GB files.
let read_limit = (WALKDIR_PER_FILE_MAX_BYTES as u64).saturating_add(1);
let mut reader = std::io::BufReader::new(file).take(read_limit);
if reader.read_to_end(&mut head_buf).is_err() {
continue;
}
exceeded_per_file_cap = head_buf.len() > WALKDIR_PER_FILE_MAX_BYTES;
if exceeded_per_file_cap {
// Truncate to a UTF-8 boundary so
// `String::from_utf8_lossy` cannot inject
// `U+FFFD` replacement chars for a split codepoint.
let cap = utf8_truncate_boundary(&head_buf, WALKDIR_PER_FILE_MAX_BYTES);
head_buf.truncate(cap);
}
}
Err(_) => continue,
}
// Binary heuristic: matches git's "is_binary" — NUL byte in
// the head 8 KiB. Do not "improve" this without re-checking git.
let head_for_binary_check = &head_buf[..head_buf.len().min(8192)];
let is_binary = head_for_binary_check.contains(&0);
emit_walkdir_diff_header(&mut out, &rel);
if is_binary {
out.push_str(&format!(
"Binary file b/{rel} differs ({} bytes)\n",
meta.len()
));
continue;
}
let text = String::from_utf8_lossy(&head_buf);
// Count newlines for the hunk header. Files missing a
// trailing newline get +1 to match git's hunk-counting.
let mut hunk_lines = text.matches('\n').count();
let trailing_synthetic_newline = !text.ends_with('\n') && !text.is_empty();
if trailing_synthetic_newline {
hunk_lines += 1;
}
out.push_str(&format!("@@ -0,0 +1,{hunk_lines} @@\n"));
let mut truncated = false;
let mut content_bytes_written = 0usize;
for line in text.split_inclusive('\n') {
// Count `out`-bytes (including the `+` prefix) against
// the per-file cap so many-short-line inputs cannot
// overshoot.
let projected = content_bytes_written + 1 + line.len();
if projected > WALKDIR_PER_FILE_MAX_BYTES {
out.push_str(&format!(
"+... (file truncated at {WALKDIR_PER_FILE_MAX_BYTES} bytes) ...\n"
));
truncated = true;
break;
}
out.push('+');
out.push_str(line);
content_bytes_written = projected;
}
// Trailing newline patch only if NOT truncated; the
// truncation marker already ends with `\n`.
if !truncated && exceeded_per_file_cap {
out.push_str(&format!(
"+... (file truncated at {WALKDIR_PER_FILE_MAX_BYTES} bytes) ...\n"
));
} else if !truncated && trailing_synthetic_newline {
out.push('\n');
}
if out.len() >= GOAL_CLASSIFIER_DIFF_MAX_BYTES {
break;
}
}
// A walk-iterator I/O error with no usable output surfaces as
// `WalkdirFailed`; partial output is preferred over erroring.
if out.is_empty()
&& walked_at_least_one
&& let Some(err) = walk_error
{
return Err(ChangesCaptureError::WalkdirFailed(err));
}
Ok(out)
}
/// Emit the per-file unified-diff header (`diff --git`, `new file
/// mode`, `--- /dev/null`, `+++ b/<rel>`) so standard diff parsers
/// accept the synthetic output. `100644` is a deliberate
/// simplification — the walkdir fallback does not mirror real
/// filesystem modes.
fn emit_walkdir_diff_header(out: &mut String, rel: &str) {
out.push_str(&format!("diff --git a/{rel} b/{rel}\n"));
out.push_str("new file mode 100644\n");
out.push_str("--- /dev/null\n");
out.push_str(&format!("+++ b/{rel}\n"));
}
/// Walk back from `desired` to the last valid UTF-8 char boundary
/// at or below it. Bounded by 3 hops (max codepoint width − 1).
fn utf8_truncate_boundary(buf: &[u8], desired: usize) -> usize {
let mut cap = desired.min(buf.len());
// Positions 0 and buf.len() are always boundaries.
if cap == 0 || cap == buf.len() {
return cap;
}
// Walk back over continuation bytes until `buf[cap]` is a leading
// byte (or we reach 0). At most 3 hops since the widest UTF-8
// codepoint has 1 leading + 3 continuation bytes.
for _ in 0..3 {
if cap == 0 || buf[cap] & 0b1100_0000 != 0b1000_0000 {
break;
}
cap -= 1;
}
cap
}
/// Convert a `chrono::DateTime` style RFC-3339 string to unix-seconds.
/// Returns `0` (the unix epoch) on parse failure so the walkdir
/// fallback degrades to "everything that exists is newer than the
/// epoch" — surfaces too much diff but is preferable to silently
/// rendering `(unavailable)` when the agent committed real work.
pub(crate) fn parse_created_at_to_unix(rfc3339: &str) -> i64 {
match chrono::DateTime::parse_from_rfc3339(rfc3339) {
Ok(dt) => dt.timestamp(),
Err(err) => {
// Loud warn — the epoch fallback can dump every file in
// the workspace into the synthetic diff.
tracing::warn!(
input = %rfc3339,
error = %err,
"goal classifier: created_at parse failed; threshold = epoch",
);
0
}
}
}
/// Current wall-clock time as unix seconds (helper for tests + sites
/// that need a "now" baseline without pulling chrono).
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "used from tests; wire production call sites as needed"
)
)]
pub(crate) fn now_unix_seconds() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Reverse-scan for the last text-bearing assistant item (skips tool-only
/// assistant turns). Used by skeptics / completed-goal paths.
pub(crate) fn extract_final_response(items: &[ConversationItem]) -> Option<String> {
for item in items.iter().rev() {
if let ConversationItem::Assistant(a) = item
&& !a.content.trim().is_empty()
{
return Some(a.content.to_string());
}
}
None
}
/// Cap (in `char`s, not bytes) on the persisted breadth anchor
/// (`first_final_response`): bounds only the on-disk value; the live panel
/// still receives the full summary. Mirrors `GOAL_STRATEGIST_RECOMMENDATION_MAX_CHARS`.
const FIRST_FINAL_RESPONSE_MAX_CHARS: usize = 4096;
/// Output of [`compose_verifier_final_response`]. `to_send` is the
/// `FINAL_RESPONSE` for this round's panel; `to_persist` is `Some` only
/// on the first round, carrying the (capped) value to freeze as the
/// goal's breadth anchor.
pub(crate) struct ComposedFinalResponse {
pub to_send: String,
pub to_persist: Option<String>,
}
/// Compose the verifier `FINAL_RESPONSE` for one verification round.
///
/// `first` is the persisted breadth anchor (`None` on the first round,
/// where `current` IS the full deliverable: sent, and returned capped to
/// persist). On re-verification the anchor leads and `current` is appended
/// under a header only when non-blank.
pub(crate) fn compose_verifier_final_response(
first: Option<&str>,
current: String,
) -> ComposedFinalResponse {
match first {
None => {
// Don't freeze a blank anchor: an empty round-1 summary would
// become the permanent breadth anchor and demote the first real
// summary. Persist only once the round carries real content.
let to_persist = if current.trim().is_empty() {
None
} else {
Some(
current
.chars()
.take(FIRST_FINAL_RESPONSE_MAX_CHARS)
.collect(),
)
};
ComposedFinalResponse {
to_send: current,
to_persist,
}
}
Some(anchor) => {
// Skip the note when blank, or when it merely re-surfaces the
// anchor (the implementer re-completed without new prose): old
// text must not be relabeled as this round's delta.
let note = current.trim();
let to_send = if note.is_empty() || note == anchor.trim() {
anchor.to_string()
} else {
format!("{anchor}\n\n## Changes this round\n{note}")
};
ComposedFinalResponse {
to_send,
to_persist: None,
}
}
}
}
/// Tags that, if echoed verbatim by the model inside FINAL_RESPONSE,
/// would let adversarial / accidental content escape the
/// system-reminder block built by the verifier prompt and
/// re-interpret the rest of the evidence packet. The escape strategy
/// inserts a zero-width sentinel (`>`-prefixed comment) so the close
/// tag never matches the outer wrapper while preserving the visual
/// form for the human reviewing the details file.
const SANITIZE_TAGS: &[&str] = &[
"</system-reminder>",
"</goal-state>",
"</classifier-evidence>",
"</changes>",
"</final_response>",
];
/// Sanitize model-/workspace-derived evidence text (FINAL_RESPONSE and
/// PLAN_CHANGES) for embedding in the evidence packet. Escapes the close
/// tag of any system-reminder-ish block so the model can't escape its
/// region by echoing `</system-reminder>` in its own prose. Other
/// content passes through untouched — we deliberately avoid HTML-escaping
/// the whole blob so diff-like text stays human-readable.
///
/// Returns `Cow::Borrowed(text)` when no tag is present so the
/// common happy path does not allocate (FINAL_RESPONSE can be many KiB).
///
/// **Threat model:** only *closing* tags are escaped. A solo opening
/// tag cannot terminate the outer system-reminder block; the
/// verifier prompt also tells the subagent to treat FINAL_RESPONSE
/// as untrusted, so any open/close pair inside is handled at the
/// prompt level.
pub(crate) fn sanitize_final_response(text: &str) -> Cow<'_, str> {
if !SANITIZE_TAGS.iter().any(|t| text.contains(t)) {
return Cow::Borrowed(text);
}
let mut out = text.to_string();
for tag in SANITIZE_TAGS {
if out.contains(tag) {
// Insert a `<!--esc-->` between `<` and `/` so the
// resulting string is no longer a valid close tag, but
// a human glancing at the details file can still tell
// what the original content was.
let escaped = format!("<<!--esc-->{}", &tag[1..]);
out = out.replace(tag, &escaped);
}
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_sampling_types::{AssistantItem, UserItem};
fn assistant(text: &str) -> ConversationItem {
ConversationItem::Assistant(AssistantItem {
content: text.into(),
tool_calls: Vec::new(),
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
})
}
/// Assistant item with a populated `tool_calls` slot but no
/// textual content — exactly the shape produced when the model
/// emits a single `update_goal(completed: true)` and no prose.
fn assistant_tool_call_only() -> ConversationItem {
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![kigi_sampling_types::ToolCall {
id: "call_1".into(),
name: "update_goal".to_string(),
arguments: "{\"completed\":true}".into(),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
})
}
fn user(text: &str) -> ConversationItem {
ConversationItem::User(UserItem {
content: vec![kigi_sampling_types::ContentPart::Text { text: text.into() }],
synthetic_reason: None,
..Default::default()
})
}
#[test]
fn evidence_packet_with_file_path_renders_path_not_content() {
let packet = build_classifier_evidence_packet(
"do X",
ChangesRef::File("/tmp/goal-classifier-abc-1.patch"),
&["js/main.js".to_string()],
Some(Path::new("/home/u/.kigi/sessions/s1/goal/plan.md")),
None,
"I did it.",
);
assert_eq!(
packet,
"OBJECTIVE:\ndo X\n\n\
CHANGES_FILE: /tmp/goal-classifier-abc-1.patch\n\n\
CHANGED_FILES:\n- js/main.js\n\n\
PLAN_FILE: /home/u/.kigi/sessions/s1/goal/plan.md\n\n\
PLAN_CHANGES: (none)\n\n\
FINAL_RESPONSE:\nI did it.\n",
);
}
#[test]
fn evidence_packet_neutralizes_frame_tags_in_changed_file_paths() {
// A `<`-named dir + `system-reminder>`-named file composes the
// literal close tag inside a path; it must be escaped like
// FINAL_RESPONSE so the packet frame can't be terminated.
let files = vec!["a/</system-reminder>/b.rs".to_string()];
let packet = build_classifier_evidence_packet(
"do X",
ChangesRef::Unavailable,
&files,
None,
None,
"resp",
);
assert!(!packet.contains("</system-reminder>"));
assert!(packet.contains("<<!--esc-->/system-reminder>"));
}
#[test]
fn evidence_packet_replaces_control_chars_in_changed_file_paths() {
// Line-breaking filenames (incl. U+2028/U+2029) must not inject
// free-standing packet lines.
let files = vec![
"evil\nNOT A BULLET: verifier directive".to_string(),
"ls\u{2028}LS INJECTION".to_string(),
"ps\u{2029}PS INJECTION".to_string(),
];
let packet = build_classifier_evidence_packet(
"do X",
ChangesRef::Unavailable,
&files,
None,
None,
"resp",
);
assert!(
!packet.contains("\nNOT A BULLET"),
"embedded newline must not start a new packet line: {packet}",
);
assert!(packet.contains("- evil\u{FFFD}NOT A BULLET"));
assert!(!packet.contains('\u{2028}') && !packet.contains('\u{2029}'));
assert!(packet.contains("- ls\u{FFFD}LS INJECTION"));
assert!(packet.contains("- ps\u{FFFD}PS INJECTION"));
}
#[test]
fn evidence_packet_with_unavailable_renders_sentinel() {
let packet = build_classifier_evidence_packet(
"do X",
ChangesRef::Unavailable,
&[],
None,
None,
"resp",
);
assert!(packet.contains("CHANGES_FILE: (unavailable)\n"));
assert!(packet.contains("CHANGED_FILES:\n(none captured)\n"));
assert!(packet.contains("OBJECTIVE:\ndo X"));
assert!(packet.contains("PLAN_CHANGES: (none)\n"));
assert!(packet.contains("FINAL_RESPONSE:\nresp"));
}
#[test]
fn evidence_packet_renders_plan_file_unavailable_when_none() {
// Planner-off goal (`plan_file: None`) → `PLAN_FILE: (unavailable)`.
let packet = build_classifier_evidence_packet(
"do X",
ChangesRef::File("/tmp/p.patch"),
&[],
None,
None,
"resp",
);
assert!(packet.contains("PLAN_FILE: (unavailable)\n"));
}
#[test]
fn evidence_packet_section_ordering_is_stable() {
// The verifier prompt references these sections by name; pin the
// order: OBJECTIVE → CHANGES_FILE → CHANGED_FILES → PLAN_FILE →
// PLAN_CHANGES → FINAL_RESPONSE.
let packet = build_classifier_evidence_packet(
"obj",
ChangesRef::File("/tmp/p.patch"),
&["a.rs".to_string()],
Some(Path::new("/tmp/plan.md")),
Some("@@ -1 +1 @@\n-old\n+new\n"),
"resp",
);
let obj = packet.find("OBJECTIVE:").unwrap();
let changes = packet.find("CHANGES_FILE:").unwrap();
let changed = packet.find("CHANGED_FILES:").unwrap();
let plan = packet.find("PLAN_FILE:").unwrap();
let plan_changes = packet.find("PLAN_CHANGES:").unwrap();
let final_resp = packet.find("FINAL_RESPONSE:").unwrap();
assert!(
obj < changes
&& changes < changed
&& changed < plan
&& plan < plan_changes
&& plan_changes < final_resp
);
}
/// When `plan_changes` is `Some`, the packet renders the diff as a
/// `PLAN_CHANGES:` block (not the sentinel) right after PLAN_FILE.
#[test]
fn evidence_packet_renders_plan_changes_block_when_present() {
let diff = "@@ -3 +3 @@\n-- [ ] criterion 3\n+- [ ] criterion 3 (relaxed)\n";
let packet = build_classifier_evidence_packet(
"obj",
ChangesRef::Unavailable,
&[],
Some(Path::new("/tmp/plan.md")),
Some(diff),
"resp",
);
assert!(
packet.contains(&format!("PLAN_CHANGES:\n{diff}")),
"PLAN_CHANGES block must carry the diff verbatim:\n{packet}",
);
assert!(!packet.contains("PLAN_CHANGES: (none)"));
let plan = packet.find("PLAN_FILE:").unwrap();
let plan_changes = packet.find("PLAN_CHANGES:").unwrap();
let final_resp = packet.find("FINAL_RESPONSE:").unwrap();
assert!(plan < plan_changes && plan_changes < final_resp);
}
#[test]
fn evidence_packet_caps_changed_files_with_overflow_note() {
let files: Vec<String> = (0..CHANGED_FILES_MAX + 5)
.map(|i| format!("f{i:04}.rs"))
.collect();
let packet = build_classifier_evidence_packet(
"obj",
ChangesRef::Unavailable,
&files,
None,
None,
"r",
);
assert!(packet.contains("- f0000.rs\n"));
assert!(packet.contains("(… and 5 more)\n"));
// Exactly CHANGED_FILES_MAX bullet lines rendered (the rest elided).
assert_eq!(packet.matches("\n- ").count(), CHANGED_FILES_MAX);
}
#[tokio::test]
async fn capture_plan_changes_returns_diff_for_edited_plan() {
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("plan.baseline.md");
let current = dir.path().join("plan.md");
std::fs::write(&baseline, "- [ ] criterion 1\n- [ ] criterion 2\n").unwrap();
std::fs::write(&current, "- [ ] criterion 1\n- [ ] criterion 2 (relaxed)\n").unwrap();
let diff = capture_plan_changes(&baseline, &current)
.await
.expect("an edited plan must produce a diff");
assert!(diff.contains("criterion 2 (relaxed)"), "got: {diff}");
assert!(diff.contains("-- [ ] criterion 2"), "got: {diff}");
// Headers carry basenames, never the absolute session path.
assert!(
diff.contains("plan.md") && diff.contains("plan.baseline.md"),
"headers must name the basenames: {diff}",
);
assert!(
!diff.contains(dir.path().to_string_lossy().as_ref()),
"diff headers must not leak the absolute session-dir path: {diff}",
);
}
#[tokio::test]
async fn capture_plan_changes_is_none_for_identical_plan() {
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("plan.baseline.md");
let current = dir.path().join("plan.md");
let body = "- [ ] criterion 1\n";
std::fs::write(&baseline, body).unwrap();
std::fs::write(&current, body).unwrap();
assert!(capture_plan_changes(&baseline, &current).await.is_none());
}
#[tokio::test]
async fn capture_plan_changes_is_none_when_a_file_is_missing() {
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("plan.baseline.md");
let current = dir.path().join("plan.md");
std::fs::write(&current, "- [ ] criterion 1\n").unwrap();
// Baseline missing → no diff (and we never even spawn git).
assert!(capture_plan_changes(&baseline, &current).await.is_none());
// Current missing → also None.
std::fs::write(&baseline, "- [ ] criterion 1\n").unwrap();
std::fs::remove_file(&current).unwrap();
assert!(capture_plan_changes(&baseline, &current).await.is_none());
}
#[test]
fn extract_changed_files_parses_git_headers_dedup_sorted() {
let diff = "diff --git a/js/b.js b/js/b.js\n@@ -1 +1 @@\n-x\n+y\n\
diff --git a/js/a.js b/js/a.js\nnew file mode 100644\n\
diff --git a/old/name.rs b/new/name.rs\nrename from old/name.rs\n";
assert_eq!(
extract_changed_files(diff),
vec![
"js/a.js".to_string(),
"js/b.js".to_string(),
"new/name.rs".to_string(),
],
);
}
#[test]
fn extract_changed_files_empty_diff_is_empty() {
assert!(extract_changed_files("").is_empty());
assert!(extract_changed_files("just prose\nno headers\n").is_empty());
}
#[test]
fn evidence_packet_plan_path_with_spaces_and_unicode_round_trips() {
let plan = "/tmp/kigi sessions/✓ goal/plan.md";
let packet = build_classifier_evidence_packet(
"obj",
ChangesRef::Unavailable,
&[],
Some(Path::new(plan)),
None,
"resp",
);
assert!(packet.contains(&format!("PLAN_FILE: {plan}\n")));
}
#[test]
fn evidence_packet_handles_empty_inputs() {
let packet =
build_classifier_evidence_packet("", ChangesRef::Unavailable, &[], None, None, "");
assert!(packet.contains("OBJECTIVE:\n"));
assert!(packet.contains("CHANGES_FILE: (unavailable)"));
assert!(packet.contains("CHANGED_FILES:\n(none captured)"));
assert!(packet.contains("PLAN_FILE: (unavailable)"));
assert!(packet.contains("PLAN_CHANGES: (none)"));
assert!(packet.contains("FINAL_RESPONSE:\n"));
}
#[test]
fn extract_final_response_skips_tool_call_only_items() {
// Tool-call-only items (populated `tool_calls`, empty
// `content`) must be skipped so each skeptic sees the
// prior assistant text turn, not a blank.
let items = vec![
assistant("I will now complete the goal."),
assistant_tool_call_only(),
];
assert_eq!(
extract_final_response(&items),
Some("I will now complete the goal.".to_string())
);
}
#[test]
fn extract_final_response_handles_empty_chat() {
assert_eq!(extract_final_response(&[]), None);
assert_eq!(extract_final_response(&[user("hi")]), None);
}
#[test]
fn compose_verifier_final_response_first_round_sends_and_persists() {
// First round: the summary IS the full deliverable — sent and persisted.
let current = "Full deliverable: built A, B, C; all 14 tests pass.";
let composed = compose_verifier_final_response(None, current.to_string());
assert_eq!(composed.to_send, current);
assert_eq!(composed.to_persist.as_deref(), Some(current));
}
#[test]
fn compose_verifier_final_response_first_round_blank_is_not_persisted() {
// A blank round-1 summary must not be frozen as the anchor, so the
// first round with real content can still claim it.
for blank in ["", " ", "\n\t \n"] {
let composed = compose_verifier_final_response(None, blank.to_string());
assert_eq!(composed.to_send, blank);
assert!(
composed.to_persist.is_none(),
"blank round must not freeze a breadth anchor",
);
}
}
#[test]
fn compose_verifier_final_response_reverify_appends_change_note() {
// Re-verification: the stored anchor leads (breadth) and the
// current message is appended under the round header (recency).
let composed =
compose_verifier_final_response(Some("FULL round-1 summary"), "fix note".to_string());
assert!(composed.to_send.contains("FULL round-1 summary"));
assert!(composed.to_send.contains("fix note"));
assert!(composed.to_send.contains("## Changes this round"));
// Anchor already stored — re-verification never re-persists.
assert!(composed.to_persist.is_none());
}
#[test]
fn compose_verifier_final_response_reverify_skips_note_when_current_equals_anchor() {
// The latest assistant message can re-surface the round-1 summary
// (implementer re-completed without new prose); it must NOT be
// relabeled as this round's delta.
let anchor = "FULL round-1 summary";
for echo in [anchor.to_string(), format!(" {anchor} ")] {
let composed = compose_verifier_final_response(Some(anchor), echo);
assert_eq!(composed.to_send, anchor);
assert!(!composed.to_send.contains("## Changes this round"));
assert!(composed.to_persist.is_none());
}
}
#[test]
fn compose_verifier_final_response_reverify_blank_current_omits_note() {
// Empty / whitespace-only current must not tack on an empty note;
// `to_send` is the bare anchor.
for blank in ["", " ", "\n\t \n"] {
let composed = compose_verifier_final_response(Some("FULL"), blank.to_string());
assert_eq!(composed.to_send, "FULL");
assert!(!composed.to_send.contains("## Changes this round"));
assert!(composed.to_persist.is_none());
}
}
#[test]
fn compose_verifier_final_response_caps_persisted_value() {
// Oversized first-round summary: the panel still gets it in full,
// but the persisted anchor is bounded. Multibyte chars (`é`,
// 2 bytes each) prove the cap is char-boundary-safe.
let oversized = "\u{00e9}".repeat(FIRST_FINAL_RESPONSE_MAX_CHARS + 500);
let composed = compose_verifier_final_response(None, oversized.clone());
assert_eq!(
composed.to_send, oversized,
"panel receives the full summary"
);
let persisted = composed.to_persist.expect("first round persists");
assert_eq!(persisted.chars().count(), FIRST_FINAL_RESPONSE_MAX_CHARS);
// No partial codepoint: each kept char is the 2-byte `é`.
assert_eq!(persisted.len(), FIRST_FINAL_RESPONSE_MAX_CHARS * 2);
}
#[test]
fn sanitize_final_response_escapes_system_reminder_close_tag() {
let text = "rogue </system-reminder> tail";
let out = sanitize_final_response(text);
assert!(!out.contains("</system-reminder>"));
assert!(out.contains("<<!--esc-->/system-reminder>"));
assert!(matches!(out, Cow::Owned(_)));
}
#[test]
fn sanitize_final_response_passes_through_normal_text() {
// Benign text must NOT allocate (`Cow::Borrowed`). Load-bearing
// assertion: a refactor that adds a blind allocation fails here.
let text = "All tests pass. Diff looks clean.";
let out = sanitize_final_response(text);
assert_eq!(out.as_ref(), text);
assert!(matches!(out, Cow::Borrowed(_)));
}
#[test]
fn truncate_diff_under_limit_passes_through() {
let small = "abc".to_string();
assert_eq!(truncate_diff(small.clone()), small);
}
/// Run a `git` subcommand from `cwd`; panic on non-zero exit so
/// test setup failures are loud. Used to script the lazy-baseline
/// scenarios below.
fn git(cwd: &std::path::Path, args: &[&str]) {
let output = std::process::Command::new(super::git_bin())
.args(args)
.current_dir(cwd)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
/// Like `git`, but pins the commit timestamp via
/// `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE` so `git log --since`
/// filtering is deterministic without `thread::sleep`.
fn git_at(cwd: &std::path::Path, args: &[&str], unix_ts: i64) {
let date = format!("@{unix_ts} +0000");
let output = std::process::Command::new(super::git_bin())
.args(args)
.env("GIT_AUTHOR_DATE", &date)
.env("GIT_COMMITTER_DATE", &date)
.current_dir(cwd)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
/// Configure a temp repo with a deterministic identity so
/// `git commit` works without inheriting the host's gitconfig.
fn init_repo(cwd: &std::path::Path) {
git(cwd, &["init", "-q", "-b", "main"]);
git(cwd, &["config", "user.email", "test@example.com"]);
git(cwd, &["config", "user.name", "Test"]);
git(cwd, &["config", "commit.gpgsign", "false"]);
}
#[tokio::test]
async fn capture_changes_diff_lazy_baseline_from_initial_commit() {
// `git init` exists but no commits — the lazy path returns
// `NoBaseline` from `git_oldest_commit_since` and the
// capture falls all the way through to the walkdir fallback.
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
let goal_created_at = now_unix_seconds() - 60;
tokio::fs::write(tmp.path().join("hello.txt"), b"hi\n")
.await
.unwrap();
// Bump the mtime forward so the walkdir filter accepts it
// even on filesystems with one-second mtime granularity.
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("hello.txt"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must produce a diff")
.diff;
assert!(
diff.contains("+++ b/hello.txt"),
"expected synthetic header for hello.txt; got {diff}"
);
assert!(diff.contains("+hi"), "expected the file body; got {diff}");
}
#[tokio::test]
async fn capture_changes_diff_lazy_baseline_when_repo_created_during_goal() {
// The goal was created BEFORE the repo was initialised; after
// creation the agent ran `git init` + `git commit`. The lazy
// path must succeed via the empty-tree synthetic parent
// because the only commit is the initial one.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 120;
init_repo(tmp.path());
tokio::fs::write(tmp.path().join("a.txt"), b"alpha\n")
.await
.unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "initial"]);
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("lazy baseline must succeed via --root")
.diff;
// `index 0000000..<sha>` is git-only — walkdir's synthetic
// header has no blob hash to embed. Distinguishes lazy-git
// recovery from a silent walkdir fall-through.
assert!(
diff.contains("index 0000000"),
"lazy git output must carry the real `index 0000000..<sha>` line; got {diff}"
);
assert!(
diff.contains("+alpha"),
"lazy `git diff` must include initial-commit additions; got {diff}"
);
}
#[tokio::test]
async fn capture_changes_diff_lazy_baseline_with_existing_history() {
// Pre-creation commits must be excluded; only the
// post-creation commit lands in the diff.
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
tokio::fs::write(tmp.path().join("pre.txt"), b"pre-existing\n")
.await
.unwrap();
// Fixed timestamps via GIT_AUTHOR_DATE / GIT_COMMITTER_DATE
// so `git log --since` filtering is deterministic.
let pre_goal_ts: i64 = 1_700_000_000;
let goal_created_at: i64 = 1_700_000_100;
let post_goal_ts: i64 = 1_700_000_200;
git_at(tmp.path(), &["add", "."], pre_goal_ts);
git_at(tmp.path(), &["commit", "-q", "-m", "pre-goal"], pre_goal_ts);
tokio::fs::write(tmp.path().join("post.txt"), b"post-goal\n")
.await
.unwrap();
git_at(tmp.path(), &["add", "."], post_goal_ts);
git_at(
tmp.path(),
&["commit", "-q", "-m", "post-goal"],
post_goal_ts,
);
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("lazy baseline must succeed via parent..HEAD")
.diff;
assert!(
diff.contains("post.txt"),
"diff must include post-creation commit; got {diff}",
);
assert!(
!diff.contains("pre.txt"),
"diff must NOT include pre-creation commit; got {diff}",
);
}
#[tokio::test]
async fn capture_changes_diff_layer1_fails_falls_through_to_lazy() {
// Stale recorded baseline → Layer 1 fails; a post-creation
// commit IS present so Layer 2 (lazy git) recovers. The
// git-only `index 0000000` token distinguishes Layer 2 from
// Layer 3.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
init_repo(tmp.path());
tokio::fs::write(tmp.path().join("recovered.txt"), b"recovered\n")
.await
.unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "post-goal"]);
// Stale baseline — a SHA that is structurally valid but
// does not exist in this repo's object database.
let stale_baseline = "deadbeefcafef00d1234567890abcdef12345678";
let diff = capture_changes_diff(Some(stale_baseline), tmp.path(), goal_created_at)
.await
.expect("layer-1 failure must cascade to lazy git")
.diff;
assert!(
diff.contains("index 0000000"),
"Layer 2 (real git) must produce an `index` line; got {diff}",
);
assert!(diff.contains("recovered.txt"));
}
#[tokio::test]
async fn capture_changes_diff_layer1_and_layer2_fail_falls_through_to_walkdir() {
// Both git layers fail (no git repo + stale baseline);
// Layer 3 (walkdir) must produce the synthetic diff. Absence
// of `index 0000000` pins which layer recovered.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
// No `git init` — `lazy_git_baseline_diff` returns
// `NoBaseline` and drops through to walkdir.
tokio::fs::write(tmp.path().join("from_walkdir.txt"), b"walkdir-only\n")
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("from_walkdir.txt"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
// Layer 1 still tries `git diff` against the stale baseline
// (the file doesn't know there's no repo); it fails and
// cascades through Layer 2 → Layer 3.
let stale_baseline = "deadbeefcafef00d1234567890abcdef12345678";
let diff = capture_changes_diff(Some(stale_baseline), tmp.path(), goal_created_at)
.await
.expect("layer-3 walkdir must succeed when both git layers fail")
.diff;
assert!(
!diff.contains("index 0000000"),
"walkdir output MUST NOT carry the git `index` line; got {diff}",
);
assert!(
diff.contains("from_walkdir.txt"),
"walkdir output must surface the modified file; got {diff}",
);
assert!(
diff.contains("+walkdir-only"),
"walkdir output must include the file body; got {diff}",
);
}
#[tokio::test]
async fn capture_changes_diff_walkdir_emits_parseable_unified_diff() {
// Synthetic walkdir output must round-trip through standard
// unified-diff parsers — pin every header line.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
tokio::fs::write(tmp.path().join("parseable.txt"), b"line one\nline two\n")
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("parseable.txt"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(diff.contains("diff --git a/parseable.txt b/parseable.txt"));
assert!(diff.contains("new file mode 100644"));
assert!(diff.contains("--- /dev/null"));
assert!(diff.contains("+++ b/parseable.txt"));
assert!(diff.contains("@@ -0,0 +1,2 @@"));
assert!(diff.contains("+line one"));
assert!(diff.contains("+line two"));
}
#[tokio::test]
async fn capture_changes_diff_walkdir_empty_when_no_modifications() {
// No files newer than threshold → `WalkdirEmpty` so the
// runner surfaces `(unavailable)`.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() + 1_000_000;
tokio::fs::write(tmp.path().join("old.txt"), b"old\n")
.await
.unwrap();
let err = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect_err("must error with WalkdirEmpty");
assert!(matches!(err, ChangesCaptureError::WalkdirEmpty));
}
#[tokio::test]
async fn capture_changes_diff_walkdir_renders_binary_marker_for_nul_byte_file() {
// NUL-byte heuristic renders the binary marker instead of
// raw bytes.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
tokio::fs::write(tmp.path().join("bin.dat"), b"hello\0world\n")
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("bin.dat"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(
diff.contains("Binary file b/bin.dat differs"),
"binary marker missing; got {diff}",
);
assert!(
!diff.contains("+hello"),
"binary file must not have its content rendered as +-lines; got {diff}",
);
}
#[tokio::test]
async fn capture_changes_diff_walkdir_includes_file_at_threshold_mtime() {
// Boundary: mtime == threshold is INCLUDED (`<` not `<=`).
let tmp = tempfile::tempdir().unwrap();
// arbitrary fixed timestamp
let goal_created_at: i64 = 1_700_000_000;
tokio::fs::write(tmp.path().join("at_threshold.txt"), b"at\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("after.txt"), b"after\n")
.await
.unwrap();
filetime::set_file_mtime(
tmp.path().join("at_threshold.txt"),
filetime::FileTime::from_unix_time(goal_created_at, 0),
)
.unwrap();
filetime::set_file_mtime(
tmp.path().join("after.txt"),
filetime::FileTime::from_unix_time(goal_created_at + 1, 0),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(
diff.contains("at_threshold.txt"),
"file with mtime == threshold must be INCLUDED; got {diff}",
);
assert!(diff.contains("after.txt"));
}
#[tokio::test]
async fn capture_changes_diff_walkdir_skips_all_well_known_directories() {
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
for sub in kigi_file_utils::SKIP_DIR_NAMES {
let dir = tmp.path().join(sub);
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("blob.bin"), b"skipped\n")
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
dir.join("blob.bin"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
}
tokio::fs::write(tmp.path().join("real.txt"), b"keeper\n")
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("real.txt"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(diff.contains("real.txt"));
for sub in kigi_file_utils::SKIP_DIR_NAMES {
assert!(
!diff.contains(&format!("b/{sub}/blob.bin")),
"walkdir must skip {sub}/; diff was: {diff}"
);
}
}
#[tokio::test]
async fn derive_empty_tree_sha_matches_canonical_sha1() {
// Pins the canonical SHA-1 empty-tree hash on a default repo.
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
let sha = derive_empty_tree_sha(tmp.path()).await;
assert_eq!(sha, EMPTY_TREE_SHA1);
}
#[tokio::test]
async fn derive_empty_tree_sha_caches_successful_result() {
// Two calls return identical strings (proxy for cache hit).
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
let a = derive_empty_tree_sha(tmp.path()).await;
let b = derive_empty_tree_sha(tmp.path()).await;
assert_eq!(a, b);
}
#[test]
fn utf8_truncate_boundary_walks_back_to_valid_char_boundary() {
// A 3-byte CJK codepoint at the cap edge must be truncated
// to its start.
// "hello中" = 8 bytes
let buf = b"hello\xe4\xb8\xad";
assert_eq!(utf8_truncate_boundary(buf, 6), 5);
assert_eq!(utf8_truncate_boundary(buf, 7), 5);
assert_eq!(utf8_truncate_boundary(buf, 8), 8);
assert_eq!(utf8_truncate_boundary(buf, 100), 8);
assert_eq!(utf8_truncate_boundary(buf, 5), 5);
// Pure ASCII: any cap is a boundary.
let ascii = b"abcdefg";
assert_eq!(utf8_truncate_boundary(ascii, 3), 3);
}
#[test]
fn truncate_diff_passes_through_at_exact_max_bytes() {
let payload = "x".repeat(GOAL_CLASSIFIER_DIFF_MAX_BYTES);
let out = truncate_diff(payload.clone());
assert_eq!(out, payload, "exactly-at-cap input must not be truncated");
}
#[test]
fn truncate_diff_truncates_at_max_plus_one() {
// 1 byte over the cap: content drops, marker is appended.
let payload_len = GOAL_CLASSIFIER_DIFF_MAX_BYTES + 1;
let payload = "x".repeat(payload_len);
let out = truncate_diff(payload);
assert!(out.contains("diff truncated"));
let marker_start = out.find("\n... (diff truncated").expect("marker present");
assert!(
marker_start < payload_len,
"content portion ({marker_start}) must be shorter than payload ({payload_len})",
);
}
#[tokio::test]
async fn capture_changes_diff_walkdir_fallback_when_not_a_git_repo() {
// No `git init` at all — capture must reach the walkdir layer
// and synthesise diff hunks from mtime > goal_created_at files.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
tokio::fs::write(tmp.path().join("note.md"), b"# notes\n")
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("note.md"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(diff.contains("+++ b/note.md"));
assert!(diff.contains("+# notes"));
}
#[tokio::test]
async fn capture_changes_diff_truncates_per_file_at_walkdir_cap() {
// One file > 64 KiB but total < 256 KiB: per-file marker
// appears, global marker does not.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
let payload = "x".repeat(WALKDIR_PER_FILE_MAX_BYTES + 4_096);
tokio::fs::write(tmp.path().join("big.txt"), payload.as_bytes())
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(
tmp.path().join("big.txt"),
filetime::FileTime::from_system_time(later),
)
.unwrap();
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(
diff.contains(&format!(
"file truncated at {WALKDIR_PER_FILE_MAX_BYTES} bytes"
)),
"per-file truncation marker missing; first 200B = {:?}",
&diff[..diff.len().min(200)],
);
}
#[tokio::test]
async fn capture_changes_diff_truncates_global_at_max_bytes() {
// Many small files summing to > 256 KiB so the global cap
// fires; per-file cap is irrelevant.
let tmp = tempfile::tempdir().unwrap();
let goal_created_at = now_unix_seconds() - 60;
// 30 × 12 KiB = ~360 KiB, comfortably over 256 KiB.
let per_file_payload = "x".repeat(12 * 1024);
for i in 0..30 {
let path = tmp.path().join(format!("file_{i}.txt"));
tokio::fs::write(&path, per_file_payload.as_bytes())
.await
.unwrap();
let later = SystemTime::now() + Duration::from_secs(30);
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(later)).unwrap();
}
let diff = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("walkdir fallback must succeed")
.diff;
assert!(
diff.contains("diff truncated"),
"global truncation marker missing; last 200B = {:?}",
&diff[diff.len().saturating_sub(200)..],
);
assert!(
diff.len() <= GOAL_CLASSIFIER_DIFF_MAX_BYTES + 256,
"output must respect the global cap (with marker slack); got {} bytes",
diff.len(),
);
}
#[tokio::test]
async fn changed_files_complete_when_git_diff_exceeds_byte_cap() {
// The big file's hunks blow the byte cap, pushing the later small
// file's header past it — its path must still be listed.
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
tokio::fs::write(tmp.path().join("aa_big.txt"), b"seed\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("zz_tail.txt"), b"seed\n")
.await
.unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "baseline"]);
let baseline = {
let out = std::process::Command::new(super::git_bin())
.args(["rev-parse", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
// > 256 KiB of changed lines in the alphabetically-first file.
let big_body: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
tokio::fs::write(tmp.path().join("aa_big.txt"), big_body.as_bytes())
.await
.unwrap();
tokio::fs::write(tmp.path().join("zz_tail.txt"), b"changed\n")
.await
.unwrap();
git(tmp.path(), &["add", "."]);
tokio::fs::write(tmp.path().join("never_added.txt"), b"untracked\n")
.await
.unwrap();
let captured = capture_changes_diff(Some(&baseline), tmp.path(), now_unix_seconds())
.await
.expect("layer-1 git diff must succeed");
assert!(
captured.diff.contains("diff truncated"),
"test premise: the diff must exceed the byte cap",
);
assert!(
captured.diff.contains("untracked file(s)"),
"the untracked note is appended AFTER truncation so the cap can't elide it",
);
assert!(
captured
.changed_files
.contains(&"never_added.txt".to_string()),
);
assert!(
!captured.diff.contains("zz_tail.txt"),
"test premise: the tail file's header must fall past the cap",
);
assert!(
captured.changed_files.contains(&"zz_tail.txt".to_string()),
"changed_files must come from the FULL diff, not the truncated one; got {:?}",
captured.changed_files,
);
}
#[tokio::test]
async fn untracked_files_appear_in_changed_files_for_git_layers() {
// `git diff <baseline>` omits never-added files entirely; the
// capture must surface them via `git ls-files --others` so
// skeptics can see files the goal created.
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n")
.await
.unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "baseline"]);
let baseline = {
let out = std::process::Command::new(super::git_bin())
.args(["rev-parse", "HEAD"])
.current_dir(tmp.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("brand_new.txt"), b"created by goal\n")
.await
.unwrap();
// Unicode + space: `-z` must deliver the path verbatim, not
// octal-escaped-and-quoted.
tokio::fs::write(tmp.path().join("日本 notes.txt"), b"unicode\n")
.await
.unwrap();
let captured = capture_changes_diff(Some(&baseline), tmp.path(), now_unix_seconds())
.await
.expect("layer-1 git diff must succeed");
assert!(
captured
.changed_files
.contains(&"brand_new.txt".to_string()),
"untracked file must be listed in changed_files; got {:?}",
captured.changed_files,
);
assert!(
captured
.changed_files
.contains(&"日本 notes.txt".to_string()),
"unicode/space untracked path must be verbatim; got {:?}",
captured.changed_files,
);
assert!(
captured.changed_files.contains(&"tracked.txt".to_string()),
"tracked modification must still be listed",
);
assert!(
captured.diff.contains("untracked file(s)"),
"patch must carry the untracked-files note",
);
}
#[tokio::test]
async fn changed_files_ignore_the_host_diff_prefix_config() {
// Both keys rewrite the `diff --git a/<old> b/<new>` header the parser keys on.
for key in ["diff.mnemonicprefix", "diff.noprefix"] {
let tmp = tempfile::tempdir().unwrap();
init_repo(tmp.path());
git(tmp.path(), &["config", key, "true"]);
tokio::fs::write(tmp.path().join("tracked.txt"), b"seed\n")
.await
.unwrap();
let goal_created_at: i64 = 1_700_000_100;
git_at(tmp.path(), &["add", "."], goal_created_at + 100);
git_at(
tmp.path(),
&["commit", "-q", "-m", "post-goal"],
goal_created_at + 100,
);
let lazy = capture_changes_diff(None, tmp.path(), goal_created_at)
.await
.expect("lazy baseline must succeed");
assert_eq!(lazy.changed_files, ["tracked.txt"], "{key}: lazy baseline");
tokio::fs::write(tmp.path().join("tracked.txt"), b"changed\n")
.await
.unwrap();
let explicit = capture_changes_diff(Some("HEAD"), tmp.path(), goal_created_at)
.await
.expect("layer-1 git diff must succeed");
assert_eq!(
explicit.changed_files,
["tracked.txt"],
"{key}: explicit baseline"
);
}
}
}