use std::path::Path;
use std::process::Command;
use super::support::{self, AgentLaunch};
use super::{
CancelToken, ChunkRequest, ChunkResult, CodeHarness, HarnessCapabilities, HarnessError, Usage,
};
fn claude_bin() -> String {
std::env::var("OCTL_CLAUDE_BIN").unwrap_or_else(|_| "claude".to_string())
}
fn claude_deepseek_bin() -> String {
std::env::var("OCTL_CLAUDE_DEEPSEEK_BIN").unwrap_or_else(|_| "claude-deepseek".to_string())
}
#[derive(Debug, Clone)]
enum ClaudeVariant {
Claude { model: Option<String> },
Deepseek { model: String },
}
#[derive(Debug, Clone)]
pub struct ClaudeHarness {
variant: ClaudeVariant,
extra_args: Vec<String>,
}
impl ClaudeHarness {
pub fn claude(model: Option<String>) -> Self {
Self {
variant: ClaudeVariant::Claude { model },
extra_args: Vec::new(),
}
}
pub fn deepseek(model: impl Into<String>) -> Self {
Self {
variant: ClaudeVariant::Deepseek {
model: model.into(),
},
extra_args: Vec::new(),
}
}
#[must_use]
pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
self.extra_args = args;
self
}
}
impl AgentLaunch for ClaudeHarness {
fn capabilities(&self) -> HarnessCapabilities {
HarnessCapabilities {
can_author_tests: true,
reports_usage: true,
honors_file_scope: false,
runs_checks: true,
}
}
fn check_credentials(&self) -> Result<(), HarnessError> {
Ok(())
}
fn build_prompt(&self, req: &ChunkRequest) -> String {
support::commit_framed_prompt(req)
}
fn build_command(
&self,
worktree: &Path,
_brief_file: &Path,
prompt: &str,
_req: &ChunkRequest,
) -> Command {
let mut cmd = match &self.variant {
ClaudeVariant::Claude { model } => {
let mut c = Command::new(claude_bin());
c.arg("-p")
.arg("--output-format")
.arg("json")
.arg("--dangerously-skip-permissions");
if let Some(m) = model {
c.arg("--model").arg(m);
}
c
}
ClaudeVariant::Deepseek { model } => {
let mut c = Command::new(claude_deepseek_bin());
c.arg("--model")
.arg(model)
.arg("-p")
.arg("--output-format")
.arg("json");
c
}
};
cmd.current_dir(worktree);
for extra in &self.extra_args {
cmd.arg(extra);
}
cmd.arg("--").arg(prompt);
cmd
}
fn parse_usage(&self, transcript: &str) -> Option<Usage> {
parse_claude_usage(transcript)
}
fn tool_label(&self) -> &'static str {
match &self.variant {
ClaudeVariant::Claude { .. } => "claude",
ClaudeVariant::Deepseek { .. } => "claude-deepseek",
}
}
fn bin_display(&self) -> String {
match &self.variant {
ClaudeVariant::Claude { .. } => claude_bin(),
ClaudeVariant::Deepseek { .. } => claude_deepseek_bin(),
}
}
}
impl CodeHarness for ClaudeHarness {
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)
}
}
pub type ClaudeDeepseekHarness = ClaudeHarness;
fn parse_claude_usage(transcript: &str) -> Option<Usage> {
support::parse_json_usage(transcript)
}
#[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 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 claude_commit_produced_maps_to_committed_with_json_usage() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-claude.sh",
"#!/bin/bash\n\
printf 'edited\\n' > out.txt\n\
git add out.txt\n\
git commit -q -m 'chunk edit'\n\
printf '%s\\n' '{\"type\":\"result\",\"total_cost_usd\":0.0012,\"usage\":{\"input_tokens\":1200,\"output_tokens\":300}}'\n",
);
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
let h = ClaudeHarness::claude(None);
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert_eq!(res.changed_files, vec![PathBuf::from("out.txt")]);
assert_eq!(res.check_results.len(), 1);
assert!(res.check_results[0].passed);
let usage = res.usage.expect("json usage parsed");
assert_eq!(usage.cost_usd, Some(0.0012));
assert_eq!(usage.input_tokens, Some(1200));
assert_eq!(usage.output_tokens, Some(300));
assert_eq!(usage.total_tokens, Some(1500));
std::env::remove_var("OCTL_CLAUDE_BIN");
}
#[test]
fn deepseek_variant_uses_its_own_binary() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let ds = write_script(
sdir.path(),
"fake-ds.sh",
"#!/bin/bash\nprintf 'ds\\n' > ds.txt\ngit add ds.txt\ngit commit -q -m ds\n",
);
std::env::set_var("OCTL_CLAUDE_BIN", "/nonexistent/should-not-run");
std::env::set_var("OCTL_CLAUDE_DEEPSEEK_BIN", &ds);
let h = ClaudeHarness::deepseek("flash");
let req = base_request(repo.path());
let res = run_and_check(&h, &req).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Committed { .. }));
assert_eq!(res.changed_files, vec![PathBuf::from("ds.txt")]);
std::env::remove_var("OCTL_CLAUDE_BIN");
std::env::remove_var("OCTL_CLAUDE_DEEPSEEK_BIN");
}
#[test]
fn no_credential_check_runs_without_env() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-claude.sh",
"#!/bin/bash\necho '{\"type\":\"result\"}'\nexit 0\n",
);
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
let h = ClaudeHarness::claude(None);
let res = run_and_check(&h, &base_request(repo.path())).unwrap();
assert_eq!(res.outcome, ChunkOutcome::NoChange);
std::env::remove_var("OCTL_CLAUDE_BIN");
}
#[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-claude.sh",
"#!/bin/bash\necho 'boom' >&2\nexit 2\n",
);
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
let h = ClaudeHarness::claude(None);
let res = run_and_check(&h, &base_request(repo.path())).unwrap();
assert!(matches!(res.outcome, ChunkOutcome::Failed { .. }));
std::env::remove_var("OCTL_CLAUDE_BIN");
}
#[test]
fn clean_exit_but_dirty_tree_maps_to_failed() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-claude.sh",
"#!/bin/bash\nprintf 'dirty\\n' >> seed.txt\nexit 0\n",
);
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
let h = ClaudeHarness::claude(None);
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_CLAUDE_BIN");
}
#[test]
fn timeout_kills_hung_claude() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(
sdir.path(),
"fake-claude.sh",
"#!/bin/bash\nsleep 30 & sleep 30\n",
);
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
let h = ClaudeHarness::claude(None);
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!(start.elapsed() < std::time::Duration::from_secs(5));
std::env::remove_var("OCTL_CLAUDE_BIN");
}
#[test]
fn cancel_in_flight_aborts_claude() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let bin = write_script(sdir.path(), "fake-claude.sh", "#!/bin/bash\nsleep 30\n");
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
let h = ClaudeHarness::claude(None);
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!(start.elapsed() < std::time::Duration::from_secs(5));
std::env::remove_var("OCTL_CLAUDE_BIN");
}
#[test]
fn spawn_failure_is_structured_error() {
let _g = env_lock();
let repo = init_repo();
std::env::set_var("OCTL_CLAUDE_BIN", "/nonexistent/claude-xyz");
let h = ClaudeHarness::claude(None);
let err = h
.run_chunk(&base_request(repo.path()), &CancelToken::new())
.unwrap_err();
assert!(matches!(err, HarnessError::ProviderFailure { .. }));
std::env::remove_var("OCTL_CLAUDE_BIN");
}
const ARGV_DUMP: &str =
"#!/bin/bash\nprintf '%s\\0' \"$@\" > \"$OCTL_TEST_ARGV_OUT\"\nexit 0\n";
fn captured_argv(argv_out: &Path) -> Vec<String> {
let raw = std::fs::read(argv_out).unwrap();
raw.split(|b| *b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect()
}
#[test]
fn claude_argv_has_single_skip_permissions_and_terminated_prompt() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let argv_out = sdir.path().join("argv");
let bin = write_script(sdir.path(), "fake-claude.sh", ARGV_DUMP);
std::env::set_var("OCTL_CLAUDE_BIN", &bin);
std::env::set_var("OCTL_TEST_ARGV_OUT", &argv_out);
let h = ClaudeHarness::claude(Some("sonnet".into()));
let mut req = base_request(repo.path());
req.brief = "--do-the-thing please".into();
let _ = run_and_check(&h, &req).unwrap();
let args = captured_argv(&argv_out);
assert_eq!(
args.iter()
.filter(|a| *a == "--dangerously-skip-permissions")
.count(),
1,
"plain claude passes exactly one skip-permissions flag: {args:?}"
);
assert!(args.iter().any(|a| a == "-p"));
assert_eq!(args.iter().filter(|a| *a == "--output-format").count(), 1);
assert!(args
.windows(2)
.any(|w| w[0] == "--model" && w[1] == "sonnet"));
assert_eq!(args.iter().filter(|a| *a == "--").count(), 1);
assert!(
args.last().unwrap().starts_with("--do-the-thing"),
"prompt survived intact after `--`: {:?}",
args.last()
);
std::env::remove_var("OCTL_CLAUDE_BIN");
std::env::remove_var("OCTL_TEST_ARGV_OUT");
}
#[test]
fn deepseek_argv_omits_skip_permissions_flag() {
let _g = env_lock();
let repo = init_repo();
let sdir = TempDir::new().unwrap();
let argv_out = sdir.path().join("argv");
let bin = write_script(sdir.path(), "fake-ds.sh", ARGV_DUMP);
std::env::set_var("OCTL_CLAUDE_DEEPSEEK_BIN", &bin);
std::env::set_var("OCTL_TEST_ARGV_OUT", &argv_out);
let h = ClaudeHarness::deepseek("flash");
let _ = run_and_check(&h, &base_request(repo.path())).unwrap();
let args = captured_argv(&argv_out);
assert_eq!(
args.iter()
.filter(|a| *a == "--dangerously-skip-permissions")
.count(),
0,
"deepseek variant must not add skip-permissions (the wrapper does): {args:?}"
);
assert!(args
.windows(2)
.any(|w| w[0] == "--model" && w[1] == "flash"));
assert!(args.iter().any(|a| a == "-p"));
assert_eq!(args.iter().filter(|a| *a == "--").count(), 1);
std::env::remove_var("OCTL_CLAUDE_DEEPSEEK_BIN");
std::env::remove_var("OCTL_TEST_ARGV_OUT");
}
#[test]
fn parse_claude_usage_from_json_line() {
let t = "some log line\n{\"type\":\"result\",\"total_cost_usd\":0.5,\"usage\":{\"input_tokens\":10,\"output_tokens\":20}}\n";
let u = parse_claude_usage(t).unwrap();
assert_eq!(u.input_tokens, Some(10));
assert_eq!(u.output_tokens, Some(20));
assert_eq!(u.total_tokens, Some(30));
assert_eq!(u.cost_usd, Some(0.5));
}
#[test]
fn parse_claude_usage_absent_is_none() {
assert!(parse_claude_usage("no json here\njust prose\n").is_none());
assert!(parse_claude_usage("{\"type\":\"result\"}").is_none());
}
#[test]
fn parse_claude_usage_prefers_terminal_result_over_early_partial() {
let t = "{\"type\":\"assistant\",\"usage\":{\"input_tokens\":10}}\n\
{\"type\":\"result\",\"total_cost_usd\":0.02,\"usage\":{\"input_tokens\":1200,\"output_tokens\":300}}\n";
let u = parse_claude_usage(t).unwrap();
assert_eq!(u.input_tokens, Some(1200));
assert_eq!(u.output_tokens, Some(300));
assert_eq!(u.total_tokens, Some(1500));
assert_eq!(u.cost_usd, Some(0.02));
}
#[test]
fn deepseek_type_alias_is_claude_harness() {
let _h: ClaudeDeepseekHarness = ClaudeHarness::deepseek("pro");
}
}