use std::{fs, path::PathBuf, sync::Arc};
use super::*;
use saya_agent::{LocalStateEffect, ToolError, ToolExecutor};
use saya_harness::workspace::Workspace;
use super::database_tools::WORKSPACE_READ_MAX_BYTES;
struct Sandbox {
outer: PathBuf,
ws: Workspace,
}
impl Sandbox {
fn new(label: &str) -> Self {
let outer =
std::env::temp_dir().join(format!("saya-wsread-{label}-{}", std::process::id()));
let _ = fs::remove_dir_all(&outer);
fs::create_dir_all(outer.join("ws")).expect("sandbox directory must create");
let ws = Workspace::open(&outer.join("ws")).expect("workspace root must open");
Self { outer, ws }
}
fn tools(&self) -> DatabaseTools {
DatabaseTools::with_registry(
crate::connection::ConnectionRegistry::new("primary"),
100,
true,
None,
)
.with_workspace(Some(Arc::new(self.ws.clone())))
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.outer);
}
}
#[tokio::test]
async fn workspace_read_returns_the_contained_content() {
let sandbox = Sandbox::new("content");
sandbox
.ws
.write("notes/summary.md", b"hello workspace")
.expect("contained write must succeed");
let tools = sandbox.tools();
let result = tools
.execute(
"workspace_read",
serde_json::json!({"path": "notes/summary.md"}),
)
.await
.expect("a contained read must succeed");
assert_eq!(result["path"], "notes/summary.md");
assert_eq!(result["content"], "hello workspace");
assert_eq!(result["size"].as_u64(), Some(15));
assert_eq!(result["truncated"], serde_json::Value::Bool(false));
}
#[tokio::test]
async fn workspace_read_surfaces_a_path_outside_the_workspace_as_a_typed_error() {
let sandbox = Sandbox::new("escape");
fs::write(sandbox.outer.join("outside.txt"), b"secret").expect("plant must succeed");
let tools = sandbox.tools();
let error = tools
.execute(
"workspace_read",
serde_json::json!({"path": "../outside.txt"}),
)
.await
.expect_err("a path outside the workspace must be refused, not served");
match error {
ToolError::Workspace(message) => {
assert!(
message.contains("escapes the run workspace"),
"the refusal must name the containment failure: {message}"
);
assert!(
message.contains("../outside.txt"),
"the refusal must name the refused path: {message}"
);
}
other => panic!("expected a typed workspace error, got: {other}"),
}
}
#[tokio::test]
async fn workspace_read_truncates_at_the_byte_bound_and_reports_it() {
let sandbox = Sandbox::new("truncate");
let total = WORKSPACE_READ_MAX_BYTES + 16;
sandbox
.ws
.write("big.bin", &vec![b'a'; total as usize])
.expect("contained write must succeed");
let tools = sandbox.tools();
let result = tools
.execute("workspace_read", serde_json::json!({"path": "big.bin"}))
.await
.expect("a bounded read must succeed");
assert_eq!(result["truncated"], serde_json::Value::Bool(true));
assert_eq!(result["size"].as_u64(), Some(total));
let content = result["content"]
.as_str()
.expect("content must be a string");
assert_eq!(
content.len() as u64,
WORKSPACE_READ_MAX_BYTES,
"content is capped at exactly the bound"
);
assert!(content.bytes().all(|byte| byte == b'a'));
}
#[tokio::test]
async fn workspace_read_denies_when_no_workspace_is_attached() {
let tools = DatabaseTools::with_registry(
crate::connection::ConnectionRegistry::new("primary"),
100,
true,
None,
);
let error = tools
.execute("workspace_read", serde_json::json!({"path": "notes.md"}))
.await
.expect_err("no workspace, no read");
assert_eq!(error, ToolError::WorkspaceUnavailable);
assert!(
error.to_string().contains("no workspace is available"),
"the denial must be readable by the model: {error}"
);
}
#[test]
fn workspace_read_definition_is_read_shaped_and_always_advertised() {
let tools = DatabaseTools::definitions(false, false, false, false, false);
let tool = tools
.iter()
.find(|tool| tool.name == "workspace_read")
.expect("workspace_read is advertised even with the data gate closed");
assert!(tool.read_only);
assert!(!tool.effect.database_data);
assert!(!tool.effect.external_side_effect);
assert!(!tool.effect.requires_approval);
assert_eq!(tool.effect.local_state, LocalStateEffect::Read);
assert_eq!(tool.parameters["required"], serde_json::json!(["path"]));
assert_eq!(tool.completion.as_deref(), Some("workspace file read"));
}
#[tokio::test]
async fn workspace_read_rejects_a_non_string_path() {
let tools = DatabaseTools::with_registry(
crate::connection::ConnectionRegistry::new("primary"),
100,
true,
None,
);
let error = tools
.execute("workspace_read", serde_json::json!({"path": 7}))
.await
.expect_err("a non-string path must be rejected at validation");
assert_eq!(error, ToolError::PathNotString);
}
#[tokio::test]
async fn workspace_read_rejects_unknown_arguments() {
let tools = DatabaseTools::with_registry(
crate::connection::ConnectionRegistry::new("primary"),
100,
true,
None,
);
let error = tools
.execute(
"workspace_read",
serde_json::json!({"path": "notes.md", "sql": "SELECT 1"}),
)
.await
.expect_err("unknown arguments must be rejected");
assert_eq!(error, ToolError::UnsupportedProperty);
}