#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::path::{Path, PathBuf};
use motosan_agent_loop::{AssistantContent, Message, StoredEntry};
use crate::error::Result;
use crate::tools::ToolCtx;
const FS_TOOL_NAMES: &[&str] = &["read", "write", "edit"];
pub async fn hydrate_read_files(entries: &[StoredEntry], ctx: &ToolCtx) -> Result<()> {
for stored in entries {
let Some(message) = stored.entry.as_message() else {
continue;
};
let assistant_parts = match message {
Message::Assistant { content, .. } => content,
_ => continue,
};
for part in assistant_parts {
let AssistantContent::ToolCall { call } = part else {
continue;
};
if !FS_TOOL_NAMES.contains(&call.name.as_str()) {
continue;
}
let Some(path_str) = call.args.get("path").and_then(|v| v.as_str()) else {
continue;
};
let abs = resolve_relative(path_str, &ctx.cwd);
let key = match tokio::fs::canonicalize(&abs).await {
Ok(canonical) => canonical,
Err(_) => abs,
};
ctx.mark_read(&key).await;
}
}
Ok(())
}
fn resolve_relative(path: &str, cwd: &Path) -> PathBuf {
let p = PathBuf::from(path);
if p.is_absolute() {
p
} else {
cwd.join(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use motosan_agent_loop::{Message, SessionEntry, ToolCallRef};
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn assistant_with_tool_call(name: &str, path: &str) -> Message {
Message::assistant_with_tool_call(
"",
ToolCallRef {
id: "t1".into(),
name: name.into(),
args: serde_json::json!({ "path": path }),
},
)
}
fn fresh_ctx(cwd: &Path) -> ToolCtx {
let (tx, _rx) = mpsc::channel(8);
ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx)
}
#[tokio::test]
async fn hydrates_read_tool_calls_into_read_files() {
let dir = tempdir().unwrap();
let file = dir.path().join("foo.rs");
tokio::fs::write(&file, "x").await.unwrap();
let ctx = fresh_ctx(dir.path());
let entries = vec![StoredEntry {
id: "e1".to_string(),
parent_id: None,
entry: SessionEntry::message(assistant_with_tool_call("read", "foo.rs")),
}];
assert!(!ctx.has_been_read(&file).await);
hydrate_read_files(&entries, &ctx).await.unwrap();
let canonical = tokio::fs::canonicalize(&file).await.unwrap();
assert!(ctx.has_been_read(&canonical).await);
}
#[tokio::test]
async fn ignores_non_fs_tool_calls() {
let dir = tempdir().unwrap();
let ctx = fresh_ctx(dir.path());
let entries = vec![StoredEntry {
id: "e1".to_string(),
parent_id: None,
entry: SessionEntry::message(assistant_with_tool_call("bash", "echo")),
}];
hydrate_read_files(&entries, &ctx).await.unwrap();
let read_count = ctx.read_files.lock().await.len();
assert_eq!(read_count, 0);
}
#[tokio::test]
async fn hydrates_write_and_edit_alongside_read() {
let dir = tempdir().unwrap();
let f1 = dir.path().join("a.txt");
let f2 = dir.path().join("b.txt");
tokio::fs::write(&f1, "1").await.unwrap();
tokio::fs::write(&f2, "2").await.unwrap();
let ctx = fresh_ctx(dir.path());
let entries = vec![
StoredEntry {
id: "e1".into(),
parent_id: None,
entry: SessionEntry::message(assistant_with_tool_call("write", "a.txt")),
},
StoredEntry {
id: "e2".into(),
parent_id: None,
entry: SessionEntry::message(assistant_with_tool_call("edit", "b.txt")),
},
];
hydrate_read_files(&entries, &ctx).await.unwrap();
let c1 = tokio::fs::canonicalize(&f1).await.unwrap();
let c2 = tokio::fs::canonicalize(&f2).await.unwrap();
assert!(ctx.has_been_read(&c1).await);
assert!(ctx.has_been_read(&c2).await);
}
#[tokio::test]
async fn falls_back_to_abs_path_when_file_missing() {
let dir = tempdir().unwrap();
let ctx = fresh_ctx(dir.path());
let entries = vec![StoredEntry {
id: "e1".into(),
parent_id: None,
entry: SessionEntry::message(assistant_with_tool_call("read", "ghost.txt")),
}];
hydrate_read_files(&entries, &ctx).await.unwrap();
let abs = dir.path().join("ghost.txt");
assert!(ctx.has_been_read(&abs).await);
}
}