use super::*;
struct PanickingTool;
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")
}
}
#[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::On,
));
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,
}]
);
}
#[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(&[]));
}
#[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::On,
));
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!(
std::fs::read_to_string(workspace.path().join("note.txt")).expect("read unchanged file"),
"first\nnew\nlast\n"
);
}
#[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::On,
));
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,
}))
.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"}),
},
))
.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,
}))
.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");
}