From b10fd56eeca4ea48c16b48d25561eb0de5c7b7c4 Mon Sep 17 00:00:00 2001 From: calvinsturm Date: Tue, 30 Jun 2026 19:20:37 -0700 Subject: [PATCH] Add lightweight update_plan tool --- src/agent.rs | 1 + src/agent/tool_helpers.rs | 55 +++++++++ src/agent_runtime.rs | 1 + src/agent_tests.rs | 49 ++++++++ src/chat_ui.rs | 44 ++++++- src/eval/runner_runtime.rs | 1 + src/events.rs | 1 + src/tools.rs | 4 + src/tools/catalog.rs | 26 ++++ src/tools/exec_plan.rs | 213 +++++++++++++++++++++++++++++++++ src/tools/schema.rs | 27 +++++ src/tools/tests.rs | 56 +++++++++ src/tui/render.rs | 75 ++++++++---- src/tui/state.rs | 9 ++ src/tui/state/events.rs | 43 ++++++- src/tui/state/tests.rs | 40 +++++++ tests/mcp_impl_regression.rs | 1 + tests/tool_call_accuracy_ci.rs | 1 + 18 files changed, 623 insertions(+), 24 deletions(-) create mode 100644 src/tools/exec_plan.rs diff --git a/src/agent.rs b/src/agent.rs index dcef5b8..aaa211a 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -132,6 +132,7 @@ pub struct Agent { pub plan_tool_enforcement: PlanToolEnforcementMode, pub mcp_pin_enforcement: McpPinEnforcementMode, pub plan_step_constraints: Vec, + pub current_plan: Vec, pub tool_call_budget: ToolCallBudget, pub mcp_runtime_trace: Vec, pub operator_queue: PendingMessageQueue, diff --git a/src/agent/tool_helpers.rs b/src/agent/tool_helpers.rs index d092491..e595209 100644 --- a/src/agent/tool_helpers.rs +++ b/src/agent/tool_helpers.rs @@ -996,6 +996,58 @@ impl Agent

{ }) } + pub(super) fn apply_update_plan_tool_success( + &mut self, + run_id: &str, + step: u32, + tc: &ToolCall, + ) { + let Ok(update) = crate::tools::parse_update_plan_args(&tc.arguments) else { + return; + }; + self.current_plan = update.items.clone(); + let in_progress = self + .current_plan + .iter() + .find(|item| matches!(item.status, crate::tools::PlanStatus::InProgress)) + .map(|item| item.step.clone()); + let pending = self + .current_plan + .iter() + .filter(|item| matches!(item.status, crate::tools::PlanStatus::Pending)) + .count(); + let completed = self + .current_plan + .iter() + .filter(|item| matches!(item.status, crate::tools::PlanStatus::Completed)) + .count(); + let items = self + .current_plan + .iter() + .map(|item| { + serde_json::json!({ + "step": item.step, + "status": item.status.as_str() + }) + }) + .collect::>(); + self.emit_event( + run_id, + step, + EventKind::PlanUpdated, + serde_json::json!({ + "tool_call_id": tc.id, + "name": tc.name, + "explanation": update.explanation, + "items": items, + "item_count": self.current_plan.len(), + "pending": pending, + "completed": completed, + "in_progress": in_progress + }), + ); + } + #[allow(clippy::too_many_arguments)] pub(super) fn handle_schema_repair_attempt( &mut self, @@ -1593,6 +1645,9 @@ impl Agent

