feat(helexa-acp): expand ~ / $HOME and fall back to local fs on ACP read errors
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 44s
CI / Format (push) Successful in 50s
CI / Clippy (push) Successful in 2m34s
build-prerelease / Build cortex binary (push) Successful in 4m29s
CI / Test (push) Successful in 5m13s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Package cortex RPM (push) Successful in 1m18s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
build-prerelease / Build neuron-ampere (push) Successful in 8m15s
build-prerelease / Build neuron-ada (push) Successful in 5m23s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 44s
CI / Format (push) Successful in 50s
CI / Clippy (push) Successful in 2m34s
build-prerelease / Build cortex binary (push) Successful in 4m29s
CI / Test (push) Successful in 5m13s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Package cortex RPM (push) Successful in 1m18s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
build-prerelease / Build neuron-ampere (push) Successful in 8m15s
build-prerelease / Build neuron-ada (push) Successful in 5m23s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Two related polish fixes for daily use: - New `path_util` module expands `~`, `~/…`, `$HOME`, and `$HOME/…` prefixes in every tool that takes a path (read_file, write_file, edit_file, list_dir, bash cwd). The expansion is also applied to the plan-mode write gate so `~/.local/share/helexa-acp/plans/…` comparisons behave correctly regardless of which form the model emits. - `read_file` now falls back to `std::fs::read_to_string` when ACP's `fs/read_text_file` errors out. Zed's workspace-scoped read was the source of "model can't see ~/git/architecture/generic.md" when the session cwd is a different project; the fallback lets the agent pull in shared material that lives outside the active workspace, the same way `list_dir` already does via local `std::fs::read_dir`. Local fallback honours line/limit args. The fallback also produces a combined error message when both ACP and local-fs reads fail, so the model sees what actually broke rather than just the ACP-side error. 14 new unit tests cover path_util's prefix matrix, fallback success/failure paths, and the line/limit slicing in fallback. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ use std::sync::Arc;
|
|||||||
mod agent;
|
mod agent;
|
||||||
mod compaction;
|
mod compaction;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod path_util;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod qwen3;
|
mod qwen3;
|
||||||
|
|||||||
184
crates/helexa-acp/src/path_util.rs
Normal file
184
crates/helexa-acp/src/path_util.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//! Path expansion shared across every tool that takes a path.
|
||||||
|
//!
|
||||||
|
//! Models often emit shell-style paths like `~/git/repo/file.rs` or
|
||||||
|
//! `$HOME/notes.md`. ACP's `fs/read_text_file` and friends — and our
|
||||||
|
//! own local `std::fs` reads — both want a real absolute path; the
|
||||||
|
//! `~` / `$HOME` forms reach them as literal strings and the open
|
||||||
|
//! fails. The tool schemas already document "absolute path" but in
|
||||||
|
//! practice the model slips up often enough that handling it
|
||||||
|
//! server-side is the difference between "works" and "the agent is
|
||||||
|
//! brittle".
|
||||||
|
//!
|
||||||
|
//! Scope is deliberately small:
|
||||||
|
//!
|
||||||
|
//! - `~` and `~/` (current user only — `~user` lookups would require
|
||||||
|
//! pulling in passwd parsing).
|
||||||
|
//! - `$HOME` and `$HOME/`.
|
||||||
|
//!
|
||||||
|
//! Any other shell variable (`$PWD`, `${HOME}`, …) passes through
|
||||||
|
//! unchanged. The shell already expands them inside `bash` tool
|
||||||
|
//! commands; for the file-tool argument fields, we deliberately
|
||||||
|
//! limit the set so the behaviour is predictable.
|
||||||
|
//!
|
||||||
|
//! Falls back to the input path verbatim when `HOME` is unset
|
||||||
|
//! (stripped-down container env). That preserves the "no surprise
|
||||||
|
//! mutations" rule — never invent a path the caller didn't ask for.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Expand `~`, `~/`, `$HOME`, and `$HOME/` prefixes against the
|
||||||
|
/// current user's home directory. All other inputs pass through
|
||||||
|
/// unchanged.
|
||||||
|
///
|
||||||
|
/// Returns the input verbatim if `HOME` isn't set in the env.
|
||||||
|
pub fn expand_path(input: &Path) -> PathBuf {
|
||||||
|
let Some(s) = input.to_str() else {
|
||||||
|
return input.to_path_buf();
|
||||||
|
};
|
||||||
|
let Ok(home) = std::env::var("HOME") else {
|
||||||
|
return input.to_path_buf();
|
||||||
|
};
|
||||||
|
let home = PathBuf::from(home);
|
||||||
|
if s == "~" || s == "$HOME" {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
if let Some(rest) = s.strip_prefix("~/") {
|
||||||
|
return home.join(rest);
|
||||||
|
}
|
||||||
|
if let Some(rest) = s.strip_prefix("$HOME/") {
|
||||||
|
return home.join(rest);
|
||||||
|
}
|
||||||
|
input.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Set HOME for the duration of the test. Tests using this run
|
||||||
|
/// serially under one mutex because env mutation isn't
|
||||||
|
/// thread-safe — `cargo test` parallel workers would race
|
||||||
|
/// without it.
|
||||||
|
fn with_home<F: FnOnce()>(home: &str, body: F) {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
static LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
let _g = LOCK.lock().unwrap();
|
||||||
|
let prior = std::env::var("HOME").ok();
|
||||||
|
// SAFETY: tests touch process-global env. The mutex
|
||||||
|
// serialises access; sub-threads in other test modules
|
||||||
|
// touching HOME aren't expected (none in this crate).
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("HOME", home);
|
||||||
|
}
|
||||||
|
body();
|
||||||
|
unsafe {
|
||||||
|
match prior {
|
||||||
|
Some(p) => std::env::set_var("HOME", p),
|
||||||
|
None => std::env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_tilde_slash() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("~/git/repo/file.rs")),
|
||||||
|
PathBuf::from("/home/me/git/repo/file.rs")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_bare_tilde() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(expand_path(Path::new("~")), PathBuf::from("/home/me"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_dollar_home_slash() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("$HOME/notes.md")),
|
||||||
|
PathBuf::from("/home/me/notes.md")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_bare_dollar_home() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(expand_path(Path::new("$HOME")), PathBuf::from("/home/me"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absolute_path_passes_through() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("/etc/hostname")),
|
||||||
|
PathBuf::from("/etc/hostname")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_path_passes_through() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("src/main.rs")),
|
||||||
|
PathBuf::from("src/main.rs")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_user_form_not_expanded() {
|
||||||
|
// ~other is shell sugar for /home/other and would require
|
||||||
|
// passwd parsing to resolve. Out of scope — pass it
|
||||||
|
// through and let the open fail with a clear error.
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("~other/x")),
|
||||||
|
PathBuf::from("~other/x")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_home_env_passes_through() {
|
||||||
|
// Lock + clear HOME for this one.
|
||||||
|
use std::sync::Mutex;
|
||||||
|
static LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
let _g = LOCK.lock().unwrap();
|
||||||
|
let prior = std::env::var("HOME").ok();
|
||||||
|
// SAFETY: serialised by LOCK above.
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("HOME");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("~/git/repo")),
|
||||||
|
PathBuf::from("~/git/repo")
|
||||||
|
);
|
||||||
|
unsafe {
|
||||||
|
if let Some(p) = prior {
|
||||||
|
std::env::set_var("HOME", p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_other_var_not_expanded() {
|
||||||
|
with_home("/home/me", || {
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("$PWD/file")),
|
||||||
|
PathBuf::from("$PWD/file")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expand_path(Path::new("${HOME}/file")),
|
||||||
|
PathBuf::from("${HOME}/file")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::path_util::expand_path;
|
||||||
use crate::session::{MODE_BYPASS, MODE_DEFAULT, MODE_PLAN};
|
use crate::session::{MODE_BYPASS, MODE_DEFAULT, MODE_PLAN};
|
||||||
use crate::store;
|
use crate::store;
|
||||||
use crate::tools::{BASH, EDIT_FILE, LIST_DIR, READ_FILE, WRITE_FILE};
|
use crate::tools::{BASH, EDIT_FILE, LIST_DIR, READ_FILE, WRITE_FILE};
|
||||||
@@ -431,7 +432,7 @@ pub async fn dispatch_tool_call(
|
|||||||
let path = args_value
|
let path = args_value
|
||||||
.get("path")
|
.get("path")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(std::path::PathBuf::from);
|
.map(|s| expand_path(std::path::Path::new(s)));
|
||||||
let inside_plan_dir = match (path.as_deref(), plan_dir.as_deref()) {
|
let inside_plan_dir = match (path.as_deref(), plan_dir.as_deref()) {
|
||||||
(Some(p), Some(pd)) => p.starts_with(pd),
|
(Some(p), Some(pd)) => p.starts_with(pd),
|
||||||
_ => false,
|
_ => false,
|
||||||
@@ -621,10 +622,38 @@ async fn exec_read_file(
|
|||||||
) -> Result<(String, Vec<ToolCallContent>), String> {
|
) -> Result<(String, Vec<ToolCallContent>), String> {
|
||||||
let args: ReadFileArgs =
|
let args: ReadFileArgs =
|
||||||
serde_json::from_value(args_value.clone()).map_err(|e| format!("read_file: {e}"))?;
|
serde_json::from_value(args_value.clone()).map_err(|e| format!("read_file: {e}"))?;
|
||||||
let content = ops
|
let path = expand_path(&args.path);
|
||||||
.read_text_file(session_id, args.path, args.line, args.limit)
|
|
||||||
.await
|
// Try the editor's filesystem first. Zed will show the open
|
||||||
.map_err(|e| format!("read_file: {e:#}"))?;
|
// buffer (if any) and respect any per-workspace mount points
|
||||||
|
// / overlays it has configured.
|
||||||
|
let acp_result = ops
|
||||||
|
.read_text_file(session_id, path.clone(), args.line, args.limit)
|
||||||
|
.await;
|
||||||
|
let content = match acp_result {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
// ACP failures on read are almost always Zed's
|
||||||
|
// workspace-boundary check (read paths outside the
|
||||||
|
// session cwd are refused). Fall back to local
|
||||||
|
// std::fs so the agent can still pull in shared
|
||||||
|
// material like `~/git/architecture/generic.md` that
|
||||||
|
// sits outside the active project. The user-process
|
||||||
|
// file permissions still apply — this is not a
|
||||||
|
// sandbox escape, just a way around Zed's
|
||||||
|
// workspace-only default.
|
||||||
|
tracing::warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"fs/read_text_file failed; falling back to local std::fs"
|
||||||
|
);
|
||||||
|
let raw = std::fs::read_to_string(&path).map_err(|fs_err| {
|
||||||
|
format!("read_file: ACP returned {e:#}; local fallback also failed: {fs_err}")
|
||||||
|
})?;
|
||||||
|
apply_line_limit(&raw, args.line, args.limit)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let blocks = vec![ToolCallContent::Content(
|
let blocks = vec![ToolCallContent::Content(
|
||||||
agent_client_protocol::schema::Content::new(ContentBlock::Text(TextContent::new(
|
agent_client_protocol::schema::Content::new(ContentBlock::Text(TextContent::new(
|
||||||
content.clone(),
|
content.clone(),
|
||||||
@@ -633,6 +662,24 @@ async fn exec_read_file(
|
|||||||
Ok((content, blocks))
|
Ok((content, blocks))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Slice a file's contents the same way ACP's `fs/read_text_file`
|
||||||
|
/// does (1-based line, optional line count). Used by the local-fs
|
||||||
|
/// fallback in `exec_read_file` so out-of-workspace reads honour
|
||||||
|
/// the same `line`/`limit` args the model passed.
|
||||||
|
fn apply_line_limit(content: &str, line: Option<u32>, limit: Option<u32>) -> String {
|
||||||
|
if line.is_none() && limit.is_none() {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
let start = line.unwrap_or(1).max(1) as usize - 1;
|
||||||
|
let count = limit.map(|l| l as usize).unwrap_or(usize::MAX);
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.skip(start)
|
||||||
|
.take(count)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
async fn exec_write_file(
|
async fn exec_write_file(
|
||||||
ops: &dyn ClientOps,
|
ops: &dyn ClientOps,
|
||||||
session_id: &SessionId,
|
session_id: &SessionId,
|
||||||
@@ -640,25 +687,22 @@ async fn exec_write_file(
|
|||||||
) -> Result<(String, Vec<ToolCallContent>), String> {
|
) -> Result<(String, Vec<ToolCallContent>), String> {
|
||||||
let args: WriteFileArgs =
|
let args: WriteFileArgs =
|
||||||
serde_json::from_value(args_value.clone()).map_err(|e| format!("write_file: {e}"))?;
|
serde_json::from_value(args_value.clone()).map_err(|e| format!("write_file: {e}"))?;
|
||||||
|
let path = expand_path(&args.path);
|
||||||
// Best-effort read of the existing file so Zed can render a diff.
|
// Best-effort read of the existing file so Zed can render a diff.
|
||||||
// Failure here just means we render the write as an additive diff
|
// Failure here just means we render the write as an additive diff
|
||||||
// — not a fatal error, the actual write below still runs.
|
// — not a fatal error, the actual write below still runs.
|
||||||
let old_text = ops
|
let old_text = ops
|
||||||
.read_text_file(session_id, args.path.clone(), None, None)
|
.read_text_file(session_id, path.clone(), None, None)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
ops.write_text_file(session_id, args.path.clone(), args.content.clone())
|
ops.write_text_file(session_id, path.clone(), args.content.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("write_file: {e:#}"))?;
|
.map_err(|e| format!("write_file: {e:#}"))?;
|
||||||
let mut diff = Diff::new(args.path.clone(), args.content.clone());
|
let mut diff = Diff::new(path.clone(), args.content.clone());
|
||||||
if let Some(old) = old_text {
|
if let Some(old) = old_text {
|
||||||
diff = diff.old_text(old);
|
diff = diff.old_text(old);
|
||||||
}
|
}
|
||||||
let summary = format!(
|
let summary = format!("wrote {} ({} bytes)", path.display(), args.content.len());
|
||||||
"wrote {} ({} bytes)",
|
|
||||||
args.path.display(),
|
|
||||||
args.content.len()
|
|
||||||
);
|
|
||||||
Ok((summary, vec![ToolCallContent::Diff(diff)]))
|
Ok((summary, vec![ToolCallContent::Diff(diff)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,41 +713,39 @@ async fn exec_edit_file(
|
|||||||
) -> Result<(String, Vec<ToolCallContent>), String> {
|
) -> Result<(String, Vec<ToolCallContent>), String> {
|
||||||
let args: EditFileArgs =
|
let args: EditFileArgs =
|
||||||
serde_json::from_value(args_value.clone()).map_err(|e| format!("edit_file: {e}"))?;
|
serde_json::from_value(args_value.clone()).map_err(|e| format!("edit_file: {e}"))?;
|
||||||
|
let path = expand_path(&args.path);
|
||||||
let original = ops
|
let original = ops
|
||||||
.read_text_file(session_id, args.path.clone(), None, None)
|
.read_text_file(session_id, path.clone(), None, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("edit_file: read {}: {e:#}", args.path.display()))?;
|
.map_err(|e| format!("edit_file: read {}: {e:#}", path.display()))?;
|
||||||
let occurrences = original.matches(args.old_text.as_str()).count();
|
let occurrences = original.matches(args.old_text.as_str()).count();
|
||||||
if occurrences == 0 {
|
if occurrences == 0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"edit_file: old_text not found in {}",
|
"edit_file: old_text not found in {}",
|
||||||
args.path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if occurrences > 1 {
|
if occurrences > 1 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"edit_file: old_text appears {occurrences} times in {} — make it unique",
|
"edit_file: old_text appears {occurrences} times in {} — make it unique",
|
||||||
args.path.display()
|
path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let new_content = original.replacen(args.old_text.as_str(), args.new_text.as_str(), 1);
|
let new_content = original.replacen(args.old_text.as_str(), args.new_text.as_str(), 1);
|
||||||
ops.write_text_file(session_id, args.path.clone(), new_content.clone())
|
ops.write_text_file(session_id, path.clone(), new_content.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("edit_file: write {}: {e:#}", args.path.display()))?;
|
.map_err(|e| format!("edit_file: write {}: {e:#}", path.display()))?;
|
||||||
let diff = Diff::new(args.path.clone(), new_content.clone()).old_text(original);
|
let diff = Diff::new(path.clone(), new_content.clone()).old_text(original);
|
||||||
let summary = format!(
|
let summary = format!("edited {} ({} bytes)", path.display(), new_content.len());
|
||||||
"edited {} ({} bytes)",
|
|
||||||
args.path.display(),
|
|
||||||
new_content.len()
|
|
||||||
);
|
|
||||||
Ok((summary, vec![ToolCallContent::Diff(diff)]))
|
Ok((summary, vec![ToolCallContent::Diff(diff)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exec_list_dir(args_value: &serde_json::Value) -> Result<(String, Vec<ToolCallContent>), String> {
|
fn exec_list_dir(args_value: &serde_json::Value) -> Result<(String, Vec<ToolCallContent>), String> {
|
||||||
let args: ListDirArgs =
|
let args: ListDirArgs =
|
||||||
serde_json::from_value(args_value.clone()).map_err(|e| format!("list_dir: {e}"))?;
|
serde_json::from_value(args_value.clone()).map_err(|e| format!("list_dir: {e}"))?;
|
||||||
let entries = std::fs::read_dir(&args.path)
|
let path = expand_path(&args.path);
|
||||||
.map_err(|e| format!("list_dir: read {}: {e}", args.path.display()))?;
|
let entries =
|
||||||
|
std::fs::read_dir(&path).map_err(|e| format!("list_dir: read {}: {e}", path.display()))?;
|
||||||
let mut lines: Vec<String> = Vec::new();
|
let mut lines: Vec<String> = Vec::new();
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
@@ -734,7 +776,15 @@ async fn exec_bash(
|
|||||||
) -> Result<(String, Vec<ToolCallContent>), String> {
|
) -> Result<(String, Vec<ToolCallContent>), String> {
|
||||||
let args: BashArgs =
|
let args: BashArgs =
|
||||||
serde_json::from_value(args_value.clone()).map_err(|e| format!("bash: {e}"))?;
|
serde_json::from_value(args_value.clone()).map_err(|e| format!("bash: {e}"))?;
|
||||||
let cwd = args.cwd.unwrap_or_else(|| session_cwd.to_path_buf());
|
// Expand the cwd if the model passed one; otherwise inherit the
|
||||||
|
// session cwd verbatim. Don't expand the command string — sh
|
||||||
|
// already handles `~` and `$HOME` inside the command line, and
|
||||||
|
// pre-expanding would break the more interesting cases
|
||||||
|
// (`echo ~`, `cd ~`, …).
|
||||||
|
let cwd = match args.cwd {
|
||||||
|
Some(c) => expand_path(&c),
|
||||||
|
None => session_cwd.to_path_buf(),
|
||||||
|
};
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
command = %args.command,
|
command = %args.command,
|
||||||
@@ -1261,4 +1311,148 @@ mod tests {
|
|||||||
"reads in plan mode must not prompt: {events:?}"
|
"reads in plan mode must not prompt: {events:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Path expansion + local read fallback ────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
// We must hold the env-mutation lock across the await — releasing
|
||||||
|
// it would let another test mutate HOME mid-dispatch and lose
|
||||||
|
// the very thing we're testing for. The clippy lint is the
|
||||||
|
// correct *default*; this is the documented exception.
|
||||||
|
#[allow(clippy::await_holding_lock)]
|
||||||
|
async fn read_file_expands_tilde_before_dispatch() {
|
||||||
|
// HOME mutation is process-global; serialise tests that
|
||||||
|
// touch it under a single std::sync::Mutex.
|
||||||
|
use std::sync::Mutex;
|
||||||
|
static LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
let _g = LOCK.lock().unwrap();
|
||||||
|
let prior = std::env::var("HOME").ok();
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("HOME", "/home/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
let fake = FakeClient::default();
|
||||||
|
// The fake's canned-read map is keyed on the expanded path,
|
||||||
|
// not the literal `~/...` — if expansion didn't happen the
|
||||||
|
// lookup would miss and ACP would error → fallback to
|
||||||
|
// local-fs (which also misses → final error). So a success
|
||||||
|
// path here proves expansion ran before dispatch.
|
||||||
|
fake.set_read(PathBuf::from("/home/me/notes.md"), Ok("body".into()));
|
||||||
|
let res = dispatch_tool_call(
|
||||||
|
&fake,
|
||||||
|
&sid(),
|
||||||
|
&mode_default(),
|
||||||
|
Path::new("/tmp"),
|
||||||
|
make_call(READ_FILE, json!({"path": "~/notes.md"})),
|
||||||
|
&CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
match prior {
|
||||||
|
Some(p) => std::env::set_var("HOME", p),
|
||||||
|
None => std::env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!res.is_error, "result: {}", res.content);
|
||||||
|
assert_eq!(res.content, "body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_file_falls_back_to_local_fs_when_acp_errors() {
|
||||||
|
// ACP read errors → local std::fs reads succeed for a file
|
||||||
|
// we control. Use a temp file under CARGO_TARGET_TMPDIR.
|
||||||
|
let tmpdir = std::env::var("CARGO_TARGET_TMPDIR")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
std::fs::create_dir_all(&tmpdir).unwrap();
|
||||||
|
let pid = std::process::id();
|
||||||
|
let target = tmpdir.join(format!("helexa-acp-fallback-{pid}.txt"));
|
||||||
|
std::fs::write(&target, "line 1\nline 2\nline 3\n").unwrap();
|
||||||
|
|
||||||
|
let fake = FakeClient::default();
|
||||||
|
// No canned read → ACP returns Err. Fallback path should
|
||||||
|
// pick up the local file.
|
||||||
|
let res = dispatch_tool_call(
|
||||||
|
&fake,
|
||||||
|
&sid(),
|
||||||
|
&mode_default(),
|
||||||
|
Path::new("/tmp"),
|
||||||
|
make_call(READ_FILE, json!({"path": target.to_str().unwrap()})),
|
||||||
|
&CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = std::fs::remove_file(&target);
|
||||||
|
assert!(!res.is_error, "expected fallback success: {}", res.content);
|
||||||
|
assert!(
|
||||||
|
res.content.contains("line 1") && res.content.contains("line 3"),
|
||||||
|
"unexpected fallback content: {}",
|
||||||
|
res.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_file_fallback_honours_line_and_limit() {
|
||||||
|
let tmpdir = std::env::var("CARGO_TARGET_TMPDIR")
|
||||||
|
.ok()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
std::fs::create_dir_all(&tmpdir).unwrap();
|
||||||
|
let pid = std::process::id();
|
||||||
|
let target = tmpdir.join(format!("helexa-acp-fallback-slice-{pid}.txt"));
|
||||||
|
std::fs::write(&target, "a\nb\nc\nd\ne\n").unwrap();
|
||||||
|
|
||||||
|
let fake = FakeClient::default();
|
||||||
|
let res = dispatch_tool_call(
|
||||||
|
&fake,
|
||||||
|
&sid(),
|
||||||
|
&mode_default(),
|
||||||
|
Path::new("/tmp"),
|
||||||
|
make_call(
|
||||||
|
READ_FILE,
|
||||||
|
json!({"path": target.to_str().unwrap(), "line": 2, "limit": 2}),
|
||||||
|
),
|
||||||
|
&CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = std::fs::remove_file(&target);
|
||||||
|
assert!(!res.is_error, "result: {}", res.content);
|
||||||
|
assert_eq!(res.content, "b\nc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_file_fallback_failure_surfaces_combined_error() {
|
||||||
|
let fake = FakeClient::default();
|
||||||
|
let res = dispatch_tool_call(
|
||||||
|
&fake,
|
||||||
|
&sid(),
|
||||||
|
&mode_default(),
|
||||||
|
Path::new("/tmp"),
|
||||||
|
make_call(
|
||||||
|
READ_FILE,
|
||||||
|
json!({"path": "/definitely/not/a/real/path/xyz"}),
|
||||||
|
),
|
||||||
|
&CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_error, "expected error: {}", res.content);
|
||||||
|
// The error message should cite BOTH the ACP failure and
|
||||||
|
// the local-fs failure so the model knows what happened.
|
||||||
|
assert!(
|
||||||
|
res.content.contains("local fallback also failed"),
|
||||||
|
"expected combined error message, got: {}",
|
||||||
|
res.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_line_limit_basic_slice() {
|
||||||
|
let body = "a\nb\nc\nd\ne";
|
||||||
|
assert_eq!(apply_line_limit(body, None, None), body);
|
||||||
|
assert_eq!(apply_line_limit(body, Some(1), Some(2)), "a\nb");
|
||||||
|
assert_eq!(apply_line_limit(body, Some(3), None), "c\nd\ne");
|
||||||
|
assert_eq!(apply_line_limit(body, None, Some(2)), "a\nb");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user