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:
2025-12-26 22:47:54 +01:00
parent f97bd44f05
commit 84fa08ab45
17 changed files with 2438 additions and 13 deletions

View 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);
}

View File

@@ -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"));
}