#![cfg(not(target_os = "emscripten"))]
use anyhow::{Context, Result};
use clap::Args;
use std::path::PathBuf;
pub use crate::cmd_share::HarnessArg;
#[derive(Args, Debug)]
pub struct ResumeArgs {
pub input: String,
#[arg(short = 'C', long)]
pub cwd: Option<PathBuf>,
#[arg(long, value_enum)]
pub harness: Option<HarnessArg>,
#[arg(long)]
pub no_cache: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub url: Option<String>,
}
pub fn run(args: ResumeArgs) -> Result<()> {
run_with_strategy(args, &RealExec)
}
pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> {
let (graph, source_harness) = resolve_input(&args)?;
let path = ensure_path_with_agent(&graph)?;
let cwd = match args.cwd.as_ref() {
Some(p) => {
std::fs::canonicalize(p).with_context(|| format!("resolve cwd path {}", p.display()))?
}
None => std::env::current_dir()?,
};
let target = pick_harness(args.harness, source_harness, None)?;
eprintln!(
"Picked harness: {}{}",
target.name(),
if Some(target) == source_harness {
" (source)"
} else {
""
}
);
let session_id = project_into_harness(path, target, &cwd)?;
let argv = argv_for(target, &session_id);
exec_harness(target.name(), &argv, &cwd, exec)
}
use toolpath::v1::{Graph, Path as TPath, PathOrRef};
pub(crate) fn infer_source_harness(path: &TPath) -> Option<crate::cmd_share::Harness> {
use crate::cmd_share::Harness;
let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref());
if let Some(source) = meta_source {
match source {
"claude-code" => return Some(Harness::Claude),
"gemini-cli" => return Some(Harness::Gemini),
"codex" => return Some(Harness::Codex),
"opencode" => return Some(Harness::Opencode),
"pi" => return Some(Harness::Pi),
_ => {} }
}
for step in &path.steps {
let actor = &step.step.actor;
if actor.starts_with("agent:claude-code") {
return Some(Harness::Claude);
}
if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") {
return Some(Harness::Gemini);
}
if actor.starts_with("agent:codex") {
return Some(Harness::Codex);
}
if actor.starts_with("agent:opencode") {
return Some(Harness::Opencode);
}
if actor.starts_with("agent:pi") {
return Some(Harness::Pi);
}
}
None
}
pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> {
if g.paths.is_empty() {
anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph");
}
if g.paths.len() > 1 {
anyhow::bail!(
"resume needs a single `Path`; input is a graph with {} paths. \
Pick one with `path query …` or split first.",
g.paths.len()
);
}
let path = match &g.paths[0] {
PathOrRef::Path(p) => p.as_ref(),
PathOrRef::Ref(_) => anyhow::bail!(
"resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document."
),
};
let has_agent = path
.steps
.iter()
.any(|s| s.step.actor.starts_with("agent:"));
if !has_agent {
anyhow::bail!(
"no agent session in input — `path resume` only works on harness-derived paths"
);
}
Ok(path)
}
pub(crate) fn resolve_input(
args: &ResumeArgs,
) -> Result<(Graph, Option<crate::cmd_share::Harness>)> {
let raw = args.input.as_str();
enum Shape<'a> {
PathbaseUrl(&'a str),
PathbaseShorthand(&'a str),
FilePath(&'a str),
CacheId(&'a str),
}
let shape = if raw.starts_with("http://") || raw.starts_with("https://") {
Shape::PathbaseUrl(raw)
} else if looks_like_pathbase_shorthand(raw) {
Shape::PathbaseShorthand(raw)
} else if std::path::Path::new(raw).is_file() {
Shape::FilePath(raw)
} else {
Shape::CacheId(raw)
};
let graph: Graph = match shape {
Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => {
let cache_id = crate::cmd_import::pathbase_cache_id_of(u, args.url.as_deref())?;
if !args.force
&& !args.no_cache
&& let Ok(cache_path) = crate::cmd_cache::cache_path(&cache_id)
&& cache_path.exists()
{
let json = std::fs::read_to_string(&cache_path)
.with_context(|| format!("read {}", cache_path.display()))?;
eprintln!("Resolved {} → {} (cached)", raw, cache_id);
Graph::from_json(&json)
.map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))?
} else {
let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?;
if !args.no_cache {
crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?;
eprintln!("Resolved {} → {}", raw, derived.cache_id);
}
derived.doc
}
}
Shape::FilePath(p) => {
let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?;
Graph::from_json(&json)
.map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
}
Shape::CacheId(id) => {
let file = crate::cmd_cache::cache_ref(id).map_err(|e| {
anyhow::anyhow!(
"couldn't resolve `{}` as a URL, file path, or cache id: {}",
raw,
e
)
})?;
let json = std::fs::read_to_string(&file)
.with_context(|| format!("read {}", file.display()))?;
Graph::from_json(&json)
.map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
}
};
let harness = graph.single_path().and_then(infer_source_harness);
Ok((graph, harness))
}
pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool {
let dirs: Vec<std::path::PathBuf> = match path_override {
Some(p) => vec![p.to_path_buf()],
None => std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default(),
};
for d in dirs {
let candidate = d.join(name);
if candidate.is_file() {
return true;
}
#[cfg(windows)]
{
let exe = d.join(format!("{name}.exe"));
if exe.is_file() {
return true;
}
}
}
false
}
const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[
crate::cmd_share::Harness::Claude,
crate::cmd_share::Harness::Gemini,
crate::cmd_share::Harness::Codex,
crate::cmd_share::Harness::Opencode,
crate::cmd_share::Harness::Pi,
];
pub(crate) fn pick_harness(
arg: Option<HarnessArg>,
source: Option<crate::cmd_share::Harness>,
path_override: Option<&std::path::Path>,
) -> Result<crate::cmd_share::Harness> {
use crate::cmd_share::Harness;
if let Some(a) = arg {
let h = Harness::from_arg(a);
if !binary_on_path(h.name(), path_override) {
anyhow::bail!(
"harness `{}` isn't on PATH; install it or pick another with `--harness`",
h.name()
);
}
return Ok(h);
}
let installed: Vec<Harness> = ALL_HARNESSES
.iter()
.copied()
.filter(|h| binary_on_path(h.name(), path_override))
.collect();
if installed.is_empty() {
anyhow::bail!(
"no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, pi"
);
}
interactive_pick(&installed, source)
}
fn interactive_pick(
installed: &[crate::cmd_share::Harness],
source: Option<crate::cmd_share::Harness>,
) -> Result<crate::cmd_share::Harness> {
if !crate::fuzzy::available() {
let hint = if crate::fuzzy::embedded_picker_available() {
"rerun in a terminal"
} else {
"install `fzf` (or build with the default `embedded-picker` feature) and rerun in a terminal"
};
anyhow::bail!("interactive picker requires a TTY; pass `--harness <X>` or {hint}");
}
let mut lines: Vec<String> = Vec::with_capacity(installed.len());
for h in installed {
let suffix = if Some(*h) == source { " (source)" } else { "" };
lines.push(format!("{}{}", h.symbol(), suffix));
}
let header = match source {
Some(s) => format!("pick a harness to resume in (source: {})", s.name()),
None => "pick a harness to resume in".to_string(),
};
let opts = crate::fuzzy::PickOptions {
with_nth: "1..",
header: Some(&header),
..Default::default()
};
let selected = match crate::fuzzy::pick(&lines, &opts)
.map_err(|e| anyhow::anyhow!("fzf failed: {}", e))?
{
crate::fuzzy::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(),
crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
crate::fuzzy::PickResult::NoMatch => {
anyhow::bail!("fzf returned no match — picker UI was empty?");
}
};
for h in installed {
if selected.starts_with(h.symbol()) {
return Ok(*h);
}
}
anyhow::bail!("picker returned an unrecognized row: {selected}")
}
pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec<String> {
use crate::cmd_share::Harness;
match harness {
Harness::Claude => vec!["-r".into(), session_id.into()],
Harness::Gemini => vec!["--resume".into(), session_id.into()],
Harness::Codex => vec!["resume".into(), session_id.into()],
Harness::Opencode => vec!["--session".into(), session_id.into()],
Harness::Pi => vec!["--session".into(), session_id.into()],
}
}
pub(crate) fn project_into_harness(
path: &TPath,
harness: crate::cmd_share::Harness,
cwd: &std::path::Path,
) -> Result<String> {
use crate::cmd_share::Harness;
match harness {
Harness::Claude => crate::cmd_export::project_claude(path, cwd),
Harness::Gemini => crate::cmd_export::project_gemini(path, cwd),
Harness::Codex => crate::cmd_export::project_codex(path, cwd),
Harness::Opencode => crate::cmd_export::project_opencode(path, cwd),
Harness::Pi => crate::cmd_export::project_pi(path, cwd),
}
}
#[derive(Debug, Clone, Default)]
pub struct CapturedExec {
pub binary: String,
pub args: Vec<String>,
pub cwd: std::path::PathBuf,
}
pub trait ExecStrategy {
fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>;
}
pub struct RealExec;
impl ExecStrategy for RealExec {
fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
let mut cmd = std::process::Command::new(binary);
cmd.args(args);
cmd.current_dir(cwd);
eprintln!(
"Resuming: {} {} (cwd: {})",
binary,
args.join(" "),
cwd.display()
);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = cmd.exec();
anyhow::bail!(
"couldn't exec `{}`: {}. Recipe: {} {} (run from {})",
binary,
err,
binary,
args.join(" "),
cwd.display()
);
}
#[cfg(not(unix))]
{
let status = cmd
.spawn()
.with_context(|| format!("spawn {}", binary))?
.wait()
.with_context(|| format!("wait for {}", binary))?;
std::process::exit(status.code().unwrap_or(1));
}
}
}
#[derive(Default)]
pub struct RecordingExec {
inner: std::sync::Mutex<CapturedExec>,
}
impl RecordingExec {
pub fn captured(&self) -> CapturedExec {
self.inner.lock().unwrap().clone()
}
}
impl ExecStrategy for RecordingExec {
fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
let mut g = self.inner.lock().unwrap();
*g = CapturedExec {
binary: binary.to_string(),
args: args.to_vec(),
cwd: cwd.to_path_buf(),
};
Ok(())
}
}
pub(crate) fn exec_harness(
binary: &str,
args: &[String],
cwd: &std::path::Path,
strategy: &dyn ExecStrategy,
) -> Result<()> {
strategy.exec(binary, args, cwd)
}
fn looks_like_pathbase_shorthand(s: &str) -> bool {
if s.starts_with('.') || s.starts_with('/') {
return false;
}
let segs: Vec<&str> = s.split('/').collect();
segs.len() == 3
&& segs
.iter()
.all(|s| !s.is_empty() && !s.contains(char::is_whitespace))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() {
let _env = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = scoped_home_for_resume();
let _path_guard = ScopedPathForResume::with_binaries(&["claude"]);
let cwd = tempfile::tempdir().unwrap();
let doc_file = cwd.path().join("doc.json");
let mut path = make_convo_path_for_resume("claude-code://resume-test-session");
path.steps[0].step.actor = "agent:claude-code".to_string();
let graph = toolpath::v1::Graph::from_path(path);
std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap();
let args = ResumeArgs {
input: doc_file.to_string_lossy().to_string(),
cwd: Some(cwd.path().to_path_buf()),
harness: Some(HarnessArg::Claude),
no_cache: false,
force: false,
url: None,
};
let recorder = RecordingExec::default();
run_with_strategy(args, &recorder).unwrap();
let cap = recorder.captured();
assert_eq!(cap.binary, "claude");
assert_eq!(cap.args[0], "-r");
assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap());
}
use crate::cmd_share::Harness;
use toolpath::v1::{Graph, PathMeta, PathOrRef};
fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step {
toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z")
.with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new")
}
fn make_path_with_actor(actor: &str) -> toolpath::v1::Path {
use toolpath::v1::{Path, PathIdentity};
let step = make_step_with_actor("s1", actor);
Path {
path: PathIdentity {
id: "p1".to_string(),
base: None,
head: "s1".to_string(),
graph_ref: None,
},
steps: vec![step],
meta: None,
}
}
#[test]
fn infer_source_harness_meta_source_wins() {
let mut path = make_path_with_actor("agent:codex");
path.meta = Some(PathMeta {
source: Some("claude-code".to_string()),
..Default::default()
});
assert_eq!(infer_source_harness(&path), Some(Harness::Claude));
}
#[test]
fn infer_source_harness_meta_source_unknown_falls_through_to_actor() {
let mut path = make_path_with_actor("agent:gemini-cli");
path.meta = Some(PathMeta {
source: Some("something-bespoke".to_string()),
..Default::default()
});
assert_eq!(infer_source_harness(&path), Some(Harness::Gemini));
}
#[test]
fn infer_source_harness_actor_sniff_codex() {
let path = make_path_with_actor("agent:codex");
assert_eq!(infer_source_harness(&path), Some(Harness::Codex));
}
#[test]
fn infer_source_harness_actor_sniff_opencode() {
let path = make_path_with_actor("agent:opencode");
assert_eq!(infer_source_harness(&path), Some(Harness::Opencode));
}
#[test]
fn infer_source_harness_actor_sniff_pi() {
let path = make_path_with_actor("agent:pi");
assert_eq!(infer_source_harness(&path), Some(Harness::Pi));
}
#[test]
fn infer_source_harness_returns_none_when_no_signal() {
let path = make_path_with_actor("human:alex");
assert_eq!(infer_source_harness(&path), None);
}
#[test]
fn ensure_path_with_agent_accepts_single_path_with_agent_actor() {
let g = Graph::from_path(make_path_with_actor("agent:claude-code"));
assert!(ensure_path_with_agent(&g).is_ok());
}
#[test]
fn ensure_path_with_agent_rejects_empty_graph() {
let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
g.paths.clear();
let err = ensure_path_with_agent(&g).unwrap_err();
assert!(err.to_string().contains("expected"));
assert!(err.to_string().contains("empty"));
}
#[test]
fn ensure_path_with_agent_rejects_multi_path_graph() {
let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor(
"agent:claude-code",
))));
let err = ensure_path_with_agent(&g).unwrap_err();
let s = err.to_string();
assert!(s.contains("single `Path`"), "actual: {s}");
assert!(s.contains("2 paths"), "actual: {s}");
}
#[test]
fn ensure_path_with_agent_rejects_agentless_path() {
let g = Graph::from_path(make_path_with_actor("human:alex"));
let err = ensure_path_with_agent(&g).unwrap_err();
assert!(err.to_string().contains("no agent session"));
}
#[test]
fn ensure_path_with_agent_rejects_path_ref_only_graph() {
use toolpath::v1::PathRef;
let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
g.paths = vec![PathOrRef::Ref(PathRef {
ref_url: "$ref://something".into(),
})];
let err = ensure_path_with_agent(&g).unwrap_err();
assert!(err.to_string().contains("inline `Path`"), "actual: {}", err);
}
#[test]
fn resolve_input_file_path() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("doc.json");
let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code"));
std::fs::write(&p, graph.to_json().unwrap()).unwrap();
let args = ResumeArgs {
input: p.to_string_lossy().to_string(),
cwd: None,
harness: None,
no_cache: false,
force: false,
url: None,
};
let (g, harness) = resolve_input(&args).unwrap();
let _path = ensure_path_with_agent(&g).unwrap();
assert_eq!(harness, Some(Harness::Claude));
}
#[test]
fn resolve_input_url_dispatches_to_pathbase_fetch() {
let _env = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
use crate::cmd_pathbase::tests::MockServer;
let body = {
let mut path = make_path_with_actor("agent:codex");
path.meta = Some(toolpath::v1::PathMeta {
source: Some("codex".to_string()),
..Default::default()
});
toolpath::v1::Graph::from_path(path).to_json().unwrap()
};
let body_static: &'static str = Box::leak(body.into_boxed_str());
let server = MockServer::start("HTTP/1.1 200 OK", body_static);
let args = ResumeArgs {
input: format!(
"{}/u/alex/repos/pathstash/graphs/fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537",
server.base()
),
cwd: None,
harness: None,
no_cache: true, force: false,
url: None,
};
let (g, harness) = resolve_input(&args).unwrap();
let _ = ensure_path_with_agent(&g).unwrap();
assert_eq!(harness, Some(Harness::Codex));
}
#[test]
fn resolve_input_url_uses_cache_on_hit_without_refetching() {
let _env = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let cfg_dir = tempfile::tempdir().unwrap();
let prev_cfg = std::env::var_os("TOOLPATH_CONFIG_DIR");
unsafe {
std::env::set_var("TOOLPATH_CONFIG_DIR", cfg_dir.path());
}
const FIXTURE_UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537";
let cache_id = format!("pathbase-alex-pathstash-{FIXTURE_UUID}");
let cache_id = cache_id.as_str();
let documents = cfg_dir.path().join("documents");
std::fs::create_dir_all(&documents).unwrap();
let cached_graph = {
let mut path = make_path_with_actor("agent:codex");
path.meta = Some(toolpath::v1::PathMeta {
source: Some("codex".to_string()),
..Default::default()
});
toolpath::v1::Graph::from_path(path)
};
std::fs::write(
documents.join(format!("{cache_id}.json")),
cached_graph.to_json().unwrap(),
)
.unwrap();
use crate::cmd_pathbase::tests::MockServer;
let server = MockServer::start("HTTP/1.1 500 Internal Server Error", "boom");
let args = ResumeArgs {
input: format!(
"{}/u/alex/repos/pathstash/graphs/{FIXTURE_UUID}",
server.base()
),
cwd: None,
harness: None,
no_cache: false,
force: false,
url: None,
};
let result = resolve_input(&args);
unsafe {
match prev_cfg {
Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v),
None => std::env::remove_var("TOOLPATH_CONFIG_DIR"),
}
}
let (g, harness) = result.expect("resolve_input should reuse cache without refetching");
let _ = ensure_path_with_agent(&g).unwrap();
assert_eq!(harness, Some(Harness::Codex));
}
#[test]
fn resolve_input_unresolvable_errors_clearly() {
let _env = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let args = ResumeArgs {
input: "definitely/not/a/real/cache/id".to_string(),
cwd: None,
harness: None,
no_cache: false,
force: false,
url: None,
};
let err = resolve_input(&args).unwrap_err();
let s = err.to_string();
assert!(s.contains("couldn't resolve"), "actual: {s}");
}
fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir {
let td = tempfile::tempdir().unwrap();
for b in binaries {
let p = td.path().join(b);
std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&p, perm).unwrap();
}
}
td
}
#[test]
fn binary_on_path_finds_present_binary() {
let td = fake_path_with(&["claude"]);
assert!(binary_on_path("claude", Some(td.path())));
assert!(!binary_on_path("gemini", Some(td.path())));
}
#[test]
fn pick_harness_explicit_arg_validates_path() {
let td = fake_path_with(&["claude"]);
let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path()));
assert_eq!(result.unwrap(), Harness::Claude);
let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err();
assert!(err.to_string().contains("`gemini` isn't on PATH"));
}
#[test]
fn pick_harness_zero_installed_errors() {
let td = fake_path_with(&[]);
let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err();
assert!(
err.to_string().contains("no installed harnesses")
|| err.to_string().contains("no harnesses on PATH"),
"actual: {}",
err
);
}
#[test]
fn argv_for_returns_harness_specific_shape() {
assert_eq!(
argv_for(Harness::Claude, "abc"),
vec!["-r".to_string(), "abc".to_string()]
);
assert_eq!(
argv_for(Harness::Gemini, "abc"),
vec!["--resume".to_string(), "abc".to_string()]
);
assert_eq!(
argv_for(Harness::Codex, "abc"),
vec!["resume".to_string(), "abc".to_string()]
);
assert_eq!(
argv_for(Harness::Opencode, "abc"),
vec!["--session".to_string(), "abc".to_string()]
);
assert_eq!(
argv_for(Harness::Pi, "abc"),
vec!["--session".to_string(), "abc".to_string()]
);
}
#[test]
fn project_into_harness_claude_round_trip() {
let _env = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _home = scoped_home_for_resume();
let cwd = tempfile::tempdir().unwrap();
let path = make_convo_path_for_resume("claude-code://resume-test-session");
let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap();
assert!(!session_id.is_empty());
}
fn make_convo_path_for_resume(artifact_key: &str) -> toolpath::v1::Path {
use std::collections::HashMap;
let mut extra = HashMap::new();
extra.insert("role".to_string(), serde_json::json!("user"));
extra.insert("text".to_string(), serde_json::json!("hello"));
let step = toolpath::v1::Step {
step: toolpath::v1::StepIdentity {
id: "s1".to_string(),
parents: vec![],
actor: "human:test".to_string(),
timestamp: "2026-01-01T00:00:00Z".to_string(),
},
change: {
let mut m = HashMap::new();
m.insert(
artifact_key.to_string(),
toolpath::v1::ArtifactChange {
raw: None,
structural: Some(toolpath::v1::StructuralChange {
change_type: "conversation.append".to_string(),
extra,
}),
},
);
m
},
meta: None,
};
toolpath::v1::Path {
path: toolpath::v1::PathIdentity {
id: "test-path".to_string(),
base: None,
head: "s1".to_string(),
graph_ref: None,
},
steps: vec![step],
meta: None,
}
}
fn scoped_home_for_resume() -> ScopedHomeForResume {
ScopedHomeForResume::new()
}
struct ScopedPathForResume {
_bin_dir: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
}
impl ScopedPathForResume {
fn with_binaries(binaries: &[&str]) -> Self {
let bin_dir = fake_path_with(binaries);
let prev = std::env::var_os("PATH");
let new_path = std::env::join_paths(
std::iter::once(bin_dir.path().to_path_buf())
.chain(std::env::split_paths(&prev.clone().unwrap_or_default())),
)
.unwrap();
unsafe {
std::env::set_var("PATH", new_path);
}
Self {
_bin_dir: bin_dir,
prev,
}
}
}
impl Drop for ScopedPathForResume {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
}
}
struct ScopedHomeForResume {
_td: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
}
impl ScopedHomeForResume {
fn new() -> Self {
let td = tempfile::tempdir().unwrap();
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", td.path());
}
Self { _td: td, prev }
}
}
impl Drop for ScopedHomeForResume {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}
#[test]
fn exec_strategy_recording_captures_invocation() {
let recorder = RecordingExec::default();
let strategy: &dyn ExecStrategy = &recorder;
exec_harness(
"claude",
&["-r".into(), "abc123".into()],
std::path::Path::new("/tmp/x"),
strategy,
)
.unwrap();
let captured = recorder.captured();
assert_eq!(captured.binary, "claude");
assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]);
assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x"));
}
}