use super::*;
struct PanickingTool;
struct ApprovalRequiredTool;
struct InterruptibleTool {
name: &'static str,
interruptible: bool,
}
impl Tool for InterruptibleTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name.into(),
description: String::new(),
parameters: serde_json::json!({}),
}
}
fn interrupt_on_active_input(&self) -> bool {
self.interruptible
}
fn call<'a>(
&'a self,
_context: ToolContext,
_arguments: Value,
) -> BoxFuture<'a, Result<String>> {
Box::pin(async { Ok(String::new()) })
}
}
impl Tool for PanickingTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "panicking".into(),
description: String::new(),
parameters: serde_json::json!({}),
}
}
fn execution_mode(&self) -> ExecutionMode {
ExecutionMode::Parallel
}
fn call<'a>(
&'a self,
_context: ToolContext,
_arguments: Value,
) -> BoxFuture<'a, Result<String>> {
panic!("intentional tool panic")
}
}
impl Tool for ApprovalRequiredTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "approval_required".into(),
description: String::new(),
parameters: serde_json::json!({}),
}
}
fn approval(&self) -> ApprovalRequirement {
ApprovalRequirement::Always
}
fn call<'a>(
&'a self,
_context: ToolContext,
_arguments: Value,
) -> BoxFuture<'a, Result<String>> {
Box::pin(async { Ok("executed".into()) })
}
}
#[tokio::test]
async fn parallel_tool_panic_preserves_call_identity() {
let mut catalog = Catalog::default();
catalog
.register(Arc::new(PanickingTool))
.expect("register tool");
let calls = [ToolCall {
call_id: "call-1".into(),
name: "panicking".into(),
arguments: serde_json::json!({}),
}];
let backend =
Arc::new(crate::backend::sandbox::local::LocalSandbox::new(".").expect("local sandbox"));
let sandbox = Arc::new(crate::backend::sandbox::Sandbox::new(
backend,
crate::backend::sandbox::ApprovalPolicy::Ask,
));
let permissions = SandboxPermissions::restore(
"session",
crate::backend::sandbox::NetworkAccess::Denied,
Vec::new(),
);
assert_eq!(
execute_batch(&catalog, &calls, sandbox, &permissions).await,
vec![ToolResult {
call_id: "call-1".into(),
name: "panicking".into(),
output: "tool panicked".into(),
is_error: true,
}]
);
}
#[tokio::test]
async fn approval_required_handler_cannot_run_without_exact_call_authority() {
let mut catalog = Catalog::default();
catalog
.register(Arc::new(ApprovalRequiredTool))
.expect("register tool");
let calls = [ToolCall {
call_id: "blocked".into(),
name: "approval_required".into(),
arguments: serde_json::json!({}),
}];
let sandbox = Arc::new(crate::backend::sandbox::Sandbox::new(
Arc::new(crate::backend::sandbox::local::LocalSandbox::new(".").expect("sandbox")),
crate::backend::sandbox::ApprovalPolicy::Ask,
));
let permissions = SandboxPermissions::restore(
"session",
crate::backend::sandbox::NetworkAccess::Allowed,
["different-call".into()],
);
let result = execute_batch(&catalog, &calls, sandbox, &permissions)
.await
.pop()
.expect("tool result");
assert!(result.is_error);
assert_eq!(result.output, "tool call is not authorized to mutate state");
}
#[test]
fn only_wholly_interruptible_batches_stop_for_active_input() {
let mut catalog = Catalog::default();
for (name, interruptible) in [("wait", true), ("write", false)] {
catalog
.register(Arc::new(InterruptibleTool {
name,
interruptible,
}))
.expect("register tool");
}
let call = |name: &str| ToolCall {
call_id: name.into(),
name: name.into(),
arguments: serde_json::json!({}),
};
assert!(catalog.interrupts_on_active_input(&[call("wait")]));
assert!(!catalog.interrupts_on_active_input(&[call("wait"), call("write"),]));
assert!(!catalog.interrupts_on_active_input(&[]));
}
async fn rejected_patch(content: &str, patch: &str) -> String {
let workspace = tempfile::tempdir().expect("workspace");
std::fs::write(workspace.path().join("note.txt"), content).expect("write fixture");
let context = ToolContext {
sandbox: Arc::new(Sandbox::new(
Arc::new(
crate::backend::sandbox::local::LocalSandbox::new(workspace.path())
.expect("local sandbox"),
),
crate::backend::sandbox::ApprovalPolicy::Ask,
)),
permissions: SandboxPermissions::restore(
"session",
crate::backend::sandbox::NetworkAccess::Denied,
["patch".into()],
)
.for_call("patch"),
};
ApplyPatch
.call(
context,
serde_json::json!({"path": "note.txt", "patch": patch}),
)
.await
.expect_err("patch rejection")
.to_string()
}
#[tokio::test]
async fn apply_patch_returns_a_unified_diff_after_writing() {
let workspace = tempfile::tempdir().expect("workspace");
let path = workspace.path().join("note.txt");
std::fs::write(&path, "first\nold\nlast\n").expect("write fixture");
let mut catalog = Catalog::default();
catalog
.register(Arc::new(ApplyPatch))
.expect("register patch tool");
let sandbox = Arc::new(Sandbox::new(
Arc::new(
crate::backend::sandbox::local::LocalSandbox::new(workspace.path())
.expect("local sandbox"),
),
crate::backend::sandbox::ApprovalPolicy::Ask,
));
let permissions = SandboxPermissions::restore(
"session",
crate::backend::sandbox::NetworkAccess::Denied,
["call-1".into(), "call-2".into()],
);
let result = execute_batch(
&catalog,
&[ToolCall {
call_id: "call-1".into(),
name: "apply_patch".into(),
arguments: serde_json::json!({
"path": "note.txt",
"patch": "--- ignored.txt\n+++ ignored.txt\n@@ -1,3 +1,3 @@\n first\n-old\n+new\n last\n"
}),
}],
Arc::clone(&sandbox),
&permissions,
)
.await
.pop()
.expect("tool result");
assert!(!result.is_error, "{}", result.output);
let patch = diffy::Patch::from_str(&result.output).expect("unified diff");
assert_eq!(
(patch.original(), patch.modified()),
(Some("note.txt"), Some("note.txt"))
);
assert!(result.output.contains("-old\n+new\n"));
assert_eq!(
diffy::apply("first\nold\nlast\n", &patch).expect("apply generated patch"),
"first\nnew\nlast\n"
);
assert_eq!(
std::fs::read_to_string(path).expect("read edited file"),
"first\nnew\nlast\n"
);
let no_op = execute_batch(
&catalog,
&[ToolCall {
call_id: "call-2".into(),
name: "apply_patch".into(),
arguments: serde_json::json!({
"path": "note.txt",
"patch": "--- ignored.txt\n+++ ignored.txt\n@@ -1,3 +1,3 @@\n first\n-new\n+new\n last\n"
}),
}],
sandbox,
&permissions,
)
.await
.pop()
.expect("no-op result");
assert!(no_op.is_error);
assert_eq!(
no_op.output,
"tool error: Patch rejected: patch applies but makes no changes."
);
assert_eq!(
std::fs::read_to_string(workspace.path().join("note.txt")).expect("read unchanged file"),
"first\nnew\nlast\n"
);
}
#[tokio::test]
async fn apply_patch_reports_failed_context_and_nearest_line() {
let context = "Project Prometheus is a cloud-hosted modeling workspace.";
let content = format!("{}{context}\nactual next line\n", "padding\n".repeat(437));
let error = rejected_patch(
&content,
&format!(
"--- ignored\n+++ ignored\n@@ -432,2 +432,2 @@\n {context}\n-expected next line\n+replacement\n"
),
)
.await;
assert_eq!(
error,
"tool error: Patch rejected: no hunks matched the file.\n\
Failed hunk starts with context:\n\
\"Project Prometheus is a cloud-hosted modeling workspace.\"\n\
The nearest match is at line 438."
);
}
#[tokio::test]
async fn apply_patch_reports_malformed_hunk_header_counts() {
let content = "line\n".repeat(441);
let error = rejected_patch(
&content,
"--- ignored\n+++ ignored\n@@ -432,3 +432,4 @@\n line\n-old\n+new\n",
)
.await;
assert_eq!(
error,
"tool error: Patch rejected: malformed unified diff.\n\
Reason: error parsing patch: Hunk header does not match hunk.\n\
Expected hunk header format: @@ -start[,count] +start[,count] @@\n\
Received: @@ -432,3 +432,4 @@\n\
Actual file has 441 lines."
);
}
#[tokio::test]
async fn apply_patch_rejects_input_without_hunks_as_malformed() {
let error = rejected_patch("line\n", "@@-1 +1@@\n-old\n+new\n").await;
assert_eq!(
error,
"tool error: Patch rejected: malformed unified diff.\n\
Reason: no hunk headers were found.\n\
Expected hunk header format: @@ -start[,count] +start[,count] @@\n\
Received: @@-1 +1@@\n\
Actual file has 1 lines."
);
}
#[test]
fn apply_patch_rejects_pathological_fuzzy_matching() {
let content = "same\n".repeat(20_000);
let mut patch = String::from("--- ignored\n+++ ignored\n@@ -1,1000 +1,1000 @@\n");
patch.push_str(&" same\n".repeat(999));
patch.push_str("-same\n+changed\n");
let patch = Patch::from_str(&patch).expect("patch");
assert!(validate_patch_complexity(&content, &patch).is_err());
}
#[tokio::test]
async fn apply_patch_cannot_make_a_file_unreadable() {
let workspace = tempfile::tempdir().expect("workspace");
let path = workspace.path().join("full.txt");
std::fs::write(&path, "x".repeat(crate::backend::sandbox::MAX_FILE_BYTES))
.expect("write fixture");
let sandbox = Arc::new(Sandbox::new(
Arc::new(
crate::backend::sandbox::local::LocalSandbox::new(workspace.path())
.expect("local sandbox"),
),
crate::backend::sandbox::ApprovalPolicy::Ask,
));
let context = ToolContext {
sandbox,
permissions: SandboxPermissions::restore(
"session",
crate::backend::sandbox::NetworkAccess::Denied,
["patch".into()],
)
.for_call("patch"),
};
let error = ApplyPatch
.call(
context,
serde_json::json!({
"path": "full.txt",
"patch": "--- ignored\n+++ ignored\n@@ -0,0 +1 @@\n+y\n"
}),
)
.await
.expect_err("oversized result");
assert!(error.to_string().contains("write limit"));
assert_eq!(
std::fs::metadata(path).expect("metadata").len(),
1024 * 1024
);
}
#[test]
fn tools_do_not_claim_footer_space() {
assert!(Tools::coding().frontend().widgets.is_empty());
}
#[test]
fn coding_renderer_preserves_patch_diff_blocks() {
let diff = "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n";
let block = Tools::coding()
.render(
&EventMsg::ToolCallEnd(crate::protocol::ToolCallEndEvent {
turn_id: "turn".into(),
call_id: "call".into(),
name: "apply_patch".into(),
output: diff.into(),
is_error: false,
}),
"session",
)
.expect("patch rendering");
assert_eq!(block.format, FrontendBlockFormat::UnifiedDiff);
assert!(!block.append);
assert_eq!(block.text, diff);
}
#[test]
fn coding_renderer_groups_read_lifecycle() {
let tools = Tools::coding();
let begin = tools
.render(
&EventMsg::ToolCallBegin(crate::protocol::ToolCallBeginEvent {
turn_id: "turn".into(),
call_id: "call".into(),
name: "read_file".into(),
arguments: serde_json::json!({"path": "note.txt"}),
}),
"session",
)
.expect("read begin rendering");
let end = tools
.render(
&EventMsg::ToolCallEnd(crate::protocol::ToolCallEndEvent {
turn_id: "turn".into(),
call_id: "call".into(),
name: "read_file".into(),
output: "contents".into(),
is_error: false,
}),
"session",
)
.expect("read end rendering");
assert_eq!(begin.group.as_deref(), Some("read:turn"));
assert_eq!(end.group.as_deref(), Some("read:turn"));
}
#[test]
fn generic_tool_renderer_does_not_infer_coding_presentation() {
let diff = "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n";
let block = render_tool_event(
&EventMsg::ToolCallEnd(crate::protocol::ToolCallEndEvent {
turn_id: "turn".into(),
call_id: "call".into(),
name: "load_skill".into(),
output: diff.into(),
is_error: false,
}),
|name| name == "load_skill",
|_, _| String::new(),
)
.expect("generic rendering");
assert_eq!(block.format, FrontendBlockFormat::PlainText);
assert_eq!(block.group, None);
assert!(block.append);
}
#[test]
fn only_starting_a_background_command_requires_approval() {
let mut catalog = Catalog::default();
catalog
.register(Arc::new(StartCommand))
.expect("start command");
catalog
.register(Arc::new(PollCommand))
.expect("poll command");
catalog
.register(Arc::new(StopCommand))
.expect("stop command");
assert!(catalog.requires_approval("start_command"));
assert!(!catalog.requires_approval("poll_command"));
assert!(!catalog.requires_approval("stop_command"));
}
#[test]
fn background_output_remains_valid_json_at_its_limit() {
let rendered = background_output(BackgroundCommandPoll {
status: crate::backend::sandbox::BackgroundCommandStatus::Running,
exit_code: None,
stdout: "\0".repeat(6_000),
stderr: String::new(),
truncated: false,
error: Some("\0".repeat(512)),
});
assert!(rendered.len() <= MAX_TOOL_OUTPUT_BYTES);
let value: Value = serde_json::from_str(&rendered).expect("valid JSON");
assert_eq!(value["status"], "running");
}