fix: update prompt tests for post-plugins-merge format

This commit is contained in:
YeonGyu-Kim
2026-04-01 18:52:23 +09:00
13 changed files with 3401 additions and 3598 deletions

View File

@@ -4,17 +4,16 @@ use std::process::Command;
use std::time::{Duration, Instant};
use api::{
read_base_url, ApiHttpClient, ContentBlockDelta, InputContentBlock, InputMessage,
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
ToolDefinition, ToolResultContentBlock,
max_tokens_for_model, resolve_model_alias, ContentBlockDelta, InputContentBlock, InputMessage,
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use plugins::{PluginManager, PluginManagerConfig, PluginTool};
use reqwest::blocking::Client;
use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock,
ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode,
PermissionPolicy, RuntimeConfig, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -56,239 +55,6 @@ pub struct ToolSpec {
pub required_permission: PermissionMode,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RegisteredTool {
pub definition: ToolDefinition,
pub required_permission: PermissionMode,
handler: RegisteredToolHandler,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
enum RegisteredToolHandler {
Builtin,
Plugin(PluginTool),
}
#[derive(Debug, Clone, PartialEq)]
pub struct GlobalToolRegistry {
entries: Vec<RegisteredTool>,
}
impl GlobalToolRegistry {
#[must_use]
pub fn builtin() -> Self {
Self {
entries: mvp_tool_specs()
.into_iter()
.map(|spec| RegisteredTool {
definition: ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
},
required_permission: spec.required_permission,
handler: RegisteredToolHandler::Builtin,
})
.collect(),
}
}
pub fn with_plugin_tools(plugin_tools: Vec<PluginTool>) -> Result<Self, String> {
let mut registry = Self::builtin();
let mut seen = registry
.entries
.iter()
.map(|entry| {
(
normalize_registry_tool_name(&entry.definition.name),
entry.definition.name.clone(),
)
})
.collect::<BTreeMap<_, _>>();
for tool in plugin_tools {
let normalized = normalize_registry_tool_name(&tool.definition().name);
if let Some(existing) = seen.get(&normalized) {
return Err(format!(
"plugin tool `{}` from `{}` conflicts with already-registered tool `{existing}`",
tool.definition().name,
tool.plugin_id()
));
}
seen.insert(normalized, tool.definition().name.clone());
registry.entries.push(RegisteredTool {
definition: ToolDefinition {
name: tool.definition().name.clone(),
description: tool.definition().description.clone(),
input_schema: tool.definition().input_schema.clone(),
},
required_permission: permission_mode_from_plugin_tool(tool.required_permission())?,
handler: RegisteredToolHandler::Plugin(tool),
});
}
Ok(registry)
}
#[must_use]
pub fn entries(&self) -> &[RegisteredTool] {
&self.entries
}
fn find_entry(&self, name: &str) -> Option<&RegisteredTool> {
let normalized = normalize_registry_tool_name(name);
self.entries.iter().find(|entry| {
normalize_registry_tool_name(entry.definition.name.as_str()) == normalized
})
}
#[must_use]
pub fn definitions(&self, allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolDefinition> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| entry.definition.clone())
.collect()
}
#[must_use]
pub fn permission_specs(
&self,
allowed_tools: Option<&BTreeSet<String>>,
) -> Vec<(String, PermissionMode)> {
self.entries
.iter()
.filter(|entry| {
allowed_tools.is_none_or(|allowed| allowed.contains(entry.definition.name.as_str()))
})
.map(|entry| (entry.definition.name.clone(), entry.required_permission))
.collect()
}
pub fn normalize_allowed_tools(
&self,
values: &[String],
) -> Result<Option<BTreeSet<String>>, String> {
if values.is_empty() {
return Ok(None);
}
let canonical_names = self
.entries
.iter()
.map(|entry| entry.definition.name.clone())
.collect::<Vec<_>>();
let mut name_map = canonical_names
.iter()
.map(|name| (normalize_registry_tool_name(name), name.clone()))
.collect::<BTreeMap<_, _>>();
for (alias, canonical) in [
("read", "read_file"),
("write", "write_file"),
("edit", "edit_file"),
("glob", "glob_search"),
("grep", "grep_search"),
] {
if canonical_names.iter().any(|name| name == canonical) {
name_map.insert(alias.to_string(), canonical.to_string());
}
}
let mut allowed = BTreeSet::new();
for value in values {
for token in value
.split(|ch: char| ch == ',' || ch.is_whitespace())
.filter(|token| !token.is_empty())
{
let normalized = normalize_registry_tool_name(token);
let canonical = name_map.get(&normalized).ok_or_else(|| {
format!(
"unsupported tool in --allowedTools: {token} (expected one of: {})",
canonical_names.join(", ")
)
})?;
allowed.insert(canonical.clone());
}
}
Ok(Some(allowed))
}
pub fn execute(&self, name: &str, input: &Value) -> Result<String, String> {
let entry = self
.find_entry(name)
.ok_or_else(|| format!("unsupported tool: {name}"))?;
match &entry.handler {
RegisteredToolHandler::Builtin => execute_tool(&entry.definition.name, input),
RegisteredToolHandler::Plugin(tool) => {
tool.execute(input).map_err(|error| error.to_string())
}
}
}
}
impl Default for GlobalToolRegistry {
fn default() -> Self {
Self::builtin()
}
}
fn normalize_registry_tool_name(value: &str) -> String {
let trimmed = value.trim();
let chars = trimmed.chars().collect::<Vec<_>>();
let mut normalized = String::new();
for (index, ch) in chars.iter().copied().enumerate() {
if matches!(ch, '-' | ' ' | '\t' | '\n') {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch == '_' {
if !normalized.ends_with('_') {
normalized.push('_');
}
continue;
}
if ch.is_uppercase() {
let prev = chars.get(index.wrapping_sub(1)).copied();
let next = chars.get(index + 1).copied();
let needs_separator = index > 0
&& !normalized.ends_with('_')
&& (prev.is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit())
|| (prev.is_some_and(char::is_uppercase)
&& next.is_some_and(char::is_lowercase)));
if needs_separator {
normalized.push('_');
}
normalized.extend(ch.to_lowercase());
continue;
}
normalized.push(ch.to_ascii_lowercase());
}
normalized.trim_matches('_').to_string()
}
fn permission_mode_from_plugin_tool(value: &str) -> Result<PermissionMode, String> {
match value {
"read-only" => Ok(PermissionMode::ReadOnly),
"workspace-write" => Ok(PermissionMode::WorkspaceWrite),
"danger-full-access" => Ok(PermissionMode::DangerFullAccess),
other => Err(format!(
"unsupported plugin tool permission `{other}` (expected read-only, workspace-write, or danger-full-access)"
)),
}
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn mvp_tool_specs() -> Vec<ToolSpec> {
@@ -557,7 +323,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "Config",
description: "Get or set Claw Code settings.",
description: "Get or set Claude Code settings.",
input_schema: json!({
"type": "object",
"properties": {
@@ -1542,11 +1308,6 @@ fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
}
if let Ok(home) = std::env::var("HOME") {
let home = std::path::PathBuf::from(home);
candidates.push(home.join(".agents").join("skills"));
candidates.push(home.join(".codex").join("skills"));
}
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
for root in candidates {
@@ -1698,22 +1459,20 @@ fn run_agent_job(job: &AgentJob) -> Result<(), String> {
fn build_agent_runtime(
job: &AgentJob,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, SubagentToolExecutor>, String> {
) -> Result<ConversationRuntime<ProviderRuntimeClient, SubagentToolExecutor>, String> {
let model = job
.manifest
.model
.clone()
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
let allowed_tools = job.allowed_tools.clone();
let tool_registry = current_tool_registry()?;
let api_client =
AnthropicRuntimeClient::new(model, allowed_tools.clone(), tool_registry.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools, tool_registry.clone());
let api_client = ProviderRuntimeClient::new(model, allowed_tools.clone())?;
let tool_executor = SubagentToolExecutor::new(allowed_tools);
Ok(ConversationRuntime::new(
Session::new(),
api_client,
tool_executor,
agent_permission_policy(&tool_registry),
agent_permission_policy(),
job.system_prompt.clone(),
))
}
@@ -1778,7 +1537,7 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
"SendUserMessage",
"PowerShell",
],
"claw-code-guide" => vec![
"claude-code-guide" => vec![
"read_file",
"glob_search",
"grep_search",
@@ -1822,12 +1581,10 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
tools.into_iter().map(str::to_string).collect()
}
fn agent_permission_policy(tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
tool_registry.permission_specs(None).into_iter().fold(
fn agent_permission_policy() -> PermissionPolicy {
mvp_tool_specs().into_iter().fold(
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|policy, (name, required_permission)| {
policy.with_tool_requirement(name, required_permission)
},
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
)
}
@@ -1878,39 +1635,39 @@ fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Optio
sections.join("")
}
struct AnthropicRuntimeClient {
struct ProviderRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiHttpClient,
client: ProviderClient,
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl AnthropicRuntimeClient {
fn new(
model: String,
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
) -> Result<Self, String> {
let client = ApiHttpClient::from_env()
.map_err(|error| error.to_string())?
.with_base_url(read_base_url());
impl ProviderRuntimeClient {
fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
let model = resolve_model_alias(&model).to_string();
let client = ProviderClient::from_model(&model).map_err(|error| error.to_string())?;
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
client,
model,
allowed_tools,
tool_registry,
})
}
}
impl ApiClient for AnthropicRuntimeClient {
impl ApiClient for ProviderRuntimeClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let tools = self.tool_registry.definitions(Some(&self.allowed_tools));
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
.into_iter()
.map(|spec| ToolDefinition {
name: spec.name.to_string(),
description: Some(spec.description.to_string()),
input_schema: spec.input_schema,
})
.collect::<Vec<_>>();
let message_request = MessageRequest {
model: self.model.clone(),
max_tokens: 32_000,
max_tokens: max_tokens_for_model(&self.model),
messages: convert_messages(&request.messages),
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
tools: (!tools.is_empty()).then_some(tools),
@@ -1925,7 +1682,7 @@ impl ApiClient for AnthropicRuntimeClient {
.await
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None;
let mut pending_tools: BTreeMap<u32, (String, String, String)> = BTreeMap::new();
let mut saw_stop = false;
while let Some(event) = stream
@@ -1936,14 +1693,15 @@ impl ApiClient for AnthropicRuntimeClient {
match event {
ApiStreamEvent::MessageStart(start) => {
for block in start.message.content {
push_output_block(block, &mut events, &mut pending_tool, true);
push_output_block(block, 0, &mut events, &mut pending_tools, true);
}
}
ApiStreamEvent::ContentBlockStart(start) => {
push_output_block(
start.content_block,
start.index,
&mut events,
&mut pending_tool,
&mut pending_tools,
true,
);
}
@@ -1954,15 +1712,13 @@ impl ApiClient for AnthropicRuntimeClient {
}
}
ContentBlockDelta::InputJsonDelta { partial_json } => {
if let Some((_, _, input)) = &mut pending_tool {
if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) {
input.push_str(&partial_json);
}
}
ContentBlockDelta::ThinkingDelta { .. }
| ContentBlockDelta::SignatureDelta { .. } => {}
},
ApiStreamEvent::ContentBlockStop(_) => {
if let Some((id, name, input)) = pending_tool.take() {
ApiStreamEvent::ContentBlockStop(stop) => {
if let Some((id, name, input)) = pending_tools.remove(&stop.index) {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
@@ -2012,82 +1768,32 @@ impl ApiClient for AnthropicRuntimeClient {
struct SubagentToolExecutor {
allowed_tools: BTreeSet<String>,
tool_registry: GlobalToolRegistry,
}
impl SubagentToolExecutor {
fn new(allowed_tools: BTreeSet<String>, tool_registry: GlobalToolRegistry) -> Self {
Self {
allowed_tools,
tool_registry,
}
fn new(allowed_tools: BTreeSet<String>) -> Self {
Self { allowed_tools }
}
}
impl ToolExecutor for SubagentToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
let entry = self
.tool_registry
.find_entry(tool_name)
.ok_or_else(|| ToolError::new(format!("unsupported tool: {tool_name}")))?;
if !self.allowed_tools.contains(entry.definition.name.as_str()) {
if !self.allowed_tools.contains(tool_name) {
return Err(ToolError::new(format!(
"tool `{tool_name}` is not enabled for this sub-agent"
)));
}
let value = serde_json::from_str(input)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
self.tool_registry
.execute(tool_name, &value)
.map_err(ToolError::new)
execute_tool(tool_name, &value).map_err(ToolError::new)
}
}
fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load().map_err(|error| error.to_string())?;
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let plugin_tools = plugin_manager
.aggregated_tools()
.map_err(|error| error.to_string())?;
GlobalToolRegistry::with_plugin_tools(plugin_tools)
}
fn build_plugin_manager(
cwd: &Path,
loader: &ConfigLoader,
runtime_config: &RuntimeConfig,
) -> PluginManager {
let plugin_settings = runtime_config.plugins();
let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
plugin_config.external_dirs = plugin_settings
.external_directories()
.iter()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
.collect();
plugin_config.install_root = plugin_settings
.install_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.registry_path = plugin_settings
.registry_path()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
plugin_config.bundled_root = plugin_settings
.bundled_root()
.map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
PluginManager::new(plugin_config)
}
fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else if value.starts_with('.') {
cwd.join(path)
} else {
config_home.join(path)
}
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
mvp_tool_specs()
.into_iter()
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
.collect()
}
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
@@ -2133,8 +1839,9 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
fn push_output_block(
block: OutputContentBlock,
block_index: u32,
events: &mut Vec<AssistantEvent>,
pending_tool: &mut Option<(String, String, String)>,
pending_tools: &mut BTreeMap<u32, (String, String, String)>,
streaming_tool_input: bool,
) {
match block {
@@ -2152,19 +1859,19 @@ fn push_output_block(
} else {
input.to_string()
};
*pending_tool = Some((id, name, initial_input));
pending_tools.insert(block_index, (id, name, initial_input));
}
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
}
}
fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
let mut events = Vec::new();
let mut pending_tool = None;
let mut pending_tools = BTreeMap::new();
for block in response.content {
push_output_block(block, &mut events, &mut pending_tool, false);
if let Some((id, name, input)) = pending_tool.take() {
for (index, block) in response.content.into_iter().enumerate() {
let index = u32::try_from(index).expect("response block index overflow");
push_output_block(block, index, &mut events, &mut pending_tools, false);
if let Some((id, name, input)) = pending_tools.remove(&index) {
events.push(AssistantEvent::ToolUse { id, name, input });
}
}
@@ -2382,7 +2089,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
"verification" | "verificationagent" | "verify" | "verifier" => {
String::from("Verification")
}
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claw-code-guide"),
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
"statusline" | "statuslinesetup" => String::from("statusline-setup"),
_ => trimmed.to_string(),
}
@@ -2882,16 +2589,16 @@ fn config_file_for_scope(scope: ConfigScope) -> Result<PathBuf, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
Ok(match scope {
ConfigScope::Global => config_home_dir()?.join("settings.json"),
ConfigScope::Settings => cwd.join(".claw").join("settings.local.json"),
ConfigScope::Settings => cwd.join(".claude").join("settings.local.json"),
})
}
fn config_home_dir() -> Result<PathBuf, String> {
if let Ok(path) = std::env::var("CLAW_CONFIG_HOME") {
if let Ok(path) = std::env::var("CLAUDE_CONFIG_HOME") {
return Ok(PathBuf::from(path));
}
let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
Ok(PathBuf::from(home).join(".claw"))
Ok(PathBuf::from(home).join(".claude"))
}
fn read_json_object(path: &Path) -> Result<serde_json::Map<String, Value>, String> {
@@ -3188,6 +2895,7 @@ fn parse_skill_description(contents: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
@@ -3200,13 +2908,10 @@ mod tests {
use super::{
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
response_to_events, AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
};
use api::{MessageResponse, OutputContentBlock, Usage};
use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission};
use runtime::{
ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session, ToolExecutor,
push_output_block, AgentInput, AgentJob, SubagentToolExecutor,
};
use api::OutputContentBlock;
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
use serde_json::json;
fn env_lock() -> &'static Mutex<()> {
@@ -3222,17 +2927,6 @@ mod tests {
std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
}
fn make_executable(path: &PathBuf) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("chmod");
}
}
#[test]
fn exposes_mvp_tools() {
let names = mvp_tool_specs()
@@ -3262,170 +2956,6 @@ mod tests {
assert!(error.contains("unsupported tool"));
}
#[test]
fn global_registry_registers_and_executes_plugin_tools() {
let script = temp_path("plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
"sh".to_string(),
vec![script.display().to_string()],
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let names = registry
.definitions(None)
.into_iter()
.map(|definition| definition.name)
.collect::<Vec<_>>();
assert!(names.contains(&"bash".to_string()));
assert!(names.contains(&"plugin_echo".to_string()));
let output = registry
.execute("plugin_echo", &json!({ "message": "hello" }))
.expect("plugin tool should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["plugin"], "demo@external");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_normalizes_plugin_tool_names_for_allowlists_and_execution() {
let script = temp_path("plugin-tool-normalized.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let allowed = registry
.normalize_allowed_tools(&[String::from("PLUGIN-ECHO")])
.expect("plugin tool allowlist should normalize")
.expect("allowlist should be present");
assert!(allowed.contains("plugin_echo"));
let output = registry
.execute("plugin-echo", &json!({ "message": "hello" }))
.expect("normalized plugin tool name should execute");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let builtin_output = GlobalToolRegistry::builtin()
.execute("structured-output", &json!({ "ok": true }))
.expect("normalized builtin tool name should execute");
let builtin_payload: serde_json::Value =
serde_json::from_str(&builtin_output).expect("valid json");
assert_eq!(builtin_payload["structured_output"]["ok"], true);
let _ = std::fs::remove_file(script);
}
#[test]
fn subagent_executor_executes_allowed_plugin_tools() {
let script = temp_path("subagent-plugin-tool.sh");
std::fs::write(
&script,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("write script");
make_executable(&script);
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "plugin_echo".to_string(),
description: Some("Echo plugin input".to_string()),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": false
}),
},
script.display().to_string(),
Vec::new(),
PluginToolPermission::WorkspaceWrite,
script.parent().map(PathBuf::from),
)])
.expect("registry should build");
let mut executor =
SubagentToolExecutor::new(BTreeSet::from([String::from("plugin_echo")]), registry);
let output = executor
.execute("plugin-echo", r#"{"message":"hello"}"#)
.expect("plugin tool should execute for subagent");
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
assert_eq!(payload["tool"], "plugin_echo");
assert_eq!(payload["input"]["message"], "hello");
let _ = std::fs::remove_file(script);
}
#[test]
fn global_registry_rejects_conflicting_plugin_tool_names() {
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
"demo@external",
"demo",
PluginToolDefinition {
name: "read-file".to_string(),
description: Some("Conflicts with builtin".to_string()),
input_schema: json!({ "type": "object" }),
},
"echo".to_string(),
Vec::new(),
PluginToolPermission::ReadOnly,
None,
)])
.expect_err("conflicting plugin tool should fail");
assert!(error.contains("conflicts with already-registered tool `read_file`"));
}
#[test]
fn web_fetch_returns_prompt_aware_summary() {
let server = TestServer::spawn(Arc::new(|request_line: &str| {
@@ -3595,6 +3125,63 @@ mod tests {
assert!(error.contains("relative URL without a base") || error.contains("empty host"));
}
#[test]
fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() {
let mut events = Vec::new();
let mut pending_tools = BTreeMap::new();
push_output_block(
OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({}),
},
1,
&mut events,
&mut pending_tools,
true,
);
push_output_block(
OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
name: "grep_search".to_string(),
input: json!({}),
},
2,
&mut events,
&mut pending_tools,
true,
);
pending_tools
.get_mut(&1)
.expect("first tool pending")
.2
.push_str("{\"path\":\"src/main.rs\"}");
pending_tools
.get_mut(&2)
.expect("second tool pending")
.2
.push_str("{\"pattern\":\"TODO\"}");
assert_eq!(
pending_tools.remove(&1),
Some((
"tool-1".to_string(),
"read_file".to_string(),
"{\"path\":\"src/main.rs\"}".to_string(),
))
);
assert_eq!(
pending_tools.remove(&2),
Some((
"tool-2".to_string(),
"grep_search".to_string(),
"{\"pattern\":\"TODO\"}".to_string(),
))
);
}
#[test]
fn todo_write_persists_and_returns_previous_state() {
let _guard = env_lock()
@@ -4005,11 +3592,8 @@ mod tests {
calls: 0,
input_path: path.display().to_string(),
},
SubagentToolExecutor::new(
BTreeSet::from([String::from("read_file")]),
GlobalToolRegistry::builtin(),
),
agent_permission_policy(&GlobalToolRegistry::builtin()),
SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
agent_permission_policy(),
vec![String::from("system prompt")],
);
@@ -4035,42 +3619,6 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn response_to_events_ignores_thinking_blocks() {
let events = response_to_events(MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
thinking: "step 1".to_string(),
signature: Some("sig_123".to_string()),
},
OutputContentBlock::Text {
text: "Final answer".to_string(),
},
],
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
request_id: None,
});
assert!(matches!(
&events[0],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
assert!(!events
.iter()
.any(|event| matches!(event, AssistantEvent::ToolUse { .. })));
}
#[test]
fn agent_rejects_blank_required_fields() {
let missing_description = execute_tool(
@@ -4495,19 +4043,19 @@ mod tests {
));
let home = root.join("home");
let cwd = root.join("cwd");
std::fs::create_dir_all(home.join(".claw")).expect("home dir");
std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir");
std::fs::create_dir_all(home.join(".claude")).expect("home dir");
std::fs::create_dir_all(cwd.join(".claude")).expect("cwd dir");
std::fs::write(
home.join(".claw").join("settings.json"),
home.join(".claude").join("settings.json"),
r#"{"verbose":false}"#,
)
.expect("write global settings");
let original_home = std::env::var("HOME").ok();
let original_claw_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_claude_home = std::env::var("CLAUDE_CONFIG_HOME").ok();
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CLAUDE_CONFIG_HOME");
std::env::set_current_dir(&cwd).expect("set cwd");
let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config");
@@ -4540,9 +4088,9 @@ mod tests {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_claw_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
match original_claude_home {
Some(value) => std::env::set_var("CLAUDE_CONFIG_HOME", value),
None => std::env::remove_var("CLAUDE_CONFIG_HOME"),
}
let _ = std::fs::remove_dir_all(root);
}