feat(plan): Add plan execution system with external tool support
Plan Execution System: - Add PlanStep, AccumulatedPlan types for multi-turn tool call accumulation - Implement AccumulatedPlanStatus for tracking plan lifecycle - Support selective approval of proposed tool calls before execution External Tools Integration: - Add ExternalToolDefinition and ExternalToolTransport to plugins crate - Extend ToolContext with external_tools registry - Add external_tool_to_llm_tool conversion for LLM compatibility JSON-RPC Communication: - Add jsonrpc crate for JSON-RPC 2.0 protocol support - Enable stdio-based communication with external tool servers UI & Engine Updates: - Add plan_panel.rs component for displaying accumulated plans - Wire plan mode into engine loop - Add plan mode integration tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ tools-ask = { path = "../../tools/ask" }
|
||||
tools-todo = { path = "../../tools/todo" }
|
||||
tools-web = { path = "../../tools/web" }
|
||||
tools-plan = { path = "../../tools/plan" }
|
||||
plugins = { path = "../../platform/plugins" }
|
||||
jsonrpc = { path = "../../integration/jsonrpc" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13"
|
||||
|
||||
@@ -14,7 +14,10 @@ use color_eyre::eyre::{Result, eyre};
|
||||
use futures_util::StreamExt;
|
||||
use llm_core::{ChatMessage, ChatOptions, LlmProvider, Tool, ToolParameters};
|
||||
use permissions::{PermissionDecision, PermissionManager, Tool as PermTool};
|
||||
use jsonrpc;
|
||||
use plugins::{ExternalToolDefinition, ExternalToolTransport};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
@@ -107,6 +110,9 @@ pub struct ToolContext {
|
||||
|
||||
/// Current agent mode (e.g., Normal or Planning).
|
||||
pub agent_mode: Arc<RwLock<AgentMode>>,
|
||||
|
||||
/// Registry of external tools from plugins
|
||||
pub external_tools: Arc<HashMap<String, ExternalToolDefinition>>,
|
||||
}
|
||||
|
||||
impl Default for ToolContext {
|
||||
@@ -117,6 +123,7 @@ impl Default for ToolContext {
|
||||
shell_manager: None,
|
||||
plan_manager: None,
|
||||
agent_mode: Arc::new(RwLock::new(AgentMode::Normal)),
|
||||
external_tools: Arc::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +178,22 @@ impl ToolContext {
|
||||
pub async fn set_mode(&self, mode: AgentMode) {
|
||||
*self.agent_mode.write().await = mode;
|
||||
}
|
||||
|
||||
/// Adds external tools from plugins to the context.
|
||||
pub fn with_external_tools(mut self, tools: HashMap<String, ExternalToolDefinition>) -> Self {
|
||||
self.external_tools = Arc::new(tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets an external tool by name, if it exists.
|
||||
pub fn get_external_tool(&self, name: &str) -> Option<&ExternalToolDefinition> {
|
||||
self.external_tools.get(name)
|
||||
}
|
||||
|
||||
/// Returns true if an external tool with the given name exists.
|
||||
pub fn has_external_tool(&self, name: &str) -> bool {
|
||||
self.external_tools.contains_key(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns definitions for all available tools that the agent can use.
|
||||
@@ -430,6 +453,39 @@ pub fn get_tool_definitions() -> Vec<Tool> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Converts an external tool definition to an LLM-compatible Tool definition.
|
||||
fn external_tool_to_llm_tool(ext: &ExternalToolDefinition) -> Tool {
|
||||
// Convert ExternalToolSchema to the JSON format expected by ToolParameters
|
||||
let properties: serde_json::Map<String, Value> = ext.input_schema.properties
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
Tool::function(
|
||||
&ext.name,
|
||||
&ext.description,
|
||||
ToolParameters::object(
|
||||
Value::Object(properties),
|
||||
ext.input_schema.required.clone(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns all tool definitions including external tools from plugins.
|
||||
///
|
||||
/// This function merges the built-in tools with any external tools
|
||||
/// registered in the ToolContext.
|
||||
pub fn get_tool_definitions_with_external(ctx: &ToolContext) -> Vec<Tool> {
|
||||
let mut tools = get_tool_definitions();
|
||||
|
||||
// Add external tools from plugins
|
||||
for ext_tool in ctx.external_tools.values() {
|
||||
tools.push(external_tool_to_llm_tool(ext_tool));
|
||||
}
|
||||
|
||||
tools
|
||||
}
|
||||
|
||||
/// Executes a single tool call and returns its result as a string.
|
||||
///
|
||||
/// This function handles permission checking and interacts with various tool-specific
|
||||
@@ -821,7 +877,64 @@ pub async fn execute_tool(
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Err(eyre!("Unknown tool: {}", tool_name)),
|
||||
// Check for external tools from plugins
|
||||
_ => {
|
||||
// Look up the tool in the external tools registry
|
||||
if let Some(ext_tool) = ctx.get_external_tool(tool_name) {
|
||||
// Check permission for external tool execution
|
||||
// External tools require explicit permission (treated like Bash)
|
||||
match perms.check(PermTool::Bash, Some(&format!("external:{}", tool_name))) {
|
||||
PermissionDecision::Allow => {
|
||||
execute_external_tool(ext_tool, arguments).await
|
||||
}
|
||||
PermissionDecision::Ask => {
|
||||
Err(eyre!("Permission required: External tool '{}' needs approval", tool_name))
|
||||
}
|
||||
PermissionDecision::Deny => {
|
||||
Err(eyre!("Permission denied: External tool '{}' is blocked", tool_name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(eyre!("Unknown tool: {}", tool_name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes an external tool via JSON-RPC.
|
||||
async fn execute_external_tool(
|
||||
tool: &ExternalToolDefinition,
|
||||
arguments: &Value,
|
||||
) -> Result<String> {
|
||||
match &tool.transport {
|
||||
ExternalToolTransport::Stdio => {
|
||||
// Get command and args
|
||||
let command = tool.command.as_ref()
|
||||
.ok_or_else(|| eyre!("Stdio tool '{}' missing command", tool.name))?;
|
||||
|
||||
// Spawn the process and call via JSON-RPC
|
||||
let executor = jsonrpc::ToolExecutor::with_timeout(tool.timeout_ms);
|
||||
let result = executor.execute_stdio(
|
||||
command,
|
||||
&tool.args,
|
||||
&std::collections::HashMap::new(), // TODO: env vars from plugin
|
||||
arguments.clone(),
|
||||
Some(tool.timeout_ms),
|
||||
).await?;
|
||||
|
||||
// Convert JSON result to string
|
||||
match result {
|
||||
Value::String(s) => Ok(s),
|
||||
other => Ok(serde_json::to_string_pretty(&other)?),
|
||||
}
|
||||
}
|
||||
ExternalToolTransport::Http => {
|
||||
let url = tool.url.as_ref()
|
||||
.ok_or_else(|| eyre!("HTTP tool '{}' missing url", tool.name))?;
|
||||
|
||||
// HTTP transport not yet implemented
|
||||
Err(eyre!("HTTP transport for tool '{}' at {} is not yet implemented", tool.name, url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -335,3 +335,59 @@ impl CheckpointManager {
|
||||
Ok(restored_files)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to accumulate streaming tool call deltas
|
||||
#[derive(Default)]
|
||||
pub struct ToolCallsBuilder {
|
||||
calls: Vec<PartialToolCallBuilder>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PartialToolCallBuilder {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
impl ToolCallsBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_deltas(&mut self, deltas: &[llm_core::ToolCallDelta]) {
|
||||
for delta in deltas {
|
||||
while self.calls.len() <= delta.index {
|
||||
self.calls.push(PartialToolCallBuilder::default());
|
||||
}
|
||||
let call = &mut self.calls[delta.index];
|
||||
if let Some(id) = &delta.id {
|
||||
call.id = Some(id.clone());
|
||||
}
|
||||
if let Some(name) = &delta.function_name {
|
||||
call.name = Some(name.clone());
|
||||
}
|
||||
if let Some(args) = &delta.arguments_delta {
|
||||
call.arguments.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(self) -> Vec<llm_core::ToolCall> {
|
||||
self.calls
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let id = p.id?;
|
||||
let name = p.name?;
|
||||
let args: serde_json::Value = serde_json::from_str(&p.arguments).ok()?;
|
||||
Some(llm_core::ToolCall {
|
||||
id,
|
||||
call_type: "function".to_string(),
|
||||
function: llm_core::FunctionCall {
|
||||
name,
|
||||
arguments: args,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
200
crates/core/agent/tests/plan_mode.rs
Normal file
200
crates/core/agent/tests/plan_mode.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! Integration tests for Plan Mode multi-turn accumulation
|
||||
|
||||
use agent_core::{
|
||||
AccumulatedPlan, AccumulatedPlanStatus, PlanApproval, PlanStep,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// Test that PlanStep can be created and has correct initial state
|
||||
#[test]
|
||||
fn test_plan_step_creation() {
|
||||
let step = PlanStep::new(
|
||||
"call_123".to_string(),
|
||||
1,
|
||||
"read".to_string(),
|
||||
json!({"path": "/src/main.rs"}),
|
||||
);
|
||||
|
||||
assert_eq!(step.id, "call_123");
|
||||
assert_eq!(step.turn, 1);
|
||||
assert_eq!(step.tool, "read");
|
||||
assert!(step.is_pending());
|
||||
assert!(!step.is_approved());
|
||||
assert!(!step.is_rejected());
|
||||
}
|
||||
|
||||
/// Test multi-turn accumulation of steps
|
||||
#[test]
|
||||
fn test_multi_turn_accumulation() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
|
||||
assert_eq!(plan.current_turn, 0);
|
||||
|
||||
// Turn 1: Agent proposes reading a file
|
||||
plan.next_turn();
|
||||
plan.add_step_with_rationale(
|
||||
"call_1".to_string(),
|
||||
"read".to_string(),
|
||||
json!({"path": "Cargo.toml"}),
|
||||
"I need to read the project configuration".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.current_turn, 1);
|
||||
|
||||
// Turn 2: Agent proposes editing the file
|
||||
plan.next_turn();
|
||||
plan.add_step_with_rationale(
|
||||
"call_2".to_string(),
|
||||
"edit".to_string(),
|
||||
json!({"path": "Cargo.toml", "old": "version = \"0.1.0\"", "new": "version = \"0.2.0\""}),
|
||||
"Bump the version".to_string(),
|
||||
);
|
||||
|
||||
// Turn 2: Agent also proposes running tests
|
||||
plan.add_step_with_rationale(
|
||||
"call_3".to_string(),
|
||||
"bash".to_string(),
|
||||
json!({"command": "cargo test"}),
|
||||
"Verify the change doesn't break anything".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(plan.steps.len(), 3);
|
||||
assert_eq!(plan.current_turn, 2);
|
||||
|
||||
// All steps should be pending
|
||||
let (pending, approved, rejected) = plan.counts();
|
||||
assert_eq!((pending, approved, rejected), (3, 0, 0));
|
||||
}
|
||||
|
||||
/// Test plan finalization and status transition
|
||||
#[test]
|
||||
fn test_plan_finalization() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.next_turn();
|
||||
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
|
||||
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
|
||||
|
||||
plan.finalize();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing);
|
||||
}
|
||||
|
||||
/// Test selective approval workflow
|
||||
#[test]
|
||||
fn test_selective_approval() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
|
||||
// Add three steps
|
||||
plan.add_step_for_current_turn("step_read".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("step_write".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("step_bash".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
plan.finalize();
|
||||
|
||||
// User approves read and write, rejects bash
|
||||
let approval = PlanApproval {
|
||||
approved_ids: vec!["step_read".to_string(), "step_write".to_string()],
|
||||
rejected_ids: vec!["step_bash".to_string()],
|
||||
};
|
||||
|
||||
approval.apply_to(&mut plan);
|
||||
|
||||
// Verify approval state
|
||||
assert!(plan.steps[0].is_approved());
|
||||
assert!(plan.steps[1].is_approved());
|
||||
assert!(plan.steps[2].is_rejected());
|
||||
|
||||
assert_eq!(plan.approved_steps().len(), 2);
|
||||
assert_eq!(plan.rejected_steps().len(), 1);
|
||||
assert!(plan.all_decided());
|
||||
}
|
||||
|
||||
/// Test execution workflow
|
||||
#[test]
|
||||
fn test_execution_workflow() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("s2".to_string(), "write".to_string(), json!({}));
|
||||
|
||||
// Finalize
|
||||
plan.finalize();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing);
|
||||
|
||||
// Approve all
|
||||
plan.approve_all();
|
||||
|
||||
// Start execution
|
||||
plan.start_execution();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Executing);
|
||||
|
||||
// Complete execution
|
||||
plan.complete();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Completed);
|
||||
}
|
||||
|
||||
/// Test plan cancellation
|
||||
#[test]
|
||||
fn test_plan_cancellation() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({}));
|
||||
plan.finalize();
|
||||
|
||||
plan.cancel();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Cancelled);
|
||||
}
|
||||
|
||||
/// Test approve_all only affects pending steps
|
||||
#[test]
|
||||
fn test_approve_all_respects_existing_decisions() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("s2".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("s3".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
// Reject s3 before approve_all
|
||||
plan.reject_step("s3");
|
||||
|
||||
// Approve all (should only affect pending)
|
||||
plan.approve_all();
|
||||
|
||||
assert!(plan.steps[0].is_approved()); // was pending, now approved
|
||||
assert!(plan.steps[1].is_approved()); // was pending, now approved
|
||||
assert!(plan.steps[2].is_rejected()); // was rejected, stays rejected
|
||||
}
|
||||
|
||||
/// Test plan with name
|
||||
#[test]
|
||||
fn test_named_plan() {
|
||||
let plan = AccumulatedPlan::with_name("Fix bug #123".to_string());
|
||||
assert_eq!(plan.name, Some("Fix bug #123".to_string()));
|
||||
}
|
||||
|
||||
/// Test step rationale tracking
|
||||
#[test]
|
||||
fn test_step_rationale() {
|
||||
let step = PlanStep::new("id".to_string(), 1, "read".to_string(), json!({}))
|
||||
.with_rationale("I need to understand the code structure".to_string());
|
||||
|
||||
assert_eq!(
|
||||
step.rationale,
|
||||
Some("I need to understand the code structure".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Test filtering steps by approval status
|
||||
#[test]
|
||||
fn test_step_filtering() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("a".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("b".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("c".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
plan.approve_step("a");
|
||||
plan.reject_step("b");
|
||||
// c remains pending
|
||||
|
||||
assert_eq!(plan.approved_steps().len(), 1);
|
||||
assert_eq!(plan.rejected_steps().len(), 1);
|
||||
assert_eq!(plan.pending_steps().len(), 1);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
// Test that ToolContext properly wires up the placeholder tools
|
||||
use agent_core::{ToolContext, execute_tool};
|
||||
use agent_core::{ToolContext, execute_tool, get_tool_definitions_with_external};
|
||||
use permissions::{Mode, PermissionManager};
|
||||
use plugins::{ExternalToolDefinition, ExternalToolTransport, ExternalToolSchema};
|
||||
use tools_todo::{TodoList, TodoStatus};
|
||||
use tools_bash::ShellManager;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_todo_write_with_context() {
|
||||
@@ -112,3 +115,110 @@ async fn test_ask_user_without_context() {
|
||||
assert!(result.is_err(), "AskUser should fail without AskSender");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External Tools Tests
|
||||
// ============================================================================
|
||||
|
||||
fn create_test_external_tool(name: &str, description: &str) -> ExternalToolDefinition {
|
||||
ExternalToolDefinition {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
transport: ExternalToolTransport::Stdio,
|
||||
command: Some("echo".to_string()),
|
||||
args: vec![],
|
||||
url: None,
|
||||
timeout_ms: 5000,
|
||||
input_schema: ExternalToolSchema {
|
||||
schema_type: "object".to_string(),
|
||||
properties: {
|
||||
let mut props = HashMap::new();
|
||||
props.insert(
|
||||
"input".to_string(),
|
||||
json!({"type": "string", "description": "Test input"}),
|
||||
);
|
||||
props
|
||||
},
|
||||
required: vec!["input".to_string()],
|
||||
},
|
||||
source_path: PathBuf::from("/test/plugin"),
|
||||
plugin_name: "test-plugin".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_external_tools() {
|
||||
let mut ext_tools = HashMap::new();
|
||||
ext_tools.insert(
|
||||
"my_custom_tool".to_string(),
|
||||
create_test_external_tool("my_custom_tool", "A custom tool for testing"),
|
||||
);
|
||||
|
||||
let ctx = ToolContext::new().with_external_tools(ext_tools);
|
||||
|
||||
assert!(ctx.has_external_tool("my_custom_tool"));
|
||||
assert!(!ctx.has_external_tool("nonexistent"));
|
||||
|
||||
let tool = ctx.get_external_tool("my_custom_tool").unwrap();
|
||||
assert_eq!(tool.name, "my_custom_tool");
|
||||
assert_eq!(tool.description, "A custom tool for testing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_tool_definitions_with_external() {
|
||||
let mut ext_tools = HashMap::new();
|
||||
ext_tools.insert(
|
||||
"external_analyzer".to_string(),
|
||||
create_test_external_tool("external_analyzer", "Analyze stuff externally"),
|
||||
);
|
||||
ext_tools.insert(
|
||||
"external_formatter".to_string(),
|
||||
create_test_external_tool("external_formatter", "Format things externally"),
|
||||
);
|
||||
|
||||
let ctx = ToolContext::new().with_external_tools(ext_tools);
|
||||
let all_tools = get_tool_definitions_with_external(&ctx);
|
||||
|
||||
// Should have built-in tools plus our 2 external tools
|
||||
let external_tool_names: Vec<_> = all_tools
|
||||
.iter()
|
||||
.filter(|t| t.function.name == "external_analyzer" || t.function.name == "external_formatter")
|
||||
.collect();
|
||||
assert_eq!(external_tool_names.len(), 2);
|
||||
|
||||
// Verify external tool schema is correct
|
||||
let analyzer = all_tools.iter().find(|t| t.function.name == "external_analyzer").unwrap();
|
||||
assert!(analyzer.function.description.contains("Analyze stuff"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_tool_without_external() {
|
||||
let ctx = ToolContext::new();
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({"input": "test"});
|
||||
let result = execute_tool("completely_unknown_tool", &arguments, &perms, &ctx).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unknown tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_tool_permission_denied() {
|
||||
let mut ext_tools = HashMap::new();
|
||||
ext_tools.insert(
|
||||
"dangerous_tool".to_string(),
|
||||
create_test_external_tool("dangerous_tool", "A dangerous tool"),
|
||||
);
|
||||
|
||||
let ctx = ToolContext::new().with_external_tools(ext_tools);
|
||||
let perms = PermissionManager::new(Mode::Plan); // Plan mode denies bash-like tools
|
||||
|
||||
let arguments = json!({"input": "test"});
|
||||
let result = execute_tool("dangerous_tool", &arguments, &perms, &ctx).await;
|
||||
|
||||
// External tools are treated like Bash, so should require permission in Plan mode
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("Permission required") || err.contains("Permission denied"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user