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

@@ -24,6 +24,7 @@ members = [
"crates/tools/todo",
"crates/tools/web",
"crates/integration/mcp-client",
"crates/integration/jsonrpc",
]
resolver = "2"

View File

@@ -19,6 +19,7 @@ llm-openai = { path = "../../llm/openai" }
tools-fs = { path = "../../tools/fs" }
tools-bash = { path = "../../tools/bash" }
tools-slash = { path = "../../tools/slash" }
tools-plan = { path = "../../tools/plan" }
auth-manager = { path = "../../platform/auth" }
config-agent = { package = "config-agent", path = "../../platform/config" }
permissions = { path = "../../platform/permissions" }

View File

@@ -214,6 +214,24 @@ impl AgentManager {
Ok(())
}
/// Execute a single tool call (used by engine for plan step execution)
pub async fn execute_single_tool(&self, tool_name: &str, args: &serde_json::Value) -> Result<String> {
let dummy_perms = permissions::PermissionManager::new(permissions::Mode::Code);
let ctx = agent_core::ToolContext::new();
let result = agent_core::execute_tool(tool_name, args, &dummy_perms, &ctx).await?;
// Notify UI of tool execution
if let Some(tx) = &self.tx_ui {
let _ = tx.send(Message::AgentResponse(AgentResponse::ToolCall {
name: tool_name.to_string(),
args: args.to_string(),
})).await;
}
Ok(result)
}
/// Execute the full reasoning loop until a final response is reached
pub async fn run(&self, input: &str) -> Result<()> {
let tools = agent_core::get_tool_definitions();

View File

@@ -1,9 +1,11 @@
use agent_core::messages::{Message, UserAction, AgentResponse};
use agent_core::messages::{Message, UserAction, AgentResponse, SystemNotification};
use agent_core::state::AppState;
use tokio::sync::{mpsc, Mutex};
use std::sync::Arc;
use std::path::PathBuf;
use llm_core::LlmProvider;
use ui::ProviderManager;
use tools_plan::PlanManager;
use crate::agent_manager::AgentManager;
/// The main background task that handles logic, API calls, and state updates.
@@ -19,6 +21,9 @@ pub async fn run_engine_loop_dynamic(
.with_ui_sender(tx_ui.clone())
);
// Plan manager for persistence
let plan_manager = PlanManager::new(PathBuf::from("."));
while let Some(msg) = rx.recv().await {
match msg {
Message::UserAction(UserAction::Input(text)) => {
@@ -35,6 +40,121 @@ pub async fn run_engine_loop_dynamic(
let mut guard = state.lock().await;
guard.set_permission_result(res);
}
Message::UserAction(UserAction::FinalizePlan) => {
let mut guard = state.lock().await;
if let Some(plan) = guard.current_plan_mut() {
plan.finalize();
let total_steps = plan.steps.len();
let status = plan.status;
drop(guard);
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanComplete {
total_steps,
status,
})).await;
}
}
Message::UserAction(UserAction::PlanApproval(approval)) => {
let mut guard = state.lock().await;
if let Some(plan) = guard.current_plan_mut() {
// Apply approval decisions
approval.apply_to(plan);
plan.start_execution();
// Get approved steps for execution
let approved_steps: Vec<_> = plan.steps.iter()
.filter(|s| s.is_approved())
.cloned()
.collect();
let total = approved_steps.len();
let skipped = plan.steps.iter().filter(|s| s.is_rejected()).count();
drop(guard);
// Execute approved steps
for (idx, step) in approved_steps.iter().enumerate() {
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanExecuting {
step_id: step.id.clone(),
step_index: idx,
total_steps: total,
})).await;
// Execute the tool
let agent_manager_clone = agent_manager.clone();
let tx_clone = tx_ui.clone();
let step_clone = step.clone();
// Execute tool and send result
if let Err(e) = agent_manager_clone.execute_single_tool(&step_clone.tool, &step_clone.args).await {
let _ = tx_clone.send(Message::AgentResponse(AgentResponse::Error(
format!("Step {} failed: {}", idx + 1, e)
))).await;
}
}
// Mark plan as completed
let mut guard = state.lock().await;
if let Some(plan) = guard.current_plan_mut() {
plan.complete();
}
drop(guard);
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanExecutionComplete {
executed: total,
skipped,
})).await;
}
}
Message::UserAction(UserAction::SavePlan(name)) => {
let guard = state.lock().await;
if let Some(plan) = guard.current_plan() {
let mut plan_to_save = plan.clone();
plan_to_save.name = Some(name.clone());
drop(guard);
match plan_manager.save_accumulated_plan(&plan_to_save).await {
Ok(path) => {
let _ = tx_ui.send(Message::System(SystemNotification::PlanSaved {
id: plan_to_save.id.clone(),
path: path.display().to_string(),
})).await;
}
Err(e) => {
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::Error(
format!("Failed to save plan: {}", e)
))).await;
}
}
}
}
Message::UserAction(UserAction::LoadPlan(id)) => {
match plan_manager.load_accumulated_plan(&id).await {
Ok(plan) => {
let name = plan.name.clone();
let steps = plan.steps.len();
let mut guard = state.lock().await;
guard.accumulated_plan = Some(plan);
drop(guard);
let _ = tx_ui.send(Message::System(SystemNotification::PlanLoaded {
id,
name,
steps,
})).await;
}
Err(e) => {
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::Error(
format!("Failed to load plan: {}", e)
))).await;
}
}
}
Message::UserAction(UserAction::CancelPlan) => {
let mut guard = state.lock().await;
if let Some(plan) = guard.current_plan_mut() {
plan.cancel();
}
guard.clear_plan();
}
Message::UserAction(UserAction::Exit) => {
let mut guard = state.lock().await;
guard.running = false;

View File

@@ -28,3 +28,4 @@ llm-ollama = { path = "../../llm/ollama" }
llm-openai = { path = "../../llm/openai" }
config-agent = { path = "../../platform/config" }
tools-todo = { path = "../../tools/todo" }
tools-plan = { path = "../../tools/plan" }

View File

@@ -1,13 +1,14 @@
use crate::{
components::{
Autocomplete, AutocompleteResult, ChatMessage, ChatPanel, CommandHelp, InputBox,
ModelPicker, PermissionPopup, PickerResult, ProviderTabs, StatusBar, TodoPanel,
ModelPicker, PermissionPopup, PickerResult, PlanPanel, ProviderTabs, StatusBar, TodoPanel,
},
events::{handle_key_event, AppEvent},
layout::AppLayout,
provider_manager::ProviderManager,
theme::{Provider, Theme, VimMode},
};
use tools_plan::AccumulatedPlanStatus;
use tools_todo::TodoList;
use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions};
use color_eyre::eyre::Result;
@@ -59,6 +60,7 @@ pub struct TuiApp {
input_box: InputBox,
status_bar: StatusBar,
todo_panel: TodoPanel,
plan_panel: PlanPanel,
permission_popup: Option<PermissionPopup>,
autocomplete: Autocomplete,
command_help: CommandHelp,
@@ -128,6 +130,7 @@ impl TuiApp {
input_box: InputBox::new(theme.clone()),
status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()),
todo_panel: TodoPanel::new(theme.clone()),
plan_panel: PlanPanel::new(theme.clone()),
permission_popup: None,
autocomplete: Autocomplete::new(theme.clone()),
command_help: CommandHelp::new(theme.clone()),
@@ -187,6 +190,7 @@ impl TuiApp {
input_box: InputBox::new(theme.clone()),
status_bar,
todo_panel: TodoPanel::new(theme.clone()),
plan_panel: PlanPanel::new(theme.clone()),
permission_popup: None,
autocomplete: Autocomplete::new(theme.clone()),
command_help: CommandHelp::new(theme.clone()),
@@ -248,6 +252,7 @@ impl TuiApp {
input_box: InputBox::new(theme.clone()),
status_bar,
todo_panel: TodoPanel::new(theme.clone()),
plan_panel: PlanPanel::new(theme.clone()),
permission_popup: None,
autocomplete: Autocomplete::new(theme.clone()),
command_help: CommandHelp::new(theme.clone()),
@@ -354,6 +359,7 @@ impl TuiApp {
self.input_box = InputBox::new(theme.clone());
self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme.clone());
self.todo_panel.set_theme(theme.clone());
self.plan_panel.set_theme(theme.clone());
self.autocomplete.set_theme(theme.clone());
self.command_help.set_theme(theme.clone());
self.provider_tabs.set_theme(theme.clone());
@@ -479,6 +485,26 @@ Commands: /help, /model <name>, /clear, /theme <name>
&self.todo_list
}
/// Get the current accumulated plan from shared state (if any)
fn get_current_plan(&self) -> Option<tools_plan::AccumulatedPlan> {
if let Some(state) = &self.shared_state {
if let Ok(guard) = state.try_lock() {
return guard.accumulated_plan.clone();
}
}
None
}
/// Show the plan panel
pub fn show_plan_panel(&mut self) {
self.plan_panel.show();
}
/// Hide the plan panel
pub fn hide_plan_panel(&mut self) {
self.plan_panel.hide();
}
/// Render the header line: OWLEN left, model + vim mode right
fn render_header(&self, frame: &mut ratatui::Frame, area: Rect) {
let vim_indicator = self.vim_mode.indicator(&self.theme.symbols);
@@ -670,12 +696,22 @@ Commands: /help, /model <name>, /clear, /theme <name>
self.model_picker.render(frame, size);
}
// 3. Command help overlay (centered modal)
// 3. Plan panel (when accumulating or reviewing a plan)
if self.plan_panel.is_visible() {
let plan = self.get_current_plan();
// Calculate centered area for plan panel (60% width, 50% height)
let plan_width = (size.width * 3 / 5).max(60).min(size.width - 4);
let plan_height = (size.height / 2).max(15).min(size.height - 4);
let plan_area = AppLayout::center_popup(size, plan_width, plan_height);
self.plan_panel.render(frame, plan_area, plan.as_ref());
}
// 4. Command help overlay (centered modal)
if self.command_help.is_visible() {
self.command_help.render(frame, size);
}
// 4. Permission popup (highest priority)
// 5. Permission popup (highest priority)
if let Some(popup) = &self.permission_popup {
popup.render(frame, size);
}
@@ -785,13 +821,130 @@ Commands: /help, /model <name>, /clear, /theme <name>
return Ok(());
}
// 2. Command help overlay
// 2. Plan panel (when reviewing accumulated plan)
if self.plan_panel.is_visible() {
match key.code {
// Navigation
KeyCode::Char('j') | KeyCode::Down => {
if let Some(plan) = self.get_current_plan() {
self.plan_panel.select_next(plan.steps.len());
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.plan_panel.select_prev();
}
// Toggle step approval
KeyCode::Char(' ') => {
if let Some(state) = &self.shared_state {
if let Ok(mut guard) = state.try_lock() {
if let Some(plan) = guard.current_plan_mut() {
let idx = self.plan_panel.selected_index();
if let Some(step) = plan.steps.get_mut(idx) {
step.approved = match step.approved {
None => Some(true),
Some(true) => Some(false),
Some(false) => None,
};
}
}
}
}
}
// Finalize plan (stop accumulating, enter review)
KeyCode::Char('f') | KeyCode::Char('F') => {
if let Some(tx) = &self.engine_tx {
let _ = tx.send(Message::UserAction(UserAction::FinalizePlan)).await;
}
}
// Approve all pending
KeyCode::Char('a') | KeyCode::Char('A') => {
if let Some(state) = &self.shared_state {
if let Ok(mut guard) = state.try_lock() {
if let Some(plan) = guard.current_plan_mut() {
for step in &mut plan.steps {
if step.approved.is_none() {
step.approved = Some(true);
}
}
}
}
}
}
// Reject all pending
KeyCode::Char('r') | KeyCode::Char('R') => {
if let Some(state) = &self.shared_state {
if let Ok(mut guard) = state.try_lock() {
if let Some(plan) = guard.current_plan_mut() {
for step in &mut plan.steps {
if step.approved.is_none() {
step.approved = Some(false);
}
}
}
}
}
}
// Execute approved steps
KeyCode::Enter => {
if let Some(plan) = self.get_current_plan() {
if plan.status == AccumulatedPlanStatus::Reviewing {
// Collect approval decisions
let approved: Vec<_> = plan.steps.iter()
.filter(|s| s.approved == Some(true))
.map(|s| s.id.clone())
.collect();
let rejected: Vec<_> = plan.steps.iter()
.filter(|s| s.approved == Some(false))
.map(|s| s.id.clone())
.collect();
let approval = tools_plan::PlanApproval {
approved_ids: approved,
rejected_ids: rejected,
};
if let Some(tx) = &self.engine_tx {
let _ = tx.send(Message::UserAction(UserAction::PlanApproval(approval))).await;
}
self.plan_panel.hide();
}
}
}
// Save plan
KeyCode::Char('s') | KeyCode::Char('S') => {
if let Some(tx) = &self.engine_tx {
// TODO: Prompt for plan name
let name = format!("plan-{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs());
let _ = tx.send(Message::UserAction(UserAction::SavePlan(name))).await;
}
}
// Cancel/close
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => {
if let Some(plan) = self.get_current_plan() {
if plan.status == AccumulatedPlanStatus::Accumulating {
// Cancel the plan entirely
if let Some(tx) = &self.engine_tx {
let _ = tx.send(Message::UserAction(UserAction::CancelPlan)).await;
}
}
}
self.plan_panel.hide();
}
_ => {}
}
return Ok(());
}
// 3. Command help overlay
if self.command_help.is_visible() {
self.command_help.handle_key(key);
return Ok(());
}
// 3. Model picker
// 4. Model picker
if self.model_picker.is_visible() {
let current_model = self.opts.model.clone();
match self.model_picker.handle_key(key, &current_model) {
@@ -951,16 +1104,23 @@ Commands: /help, /model <name>, /clear, /theme <name>
}
);
self.chat_panel.add_message(ChatMessage::System(msg));
// Auto-show plan panel when steps are being accumulated
if !self.plan_panel.is_visible() {
self.plan_panel.show();
}
}
AgentResponse::PlanComplete { total_steps, status: _ } => {
self.chat_panel.add_message(ChatMessage::System(
format!("--- PLAN COMPLETE ({} steps) ---", total_steps)
));
self.chat_panel.add_message(ChatMessage::System(
"Review and approve plan (y/n in status bar)".to_string()
"Press [Enter] to execute or [Esc] to cancel".to_string()
));
// Show plan panel for review
self.plan_panel.show();
self.plan_panel.reset_selection();
self.status_bar.set_state(crate::components::AppState::WaitingPermission);
self.status_bar.set_pending_permission(Some("PLAN".to_string()));
self.status_bar.set_pending_permission(Some("PLAN REVIEW".to_string()));
}
AgentResponse::PlanExecuting { step_id: _, step_index, total_steps } => {
self.chat_panel.add_message(ChatMessage::System(
@@ -971,6 +1131,10 @@ Commands: /help, /model <name>, /clear, /theme <name>
self.chat_panel.add_message(ChatMessage::System(
format!("Plan execution complete: {} executed, {} skipped", executed, skipped)
));
// Hide plan panel after execution
self.plan_panel.hide();
self.status_bar.set_state(crate::components::AppState::Idle);
self.status_bar.set_pending_permission(None);
}
}
}
@@ -1606,6 +1770,18 @@ Commands: /help, /model <name>, /clear, /theme <name>
"Context compaction not yet implemented".to_string()
));
}
"/plan" => {
// Toggle plan panel visibility
if self.plan_panel.is_visible() {
self.plan_panel.hide();
} else if self.get_current_plan().is_some() {
self.plan_panel.show();
} else {
self.chat_panel.add_message(ChatMessage::System(
"No active plan. Start planning mode with a prompt.".to_string()
));
}
}
"/provider" => {
// Show available providers
self.chat_panel.add_message(ChatMessage::System(

View File

@@ -6,6 +6,7 @@ mod command_help;
mod input_box;
mod model_picker;
mod permission_popup;
mod plan_panel;
mod provider_tabs;
mod status_bar;
mod todo_panel;
@@ -16,6 +17,7 @@ pub use command_help::{Command, CommandHelp};
pub use input_box::{InputBox, InputEvent};
pub use model_picker::{ModelPicker, PickerResult, PickerState};
pub use permission_popup::{PermissionOption, PermissionPopup};
pub use plan_panel::PlanPanel;
pub use provider_tabs::ProviderTabs;
pub use status_bar::{AppState, StatusBar};
pub use todo_panel::TodoPanel;

View File

@@ -0,0 +1,322 @@
//! Plan panel component for displaying accumulated plan steps
//!
//! Shows the current plan with steps, approval status, and keybindings.
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
use tools_plan::{AccumulatedPlan, AccumulatedPlanStatus, PlanStep};
use crate::theme::Theme;
/// Plan panel component for displaying and interacting with accumulated plans
pub struct PlanPanel {
theme: Theme,
/// Currently selected step index
selected: usize,
/// Scroll offset for long lists
scroll_offset: usize,
/// Whether the panel is visible
visible: bool,
}
impl PlanPanel {
pub fn new(theme: Theme) -> Self {
Self {
theme,
selected: 0,
scroll_offset: 0,
visible: false,
}
}
/// Show the panel
pub fn show(&mut self) {
self.visible = true;
self.selected = 0;
self.scroll_offset = 0;
}
/// Hide the panel
pub fn hide(&mut self) {
self.visible = false;
}
/// Check if visible
pub fn is_visible(&self) -> bool {
self.visible
}
/// Update theme
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
}
/// Move selection up
pub fn select_prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
// Adjust scroll if needed
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
}
}
/// Move selection down
pub fn select_next(&mut self, total_steps: usize) {
if self.selected < total_steps.saturating_sub(1) {
self.selected += 1;
}
}
/// Get the currently selected step index
pub fn selected_index(&self) -> usize {
self.selected
}
/// Reset selection to first step
pub fn reset_selection(&mut self) {
self.selected = 0;
self.scroll_offset = 0;
}
/// Get the minimum height needed for the panel
pub fn min_height(&self) -> u16 {
if self.visible { 10 } else { 0 }
}
/// Render the plan panel
pub fn render(&self, frame: &mut Frame, area: Rect, plan: Option<&AccumulatedPlan>) {
if !self.visible {
return;
}
let plan = match plan {
Some(p) => p,
None => {
// Show "no plan" message
let block = Block::default()
.borders(Borders::ALL)
.title(" PLAN ")
.border_style(self.theme.border);
let paragraph = Paragraph::new("No active plan")
.style(self.theme.status_dim)
.block(block);
frame.render_widget(paragraph, area);
return;
}
};
let mut lines: Vec<Line> = Vec::new();
// Title line with status
let status_str = match plan.status {
AccumulatedPlanStatus::Accumulating => "Accumulating",
AccumulatedPlanStatus::Reviewing => "Reviewing",
AccumulatedPlanStatus::Executing => "Executing",
AccumulatedPlanStatus::Completed => "Completed",
AccumulatedPlanStatus::Cancelled => "Cancelled",
};
let status_color = match plan.status {
AccumulatedPlanStatus::Accumulating => self.theme.palette.warning,
AccumulatedPlanStatus::Reviewing => self.theme.palette.info,
AccumulatedPlanStatus::Executing => self.theme.palette.success,
AccumulatedPlanStatus::Completed => self.theme.palette.success,
AccumulatedPlanStatus::Cancelled => self.theme.palette.error,
};
let name = plan.name.as_deref().unwrap_or("unnamed");
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", name),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(" | ", self.theme.status_dim),
Span::styled(status_str, Style::default().fg(status_color)),
Span::styled(
format!(" ({} steps)", plan.steps.len()),
self.theme.status_dim,
),
]));
// Separator
lines.push(Line::from("".repeat(area.width.saturating_sub(2) as usize)));
// Steps
if plan.steps.is_empty() {
lines.push(Line::from(Span::styled(
" No steps yet...",
self.theme.status_dim,
)));
} else {
let visible_height = area.height.saturating_sub(6) as usize; // Account for header, footer, borders
let start = self.scroll_offset;
let end = (start + visible_height).min(plan.steps.len());
for (idx, step) in plan.steps.iter().enumerate().skip(start).take(end - start) {
let is_selected = idx == self.selected;
let line = self.render_step(step, idx + 1, is_selected);
lines.push(line);
// Show rationale if selected and available
if is_selected {
if let Some(rationale) = &step.rationale {
let truncated = if rationale.len() > 60 {
format!("{}...", &rationale[..57])
} else {
rationale.clone()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("\"{}\"", truncated),
Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC),
),
]));
}
}
}
}
// Footer with keybindings
lines.push(Line::from("".repeat(area.width.saturating_sub(2) as usize)));
let keybinds = match plan.status {
AccumulatedPlanStatus::Accumulating => {
"[F]inalize [C]ancel"
}
AccumulatedPlanStatus::Reviewing => {
"[j/k]nav [Space]toggle [A]pprove all [R]eject all [Enter]execute [S]ave [C]ancel"
}
_ => "[C]lose",
};
lines.push(Line::from(Span::styled(keybinds, self.theme.status_dim)));
// Create block with border
let block = Block::default()
.borders(Borders::ALL)
.title(" PLAN ")
.border_style(self.theme.border);
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
// Scrollbar if needed
if plan.steps.len() > (area.height.saturating_sub(6) as usize) {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""));
let mut scrollbar_state = ScrollbarState::new(plan.steps.len())
.position(self.selected);
frame.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin { vertical: 2, horizontal: 0 }),
&mut scrollbar_state,
);
}
}
/// Render a single step line
fn render_step(&self, step: &PlanStep, num: usize, selected: bool) -> Line<'static> {
let checkbox = match step.approved {
None => "", // Pending
Some(true) => "", // Approved
Some(false) => "", // Rejected
};
let checkbox_color = match step.approved {
None => self.theme.palette.warning,
Some(true) => self.theme.palette.success,
Some(false) => self.theme.palette.error,
};
// Truncate args for display
let args_str = step.args.to_string();
let args_display = if args_str.len() > 40 {
format!("{}...", &args_str[..37])
} else {
args_str
};
let mut spans = vec![
Span::styled(
if selected { ">" } else { " " },
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(format!("[{}] ", num), self.theme.status_dim),
Span::styled(checkbox, Style::default().fg(checkbox_color)),
Span::raw(" "),
Span::styled(
format!("{:<10}", step.tool),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(args_display, self.theme.status_dim),
];
if selected {
// Highlight the entire line for selected step
for span in &mut spans {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
}
Line::from(spans)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
#[test]
fn test_plan_panel_creation() {
let theme = Theme::default();
let panel = PlanPanel::new(theme);
assert!(!panel.is_visible());
assert_eq!(panel.selected_index(), 0);
}
#[test]
fn test_plan_panel_navigation() {
let theme = Theme::default();
let mut panel = PlanPanel::new(theme);
panel.show();
// Can't go below 0
panel.select_prev();
assert_eq!(panel.selected_index(), 0);
// Navigate down
panel.select_next(5);
assert_eq!(panel.selected_index(), 1);
panel.select_next(5);
assert_eq!(panel.selected_index(), 2);
// Can't go past end
panel.select_next(3);
panel.select_next(3);
assert_eq!(panel.selected_index(), 2);
// Navigate back up
panel.select_prev();
assert_eq!(panel.selected_index(), 1);
}
#[test]
fn test_plan_panel_visibility() {
let theme = Theme::default();
let mut panel = PlanPanel::new(theme);
assert!(!panel.is_visible());
panel.show();
assert!(panel.is_visible());
panel.hide();
assert!(!panel.is_visible());
}
}

View File

@@ -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"

View File

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

View File

@@ -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()
}
}

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