{ } else { None }; + if final_ok && tc.name == "update_plan" { + self.apply_update_plan_tool_success(&run_id, step, tc); + } self.update_taint_for_tool_result(&run_id, step, tc, &content, messages.len(), taint_state); self.record_allowed_tool_result( &run_id, diff --git a/src/agent_runtime.rs b/src/agent_runtime.rs index b8eca2d..56db6fb 100644 --- a/src/agent_runtime.rs +++ b/src/agent_runtime.rs @@ -337,6 +337,7 @@ pub(crate) async fn run_agent_with_ui( plan_tool_enforcement: effective_plan_tool_enforcement, mcp_pin_enforcement: args.mcp_pin_enforcement, plan_step_constraints, + current_plan: Vec::new(), tool_call_budget: ToolCallBudget { max_wall_time_ms: if args.no_limits { 0 diff --git a/src/agent_tests.rs b/src/agent_tests.rs index b8d17a2..0646868 100644 --- a/src/agent_tests.rs +++ b/src/agent_tests.rs @@ -194,6 +194,7 @@ fn edit_workflow_system_prompt_matches_write_file_contract() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -340,6 +341,7 @@ async fn compaction_failure_emits_run_end_provider_error() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -459,6 +461,7 @@ async fn non_stream_mode_uses_non_stream_generate() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -554,6 +557,7 @@ async fn task_memory_message_is_injected_into_transcript() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -663,6 +667,7 @@ async fn build_initial_messages_contains_tool_contract_version_marker() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -3373,6 +3378,7 @@ async fn emits_tool_exec_target_before_exec_start() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -3481,6 +3487,7 @@ async fn plan_tool_enforcement_hard_denies_disallowed_tool() { step_id: "S1".to_string(), intended_tools: vec!["list_dir".to_string()], }], + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -3589,6 +3596,7 @@ async fn operator_interrupt_delivers_post_tool_and_cancels_remaining_turn_work() plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -3705,6 +3713,7 @@ async fn operator_next_delivers_at_turn_idle_without_interrupt() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -3818,6 +3827,7 @@ async fn halting_is_blocked_when_plan_steps_are_pending() { step_id: "S1".to_string(), intended_tools: vec!["read_file".to_string()], }], + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -3918,6 +3928,7 @@ async fn emits_step_lifecycle_events_for_pending_plan_halt() { step_id: "S1".to_string(), intended_tools: vec!["read_file".to_string()], }], + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4021,6 +4032,7 @@ async fn tool_budget_exceeded_returns_deterministic_exit() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget { max_total_tool_calls: 1, ..ToolCallBudget::default() @@ -4124,6 +4136,7 @@ async fn multiple_tool_calls_in_single_step_fail_with_protocol_violation() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4227,6 +4240,7 @@ async fn planner_enforced_final_output_uses_user_output_field() { step_id: "S1".to_string(), intended_tools: vec!["read_file".to_string()], }], + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4335,6 +4349,7 @@ async fn schema_repair_retry_happens_before_execution() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4452,6 +4467,7 @@ async fn repeated_malformed_tool_calls_fail_fast_with_protocol_violation() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4587,6 +4603,7 @@ async fn edit_aliases_do_not_trip_malformed_tool_guard() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4695,6 +4712,7 @@ async fn repeated_failed_unknown_tool_calls_are_blocked_by_repeat_guard() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4808,6 +4826,7 @@ async fn repeated_invalid_patch_format_fails_fast_with_protocol_violation() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -4949,6 +4968,7 @@ async fn runtime_post_write_verification_allows_finalize_without_model_read_back plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5118,6 +5138,7 @@ async fn runtime_post_write_missing_closeout_gets_one_bounded_final_answer_turn( plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5292,6 +5313,7 @@ async fn runtime_pre_tool_plan_text_does_not_count_as_post_tool_closeout() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5447,6 +5469,7 @@ async fn post_write_known_validation_goes_directly_to_validation_only_phase() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5578,6 +5601,7 @@ async fn echoed_tool_result_wrapper_is_blocked_before_finalization() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5707,6 +5731,7 @@ async fn fabricated_tool_result_after_read_gets_path_aware_write_recovery() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5836,6 +5861,7 @@ async fn echoed_box_wrapper_is_blocked_before_finalization() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -5962,6 +5988,7 @@ async fn runtime_noop_apply_patch_does_not_finalize_ok() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -6110,6 +6137,7 @@ async fn runtime_exact_final_answer_retry_allows_one_bounded_retry() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -6250,6 +6278,7 @@ async fn runtime_exact_final_answer_retry_classifies_noncompliant_output() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -6391,6 +6420,7 @@ async fn runtime_exact_final_answer_requires_successful_validation_command() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -6545,6 +6575,7 @@ async fn runtime_required_validation_guard_allows_one_bounded_shell_retry() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -6702,6 +6733,7 @@ async fn runtime_required_validation_phase_direct_handoff_repairs_empty_turn() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -6866,6 +6898,7 @@ async fn runtime_required_validation_phase_repairs_exact_final_answer_into_shell plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7025,6 +7058,7 @@ async fn runtime_required_validation_phase_repairs_prose_only_into_shell() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7182,6 +7216,7 @@ async fn runtime_required_validation_phase_repairs_wrong_tool_into_shell() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7349,6 +7384,7 @@ async fn runtime_post_validation_phase_blocks_tool_drift_and_recovers_to_final_a plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7516,6 +7552,7 @@ async fn runtime_failed_validation_blocks_shell_retry_until_code_changes() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7669,6 +7706,7 @@ async fn runtime_exact_final_answer_allows_matching_successful_validation_comman plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7816,6 +7854,7 @@ async fn runtime_exact_final_answer_recovers_wrapped_exact_block_after_validatio plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -7954,6 +7993,7 @@ async fn runtime_read_then_done_recovers_with_corrective_write_instruction() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -8111,6 +8151,7 @@ async fn runtime_post_write_guard_retry_is_machine_classified() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -8294,6 +8335,7 @@ async fn runtime_post_write_verification_timeout_fails_deterministically() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget { post_write_verify_timeout_ms: 50, tool_exec_timeout_ms: 30_000, @@ -8452,6 +8494,7 @@ async fn runtime_tool_execution_timeout_is_bounded() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget { tool_exec_timeout_ms: 50, post_write_verify_timeout_ms: 5_000, @@ -8569,6 +8612,7 @@ async fn invalid_patch_format_attempts_are_scoped_per_tool_key() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -8684,6 +8728,7 @@ async fn tool_only_prompt_repairs_once_then_allows_tool_call() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -8827,6 +8872,7 @@ async fn repeated_failed_str_replace_forces_pivot_before_repeat_block() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -8993,6 +9039,7 @@ async fn repeated_failed_apply_patch_forces_smaller_fix_pivot_before_repeat_bloc plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -9121,6 +9168,7 @@ async fn tool_only_prompt_repeated_prose_fails_fast() { plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), @@ -9235,6 +9283,7 @@ async fn invalid_done_transition_fails_with_planner_error() { intended_tools: Vec::new(), }, ], + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: PendingMessageQueue::default(), diff --git a/src/chat_ui.rs b/src/chat_ui.rs index 2209bca..e53eab0 100644 --- a/src/chat_ui.rs +++ b/src/chat_ui.rs @@ -9,7 +9,7 @@ mod overlay; use overlay::{draw_learn_overlay, render_with_caret}; pub(crate) use overlay::{LearnOverlayRenderModel, LearnOverlaySummaryChoice, LearnOverlayTab}; -use crate::tui::state::{ToolRow, UiState}; +use crate::tui::state::{PlanRow, ToolRow, UiState}; #[allow(clippy::too_many_arguments)] pub(crate) fn draw_chat_frame( @@ -133,7 +133,8 @@ pub(crate) fn draw_chat_frame( outer[1], ); - let has_side = show_tools || show_approvals || show_thinking_panel; + let has_plan = !ui_state.plan_items.is_empty(); + let has_side = has_plan || show_tools || show_approvals || show_thinking_panel; let (chat_area, separator_area, side_area) = if has_side { let cols = Layout::default() .direction(Direction::Horizontal) @@ -214,11 +215,15 @@ pub(crate) fn draw_chat_frame( if let Some(side) = side_area { #[derive(Clone, Copy)] enum SidePane { + Plan, Tools, Approvals, Reasoning, } let mut panes = Vec::new(); + if has_plan { + panes.push(SidePane::Plan); + } if show_tools { panes.push(SidePane::Tools); } @@ -242,6 +247,7 @@ pub(crate) fn draw_chat_frame( for (idx, pane) in panes.iter().enumerate() { let area = splits[idx]; match pane { + SidePane::Plan => draw_plan_pane(f, area, &ui_state.plan_items), SidePane::Tools => draw_tools_pane( f, area, @@ -454,6 +460,40 @@ pub(crate) fn draw_chat_frame( } } +fn draw_plan_pane(f: &mut ratatui::Frame<'_>, area: ratatui::layout::Rect, items: &[PlanRow]) { + let completed = items + .iter() + .filter(|item| item.status == "completed") + .count(); + let title = format!("Plan {completed}/{}", items.len()); + let body = items + .iter() + .map(|item| { + let mark = match item.status.as_str() { + "completed" => "x", + "in_progress" => ">", + _ => " ", + }; + format!("[{mark}] {}", item.step) + }) + .collect::>() + .join("\n"); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(area); + f.render_widget( + Paragraph::new(title).style(Style::default().fg(Color::DarkGray)), + layout[0], + ); + f.render_widget( + Paragraph::new(body) + .style(Style::default().fg(Color::DarkGray)) + .wrap(Wrap { trim: false }), + layout[1], + ); +} + fn draw_tools_pane( f: &mut ratatui::Frame<'_>, area: ratatui::layout::Rect, diff --git a/src/eval/runner_runtime.rs b/src/eval/runner_runtime.rs index 28226aa..181fc64 100644 --- a/src/eval/runner_runtime.rs +++ b/src/eval/runner_runtime.rs @@ -514,6 +514,7 @@ pub(crate) async fn run_single( plan_tool_enforcement: crate::agent::PlanToolEnforcementMode::Off, mcp_pin_enforcement: crate::agent::McpPinEnforcementMode::Hard, plan_step_constraints: Vec::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget { max_wall_time_ms: task_max_wall_time_ms, max_total_tool_calls: 0, diff --git a/src/events.rs b/src/events.rs index 8870519..8b5a25f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -19,6 +19,7 @@ pub enum EventKind { ToolExecStart, ToolExecEnd, ShellOutputChunk, + PlanUpdated, PostWriteVerifyStart, PostWriteVerifyEnd, ToolRetry, diff --git a/src/tools.rs b/src/tools.rs index 72cf668..d04c5e2 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -11,6 +11,7 @@ use crate::types::{Message, SideEffects, ToolCall}; mod catalog; mod envelope; mod exec_fs; +mod exec_plan; mod exec_shell; mod exec_support; mod exec_write; @@ -22,6 +23,8 @@ pub use envelope::{ envelope_to_message, invalid_args_tool_message, to_tool_result_envelope, to_tool_result_envelope_with_error, }; +pub(crate) use exec_plan::parse_update_plan_args; +pub use exec_plan::{PlanItem, PlanStatus}; use exec_support::ToolExecution; pub use schema::{ compact_builtin_schema, invalid_args_detail, minimal_builtin_example, @@ -198,6 +201,7 @@ pub async fn execute_tool_streaming( "read_file" => exec_fs::run_read_file(rt, &normalized_args).await, "glob" => exec_fs::run_glob(rt, &normalized_args).await, "grep" => exec_fs::run_grep(rt, &normalized_args).await, + "update_plan" => exec_plan::run_update_plan(rt, &normalized_args).await, "shell" => exec_shell::run_shell(rt, &normalized_args, shell_stream).await, "write_file" => exec_write::run_write_file(rt, &normalized_args).await, "apply_patch" => exec_write::run_apply_patch(rt, &normalized_args).await, diff --git a/src/tools/catalog.rs b/src/tools/catalog.rs index 94577c6..612a021 100644 --- a/src/tools/catalog.rs +++ b/src/tools/catalog.rs @@ -5,6 +5,7 @@ use crate::types::{SideEffects, ToolDef}; pub fn tool_side_effects(tool_name: &str) -> SideEffects { match tool_name { "list_dir" | "read_file" | "glob" | "grep" => SideEffects::FilesystemRead, + "update_plan" => SideEffects::None, "shell" => SideEffects::ShellExec, "write_file" | "apply_patch" | "edit" | "str_replace" => SideEffects::FilesystemWrite, _ if tool_name.starts_with("mcp.playwright.") => SideEffects::Browser, @@ -64,6 +65,31 @@ pub fn builtin_tools_enabled(enable_write_tools: bool, enable_shell_tool: bool) }), side_effects: SideEffects::FilesystemRead, }, + ToolDef { + name: "update_plan".to_string(), + description: "Update the current in-run plan. Provide the full current list of steps with status pending, in_progress, or completed; at most one item may be in_progress.".to_string(), + parameters: json!({ + "type":"object", + "properties":{ + "explanation":{"type":"string"}, + "items":{ + "type":"array", + "items":{ + "type":"object", + "properties":{ + "step":{"type":"string"}, + "status":{"type":"string","enum":["pending","in_progress","completed"]} + }, + "required":["step","status"] + }, + "minItems":1, + "maxItems":20 + } + }, + "required":["items"] + }), + side_effects: SideEffects::None, + }, ]; if enable_shell_tool { tools.push(ToolDef { diff --git a/src/tools/exec_plan.rs b/src/tools/exec_plan.rs new file mode 100644 index 0000000..07baf0e --- /dev/null +++ b/src/tools/exec_plan.rs @@ -0,0 +1,213 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::types::SideEffects; + +use super::exec_support::{base_meta, failed_exec, ToolExecution}; +use super::{ToolErrorCode, ToolErrorDetail, ToolRuntime}; + +pub(crate) const MAX_PLAN_ITEMS: usize = 20; +pub(crate) const MAX_PLAN_STEP_CHARS: usize = 240; +pub(crate) const MAX_PLAN_EXPLANATION_CHARS: usize = 500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanStatus { + Pending, + InProgress, + Completed, +} + +impl PlanStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::InProgress => "in_progress", + Self::Completed => "completed", + } + } + + fn from_str(raw: &str) -> Option { + match raw { + "pending" => Some(Self::Pending), + "in_progress" => Some(Self::InProgress), + "completed" => Some(Self::Completed), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PlanItem { + pub step: String, + pub status: PlanStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlanUpdate { + pub(crate) explanation: Option, + pub(crate) items: Vec, +} + +pub(crate) fn parse_update_plan_args(args: &Value) -> Result { + let obj = args + .as_object() + .ok_or_else(|| "arguments must be a JSON object".to_string())?; + let explanation = obj + .get("explanation") + .and_then(|v| v.as_str()) + .map(|s| truncate_chars(s.trim(), MAX_PLAN_EXPLANATION_CHARS)) + .filter(|s| !s.is_empty()); + let items = obj + .get("items") + .and_then(|v| v.as_array()) + .ok_or_else(|| "items must be an array".to_string())?; + if items.is_empty() { + return Err("items must contain at least one plan item".to_string()); + } + if items.len() > MAX_PLAN_ITEMS { + return Err(format!( + "items must contain at most {MAX_PLAN_ITEMS} entries" + )); + } + + let mut parsed = Vec::with_capacity(items.len()); + let mut in_progress_count = 0usize; + for (idx, item) in items.iter().enumerate() { + let item_obj = item + .as_object() + .ok_or_else(|| format!("items[{idx}] must be an object"))?; + let step = item_obj + .get("step") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("items[{idx}].step must be a non-empty string"))?; + let status_raw = item_obj + .get("status") + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("items[{idx}].status must be a string"))?; + let status = PlanStatus::from_str(status_raw).ok_or_else(|| { + format!("items[{idx}].status must be one of pending, in_progress, completed") + })?; + if matches!(status, PlanStatus::InProgress) { + in_progress_count += 1; + } + parsed.push(PlanItem { + step: truncate_chars(step, MAX_PLAN_STEP_CHARS), + status, + }); + } + if in_progress_count > 1 { + return Err("at most one plan item can be in_progress".to_string()); + } + + Ok(PlanUpdate { + explanation, + items: parsed, + }) +} + +pub(super) async fn run_update_plan(rt: &ToolRuntime, args: &Value) -> ToolExecution { + let update = match parse_update_plan_args(args) { + Ok(update) => update, + Err(err) => { + return failed_exec( + rt, + SideEffects::None, + format!("update_plan failed: {err}"), + Some(ToolErrorDetail { + code: ToolErrorCode::ToolArgsInvalid, + message: err, + expected_schema: super::compact_builtin_schema("update_plan"), + received_args: Some(args.clone()), + minimal_example: super::minimal_builtin_example("update_plan"), + available_tools: None, + }), + ); + } + }; + let pending = update + .items + .iter() + .filter(|item| matches!(item.status, PlanStatus::Pending)) + .count(); + let completed = update + .items + .iter() + .filter(|item| matches!(item.status, PlanStatus::Completed)) + .count(); + let in_progress = update + .items + .iter() + .find(|item| matches!(item.status, PlanStatus::InProgress)) + .map(|item| item.step.clone()); + + ToolExecution { + ok: true, + content: serde_json::json!({ + "updated": true, + "items": update.items.len(), + "pending": pending, + "completed": completed, + "in_progress": in_progress + }) + .to_string(), + truncated: false, + error: None, + meta: base_meta(rt, SideEffects::None), + } +} + +fn truncate_chars(input: &str, max_chars: usize) -> String { + if input.chars().count() <= max_chars { + return input.to_string(); + } + input.chars().take(max_chars).collect() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{parse_update_plan_args, PlanStatus, MAX_PLAN_STEP_CHARS}; + + #[test] + fn parse_update_plan_accepts_one_in_progress() { + let update = parse_update_plan_args(&json!({ + "explanation": "working", + "items": [ + {"step":"Inspect code", "status":"completed"}, + {"step":"Implement plan tool", "status":"in_progress"}, + {"step":"Run tests", "status":"pending"} + ] + })) + .expect("valid plan"); + assert_eq!(update.explanation.as_deref(), Some("working")); + assert_eq!(update.items.len(), 3); + assert_eq!(update.items[1].status, PlanStatus::InProgress); + } + + #[test] + fn parse_update_plan_rejects_multiple_in_progress_items() { + let err = parse_update_plan_args(&json!({ + "items": [ + {"step":"A", "status":"in_progress"}, + {"step":"B", "status":"in_progress"} + ] + })) + .expect_err("multiple in-progress entries rejected"); + assert!(err.contains("at most one")); + } + + #[test] + fn parse_update_plan_truncates_long_step_text() { + let update = parse_update_plan_args(&json!({ + "items": [ + {"step":"x".repeat(MAX_PLAN_STEP_CHARS + 20), "status":"pending"} + ] + })) + .expect("valid plan"); + assert_eq!(update.items[0].step.len(), MAX_PLAN_STEP_CHARS); + } +} diff --git a/src/tools/schema.rs b/src/tools/schema.rs index 5b78eba..4b27a74 100644 --- a/src/tools/schema.rs +++ b/src/tools/schema.rs @@ -28,6 +28,26 @@ pub fn compact_builtin_schema(tool_name: &str) -> Option { "ignore_case":{"type":"boolean"} } })), + "update_plan" => Some(json!({ + "type":"object", + "required":["items"], + "properties":{ + "explanation":{"type":"string"}, + "items":{ + "type":"array", + "minItems":1, + "maxItems":20, + "items":{ + "type":"object", + "required":["step","status"], + "properties":{ + "step":{"type":"string"}, + "status":{"type":"string","enum":["pending","in_progress","completed"]} + } + } + } + } + })), "shell" => Some(json!({ "type":"object", "required":["cmd"], @@ -84,6 +104,9 @@ pub fn minimal_builtin_example(tool_name: &str) -> Option { "read_file" => Some(json!({"path":"src/main.rs"})), "glob" => Some(json!({"pattern":"src/**/*.rs","path":".","max_results":200})), "grep" => Some(json!({"pattern":"TODO","path":".","max_results":200,"ignore_case":false})), + "update_plan" => Some( + json!({"items":[{"step":"Inspect the code","status":"in_progress"},{"step":"Run tests","status":"pending"}]}), + ), "shell" => Some(json!({"cmd":"echo","args":["hello"]})), "write_file" => Some(json!({"path":"notes.txt","content":"hello"})), "apply_patch" => Some(json!({"path":"src/main.rs","patch":"@@ -1 +1 @@\n-a\n+b\n"})), @@ -102,6 +125,7 @@ pub fn sorted_builtin_tool_names() -> Vec { "list_dir".to_string(), "glob".to_string(), "grep".to_string(), + "update_plan".to_string(), "read_file".to_string(), "edit".to_string(), "apply_patch".to_string(), @@ -172,6 +196,9 @@ pub fn validate_builtin_tool_args( } } } + "update_plan" => { + super::exec_plan::parse_update_plan_args(args).map(|_| ())?; + } "shell" => { require_non_empty_string(obj, "cmd")?; if let Some(v) = obj.get("args") { diff --git a/src/tools/tests.rs b/src/tools/tests.rs index eb2fe9e..0f22990 100644 --- a/src/tools/tests.rs +++ b/src/tools/tests.rs @@ -36,6 +36,7 @@ fn write_tools_not_exposed_by_default() { let names = tools.into_iter().map(|t| t.name).collect::>(); assert!(names.iter().any(|n| n == "glob")); assert!(names.iter().any(|n| n == "grep")); + assert!(names.iter().any(|n| n == "update_plan")); assert!(!names.iter().any(|n| n == "shell")); assert!(!names.iter().any(|n| n == "edit")); assert!(!names.iter().any(|n| n == "write_file")); @@ -92,6 +93,7 @@ fn builtin_tool_order_biases_toward_read_edit_verify_validate_flow() { "list_dir", "glob", "grep", + "update_plan", "read_file", "edit", "apply_patch", @@ -110,6 +112,7 @@ fn side_effects_map_builtin_and_mcp() { assert_eq!(tool_side_effects("list_dir"), SideEffects::FilesystemRead); assert_eq!(tool_side_effects("glob"), SideEffects::FilesystemRead); assert_eq!(tool_side_effects("grep"), SideEffects::FilesystemRead); + assert_eq!(tool_side_effects("update_plan"), SideEffects::None); assert_eq!( tool_side_effects("mcp.playwright.browser_snapshot"), SideEffects::Browser @@ -117,6 +120,58 @@ fn side_effects_map_builtin_and_mcp() { assert_eq!(tool_side_effects("mcp.other.echo"), SideEffects::Network); } +#[tokio::test] +async fn update_plan_returns_compact_side_effect_free_result() { + let rt = ToolRuntime { + workdir: PathBuf::from("."), + allow_shell: false, + allow_shell_in_workdir_only: false, + allow_write: false, + max_tool_output_bytes: 200_000, + max_read_bytes: 200_000, + unsafe_bypass_allow_flags: false, + tool_args_strict: ToolArgsStrict::On, + exec_target_kind: ExecTargetKind::Host, + exec_target: std::sync::Arc::new(HostTarget), + }; + let tc = ToolCall { + id: "plan_1".to_string(), + name: "update_plan".to_string(), + arguments: json!({ + "items": [ + {"step":"Inspect code", "status":"completed"}, + {"step":"Implement update_plan", "status":"in_progress"}, + {"step":"Run tests", "status":"pending"} + ] + }), + }; + let msg = execute_tool(&rt, &tc).await; + let parsed: Value = serde_json::from_str(&msg.content.unwrap_or_default()).expect("json"); + assert_eq!(parsed["ok"], json!(true)); + assert_eq!(parsed["meta"]["side_effects"], json!("none")); + let inner: Value = serde_json::from_str(parsed["content"].as_str().unwrap()).expect("inner"); + assert_eq!(inner["items"], json!(3)); + assert_eq!(inner["completed"], json!(1)); + assert_eq!(inner["pending"], json!(1)); + assert_eq!(inner["in_progress"], json!("Implement update_plan")); +} + +#[test] +fn update_plan_rejects_multiple_in_progress_items() { + let err = validate_builtin_tool_args( + "update_plan", + &json!({ + "items": [ + {"step":"A", "status":"in_progress"}, + {"step":"B", "status":"in_progress"} + ] + }), + ToolArgsStrict::On, + ) + .expect_err("invalid plan rejected"); + assert!(err.contains("at most one")); +} + #[tokio::test] async fn write_file_denied_when_allow_write_false() { let rt = ToolRuntime { @@ -266,6 +321,7 @@ async fn unknown_tool_payload_includes_sorted_available_tools() { json!("read_file"), json!("shell"), json!("str_replace"), + json!("update_plan"), json!("write_file"), ]; assert_eq!(got, expected); diff --git a/src/tui/render.rs b/src/tui/render.rs index 4889870..f8174d1 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -4,7 +4,7 @@ use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}; use ratatui::Frame; -use crate::tui::state::{ApprovalRow, ToolRow, UiState}; +use crate::tui::state::{ApprovalRow, PlanRow, ToolRow, UiState}; pub fn draw(frame: &mut Frame<'_>, state: &UiState, approvals_selected: usize) { let outer = Layout::default() @@ -64,7 +64,18 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, approvals_selected: usize) { .wrap(Wrap { trim: false }), mid[0], ); - let right = if state.show_details { + let has_plan = !state.plan_items.is_empty(); + let right = if state.show_details && has_plan { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(7), + Constraint::Length(6), + Constraint::Percentage(45), + Constraint::Percentage(55), + ]) + .split(mid[1]) + } else if state.show_details { Layout::default() .direction(Direction::Vertical) .constraints([ @@ -73,6 +84,15 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, approvals_selected: usize) { Constraint::Percentage(52), ]) .split(mid[1]) + } else if has_plan { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(mid[1]) } else { Layout::default() .direction(Direction::Vertical) @@ -122,26 +142,14 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, approvals_selected: usize) { ); } - draw_tools_table( - frame, - if state.show_details { - right[1] - } else { - right[0] - }, - state, - ); + let mut next_right_idx = usize::from(state.show_details); + if has_plan { + draw_plan_table(frame, right[next_right_idx], &state.plan_items); + next_right_idx += 1; + } - draw_approvals_table( - frame, - if state.show_details { - right[2] - } else { - right[1] - }, - state, - approvals_selected, - ); + draw_tools_table(frame, right[next_right_idx], state); + draw_approvals_table(frame, right[next_right_idx + 1], state, approvals_selected); let logs = state.logs.join("\n"); frame.render_widget( @@ -152,6 +160,31 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, approvals_selected: usize) { ); } +fn draw_plan_table(frame: &mut Frame<'_>, area: Rect, items: &[PlanRow]) { + let completed = items + .iter() + .filter(|item| item.status == "completed") + .count(); + let rows = items.iter().map(|item| { + let mark = match item.status.as_str() { + "completed" => "x", + "in_progress" => ">", + _ => " ", + }; + Row::new(vec![Cell::from(mark), Cell::from(fit_cell(&item.step, 48))]) + }); + frame.render_widget( + Table::new(rows, [Constraint::Length(1), Constraint::Min(8)]) + .header(Row::new(vec!["", "Step"])) + .block( + Block::default() + .title(format!("Plan {completed}/{}", items.len())) + .borders(Borders::ALL), + ), + area, + ); +} + fn draw_tools_table(frame: &mut Frame<'_>, area: Rect, state: &UiState) { let inner_width = area.width.saturating_sub(2).max(1); let compact = inner_width < 52; diff --git a/src/tui/state.rs b/src/tui/state.rs index 071e634..c7bd8af 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -37,6 +37,12 @@ pub struct ApprovalRow { pub exec_target: String, } +#[derive(Debug, Clone, Default)] +pub struct PlanRow { + pub step: String, + pub status: String, +} + #[derive(Debug, Clone)] pub struct UiState { pub run_id: String, @@ -58,6 +64,7 @@ pub struct UiState { pub net_status: String, pub assistant_text: String, pub tool_calls: Vec, + pub plan_items: Vec, pub pending_approvals: Vec, pub logs: Vec, pub exit_reason: Option, @@ -101,6 +108,7 @@ impl UiState { net_status: "OK".to_string(), assistant_text: String::new(), tool_calls: Vec::new(), + plan_items: Vec::new(), pending_approvals: Vec::new(), logs: Vec::new(), exit_reason: None, @@ -138,6 +146,7 @@ impl UiState { EventKind::ToolExecStart => self.apply_tool_exec_start_event(ev), EventKind::ToolExecEnd => self.apply_tool_exec_end_event(ev), EventKind::ShellOutputChunk => self.apply_shell_output_chunk_event(ev), + EventKind::PlanUpdated => self.apply_plan_updated_event(ev), EventKind::PostWriteVerifyStart => self.apply_post_write_verify_start_event(ev), EventKind::PostWriteVerifyEnd => self.apply_post_write_verify_end_event(ev), EventKind::PolicyLoaded => self.apply_policy_loaded_event(ev), diff --git a/src/tui/state/events.rs b/src/tui/state/events.rs index da91d69..d291e91 100644 --- a/src/tui/state/events.rs +++ b/src/tui/state/events.rs @@ -3,7 +3,7 @@ use crate::events::{Event, EventKind}; use super::support::{ class_to_reason_token, is_mcp_tool, is_protocol_violation_text, reason_token, truncate_chars, }; -use super::UiState; +use super::{PlanRow, UiState}; impl UiState { pub(super) fn apply_tool_call_detected_event(&mut self, ev: &Event) { @@ -700,6 +700,47 @@ impl UiState { } } + pub(super) fn apply_plan_updated_event(&mut self, ev: &Event) { + let items = ev + .data + .get("items") + .and_then(|v| v.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(PlanRow { + step: item.get("step")?.as_str()?.to_string(), + status: item.get("status")?.as_str()?.to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default(); + self.plan_items = items; + let total = self.plan_items.len(); + let in_progress = self + .plan_items + .iter() + .find(|item| item.status == "in_progress") + .map(|item| item.step.as_str()) + .unwrap_or("-"); + let completed = self + .plan_items + .iter() + .filter(|item| item.status == "completed") + .count(); + if let Some(explanation) = ev.data.get("explanation").and_then(|v| v.as_str()) { + if !explanation.trim().is_empty() { + self.push_log(format!( + "plan: {completed}/{total} active={in_progress} note={explanation}" + )); + return; + } + } + self.push_log(format!("plan: {completed}/{total} active={in_progress}")); + } + /// Render a live shell output chunk into the log/tail area while the command /// is still running. stderr is distinguished with an `err>` prefix; stdout /// with `out>`. Each incoming line is pushed separately so the ring-buffered diff --git a/src/tui/state/tests.rs b/src/tui/state/tests.rs index c69ab07..1aaca24 100644 --- a/src/tui/state/tests.rs +++ b/src/tui/state/tests.rs @@ -212,6 +212,46 @@ fn shell_output_chunk_logs_stream_identity_and_caps() { ); } +#[test] +fn plan_updated_event_replaces_current_plan_and_logs_summary() { + let mut s = UiState::new(10); + s.apply_event(&Event::new( + "r1".to_string(), + 1, + EventKind::PlanUpdated, + serde_json::json!({ + "tool_call_id":"plan1", + "items":[ + {"step":"Inspect code","status":"completed"}, + {"step":"Implement update_plan","status":"in_progress"}, + {"step":"Run tests","status":"pending"} + ], + "explanation":"implementation underway" + }), + )); + + assert_eq!(s.plan_items.len(), 3); + assert_eq!(s.plan_items[1].step, "Implement update_plan"); + assert_eq!(s.plan_items[1].status, "in_progress"); + let last = s.logs.last().cloned().unwrap_or_default(); + assert!(last.contains("plan: 1/3")); + assert!(last.contains("active=Implement update_plan")); + + s.apply_event(&Event::new( + "r1".to_string(), + 1, + EventKind::PlanUpdated, + serde_json::json!({ + "tool_call_id":"plan2", + "items":[ + {"step":"Inspect code","status":"completed"} + ] + }), + )); + assert_eq!(s.plan_items.len(), 1); + assert_eq!(s.plan_items[0].status, "completed"); +} + #[test] fn approvals_refresh_and_transition() { let tmp = tempdir().expect("tmp"); diff --git a/tests/mcp_impl_regression.rs b/tests/mcp_impl_regression.rs index a241e06..496fd0f 100644 --- a/tests/mcp_impl_regression.rs +++ b/tests/mcp_impl_regression.rs @@ -257,6 +257,7 @@ fn make_agent_with_mcp( plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: localagent::operator_queue::PendingMessageQueue::default(), diff --git a/tests/tool_call_accuracy_ci.rs b/tests/tool_call_accuracy_ci.rs index fca4f75..7c4ca90 100644 --- a/tests/tool_call_accuracy_ci.rs +++ b/tests/tool_call_accuracy_ci.rs @@ -195,6 +195,7 @@ fn build_agent( plan_tool_enforcement: PlanToolEnforcementMode::Off, mcp_pin_enforcement: McpPinEnforcementMode::Hard, plan_step_constraints: Vec::::new(), + current_plan: Vec::new(), tool_call_budget: ToolCallBudget::default(), mcp_runtime_trace: Vec::new(), operator_queue: localagent::operator_queue::PendingMessageQueue::default(),