use std::path::Path;
use std::process::Command;
use super::support::{self, AgentLaunch};
use super::{
CancelToken, ChunkRequest, ChunkResult, CodeHarness, HarnessCapabilities, HarnessError, Usage,
};
#[derive(Debug, Clone)]
pub struct AiderConfig {
pub model: String,
pub api_key_env: String,
pub extra_args: Vec<String>,
}
impl AiderConfig {
pub fn new(model: impl Into<String>) -> Self {
Self {
model: model.into(),
api_key_env: "DEEPSEEK_API_KEY".to_string(),
extra_args: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct AiderHarness {
config: AiderConfig,
}
impl AiderHarness {
pub fn new(config: AiderConfig) -> Self {
Self { config }
}
}
fn aider_bin() -> String {
std::env::var("OCTL_AIDER_BIN").unwrap_or_else(|_| "aider".to_string())
}
fn parse_usage(transcript: &str) -> Option<Usage> {
let mut usage = Usage::default();
let mut found = false;
for line in transcript.lines() {
if let Some(cost) = parse_cost(line) {
usage.cost_usd = Some(cost);
found = true;
}
if let Some((sent, received)) = parse_tokens(line) {
usage.input_tokens = sent;
usage.output_tokens = received;
if let (Some(s), Some(r)) = (sent, received) {
usage.total_tokens = Some(s + r);
}
found = true;
}
}
found.then_some(usage)
}
fn parse_cost(line: &str) -> Option<f64> {
let after = line.split("Cost:").nth(1)?;
let dollar = after.split('$').nth(1)?;
let num: String = dollar
.trim_start()
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
num.parse::<f64>().ok()
}
fn parse_tokens(line: &str) -> Option<(Option<u64>, Option<u64>)> {
let after = line.split("Tokens:").nth(1)?;
let sent = after
.split("sent")
.next()
.and_then(|s| parse_token_count(s.trim()));
let received = after
.split("sent,")
.nth(1)
.and_then(|s| s.split("received").next())
.and_then(|s| parse_token_count(s.trim()));
if sent.is_none() && received.is_none() {
return None;
}
Some((sent, received))
}
fn parse_token_count(s: &str) -> Option<u64> {
let s = s.trim();
let (num_part, mult) = if let Some(stripped) = s.strip_suffix(['k', 'K']) {
(stripped, 1_000.0)
} else if let Some(stripped) = s.strip_suffix(['m', 'M']) {
(stripped, 1_000_000.0)
} else {
(s, 1.0)
};
let num: String = num_part
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if num.is_empty() {
return None;
}
num.parse::<f64>()
.ok()
.filter(|v| v.is_finite() && *v >= 0.0)
.map(|v| (v * mult) as u64)
}
impl AgentLaunch for AiderHarness {
fn capabilities(&self) -> HarnessCapabilities {
HarnessCapabilities {
can_author_tests: true,
reports_usage: true,
honors_file_scope: false,
runs_checks: true,
}
}
fn commits_in_agent(&self) -> bool {
false
}
fn check_credentials(&self) -> Result<(), HarnessError> {
if std::env::var(&self.config.api_key_env).is_err() {
return Err(HarnessError::MissingCredential {
var: self.config.api_key_env.clone(),
});
}
Ok(())
}
fn build_prompt(&self, req: &ChunkRequest) -> String {
req.brief.clone()
}
fn build_command(
&self,
worktree: &Path,
brief_file: &Path,
_prompt: &str,
req: &ChunkRequest,
) -> Command {
let mut cmd = Command::new(aider_bin());
cmd.current_dir(worktree)
.arg("--model")
.arg(&self.config.model)
.arg("--yes-always")
.arg("--no-check-update")
.arg("--no-analytics")
.arg("--map-tokens")
.arg("0")
.arg("--message-file")
.arg(brief_file);
for extra in &self.config.extra_args {
cmd.arg(extra);
}
cmd.arg("--");
for file in &req.files {
cmd.arg(file);
}
cmd
}
fn parse_usage(&self, transcript: &str) -> Option<Usage> {
parse_usage(transcript)
}
fn tool_label(&self) -> &'static str {
"aider"
}
fn bin_display(&self) -> String {
aider_bin()
}
}
impl CodeHarness for AiderHarness {
fn capabilities(&self) -> HarnessCapabilities {
<Self as AgentLaunch>::capabilities(self)
}
fn run_chunk(
&self,
req: &ChunkRequest,
cancel: &CancelToken,
) -> Result<ChunkResult, HarnessError> {
support::run_chunk(self, req, cancel)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness::conformance::{run_and_check, run_and_check_with_cancel};
use crate::harness::{Check, ChunkOutcome};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::TempDir;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::harness::support::test_env::lock()
}
fn write_script(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
fn init_repo() -> TempDir {
let dir = TempDir::new().unwrap();
let run = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(dir.path())
.args(args)
.output()
.unwrap();
assert!(ok.status.success(), "git {args:?}: {ok:?}");
};
run(&["init", "-q", "-b", "main"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.path().join("seed.txt"), "seed\n").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "seed"]);
dir
}
fn head_of(worktree: &Path) -> String {
let out = Command::new("git")
.arg("-C")
.arg(worktree)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn head_message(worktree: &Path) -> String {
let out = Command::new("git")
.arg("-C")
.arg(worktree)
.args(["log", "-1", "--format=%s"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn base_request(worktree: &Path) -> ChunkRequest {
ChunkRequest {
run_id: "run1".into(),
chunk_id: "c1".into(),
attempt_id: "a1".into(),
worktree_path: worktree.to_path_buf(),
base_commit: head_of(worktree),
plan_rev: "v1".into(),
brief: "do the thing".into(),
checks: vec![Check {
id: "chk1".into(),
desc: "always passes".into(),
run: "true".into(),
timeout: None,
}],
files: vec![PathBuf::from("out.txt")],
timeout: None,
}
}
#[test]
fn missing_credential_fails_fast() {
let _g = env_lock();
let repo = init_repo();
std::env::remove_var("DEEPSEEK_API_KEY");
let h = AiderHarness::new(AiderConfig::new("deepseek/deepseek-chat"));
let err = h
.run_chunk(&base_request(repo.path()), &CancelToken::new())
.unwrap_err();
assert_eq!(
err,
HarnessError::MissingCredential {
var: "DEEPSEEK_API_KEY".into()
}
);
}
#[test]
fn dirty_worktree_is_rejected() {
let _g = env_lock();
let repo = init_repo();
std::fs::write(repo.path().join("dirt.txt"), "x").unwrap();
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let err = h
.run_chunk(&base_request(repo.path()), &CancelToken::new())
.unwrap_err();
assert!(matches!(err, HarnessError::DirtyWorktree { .. }));
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn commit_produced_maps_to_committed() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\n\
printf 'edited\\n' > out.txt\n\
git add out.txt\n\
git commit -q -m 'chunk edit'\n\
echo 'Tokens: 1.2k sent, 300 received. Cost: $0.0004 message, $0.0004 session.'\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert!(res.resulting_commit.is_some());
assert_eq!(res.changed_files, vec![PathBuf::from("out.txt")]);
assert_eq!(res.check_results.len(), 1);
assert!(res.check_results[0].passed);
assert!(res.transcript_ref.is_some());
let usage = res.usage.expect("usage parsed");
assert_eq!(usage.cost_usd, Some(0.0004));
assert_eq!(usage.input_tokens, Some(1200));
assert_eq!(usage.output_tokens, Some(300));
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn no_commit_clean_exit_maps_to_no_change() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\necho 'nothing to do'\nexit 0\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert_eq!(res.outcome, ChunkOutcome::NoChange);
assert!(res.resulting_commit.is_none());
assert!(res.changed_files.is_empty());
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn no_commit_nonzero_exit_maps_to_failed() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\necho 'provider blew up' >&2\nexit 3\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Failed { .. }));
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn provider_spawn_failure_is_structured_error() {
let _g = env_lock();
let repo = init_repo();
std::env::set_var("OCTL_AIDER_BIN", "/nonexistent/aider-xyz");
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let err = h
.run_chunk(&base_request(repo.path()), &CancelToken::new())
.unwrap_err();
assert!(matches!(err, HarnessError::ProviderFailure { .. }));
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn check_failure_still_committed() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\nprintf 'x\\n' > out.txt\ngit add out.txt\ngit commit -q -m e\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let mut req = base_request(repo.path());
req.checks = vec![Check {
id: "chk1".into(),
desc: "always fails".into(),
run: "exit 1".into(),
timeout: None,
}];
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert_eq!(res.check_results.len(), 1);
assert!(!res.check_results[0].passed);
assert_eq!(res.check_results[0].exit_code, Some(1));
assert_eq!(res.check_results[0].check_id, "chk1");
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn base_commit_mismatch_is_rejected() {
let _g = env_lock();
let repo = init_repo();
std::fs::write(repo.path().join("seed.txt"), "seed2\n").unwrap();
Command::new("git")
.arg("-C")
.arg(repo.path())
.args(["commit", "-qam", "advance"])
.output()
.unwrap();
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let mut req = base_request(repo.path());
req.base_commit = head_of(repo.path()) + "~1";
let h = AiderHarness::new(AiderConfig::new("m"));
let parent = String::from_utf8_lossy(
&Command::new("git")
.arg("-C")
.arg(repo.path())
.args(["rev-parse", "HEAD~1"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
req.base_commit = parent;
let err = h.run_chunk(&req, &CancelToken::new()).unwrap_err();
assert!(matches!(err, HarnessError::InvalidWorktree { .. }));
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn uncommitted_edits_are_committed_by_the_adapter() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\nprintf 'dirty\\n' >> seed.txt\nprintf 'new\\n' > out.txt\nexit 0\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert!(res.resulting_commit.is_some());
let mut files = res.changed_files.clone();
files.sort();
assert_eq!(
files,
vec![PathBuf::from("out.txt"), PathBuf::from("seed.txt")]
);
assert_eq!(res.check_results.len(), 1);
assert!(res.check_results[0].passed);
assert_eq!(
head_message(repo.path()),
"aider: chunk c1 attempt a1 (adapter-committed)"
);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn aider_scratch_droppings_are_not_committed() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\n\
printf 'real\\n' > out.txt\n\
printf 'chat\\n' > .aider.chat.history.md\n\
mkdir -p .aider.tags.cache.v4 && printf 'x\\n' > .aider.tags.cache.v4/cache.db\n\
exit 0\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let res = run_and_check(&h, &base_request(repo.path())).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert_eq!(res.changed_files, vec![PathBuf::from("out.txt")]);
assert!(
!res.changed_files
.iter()
.any(|p| p.to_string_lossy().contains(".aider")),
"aider scratch files must not be committed: {:?}",
res.changed_files
);
assert!(repo.path().join(".aider.chat.history.md").exists());
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn history_rewrite_left_dirty_is_not_a_commit() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\n\
git checkout -q --orphan rogue\n\
git rm -q -rf . >/dev/null 2>&1\n\
printf 'x\\n' > other.txt\n\
exit 0\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let res = run_and_check(&h, &base_request(repo.path())).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Failed { .. }));
assert!(res.resulting_commit.is_none());
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn nonzero_exit_leaves_dirty_tree_uncommitted() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\nprintf 'dirty\\n' >> seed.txt\necho boom >&2\nexit 3\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Failed { .. }));
assert!(res.resulting_commit.is_none());
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn history_rewrite_is_not_a_commit() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\n\
git checkout -q --orphan rogue\n\
git rm -q -rf . >/dev/null 2>&1\n\
printf 'x\\n' > other.txt\n\
git add other.txt\n\
git commit -q -m rogue\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Failed { .. }));
assert!(res.resulting_commit.is_none());
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn multiple_commits_reported_as_committed_tip_with_full_diff() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\n\
printf 'a\\n' > a.txt && git add a.txt && git commit -q -m a\n\
printf 'b\\n' > out.txt && git add out.txt && git commit -q -m b\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
let mut files = res.changed_files.clone();
files.sort();
assert_eq!(
files,
vec![PathBuf::from("a.txt"), PathBuf::from("out.txt")]
);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn timeout_kills_hung_aider() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\nsleep 30 & sleep 30\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let mut req = base_request(repo.path());
req.timeout = Some(std::time::Duration::from_millis(200));
let start = std::time::Instant::now();
let res = run_and_check(&h, &req).unwrap();
assert_eq!(res.outcome, ChunkOutcome::Timeout);
assert!(res.resulting_commit.is_none());
assert!(res.changed_files.is_empty());
assert!(res.check_results.is_empty());
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"timeout must fire promptly, not wait for the hung child"
);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn cancel_before_run_returns_cancelled_without_spawning() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\nprintf 'x\\n' > out.txt\ngit add out.txt\ngit commit -q -m e\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let before = head_of(repo.path());
let cancel = CancelToken::new();
cancel.cancel();
let res = run_and_check_with_cancel(&h, &req, &cancel).unwrap();
assert_eq!(res.outcome, ChunkOutcome::Cancelled);
assert!(res.resulting_commit.is_none());
assert_eq!(head_of(repo.path()), before);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn cancel_in_flight_aborts_aider() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(sdir.path(), "fake-aider.sh", "#!/bin/bash\nsleep 30\n");
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let req = base_request(repo.path());
let cancel = CancelToken::new();
let trip = cancel.clone();
let handle = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
trip.cancel();
});
let start = std::time::Instant::now();
let res = run_and_check_with_cancel(&h, &req, &cancel).unwrap();
handle.join().unwrap();
assert_eq!(res.outcome, ChunkOutcome::Cancelled);
assert!(res.resulting_commit.is_none());
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"cancel must abort promptly, not wait for the hung child"
);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn per_check_timeout_kills_wedged_check() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-aider.sh",
"#!/bin/bash\nprintf 'x\\n' > out.txt\ngit add out.txt\ngit commit -q -m e\n",
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let mut req = base_request(repo.path());
req.checks = vec![Check {
id: "slow".into(),
desc: "wedged".into(),
run: "sleep 30".into(),
timeout: Some(std::time::Duration::from_millis(200)),
}];
let start = std::time::Instant::now();
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert_eq!(res.check_results.len(), 1);
assert!(!res.check_results[0].passed);
assert_eq!(res.check_results[0].exit_code, None);
assert!(res.check_results[0].stderr.contains("exceeded its timeout"));
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"a wedged check must be killed by its timeout, not hang the chunk"
);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn cancel_during_checks_keeps_commit_and_completes_check_results() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let sentinel = sdir.path().join("committed");
let bin = write_script(
sdir.path(),
"fake-aider.sh",
&format!(
"#!/bin/bash\n\
printf 'x\\n' > out.txt\n\
git add out.txt\n\
git commit -q -m e\n\
touch {}\n",
sentinel.display()
),
);
std::env::set_var("OCTL_AIDER_BIN", &bin);
std::env::set_var("DEEPSEEK_API_KEY", "test-key");
let h = AiderHarness::new(AiderConfig::new("m"));
let mut req = base_request(repo.path());
req.checks = vec![
Check {
id: "c1".into(),
desc: "blocks".into(),
run: "sleep 30".into(),
timeout: None,
},
Check {
id: "c2".into(),
desc: "would pass".into(),
run: "true".into(),
timeout: None,
},
];
let cancel = CancelToken::new();
let trip = cancel.clone();
let sentinel_seen = sentinel.clone();
let handle = std::thread::spawn(move || {
while !sentinel_seen.exists() {
std::thread::sleep(std::time::Duration::from_millis(5));
}
trip.cancel();
});
let start = std::time::Instant::now();
let res = run_and_check_with_cancel(&h, &req, &cancel).unwrap();
handle.join().unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert_eq!(res.check_results.len(), 2);
assert_eq!(res.check_results[0].check_id, "c1");
assert_eq!(res.check_results[1].check_id, "c2");
assert!(res.check_results.iter().all(|c| !c.passed));
assert!(res.check_results[1].stderr.contains("not run"));
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"the blocked check must be killed by cancel, not run to completion"
);
std::env::remove_var("OCTL_AIDER_BIN");
std::env::remove_var("DEEPSEEK_API_KEY");
}
#[test]
fn parse_usage_reads_cost_and_tokens() {
let u = parse_usage("Tokens: 2.0k sent, 500 received. Cost: $0.01 message.").unwrap();
assert_eq!(u.input_tokens, Some(2000));
assert_eq!(u.output_tokens, Some(500));
assert_eq!(u.total_tokens, Some(2500));
assert_eq!(u.cost_usd, Some(0.01));
}
#[test]
fn parse_usage_handles_million_suffix() {
let u = parse_usage("Tokens: 1.5M sent, 2k received. Cost: $1.20 message.").unwrap();
assert_eq!(u.input_tokens, Some(1_500_000));
assert_eq!(u.output_tokens, Some(2_000));
assert_eq!(u.cost_usd, Some(1.20));
}
#[test]
fn parse_usage_absent_is_none() {
assert!(parse_usage("no accounting here\n").is_none());
}
}