View File

@@ -0,0 +1,17 @@
[package]
name = "jsonrpc"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "JSON-RPC 2.0 client for external tool communication"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["process", "io-util", "time", "sync"] }
color-eyre = "0.6"
uuid = { version = "1.0", features = ["v4"] }
[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }

View File

@@ -0,0 +1,391 @@
//! JSON-RPC 2.0 client for external tool communication
//!
//! This crate provides a JSON-RPC 2.0 client for invoking external tools
//! via stdio (spawning a process) or HTTP endpoints.
use color_eyre::eyre::{eyre, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::time::timeout;
use uuid::Uuid;
// ============================================================================
// JSON-RPC 2.0 Protocol Types
// ============================================================================
/// JSON-RPC 2.0 request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
/// JSON-RPC version (must be "2.0")
pub jsonrpc: String,
/// Request ID for matching responses
pub id: serde_json::Value,
/// Method name to invoke
pub method: String,
/// Method parameters
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
impl JsonRpcRequest {
/// Create a new JSON-RPC request
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::String(Uuid::new_v4().to_string()),
method: method.into(),
params,
}
}
/// Create a request with a specific ID
pub fn with_id(id: impl Into<serde_json::Value>, method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: id.into(),
method: method.into(),
params,
}
}
/// Serialize to JSON string with newline
pub fn to_json_line(&self) -> Result<String> {
let mut json = serde_json::to_string(self)?;
json.push('\n');
Ok(json)
}
}
/// JSON-RPC 2.0 response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
/// JSON-RPC version (must be "2.0")
pub jsonrpc: String,
/// Request ID this is responding to
pub id: serde_json::Value,
/// Result (on success)
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
/// Error (on failure)
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
impl JsonRpcResponse {
/// Check if the response is an error
pub fn is_error(&self) -> bool {
self.error.is_some()
}
/// Get the result or error as a Result type
pub fn into_result(self) -> Result<serde_json::Value> {
if let Some(err) = self.error {
Err(eyre!("JSON-RPC error {}: {}", err.code, err.message))
} else {
self.result.ok_or_else(|| eyre!("No result in response"))
}
}
}
/// JSON-RPC 2.0 error
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
/// Error code (standard or application-specific)
pub code: i64,
/// Short error message
pub message: String,
/// Additional error data
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
/// Standard JSON-RPC 2.0 error codes
pub mod error_codes {
/// Invalid JSON was received
pub const PARSE_ERROR: i64 = -32700;
/// The JSON sent is not a valid Request object
pub const INVALID_REQUEST: i64 = -32600;
/// The method does not exist or is not available
pub const METHOD_NOT_FOUND: i64 = -32601;
/// Invalid method parameter(s)
pub const INVALID_PARAMS: i64 = -32602;
/// Internal JSON-RPC error
pub const INTERNAL_ERROR: i64 = -32603;
}
// ============================================================================
// Stdio Client
// ============================================================================
/// JSON-RPC client over stdio (spawned process)
pub struct StdioClient {
/// Spawned child process
child: Child,
/// Request timeout
timeout: Duration,
}
impl StdioClient {
/// Spawn a new stdio client
pub async fn spawn(
command: impl AsRef<str>,
args: &[String],
env: &HashMap<String, String>,
timeout_ms: u64,
) -> Result<Self> {
let mut cmd = Command::new(command.as_ref());
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
// Add environment variables
for (key, value) in env {
cmd.env(key, value);
}
let child = cmd.spawn()?;
Ok(Self {
child,
timeout: Duration::from_millis(timeout_ms),
})
}
/// Spawn from a path (resolving relative paths)
pub async fn spawn_from_path(
command: &PathBuf,
args: &[String],
env: &HashMap<String, String>,
working_dir: Option<&PathBuf>,
timeout_ms: u64,
) -> Result<Self> {
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
for (key, value) in env {
cmd.env(key, value);
}
let child = cmd.spawn()?;
Ok(Self {
child,
timeout: Duration::from_millis(timeout_ms),
})
}
/// Send a request and wait for response
pub async fn call(&mut self, method: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
let request = JsonRpcRequest::new(method, params);
let request_id = request.id.clone();
// Get handles to stdin/stdout
let stdin = self.child.stdin.as_mut()
.ok_or_else(|| eyre!("Failed to get stdin handle"))?;
let stdout = self.child.stdout.take()
.ok_or_else(|| eyre!("Failed to get stdout handle"))?;
// Write request
let request_json = request.to_json_line()?;
stdin.write_all(request_json.as_bytes()).await?;
stdin.flush().await?;
// Read response with timeout
let mut reader = BufReader::new(stdout);
let mut line = String::new();
let result = timeout(self.timeout, reader.read_line(&mut line)).await;
// Restore stdout for future calls
self.child.stdout = Some(reader.into_inner());
match result {
Ok(Ok(0)) => Err(eyre!("Process closed stdout")),
Ok(Ok(_)) => {
let response: JsonRpcResponse = serde_json::from_str(&line)?;
// Verify ID matches
if response.id != request_id {
return Err(eyre!(
"Response ID mismatch: expected {:?}, got {:?}",
request_id,
response.id
));
}
response.into_result()
}
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(eyre!("Request timed out after {:?}", self.timeout)),
}
}
/// Check if the process is still running
pub fn is_alive(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// Kill the process
pub async fn kill(&mut self) -> Result<()> {
self.child.kill().await?;
Ok(())
}
}
impl Drop for StdioClient {
fn drop(&mut self) {
// Try to kill the process on drop
let _ = self.child.start_kill();
}
}
// ============================================================================
// Tool Executor
// ============================================================================
/// Executor for external tools via JSON-RPC
pub struct ToolExecutor {
/// Default timeout for tool calls
default_timeout: Duration,
}
impl Default for ToolExecutor {
fn default() -> Self {
Self {
default_timeout: Duration::from_secs(30),
}
}
}
impl ToolExecutor {
/// Create a new tool executor with custom timeout
pub fn with_timeout(timeout_ms: u64) -> Self {
Self {
default_timeout: Duration::from_millis(timeout_ms),
}
}
/// Execute a tool via stdio
pub async fn execute_stdio(
&self,
command: &str,
args: &[String],
env: &HashMap<String, String>,
tool_params: serde_json::Value,
timeout_ms: Option<u64>,
) -> Result<serde_json::Value> {
let timeout = timeout_ms.unwrap_or(self.default_timeout.as_millis() as u64);
let mut client = StdioClient::spawn(command, args, env, timeout).await?;
// The standard method name for tool execution
let result = client.call("execute", Some(tool_params)).await?;
// Kill the process after we're done
let _ = client.kill().await;
Ok(result)
}
/// Execute a tool via HTTP (not implemented yet)
pub async fn execute_http(
&self,
_url: &str,
_tool_params: serde_json::Value,
_timeout_ms: Option<u64>,
) -> Result<serde_json::Value> {
Err(eyre!("HTTP transport not yet implemented"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_jsonrpc_request_serialization() {
let request = JsonRpcRequest::new("test_method", Some(serde_json::json!({"arg": "value"})));
assert_eq!(request.jsonrpc, "2.0");
assert_eq!(request.method, "test_method");
assert!(request.params.is_some());
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"test_method\""));
}
#[test]
fn test_jsonrpc_response_success() {
let json = r#"{"jsonrpc":"2.0","id":"123","result":{"data":"hello"}}"#;
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert!(!response.is_error());
assert_eq!(response.id, serde_json::json!("123"));
assert_eq!(response.result.unwrap()["data"], "hello");
}
#[test]
fn test_jsonrpc_response_error() {
let json = r#"{"jsonrpc":"2.0","id":"123","error":{"code":-32600,"message":"Invalid Request"}}"#;
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert!(response.is_error());
let err = response.error.unwrap();
assert_eq!(err.code, error_codes::INVALID_REQUEST);
assert_eq!(err.message, "Invalid Request");
}
#[test]
fn test_jsonrpc_request_line_format() {
let request = JsonRpcRequest::with_id("test-id", "method", None);
let line = request.to_json_line().unwrap();
assert!(line.ends_with('\n'));
assert!(!line.ends_with("\n\n"));
}
#[test]
fn test_response_into_result_success() {
let response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!("1"),
result: Some(serde_json::json!({"success": true})),
error: None,
};
let result = response.into_result().unwrap();
assert_eq!(result["success"], true);
}
#[test]
fn test_response_into_result_error() {
let response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::json!("1"),
result: None,
error: Some(JsonRpcError {
code: -32600,
message: "Invalid".to_string(),
data: None,
}),
};
let result = response.into_result();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("-32600"));
}
}

