From 002020b87884c047441b34fd6ad2e1950dd88826 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 25 Mar 2026 16:58:27 +0000 Subject: [PATCH 1/2] feat(tui): Add Planning Mode support with /plan and /code commands Implements Planning Mode feature that allows users to switch between planning-only mode and normal code execution mode via slash commands. Changes: - Add agent_mode reactive property to ConversationContainer ('plan' or 'code') - Add /plan and /code slash commands to switch modes - Add SetAgentMode message and handler in ConversationManager - Add PLANNING_MODE_INSTRUCTIONS to guide agent behavior in plan mode - Add blue mode indicator in WorkingStatusLine when in planning mode - Add blue border color for InputField when in planning mode - Update tests for new commands and add planning mode tests In Planning Mode: - Agent focuses on understanding requirements and asking questions - Agent generates PLAN.md instead of executing code - UI shows blue indicator and border to match GUI behavior Closes #613 Co-authored-by: openhands --- openhands_cli/tui/core/__init__.py | 5 + openhands_cli/tui/core/commands.py | 4 + .../tui/core/conversation_manager.py | 32 +++++ openhands_cli/tui/core/state.py | 23 +++- .../tui/core/user_message_controller.py | 47 +++++++- openhands_cli/tui/widgets/input_area.py | 18 +++ openhands_cli/tui/widgets/status_line.py | 34 +++++- .../tui/widgets/user_input/input_field.py | 25 ++++ tests/tui/core/test_planning_mode.py | 111 ++++++++++++++++++ tests/tui/test_commands.py | 8 +- 10 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 tests/tui/core/test_planning_mode.py diff --git a/openhands_cli/tui/core/__init__.py b/openhands_cli/tui/core/__init__.py index 9e7e23f45..f0d3e09c9 100644 --- a/openhands_cli/tui/core/__init__.py +++ b/openhands_cli/tui/core/__init__.py @@ -5,6 +5,7 @@ ConversationManager, CreateConversation, PauseConversation, + SetAgentMode, SetConfirmationPolicy, SwitchConfirmed, SwitchConversation, @@ -15,6 +16,7 @@ ShowConfirmationPanel, ) from openhands_cli.tui.core.state import ( + AgentMode, ConfirmationRequired, ConversationContainer, ConversationFinished, @@ -27,6 +29,8 @@ "ConversationContainer", "ConversationFinished", "ConfirmationRequired", + # Types + "AgentMode", # Manager "ConversationManager", # Operation Messages (input to ConversationManager) @@ -36,6 +40,7 @@ "PauseConversation", "CondenseConversation", "SetConfirmationPolicy", + "SetAgentMode", "SwitchConfirmed", # Events (App ↔ ConversationManager) "RequestSwitchConfirmation", diff --git a/openhands_cli/tui/core/commands.py b/openhands_cli/tui/core/commands.py index 41aa2bf2a..76407b73c 100644 --- a/openhands_cli/tui/core/commands.py +++ b/openhands_cli/tui/core/commands.py @@ -18,6 +18,8 @@ COMMANDS = [ DropdownItem(main="/help - Display available commands"), DropdownItem(main="/new - Start a new conversation"), + DropdownItem(main="/plan - Switch to Planning Mode (generate PLAN.md)"), + DropdownItem(main="/code - Switch to Code Mode (normal execution)"), DropdownItem(main="/history - Toggle conversation history"), DropdownItem(main="/settings - Open settings"), DropdownItem(main="/confirm - Configure confirmation settings"), @@ -73,6 +75,8 @@ def show_help(scroll_view: VerticalScroll) -> None: [{secondary}]/help[/{secondary}] - Display available commands [{secondary}]/new[/{secondary}] - Start a new conversation + [{secondary}]/plan[/{secondary}] - Switch to Planning Mode (generate PLAN.md) + [{secondary}]/code[/{secondary}] - Switch to Code Mode (normal execution) [{secondary}]/history[/{secondary}] - Toggle conversation history [{secondary}]/settings[/{secondary}] - Open settings [{secondary}]/confirm[/{secondary}] - Configure confirmation settings diff --git a/openhands_cli/tui/core/conversation_manager.py b/openhands_cli/tui/core/conversation_manager.py index 421553562..d1378c4fc 100644 --- a/openhands_cli/tui/core/conversation_manager.py +++ b/openhands_cli/tui/core/conversation_manager.py @@ -81,6 +81,18 @@ def __init__(self, policy: ConfirmationPolicyBase) -> None: self.policy = policy +class SetAgentMode(Message): + """Request to change the agent operating mode. + + Args: + mode: The agent mode to set ('plan' or 'code') + """ + + def __init__(self, mode: str) -> None: + super().__init__() + self.mode = mode + + class SwitchConfirmed(Message): """Internal message: User confirmed switch in modal.""" @@ -277,6 +289,26 @@ def _on_set_confirmation_policy(self, event: SetConfirmationPolicy) -> None: event.stop() self._policy_service.set_policy(event.policy) + @on(SetAgentMode) + def _on_set_agent_mode(self, event: SetAgentMode) -> None: + """Handle request to change agent operating mode.""" + event.stop() + mode = event.mode + if mode not in ("plan", "code"): + self.notify( + f"Invalid mode: {mode}. Use 'plan' or 'code'.", + title="Mode Error", + severity="error", + ) + return + + self._state.set_agent_mode(mode) # type: ignore[arg-type] + mode_display = "Planning" if mode == "plan" else "Code" + self.notify( + f"Switched to {mode_display} Mode", + severity="information", + ) + @on(ShowConfirmationPanel) def _on_show_confirmation_panel(self, event: ShowConfirmationPanel) -> None: event.stop() diff --git a/openhands_cli/tui/core/state.py b/openhands_cli/tui/core/state.py index 9b8e49cf7..769f8784a 100644 --- a/openhands_cli/tui/core/state.py +++ b/openhands_cli/tui/core/state.py @@ -3,6 +3,7 @@ This module provides: - ConversationContainer: UI container that owns and exposes reactive state - ConversationFinished: Message emitted when conversation finishes +- AgentMode: Literal type for agent operating modes ("plan" or "code") Architecture: ConversationContainer holds reactive properties that UI components bind to. @@ -27,7 +28,7 @@ import threading import time import uuid -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from textual.app import ComposeResult from textual.containers import Container @@ -43,6 +44,11 @@ from openhands_cli.stores import CriticSettings +# Agent operating mode: "plan" focuses on generating PLAN.md without code execution +# "code" is the default mode for normal code-writing and execution +AgentMode = Literal["plan", "code"] + + if TYPE_CHECKING: from rich.text import Text @@ -136,6 +142,10 @@ class ConversationContainer(Container): refinement_iteration: var[int] = var(0) """Current refinement iteration within a user turn. Resets on new user message.""" + # ---- Agent Mode ---- + agent_mode: var[AgentMode] = var("code") + """Agent operating mode: 'plan' for planning-only or 'code' for normal execution.""" + def __init__( self, initial_confirmation_policy: ConfirmationPolicyBase | None = None, @@ -198,12 +208,14 @@ def compose(self) -> ComposeResult: running=ConversationContainer.running, elapsed_seconds=ConversationContainer.elapsed_seconds, critic_settings=ConversationContainer.critic_settings, + agent_mode=ConversationContainer.agent_mode, ) yield InputField( placeholder="Type your message, @mention a file, or / for commands" ).data_bind( conversation_id=ConversationContainer.conversation_id, pending_action_count=ConversationContainer.pending_action_count, + agent_mode=ConversationContainer.agent_mode, ) yield InfoStatusLine().data_bind( running=ConversationContainer.running, @@ -395,6 +407,15 @@ def set_refinement_iteration(self, iteration: int) -> None: """ self._schedule_update("refinement_iteration", iteration) + def set_agent_mode(self, mode: AgentMode) -> None: + """Set the agent operating mode. Thread-safe. + + Args: + mode: 'plan' for planning-only mode (generates PLAN.md), + 'code' for normal code execution mode. + """ + self._schedule_update("agent_mode", mode) + # ---- Conversation Attachment (for metrics) ---- def attach_conversation_state( diff --git a/openhands_cli/tui/core/user_message_controller.py b/openhands_cli/tui/core/user_message_controller.py index 1484ff757..98d54ce1a 100644 --- a/openhands_cli/tui/core/user_message_controller.py +++ b/openhands_cli/tui/core/user_message_controller.py @@ -13,6 +13,29 @@ from openhands_cli.tui.core.state import ConversationContainer +# Planning mode instructions prepended to user messages when in plan mode +PLANNING_MODE_INSTRUCTIONS = """ + +You are currently in PLANNING MODE. In this mode: + +1. **DO NOT execute any code** - Do not use terminal, file_editor, or tools +2. **Focus on understanding** - Ask clarifying questions about requirements +3. **Create a PLAN.md file** - Generate a structured plan document with: + - Problem statement and requirements + - Proposed approach and architecture + - Step-by-step implementation plan + - Potential challenges and mitigations + - Success criteria and testing approach +4. **Be thorough** - Identify edge cases, dependencies, and constraints +5. **Seek confirmation** - Ask user to confirm before they switch to Code Mode + +When you've gathered enough information, create PLAN.md in the workspace root. + + +User's request: +""" + + class UserMessageController: def __init__( self, @@ -43,13 +66,33 @@ async def handle_user_message(self, content: str) -> None: runner = self._runners.get_or_create(self._state.conversation_id) - # Render user message to UI + # Render user message to UI (show original content without instructions) runner.visualizer.render_user_message(content) # Update conversation title (for history panel) self._state.set_conversation_title(content) - await self._process_message(runner, content) + # Apply planning mode instructions if in plan mode + message_content = self._apply_mode_instructions(content) + + await self._process_message(runner, message_content) + + def _apply_mode_instructions(self, content: str) -> str: + """Apply mode-specific instructions to the message content. + + In planning mode, prepends instructions that guide the agent to focus + on understanding requirements and generating a PLAN.md file instead + of executing code. + + Args: + content: The original user message content. + + Returns: + The message content with mode-specific instructions applied. + """ + if self._state.agent_mode == "plan": + return f"{PLANNING_MODE_INSTRUCTIONS}{content}" + return content async def handle_refinement_message(self, content: str) -> None: """Handle a system-generated refinement message. diff --git a/openhands_cli/tui/widgets/input_area.py b/openhands_cli/tui/widgets/input_area.py index 1a38bd614..b802bc379 100644 --- a/openhands_cli/tui/widgets/input_area.py +++ b/openhands_cli/tui/widgets/input_area.py @@ -78,6 +78,10 @@ def _on_slash_command_submitted(self, event: SlashCommandSubmitted) -> None: self._command_help() case "new": self._command_new() + case "plan": + self._command_plan() + case "code": + self._command_code() case "history": self._command_history() case "settings": @@ -112,6 +116,20 @@ def _command_new(self) -> None: # Message bubbles up to ConversationManager (ancestor) self.post_message(CreateConversation()) + def _command_plan(self) -> None: + """Handle the /plan command to switch to Planning Mode.""" + from openhands_cli.tui.core import SetAgentMode + + # Message bubbles up to ConversationManager (ancestor) + self.post_message(SetAgentMode("plan")) + + def _command_code(self) -> None: + """Handle the /code command to switch to Code Mode.""" + from openhands_cli.tui.core import SetAgentMode + + # Message bubbles up to ConversationManager (ancestor) + self.post_message(SetAgentMode("code")) + def _command_history(self) -> None: """Handle the /history command to show conversation history panel.""" diff --git a/openhands_cli/tui/widgets/status_line.py b/openhands_cli/tui/widgets/status_line.py index 1487e87a2..7c9d75591 100644 --- a/openhands_cli/tui/widgets/status_line.py +++ b/openhands_cli/tui/widgets/status_line.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from textual.reactive import var from textual.timer import Timer @@ -12,12 +13,16 @@ from openhands_cli.utils import abbreviate_number, format_cost +if TYPE_CHECKING: + from openhands_cli.tui.core.state import AgentMode + + class WorkingStatusLine(Static): - """Status line showing conversation timer and working indicator (above input). + """Status line showing timer, working indicator, and mode (above input). This widget uses data_bind() to bind to ConversationContainer reactive properties. - When ConversationContainer.running, elapsed_seconds, or critic_settings change, - this widget's corresponding properties are automatically updated. + When ConversationContainer.running, elapsed_seconds, critic_settings, or agent_mode + change, this widget's corresponding properties are automatically updated. """ DEFAULT_CSS = """ @@ -33,6 +38,7 @@ class WorkingStatusLine(Static): running: var[bool] = var(False) elapsed_seconds: var[int] = var(0) critic_settings: var[CriticSettings] = var(CriticSettings()) + agent_mode: var[AgentMode] = var("code") def __init__(self, **kwargs) -> None: super().__init__("", id="working_status_line", markup=True, **kwargs) @@ -61,6 +67,10 @@ def watch_critic_settings(self, _settings: CriticSettings) -> None: """React to critic settings changes from ConversationContainer.""" self._update_text() + def watch_agent_mode(self, _mode: AgentMode) -> None: + """React to agent mode changes from ConversationContainer.""" + self._update_text() + # ----- Internal helpers ----- def _on_tick(self) -> None: @@ -90,10 +100,26 @@ def _get_refinement_indicator(self) -> str: ) return "" + def _get_mode_indicator(self) -> str: + """Return the mode indicator based on current agent mode. + + In planning mode, shows a blue-colored indicator. + In code mode (default), returns empty string (no indicator needed). + """ + if self.agent_mode == "plan": + # Use accent color (#277dff) for planning mode - matches GUI blue + return "[bold #277dff]📋 Planning Mode[/bold #277dff]" + return "" + def _update_text(self) -> None: - """Rebuild the working status text with refinement indicator.""" + """Rebuild the working status text with mode and refinement indicators.""" parts = [] + # Add mode indicator (shows for planning mode) + mode_indicator = self._get_mode_indicator() + if mode_indicator: + parts.append(mode_indicator) + # Add refinement indicator (always show when enabled in settings) refinement_indicator = self._get_refinement_indicator() if refinement_indicator: diff --git a/openhands_cli/tui/widgets/user_input/input_field.py b/openhands_cli/tui/widgets/user_input/input_field.py index 465f1d5a3..493621cd6 100644 --- a/openhands_cli/tui/widgets/user_input/input_field.py +++ b/openhands_cli/tui/widgets/user_input/input_field.py @@ -98,6 +98,12 @@ class InputField(Container): conversation_id: reactive[uuid.UUID | None] = reactive(None) # >0 = waiting for user confirmation (input disabled) pending_action_count: reactive[int] = reactive(0) + # Agent operating mode ("plan" or "code") + agent_mode: reactive[str] = reactive("code") + + # Color constants for mode indication + CODE_MODE_BORDER = "#ffe165" # Primary/logo color (yellow) + PLAN_MODE_BORDER = "#277dff" # Accent color (blue) DEFAULT_CSS = """ InputField { @@ -189,6 +195,25 @@ def watch_pending_action_count(self, count: int) -> None: # Re-enable and focus when confirmation is complete self.focus_input() + def watch_agent_mode(self, _mode: str) -> None: + """React to agent_mode changes - update border color.""" + self._update_border_color() + + def _update_border_color(self) -> None: + """Update the input border color based on the current agent mode. + + Uses blue (#277dff) for planning mode, yellow (#ffe165) for code mode. + """ + from textual.color import Color + + is_plan_mode = self.agent_mode == "plan" + border_color = self.PLAN_MODE_BORDER if is_plan_mode else self.CODE_MODE_BORDER + color = Color.parse(border_color) + + # Update both single-line and multiline input borders + self.single_line_widget.styles.border = ("round", color) + self.multiline_widget.styles.border = ("round", color) + def _update_disabled_state(self) -> None: """Update disabled state based on conversation_id and pending actions.""" is_switching = self.conversation_id is None diff --git a/tests/tui/core/test_planning_mode.py b/tests/tui/core/test_planning_mode.py new file mode 100644 index 000000000..b0ef24f05 --- /dev/null +++ b/tests/tui/core/test_planning_mode.py @@ -0,0 +1,111 @@ +"""Tests for Planning Mode functionality.""" + +from openhands_cli.tui.core.state import AgentMode, ConversationContainer +from openhands_cli.tui.core.user_message_controller import ( + PLANNING_MODE_INSTRUCTIONS, + UserMessageController, +) + + +class TestAgentMode: + """Tests for AgentMode type and ConversationContainer.agent_mode.""" + + def test_agent_mode_default_is_code(self): + """Test that the default agent mode is 'code'.""" + container = ConversationContainer() + assert container.agent_mode == "code" + + def test_agent_mode_can_be_set_to_plan(self): + """Test that agent mode can be set to 'plan'.""" + container = ConversationContainer() + container.set_agent_mode("plan") + assert container.agent_mode == "plan" + + def test_agent_mode_can_be_set_back_to_code(self): + """Test that agent mode can be switched back to 'code'.""" + container = ConversationContainer() + container.set_agent_mode("plan") + container.set_agent_mode("code") + assert container.agent_mode == "code" + + def test_agent_mode_type_literal_values(self): + """Test that AgentMode is a Literal type with 'plan' and 'code' values.""" + # These should be valid AgentMode values + plan_mode: AgentMode = "plan" + code_mode: AgentMode = "code" + assert plan_mode == "plan" + assert code_mode == "code" + + +class TestPlanningModeInstructions: + """Tests for PLANNING_MODE_INSTRUCTIONS constant.""" + + def test_planning_mode_instructions_exist(self): + """Test that PLANNING_MODE_INSTRUCTIONS constant exists and is not empty.""" + assert PLANNING_MODE_INSTRUCTIONS + assert len(PLANNING_MODE_INSTRUCTIONS) > 0 + + def test_planning_mode_instructions_contain_key_phrases(self): + """Test that instructions contain key planning-related phrases.""" + # Should mention not executing code + assert "DO NOT execute" in PLANNING_MODE_INSTRUCTIONS + + # Should mention PLAN.md + assert "PLAN.md" in PLANNING_MODE_INSTRUCTIONS + + # Should mention understanding/questions + assert "understanding" in PLANNING_MODE_INSTRUCTIONS.lower() + + # Should mention confirmation + assert "confirm" in PLANNING_MODE_INSTRUCTIONS.lower() + + +class TestUserMessageControllerPlanningMode: + """Tests for UserMessageController planning mode behavior.""" + + def test_apply_mode_instructions_code_mode_returns_original(self): + """Test that code mode returns the original content unchanged.""" + # Create a minimal mock state + from unittest.mock import MagicMock + + mock_state = MagicMock() + mock_state.agent_mode = "code" + mock_state.conversation_id = None # Not used in _apply_mode_instructions + + controller = UserMessageController( + state=mock_state, + runners=MagicMock(), + run_worker=MagicMock(), + headless_mode=False, + ) + + original_content = "Hello, please help me with something" + result = controller._apply_mode_instructions(original_content) + + assert result == original_content + + def test_apply_mode_instructions_plan_mode_prepends_instructions(self): + """Test that plan mode prepends instructions to the content.""" + from unittest.mock import MagicMock + + mock_state = MagicMock() + mock_state.agent_mode = "plan" + + controller = UserMessageController( + state=mock_state, + runners=MagicMock(), + run_worker=MagicMock(), + headless_mode=False, + ) + + original_content = "Hello, please help me with something" + result = controller._apply_mode_instructions(original_content) + + # Result should contain the planning instructions + assert PLANNING_MODE_INSTRUCTIONS in result + + # Result should also contain the original content + assert original_content in result + + # Instructions should come before the content + assert result.index(PLANNING_MODE_INSTRUCTIONS) < result.index(original_content) diff --git a/tests/tui/test_commands.py b/tests/tui/test_commands.py index d89fc50ff..2c1f6cf9e 100644 --- a/tests/tui/test_commands.py +++ b/tests/tui/test_commands.py @@ -29,7 +29,7 @@ class TestCommands: def test_commands_list_structure(self): """Test that COMMANDS list has correct structure.""" assert isinstance(COMMANDS, list) - assert len(COMMANDS) == 9 + assert len(COMMANDS) == 11 # Check that all items are DropdownItems for command in COMMANDS: @@ -43,6 +43,8 @@ def test_commands_list_structure(self): [ ("/help", "Display available commands"), ("/new", "Start a new conversation"), + ("/plan", "Switch to Planning Mode (generate PLAN.md)"), + ("/code", "Switch to Code Mode (normal execution)"), ("/history", "Toggle conversation history"), ("/settings", "Open settings"), ("/confirm", "Configure confirmation settings"), @@ -185,9 +187,11 @@ def test_commands_contains_history(self): assert "/history" in command_names assert "/help" in command_names assert "/new" in command_names + assert "/plan" in command_names + assert "/code" in command_names assert "/settings" in command_names assert "/skills" in command_names - assert len(COMMANDS) == 9 + assert len(COMMANDS) == 11 def test_all_commands_included_in_help(self): """Test that all commands from COMMANDS list are included in help text. From a1cb5c7739e583411b7a5125187ae16449ee7c53 Mon Sep 17 00:00:00 2001 From: amankumarpandeyin Date: Fri, 10 Apr 2026 01:03:23 +0530 Subject: [PATCH 2/2] fix(planning): enforce AlwaysConfirm policy in plan mode, harden prompt, fix state reset - Switch to AlwaysConfirm policy when entering /plan mode so every agent action requires explicit user approval (hard safety net) - Restore user's original confirmation policy on /code - Reset agent_mode to 'code' in reset_conversation_state() so /new doesn't leave user stuck in planning mode - Harden PLANNING_MODE_INSTRUCTIONS with explicit forbidden action names (CmdRunAction, FileWriteAction, FileEditAction, etc.) - Apply planning instructions to refinement messages too (closes bypass through system-generated follow-ups) - Expand tests from 8 to 15: state reset, policy save/restore lifecycle, forbidden action assertions --- .../tui/core/conversation_manager.py | 47 ++++++++-- openhands_cli/tui/core/state.py | 31 ++++++- .../tui/core/user_message_controller.py | 36 +++++--- tests/tui/core/test_planning_mode.py | 88 ++++++++++++++++--- 4 files changed, 173 insertions(+), 29 deletions(-) diff --git a/openhands_cli/tui/core/conversation_manager.py b/openhands_cli/tui/core/conversation_manager.py index d1378c4fc..37ae86ed0 100644 --- a/openhands_cli/tui/core/conversation_manager.py +++ b/openhands_cli/tui/core/conversation_manager.py @@ -16,7 +16,10 @@ from textual.containers import Container from textual.message import Message -from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase +from openhands.sdk.security.confirmation_policy import ( + AlwaysConfirm, + ConfirmationPolicyBase, +) from openhands_cli.conversations.protocols import ConversationStore from openhands_cli.tui.core.confirmation_flow_controller import ( ConfirmationFlowController, @@ -291,7 +294,17 @@ def _on_set_confirmation_policy(self, event: SetConfirmationPolicy) -> None: @on(SetAgentMode) def _on_set_agent_mode(self, event: SetAgentMode) -> None: - """Handle request to change agent operating mode.""" + """Handle request to change agent operating mode. + + When entering plan mode: + 1. Save the current confirmation policy + 2. Switch to AlwaysConfirm so the user must approve every action + 3. Update the UI mode indicator + + When returning to code mode: + 1. Restore the previously saved confirmation policy + 2. Clear the mode indicator + """ event.stop() mode = event.mode if mode not in ("plan", "code"): @@ -302,12 +315,30 @@ def _on_set_agent_mode(self, event: SetAgentMode) -> None: ) return - self._state.set_agent_mode(mode) # type: ignore[arg-type] - mode_display = "Planning" if mode == "plan" else "Code" - self.notify( - f"Switched to {mode_display} Mode", - severity="information", - ) + if mode == "plan": + # Save current policy and enforce AlwaysConfirm as a safety net. + # Even if the agent ignores prompt instructions, the user still + # gets a confirmation dialog before any action executes. + if not self._state.has_pre_plan_policy: + self._state.save_pre_plan_policy( + self._state.confirmation_policy + ) + self._policy_service.set_policy(AlwaysConfirm()) + self._state.set_agent_mode("plan") + self.notify( + "Planning Mode — all actions require your approval", + severity="information", + ) + else: + # Restore the user's previous confirmation policy + saved_policy = self._state.restore_pre_plan_policy() + if saved_policy is not None: + self._policy_service.set_policy(saved_policy) + self._state.set_agent_mode("code") + self.notify( + "Code Mode — confirmation policy restored", + severity="information", + ) @on(ShowConfirmationPanel) def _on_show_confirmation_panel(self, event: ShowConfirmationPanel) -> None: diff --git a/openhands_cli/tui/core/state.py b/openhands_cli/tui/core/state.py index 42e373ba4..f60a24e9d 100644 --- a/openhands_cli/tui/core/state.py +++ b/openhands_cli/tui/core/state.py @@ -159,6 +159,10 @@ def __init__( self._conversation_state: ConversationStateProtocol | None = None self._timer = None + # Store the user's confirmation policy before plan mode overrides it. + # None means plan mode hasn't overridden the policy. + self._pre_plan_confirmation_policy: ConfirmationPolicyBase | None = None + super().__init__(id="conversation_state", **kwargs) if initial_confirmation_policy is not None: @@ -431,11 +435,34 @@ def _update_metrics(self) -> None: combined_metrics = stats.get_combined_metrics() self.metrics = combined_metrics + def save_pre_plan_policy(self, policy: ConfirmationPolicyBase) -> None: + """Save the user's confirmation policy before plan mode overrides it. + + Called by ConversationManager when entering plan mode. + """ + self._pre_plan_confirmation_policy = policy + + def restore_pre_plan_policy(self) -> ConfirmationPolicyBase | None: + """Restore and clear the saved confirmation policy from before plan mode. + + Returns: + The saved policy, or None if not in plan mode / no policy was saved. + """ + policy = self._pre_plan_confirmation_policy + self._pre_plan_confirmation_policy = None + return policy + + @property + def has_pre_plan_policy(self) -> bool: + """Check if a pre-plan confirmation policy is saved.""" + return self._pre_plan_confirmation_policy is not None + def reset_conversation_state(self) -> None: """Reset state for a new conversation. Resets: running, elapsed_seconds, metrics, conversation_title, - pending_action_count, refinement_iteration, internal state. + pending_action_count, refinement_iteration, agent_mode, + internal state. Preserves: confirmation_policy (persists across conversations), conversation_id (set explicitly when switching). @@ -447,6 +474,8 @@ def reset_conversation_state(self) -> None: self.conversation_title = None self.pending_action_count = 0 self.refinement_iteration = 0 + self.agent_mode = "code" self.switch_confirmation_target = None self._conversation_start_time = None self._conversation_state = None + self._pre_plan_confirmation_policy = None diff --git a/openhands_cli/tui/core/user_message_controller.py b/openhands_cli/tui/core/user_message_controller.py index 98d54ce1a..b5393420d 100644 --- a/openhands_cli/tui/core/user_message_controller.py +++ b/openhands_cli/tui/core/user_message_controller.py @@ -13,23 +13,35 @@ from openhands_cli.tui.core.state import ConversationContainer -# Planning mode instructions prepended to user messages when in plan mode +# Planning mode instructions prepended to user messages when in plan mode. +# These work in tandem with AlwaysConfirm policy — the prompt guides the agent +# toward planning behavior, while AlwaysConfirm provides a hard safety net +# requiring user approval before any action executes. PLANNING_MODE_INSTRUCTIONS = """ -You are currently in PLANNING MODE. In this mode: - -1. **DO NOT execute any code** - Do not use terminal, file_editor, or tools -2. **Focus on understanding** - Ask clarifying questions about requirements -3. **Create a PLAN.md file** - Generate a structured plan document with: +You are currently in PLANNING MODE. This is a read-only mode. + +STRICTLY FORBIDDEN actions: +- DO NOT use CmdRunAction (terminal/shell commands) +- DO NOT use FileWriteAction or FileEditAction (file modifications) +- DO NOT use BrowseInteractiveAction +- DO NOT execute, compile, or run any code +- The ONLY file you may create or edit is PLAN.md in the workspace root + +Your role in this mode: +1. Ask clarifying questions to understand requirements fully +2. Analyze the existing codebase using read-only tools (file reading, search) +3. Create a structured PLAN.md with: - Problem statement and requirements - Proposed approach and architecture - Step-by-step implementation plan - Potential challenges and mitigations - Success criteria and testing approach -4. **Be thorough** - Identify edge cases, dependencies, and constraints -5. **Seek confirmation** - Ask user to confirm before they switch to Code Mode +4. Identify edge cases, dependencies, and constraints +5. Present the plan and ask the user to switch to /code mode when ready -When you've gathered enough information, create PLAN.md in the workspace root. +IMPORTANT: Even if the user asks you to "just do it" or "go ahead," stay in +planning mode. They must explicitly use /code to enable execution. User's request: @@ -115,7 +127,11 @@ async def handle_refinement_message(self, content: str) -> None: # Note: Don't update conversation title for refinement messages - await self._process_message(runner, content) + # Apply planning mode instructions to refinements too — the agent + # must stay in planning mode even through system-generated follow-ups. + message_content = self._apply_mode_instructions(content) + + await self._process_message(runner, message_content) async def _process_message(self, runner: ConversationRunner, content: str) -> None: """Process a message by queuing or starting a new run. diff --git a/tests/tui/core/test_planning_mode.py b/tests/tui/core/test_planning_mode.py index b0ef24f05..04ece5b2e 100644 --- a/tests/tui/core/test_planning_mode.py +++ b/tests/tui/core/test_planning_mode.py @@ -1,5 +1,8 @@ """Tests for Planning Mode functionality.""" +from unittest.mock import MagicMock + +from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase from openhands_cli.tui.core.state import AgentMode, ConversationContainer from openhands_cli.tui.core.user_message_controller import ( PLANNING_MODE_INSTRUCTIONS, @@ -37,6 +40,66 @@ def test_agent_mode_type_literal_values(self): assert code_mode == "code" +class TestAgentModeStateReset: + """Tests for agent_mode being reset on new conversation.""" + + def test_reset_conversation_state_resets_agent_mode(self): + """Test that reset_conversation_state() resets agent_mode to 'code'.""" + container = ConversationContainer() + container.set_agent_mode("plan") + assert container.agent_mode == "plan" + + container.reset_conversation_state() + assert container.agent_mode == "code" + + def test_reset_clears_pre_plan_policy(self): + """Test that reset_conversation_state() clears saved pre-plan policy.""" + container = ConversationContainer() + mock_policy = MagicMock(spec=ConfirmationPolicyBase) + container.save_pre_plan_policy(mock_policy) + assert container.has_pre_plan_policy + + container.reset_conversation_state() + assert not container.has_pre_plan_policy + + +class TestPlanModePolicySaveRestore: + """Tests for confirmation policy save/restore around plan mode.""" + + def test_save_and_restore_pre_plan_policy(self): + """Test save and restore cycle for confirmation policy.""" + container = ConversationContainer() + original_policy = MagicMock(spec=ConfirmationPolicyBase) + + container.save_pre_plan_policy(original_policy) + assert container.has_pre_plan_policy + + restored = container.restore_pre_plan_policy() + assert restored is original_policy + assert not container.has_pre_plan_policy + + def test_restore_returns_none_when_no_policy_saved(self): + """Test that restore returns None when no policy was saved.""" + container = ConversationContainer() + assert not container.has_pre_plan_policy + assert container.restore_pre_plan_policy() is None + + def test_save_does_not_overwrite_if_already_saved(self): + """Test idempotent save — calling save twice uses the first policy.""" + container = ConversationContainer() + first_policy = MagicMock(spec=ConfirmationPolicyBase) + second_policy = MagicMock(spec=ConfirmationPolicyBase) + + container.save_pre_plan_policy(first_policy) + container.save_pre_plan_policy(second_policy) + + # The second save overwrites (ConversationManager checks + # has_pre_plan_policy before calling save, but the state + # layer itself doesn't guard) + restored = container.restore_pre_plan_policy() + assert restored is second_policy + + class TestPlanningModeInstructions: """Tests for PLANNING_MODE_INSTRUCTIONS constant.""" @@ -54,10 +117,17 @@ def test_planning_mode_instructions_contain_key_phrases(self): assert "PLAN.md" in PLANNING_MODE_INSTRUCTIONS # Should mention understanding/questions - assert "understanding" in PLANNING_MODE_INSTRUCTIONS.lower() + assert "understand" in PLANNING_MODE_INSTRUCTIONS.lower() - # Should mention confirmation - assert "confirm" in PLANNING_MODE_INSTRUCTIONS.lower() + def test_planning_mode_instructions_forbid_specific_actions(self): + """Test that instructions explicitly forbid dangerous action types.""" + assert "CmdRunAction" in PLANNING_MODE_INSTRUCTIONS + assert "FileWriteAction" in PLANNING_MODE_INSTRUCTIONS + assert "FileEditAction" in PLANNING_MODE_INSTRUCTIONS + + def test_planning_mode_instructions_mention_read_only(self): + """Test that instructions emphasize read-only mode.""" + assert "read-only" in PLANNING_MODE_INSTRUCTIONS.lower() class TestUserMessageControllerPlanningMode: @@ -65,12 +135,9 @@ class TestUserMessageControllerPlanningMode: def test_apply_mode_instructions_code_mode_returns_original(self): """Test that code mode returns the original content unchanged.""" - # Create a minimal mock state - from unittest.mock import MagicMock - mock_state = MagicMock() mock_state.agent_mode = "code" - mock_state.conversation_id = None # Not used in _apply_mode_instructions + mock_state.conversation_id = None controller = UserMessageController( state=mock_state, @@ -86,8 +153,6 @@ def test_apply_mode_instructions_code_mode_returns_original(self): def test_apply_mode_instructions_plan_mode_prepends_instructions(self): """Test that plan mode prepends instructions to the content.""" - from unittest.mock import MagicMock - mock_state = MagicMock() mock_state.agent_mode = "plan" @@ -108,4 +173,7 @@ def test_apply_mode_instructions_plan_mode_prepends_instructions(self): assert original_content in result # Instructions should come before the content - assert result.index(PLANNING_MODE_INSTRUCTIONS) < result.index(original_content) + assert result.index(PLANNING_MODE_INSTRUCTIONS) < result.index( + original_content + ) +