use crate::daemon::DaemonCommand;
use crate::tools::context::ToolContext;
use crate::tools::{AllowedCaller, Tool, ToolExecError, resolve_path};
use choreo_keystore::ServiceCredential;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use tracing::info;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub(crate) struct SetWorkingDirArgs {
pub(crate) path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SetWorkingDirResult {
pub path: String,
}
pub(crate) fn resolve_working_dir_path(
path: &str,
working_dir: Option<&Path>,
) -> Result<PathBuf, ToolExecError> {
let resolved = resolve_path(path, working_dir);
let canonical = resolved.canonicalize().map_err(|e| {
ToolExecError(format!(
"path '{}' does not exist or cannot be resolved: {e}",
resolved.display()
))
})?;
Ok(canonical)
}
fn execute_set_working_dir(
args: &SetWorkingDirArgs,
working_dir: Option<&Path>,
ctx: Option<&ToolContext>,
) -> Result<SetWorkingDirResult, ToolExecError> {
let ctx = ctx.ok_or_else(|| ToolExecError("no session context".into()))?;
let canonical = resolve_working_dir_path(&args.path, working_dir)?;
info!(
session_id = ctx.session_id,
path = %canonical.display(),
"setting session working directory",
);
let (reply, rx) = mpsc::channel();
ctx.daemon_tx
.send(DaemonCommand::SetWorkingDir {
session_id: ctx.session_id,
path: canonical.clone(),
reply,
})
.map_err(|e| ToolExecError(format!("daemon communication failed: {e}")))?;
let outcome = rx
.recv()
.map_err(|e| ToolExecError(format!("daemon did not respond: {e}")))?;
outcome.map_err(ToolExecError)?;
Ok(SetWorkingDirResult {
path: canonical.to_string_lossy().into_owned(),
})
}
pub fn describe_invocation(args: &SetWorkingDirArgs) -> String {
format!("Changing session working directory to '{}'.", args.path)
}
pub(crate) struct SetWorkingDir;
impl Tool for SetWorkingDir {
type Args = SetWorkingDirArgs;
type Return = SetWorkingDirResult;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"set_working_dir"
}
fn group(&self) -> &'static str {
"core"
}
fn description(&self) -> &'static str {
"Change the working directory for this session. All subsequent file operations, \
shell commands, and context discovery (AGENTS.md, CLAUDE.md, skills) will \
resolve relative to this new directory. The change takes effect on the next turn."
}
fn describe_invocation(&self, args: &Self::Args) -> String {
describe_invocation(args)
}
fn return_string(ret: &Self::Return) -> String {
format!("Working directory changed to '{}'.", ret.path)
}
fn allowed_callers(&self) -> Vec<AllowedCaller> {
vec![AllowedCaller::Direct]
}
fn execute(
&self,
args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
working_dir: Option<&Path>,
ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
execute_set_working_dir(&args, working_dir, ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::context::ToolContext;
use std::sync::Arc;
fn test_context() -> (
ToolContext,
std::sync::mpsc::Sender<DaemonCommand>,
std::sync::mpsc::Receiver<DaemonCommand>,
) {
let (daemon_tx, daemon_rx) = std::sync::mpsc::channel::<DaemonCommand>();
let dir = tempfile::tempdir().unwrap();
let db_path = dir.keep(); let db = Arc::new(redb::Database::create(db_path.join("test.redb")).unwrap());
let ctx = ToolContext::new(42, db, daemon_tx.clone());
(ctx, daemon_tx, daemon_rx)
}
fn run_with_daemon_reply(
args: SetWorkingDirArgs,
working_dir: Option<PathBuf>,
reply: Result<String, String>,
) -> (Result<SetWorkingDirResult, ToolExecError>, DaemonCommand) {
let (ctx, _daemon_tx, daemon_rx) = test_context();
let wd = working_dir.clone();
let handle =
std::thread::spawn(move || execute_set_working_dir(&args, wd.as_deref(), Some(&ctx)));
let cmd = daemon_rx.recv().unwrap();
match &cmd {
DaemonCommand::SetWorkingDir { reply: tx, .. } => {
tx.send(reply).unwrap();
}
other => panic!(
"expected SetWorkingDir, got {:?}",
std::mem::discriminant(other)
),
}
(handle.join().unwrap(), cmd)
}
#[test]
fn execute_set_working_dir_sends_daemon_command() {
let dir = tempfile::tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let path = canonical.to_string_lossy().into_owned();
let args = SetWorkingDirArgs { path: path.clone() };
let (result, cmd) = run_with_daemon_reply(args, None, Ok(path.clone()));
assert!(result.is_ok(), "expected ok: {:?}", result.err());
assert_eq!(result.unwrap().path, path);
match cmd {
DaemonCommand::SetWorkingDir {
session_id,
path: sent_path,
..
} => {
assert_eq!(session_id, 42);
assert_eq!(sent_path.to_string_lossy(), path);
}
_ => panic!("expected SetWorkingDir command"),
}
}
#[test]
fn execute_resolves_relative_path_against_working_dir() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let args = SetWorkingDirArgs { path: "sub".into() };
let (result, _cmd) = run_with_daemon_reply(
args,
Some(dir.path().to_path_buf()),
Ok(sub.to_string_lossy().into_owned()),
);
assert!(result.is_ok(), "expected ok: {:?}", result.err());
assert_eq!(
result.unwrap().path,
sub.canonicalize().unwrap().to_string_lossy(),
"relative path should resolve against working_dir and canonicalize"
);
}
#[test]
fn execute_expands_tilde() {
let home = dirs::home_dir().expect("home dir should exist in test env");
let args = SetWorkingDirArgs { path: "~".into() };
let (result, _cmd) =
run_with_daemon_reply(args, None, Ok(home.to_string_lossy().into_owned()));
assert!(result.is_ok(), "expected ok: {:?}", result.err());
assert_eq!(result.unwrap().path, home.to_string_lossy());
}
#[test]
fn execute_forwards_daemon_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_string_lossy().into_owned();
let args = SetWorkingDirArgs { path };
let (result, _cmd) = run_with_daemon_reply(args, None, Err("session is not active".into()));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("session is not active")
);
}
#[test]
fn execute_no_context_returns_error() {
let args = SetWorkingDirArgs {
path: "/tmp".into(),
};
let result = execute_set_working_dir(&args, None, None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no session context")
);
}
#[test]
fn execute_nonexistent_path_returns_error() {
let (ctx, _daemon_tx, _daemon_rx) = test_context();
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let args = SetWorkingDirArgs {
path: missing.to_string_lossy().into_owned(),
};
let result = execute_set_working_dir(&args, None, Some(&ctx));
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("does not exist or cannot be resolved")
);
}
#[test]
fn describe_invocation_includes_path() {
let args = SetWorkingDirArgs {
path: "/home/user/project".into(),
};
let desc = describe_invocation(&args);
assert_eq!(
desc,
"Changing session working directory to '/home/user/project'."
);
}
#[test]
fn return_string_formats_correctly() {
let result = SetWorkingDirResult {
path: "/tmp".into(),
};
let s = SetWorkingDir::return_string(&result);
assert_eq!(s, "Working directory changed to '/tmp'.");
}
#[test]
fn tool_schema_has_required_path() {
let tool = SetWorkingDir;
let schema = tool.schema();
let props = schema["properties"].as_object().unwrap();
assert!(props.contains_key("path"));
assert_eq!(props["path"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "path"));
}
#[test]
fn tool_restricted_to_direct_callers() {
let tool = SetWorkingDir;
let callers = tool.allowed_callers();
assert_eq!(callers, vec![AllowedCaller::Direct]);
assert!(!callers.contains(&AllowedCaller::Programmatic));
}
#[test]
fn execute_postcard_args_round_trip() {
let args = SetWorkingDirArgs {
path: "/tmp".into(),
};
let args_bytes = postcard::to_allocvec(&args).unwrap();
let decoded: SetWorkingDirArgs = postcard::from_bytes(&args_bytes).unwrap();
assert_eq!(decoded.path, "/tmp");
}
}