View File

@@ -637,6 +637,215 @@ impl Default for PluginManager {
}
}
// ============================================================================
// External Tool Support (Phase 4)
// ============================================================================
/// Transport type for external tools
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExternalToolTransport {
/// JSON-RPC 2.0 over stdin/stdout
#[default]
Stdio,
/// HTTP JSON-RPC
Http,
}
/// JSON Schema for tool arguments (simplified)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalToolSchema {
/// Type should be "object" for tool arguments
#[serde(rename = "type")]
pub schema_type: String,
/// Required properties
#[serde(default)]
pub required: Vec<String>,
/// Property definitions
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
impl Default for ExternalToolSchema {
fn default() -> Self {
Self {
schema_type: "object".to_string(),
required: Vec::new(),
properties: HashMap::new(),
}
}
}
/// External tool definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalToolDefinition {
/// Tool name (must be unique)
pub name: String,
/// Tool description (shown to LLM)
pub description: String,
/// Transport type (how to invoke)
#[serde(default)]
pub transport: ExternalToolTransport,
/// Command to execute (for stdio transport)
pub command: Option<String>,
/// Arguments for the command
#[serde(default)]
pub args: Vec<String>,
/// URL endpoint (for http transport)
pub url: Option<String>,
/// Timeout in milliseconds
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
/// JSON Schema for tool arguments
#[serde(default)]
pub input_schema: ExternalToolSchema,
/// Source plugin and path
#[serde(skip)]
pub source_path: PathBuf,
/// Source plugin name
#[serde(skip)]
pub plugin_name: String,
}
fn default_timeout() -> u64 {
30000 // 30 seconds
}
impl ExternalToolDefinition {
/// Resolve command path relative to plugin directory
pub fn resolved_command(&self, plugin_base: &Path) -> Option<PathBuf> {
self.command.as_ref().map(|cmd| {
if cmd.starts_with("./") || cmd.starts_with("../") {
plugin_base.join(cmd)
} else {
PathBuf::from(cmd)
}
})
}
/// Convert to LLM tool definition format
pub fn to_llm_tool(&self) -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.input_schema
}
})
}
}
impl Plugin {
/// Get the path to a tool definition file
pub fn tool_path(&self, tool_name: &str) -> PathBuf {
self.base_path.join("tools").join(format!("{}.yaml", tool_name))
}
/// Auto-discover tools in the tools/ directory
pub fn discover_tools(&self) -> Vec<String> {
let tools_dir = self.base_path.join("tools");
if !tools_dir.exists() {
return Vec::new();
}
std::fs::read_dir(&tools_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.extension().map(|ext| ext == "yaml" || ext == "yml" || ext == "json").unwrap_or(false)
})
.filter_map(|e| {
e.path().file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect()
}
/// Parse a tool definition file
pub fn parse_tool(&self, name: &str) -> Result<ExternalToolDefinition> {
// Try yaml first, then yml, then json
let yaml_path = self.base_path.join("tools").join(format!("{}.yaml", name));
let yml_path = self.base_path.join("tools").join(format!("{}.yml", name));
let json_path = self.base_path.join("tools").join(format!("{}.json", name));
let (path, content) = if yaml_path.exists() {
(yaml_path.clone(), fs::read_to_string(&yaml_path)?)
} else if yml_path.exists() {
(yml_path.clone(), fs::read_to_string(&yml_path)?)
} else if json_path.exists() {
(json_path.clone(), fs::read_to_string(&json_path)?)
} else {
return Err(eyre!("Tool definition not found for: {}", name));
};
let mut tool: ExternalToolDefinition = if path.extension().map(|e| e == "json").unwrap_or(false) {
serde_json::from_str(&content)?
} else {
serde_yaml::from_str(&content)?
};
// Set metadata
tool.source_path = path;
tool.plugin_name = self.manifest.name.clone();
Ok(tool)
}
/// Get all tool names (from manifest + discovered)
pub fn all_tool_names(&self) -> Vec<String> {
let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
// Tools from manifest (if we add that field)
// For now, just use discovery
names.extend(self.discover_tools());
names.into_iter().collect()
}
}
impl PluginManager {
/// Get all external tools from all plugins
pub fn all_external_tools(&self) -> Vec<ExternalToolDefinition> {
let mut tools = Vec::new();
for plugin in &self.plugins {
for tool_name in plugin.all_tool_names() {
match plugin.parse_tool(&tool_name) {
Ok(tool) => tools.push(tool),
Err(e) => {
eprintln!("Warning: Failed to parse tool {} from {}: {}",
tool_name, plugin.manifest.name, e);
}
}
}
}
tools
}
/// Find a specific external tool by name
pub fn find_external_tool(&self, name: &str) -> Option<ExternalToolDefinition> {
for plugin in &self.plugins {
if let Ok(tool) = plugin.parse_tool(name) {
return Some(tool);
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -821,4 +1030,100 @@ mod tests {
Ok(())
}
#[test]
fn test_external_tool_discovery() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let plugin_dir = temp_dir.path().join("tool-plugin");
// Create plugin structure
fs::create_dir_all(&plugin_dir)?;
fs::create_dir_all(plugin_dir.join("tools"))?;
// Write manifest
let manifest = PluginManifest {
name: "tool-plugin".to_string(),
version: "1.0.0".to_string(),
description: None,
author: None,
commands: vec![],
agents: vec![],
skills: vec![],
hooks: HashMap::new(),
mcp_servers: vec![],
};
fs::write(
plugin_dir.join("plugin.json"),
serde_json::to_string_pretty(&manifest)?,
)?;
// Write tool definition
let tool_yaml = r#"
name: calculator
description: Perform mathematical calculations
transport: stdio
command: python
args: ["-m", "calculator"]
timeout_ms: 5000
input_schema:
type: object
required:
- expression
properties:
expression:
type: string
description: Mathematical expression to evaluate
"#;
fs::write(plugin_dir.join("tools/calculator.yaml"), tool_yaml)?;
// Test discovery
let mut manager = PluginManager::with_dirs(vec![temp_dir.path().to_path_buf()]);
manager.load_all()?;
let tools = manager.all_external_tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "calculator");
assert_eq!(tools[0].description, "Perform mathematical calculations");
assert_eq!(tools[0].transport, ExternalToolTransport::Stdio);
assert_eq!(tools[0].command, Some("python".to_string()));
assert_eq!(tools[0].args, vec!["-m", "calculator"]);
assert_eq!(tools[0].timeout_ms, 5000);
assert_eq!(tools[0].input_schema.required, vec!["expression"]);
Ok(())
}
#[test]
fn test_external_tool_to_llm_format() -> Result<()> {
let tool = ExternalToolDefinition {
name: "file_reader".to_string(),
description: "Read a file from disk".to_string(),
transport: ExternalToolTransport::Stdio,
command: Some("./tools/reader".to_string()),
args: vec![],
url: None,
timeout_ms: 10000,
input_schema: ExternalToolSchema {
schema_type: "object".to_string(),
required: vec!["path".to_string()],
properties: {
let mut props = HashMap::new();
props.insert("path".to_string(), serde_json::json!({
"type": "string",
"description": "File path to read"
}));
props
},
},
source_path: PathBuf::new(),
plugin_name: "test".to_string(),
};
let llm_format = tool.to_llm_tool();
assert_eq!(llm_format["type"], "function");
assert_eq!(llm_format["function"]["name"], "file_reader");
assert_eq!(llm_format["function"]["description"], "Read a file from disk");
Ok(())
}
}

View File

@@ -1,8 +1,12 @@
//! Planning mode tools for the Owlen agent
//!
//! Provides EnterPlanMode and ExitPlanMode tools that allow the agent
//! to enter a planning phase where only read-only operations are allowed,
//! and then present a plan for user approval.
//! This crate provides two related planning features:
//!
//! 1. **Plan Documents** - Markdown files for describing implementation plans
//! (EnterPlanMode/ExitPlanMode tools)
//!
//! 2. **Plan Execution** - Accumulating proposed tool calls across LLM turns
//! for selective approval before execution (PlanStep, AccumulatedPlan)
use color_eyre::eyre::Result;
use serde::{Deserialize, Serialize};
@@ -10,6 +14,291 @@ use std::path::PathBuf;
use chrono::{DateTime, Utc};
use uuid::Uuid;
// ============================================================================
// Plan Execution Types (Multi-turn tool call accumulation)
// ============================================================================
/// A single proposed tool call in an accumulated plan
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStep {
/// Unique identifier for this step (matches LLM's tool_call_id)
pub id: String,
/// Which LLM turn proposed this step (1-indexed)
pub turn: usize,
/// Tool name to execute
pub tool: String,
/// Arguments for the tool call
pub args: serde_json::Value,
/// LLM's reasoning/rationale for this step (from response content)
pub rationale: Option<String>,
/// User's approval decision: None = pending, Some(true) = approved, Some(false) = rejected
pub approved: Option<bool>,
}
impl PlanStep {
/// Create a new pending plan step
pub fn new(id: String, turn: usize, tool: String, args: serde_json::Value) -> Self {
Self {
id,
turn,
tool,
args,
rationale: None,
approved: None,
}
}
/// Set the rationale for this step
pub fn with_rationale(mut self, rationale: String) -> Self {
self.rationale = Some(rationale);
self
}
/// Check if this step is pending approval
pub fn is_pending(&self) -> bool {
self.approved.is_none()
}
/// Check if this step is approved
pub fn is_approved(&self) -> bool {
self.approved == Some(true)
}
/// Check if this step is rejected
pub fn is_rejected(&self) -> bool {
self.approved == Some(false)
}
}
/// Status of an accumulated plan
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AccumulatedPlanStatus {
/// Agent is proposing steps (accumulating)
#[default]
Accumulating,
/// User is reviewing the plan
Reviewing,
/// Approved steps are being executed
Executing,
/// All approved steps have been executed
Completed,
/// Plan was cancelled by user
Cancelled,
}
/// An accumulated plan of proposed tool calls
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccumulatedPlan {
/// Unique identifier for this plan
pub id: String,
/// Optional user-provided name
pub name: Option<String>,
/// When the plan was started
pub created_at: DateTime<Utc>,
/// Current status
pub status: AccumulatedPlanStatus,
/// Accumulated steps across LLM turns
pub steps: Vec<PlanStep>,
/// Current LLM turn counter
pub current_turn: usize,
}
impl Default for AccumulatedPlan {
fn default() -> Self {
Self::new()
}
}
impl AccumulatedPlan {
/// Create a new empty accumulated plan
pub fn new() -> Self {
Self {
id: Uuid::new_v4().to_string(),
name: None,
created_at: Utc::now(),
status: AccumulatedPlanStatus::Accumulating,
steps: Vec::new(),
current_turn: 0,
}
}
/// Create a plan with a specific name
pub fn with_name(name: String) -> Self {
let mut plan = Self::new();
plan.name = Some(name);
plan
}
/// Add a step to the plan
pub fn add_step(&mut self, step: PlanStep) {
self.steps.push(step);
}
/// Start a new turn (increment turn counter)
pub fn next_turn(&mut self) {
self.current_turn += 1;
}
/// Add a step for the current turn
pub fn add_step_for_current_turn(&mut self, id: String, tool: String, args: serde_json::Value) {
let step = PlanStep::new(id, self.current_turn, tool, args);
self.steps.push(step);
}
/// Add a step with rationale for the current turn
pub fn add_step_with_rationale(
&mut self,
id: String,
tool: String,
args: serde_json::Value,
rationale: String,
) {
let step = PlanStep::new(id, self.current_turn, tool, args).with_rationale(rationale);
self.steps.push(step);
}
/// Get all pending steps
pub fn pending_steps(&self) -> Vec<&PlanStep> {
self.steps.iter().filter(|s| s.is_pending()).collect()
}
/// Get all approved steps
pub fn approved_steps(&self) -> Vec<&PlanStep> {
self.steps.iter().filter(|s| s.is_approved()).collect()
}
/// Get all rejected steps
pub fn rejected_steps(&self) -> Vec<&PlanStep> {
self.steps.iter().filter(|s| s.is_rejected()).collect()
}
/// Approve a step by ID
pub fn approve_step(&mut self, id: &str) -> bool {
if let Some(step) = self.steps.iter_mut().find(|s| s.id == id) {
step.approved = Some(true);
true
} else {
false
}
}
/// Reject a step by ID
pub fn reject_step(&mut self, id: &str) -> bool {
if let Some(step) = self.steps.iter_mut().find(|s| s.id == id) {
step.approved = Some(false);
true
} else {
false
}
}
/// Approve all pending steps
pub fn approve_all(&mut self) {
for step in &mut self.steps {
if step.approved.is_none() {
step.approved = Some(true);
}
}
}
/// Reject all pending steps
pub fn reject_all(&mut self) {
for step in &mut self.steps {
if step.approved.is_none() {
step.approved = Some(false);
}
}
}
/// Check if all steps have been decided (no pending)
pub fn all_decided(&self) -> bool {
self.steps.iter().all(|s| s.approved.is_some())
}
/// Get step count by approval status
pub fn counts(&self) -> (usize, usize, usize) {
let pending = self.steps.iter().filter(|s| s.is_pending()).count();
let approved = self.steps.iter().filter(|s| s.is_approved()).count();
let rejected = self.steps.iter().filter(|s| s.is_rejected()).count();
(pending, approved, rejected)
}
/// Transition to reviewing status
pub fn finalize(&mut self) {
self.status = AccumulatedPlanStatus::Reviewing;
}
/// Transition to executing status
pub fn start_execution(&mut self) {
self.status = AccumulatedPlanStatus::Executing;
}
/// Transition to completed status
pub fn complete(&mut self) {
self.status = AccumulatedPlanStatus::Completed;
}
/// Cancel the plan
pub fn cancel(&mut self) {
self.status = AccumulatedPlanStatus::Cancelled;
}
}
/// User's approval decisions for plan steps
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanApproval {
/// IDs of steps that were approved
pub approved_ids: Vec<String>,
/// IDs of steps that were rejected
pub rejected_ids: Vec<String>,
}
impl PlanApproval {
/// Create a new approval with empty lists
pub fn new() -> Self {
Self {
approved_ids: Vec::new(),
rejected_ids: Vec::new(),
}
}
/// Create an approval that approves all given IDs
pub fn approve_all(ids: Vec<String>) -> Self {
Self {
approved_ids: ids,
rejected_ids: Vec::new(),
}
}
/// Create an approval that rejects all given IDs
pub fn reject_all(ids: Vec<String>) -> Self {
Self {
approved_ids: Vec::new(),
rejected_ids: ids,
}
}
/// Apply this approval to a plan
pub fn apply_to(&self, plan: &mut AccumulatedPlan) {
for id in &self.approved_ids {
plan.approve_step(id);
}
for id in &self.rejected_ids {
plan.reject_step(id);
}
}
}
impl Default for PlanApproval {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Plan Document Types (Original functionality)
// ============================================================================
/// Agent mode - normal execution or planning
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AgentMode {
@@ -218,6 +507,75 @@ impl PlanManager {
plans.sort();
Ok(plans)
}
// ========================================================================
// AccumulatedPlan persistence methods
// ========================================================================
/// Get the directory for accumulated plans (JSON files)
fn accumulated_plans_dir(&self) -> PathBuf {
self.plans_dir.join("accumulated")
}
/// Save an accumulated plan to disk
pub async fn save_accumulated_plan(&self, plan: &AccumulatedPlan) -> Result<PathBuf> {
let dir = self.accumulated_plans_dir();
tokio::fs::create_dir_all(&dir).await?;
let filename = format!("{}.json", plan.id);
let path = dir.join(&filename);
let json = serde_json::to_string_pretty(plan)?;
tokio::fs::write(&path, json).await?;
Ok(path)
}
/// Load an accumulated plan by ID
pub async fn load_accumulated_plan(&self, id: &str) -> Result<AccumulatedPlan> {
let path = self.accumulated_plans_dir().join(format!("{}.json", id));
let content = tokio::fs::read_to_string(&path).await?;
let plan: AccumulatedPlan = serde_json::from_str(&content)?;
Ok(plan)
}
/// List all accumulated plans
pub async fn list_accumulated_plans(&self) -> Result<Vec<AccumulatedPlan>> {
let dir = self.accumulated_plans_dir();
let mut plans = Vec::new();
if !dir.exists() {
return Ok(plans);
}
let mut entries = tokio::fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if let Ok(plan) = serde_json::from_str(&content) {
plans.push(plan);
}
}
}
}
// Sort by creation time (newest first)
plans.sort_by(|a: &AccumulatedPlan, b: &AccumulatedPlan| {
b.created_at.cmp(&a.created_at)
});
Ok(plans)
}
/// Delete an accumulated plan
pub async fn delete_accumulated_plan(&self, id: &str) -> Result<()> {
let path = self.accumulated_plans_dir().join(format!("{}.json", id));
if path.exists() {
tokio::fs::remove_file(&path).await?;
}
Ok(())
}
}
/// Enter planning mode
@@ -247,6 +605,11 @@ pub fn is_tool_allowed_in_plan_mode(tool_name: &str) -> bool {
mod tests {
use super::*;
use tempfile::TempDir;
use serde_json::json;
// ========================================================================
// Plan Document Tests
// ========================================================================
#[tokio::test]
async fn test_create_plan() {
@@ -288,4 +651,231 @@ mod tests {
assert!(!mode.is_planning());
assert!(mode.plan_file().is_none());
}
// ========================================================================
// Plan Step Tests
// ========================================================================
#[test]
fn test_plan_step_new() {
let step = PlanStep::new(
"call_1".to_string(),
1,
"read".to_string(),
json!({"path": "/src/main.rs"}),
);
assert_eq!(step.id, "call_1");
assert_eq!(step.turn, 1);
assert_eq!(step.tool, "read");
assert!(step.is_pending());
assert!(!step.is_approved());
assert!(!step.is_rejected());
}
#[test]
fn test_plan_step_with_rationale() {
let step = PlanStep::new(
"call_1".to_string(),
1,
"read".to_string(),
json!({"path": "/src/main.rs"}),
).with_rationale("Need to read the main entry point".to_string());
assert_eq!(step.rationale, Some("Need to read the main entry point".to_string()));
}
// ========================================================================
// Accumulated Plan Tests
// ========================================================================
#[test]
fn test_accumulated_plan_new() {
let plan = AccumulatedPlan::new();
assert!(!plan.id.is_empty());
assert!(plan.name.is_none());
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
assert!(plan.steps.is_empty());
assert_eq!(plan.current_turn, 0);
}
#[test]
fn test_accumulated_plan_with_name() {
let plan = AccumulatedPlan::with_name("Fix bug #123".to_string());
assert_eq!(plan.name, Some("Fix bug #123".to_string()));
}
#[test]
fn test_accumulated_plan_add_steps() {
let mut plan = AccumulatedPlan::new();
plan.next_turn();
plan.add_step_for_current_turn(
"call_1".to_string(),
"read".to_string(),
json!({"path": "src/main.rs"}),
);
plan.next_turn();
plan.add_step_with_rationale(
"call_2".to_string(),
"edit".to_string(),
json!({"path": "src/main.rs", "old": "foo", "new": "bar"}),
"Fix the typo".to_string(),
);
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].turn, 1);
assert_eq!(plan.steps[1].turn, 2);
assert!(plan.steps[1].rationale.is_some());
}
#[test]
fn test_accumulated_plan_approval() {
let mut plan = AccumulatedPlan::new();
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({}));
plan.add_step_for_current_turn("call_3".to_string(), "bash".to_string(), json!({}));
// Initial state: all pending
let (pending, approved, rejected) = plan.counts();
assert_eq!((pending, approved, rejected), (3, 0, 0));
// Approve one, reject one
plan.approve_step("call_1");
plan.reject_step("call_3");
let (pending, approved, rejected) = plan.counts();
assert_eq!((pending, approved, rejected), (1, 1, 1));
assert!(!plan.all_decided());
// Approve remaining
plan.approve_step("call_2");
assert!(plan.all_decided());
}
#[test]
fn test_accumulated_plan_approve_all() {
let mut plan = AccumulatedPlan::new();
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({}));
plan.approve_all();
assert!(plan.all_decided());
assert_eq!(plan.approved_steps().len(), 2);
}
#[test]
fn test_accumulated_plan_status_transitions() {
let mut plan = AccumulatedPlan::new();
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
plan.finalize();
assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing);
plan.start_execution();
assert_eq!(plan.status, AccumulatedPlanStatus::Executing);
plan.complete();
assert_eq!(plan.status, AccumulatedPlanStatus::Completed);
}
#[test]
fn test_accumulated_plan_cancel() {
let mut plan = AccumulatedPlan::new();
plan.cancel();
assert_eq!(plan.status, AccumulatedPlanStatus::Cancelled);
}
// ========================================================================
// Plan Approval Tests
// ========================================================================
#[test]
fn test_plan_approval_apply() {
let mut plan = AccumulatedPlan::new();
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
plan.add_step_for_current_turn("call_2".to_string(), "write".to_string(), json!({}));
plan.add_step_for_current_turn("call_3".to_string(), "bash".to_string(), json!({}));
let approval = PlanApproval {
approved_ids: vec!["call_1".to_string(), "call_2".to_string()],
rejected_ids: vec!["call_3".to_string()],
};
approval.apply_to(&mut plan);
assert!(plan.steps[0].is_approved());
assert!(plan.steps[1].is_approved());
assert!(plan.steps[2].is_rejected());
}
#[test]
fn test_plan_approval_helpers() {
let ids = vec!["a".to_string(), "b".to_string()];
let approval = PlanApproval::approve_all(ids.clone());
assert_eq!(approval.approved_ids, ids);
assert!(approval.rejected_ids.is_empty());
let rejection = PlanApproval::reject_all(ids.clone());
assert!(rejection.approved_ids.is_empty());
assert_eq!(rejection.rejected_ids, ids);
}
// ========================================================================
// Accumulated Plan Persistence Tests
// ========================================================================
#[tokio::test]
async fn test_save_and_load_accumulated_plan() {
let temp_dir = TempDir::new().unwrap();
let manager = PlanManager::new(temp_dir.path().to_path_buf());
let mut plan = AccumulatedPlan::with_name("Test Plan".to_string());
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({"path": "test.txt"}));
plan.approve_step("call_1");
let path = manager.save_accumulated_plan(&plan).await.unwrap();
assert!(path.exists());
let loaded = manager.load_accumulated_plan(&plan.id).await.unwrap();
assert_eq!(loaded.id, plan.id);
assert_eq!(loaded.name, Some("Test Plan".to_string()));
assert_eq!(loaded.steps.len(), 1);
assert!(loaded.steps[0].is_approved());
}
#[tokio::test]
async fn test_list_accumulated_plans() {
let temp_dir = TempDir::new().unwrap();
let manager = PlanManager::new(temp_dir.path().to_path_buf());
// Create two plans
let plan1 = AccumulatedPlan::with_name("Plan 1".to_string());
let plan2 = AccumulatedPlan::with_name("Plan 2".to_string());
manager.save_accumulated_plan(&plan1).await.unwrap();
manager.save_accumulated_plan(&plan2).await.unwrap();
let plans = manager.list_accumulated_plans().await.unwrap();
assert_eq!(plans.len(), 2);
}
#[tokio::test]
async fn test_delete_accumulated_plan() {
let temp_dir = TempDir::new().unwrap();
let manager = PlanManager::new(temp_dir.path().to_path_buf());
let plan = AccumulatedPlan::new();
let id = plan.id.clone();
manager.save_accumulated_plan(&plan).await.unwrap();
assert!(manager.load_accumulated_plan(&id).await.is_ok());
manager.delete_accumulated_plan(&id).await.unwrap();
assert!(manager.load_accumulated_plan(&id).await.is_err());
}
}