diff --git a/openhands_cli/tui/core/conversation_manager.py b/openhands_cli/tui/core/conversation_manager.py index 421553562..8944cd911 100644 --- a/openhands_cli/tui/core/conversation_manager.py +++ b/openhands_cli/tui/core/conversation_manager.py @@ -305,6 +305,11 @@ def pause_conversation(self) -> None: """Pause the current conversation.""" self.post_message(PauseConversation()) + def replay_history(self, conversation_id: uuid.UUID) -> None: + """Create (or retrieve) a runner for the conversation and replay its events.""" + runner = self._runners.get_or_create(conversation_id) + runner.replay_events() + def reload_visualizer_configuration(self) -> None: """Reload the visualizer configuration for the current conversation.""" runner = self._runners.current diff --git a/openhands_cli/tui/core/conversation_runner.py b/openhands_cli/tui/core/conversation_runner.py index c643b583e..a0fcc5a19 100644 --- a/openhands_cli/tui/core/conversation_runner.py +++ b/openhands_cli/tui/core/conversation_runner.py @@ -92,6 +92,35 @@ def __init__( def is_confirmation_mode_active(self) -> bool: return self._state.is_confirmation_active + # Maximum number of events to replay on resume/switch to avoid + # O(n) memory and rendering cost for long conversations. + MAX_REPLAY_EVENTS = 50 + + def replay_events(self) -> None: + """Replay the tail of persisted events through the visualizer. + + Only the last MAX_REPLAY_EVENTS are replayed to avoid loading + unbounded history into memory. User messages are rendered via + render_user_message since on_event skips them. + """ + events = self.conversation.state.events + start = max(0, len(events) - self.MAX_REPLAY_EVENTS) + + # Render summary banner if events were truncated + if start > 0: + self.visualizer.render_replay_summary(start) + + for event in events[start:]: + if ConversationVisualizer.is_user_initiated_message(event): + text = str(event.visualize) + if text.strip(): + self.visualizer.render_user_message(text) + continue + self.visualizer.on_event(event) + + # Scroll to the most recent content so it's visible on load + self.visualizer.scroll_to_bottom() + async def queue_message(self, user_input: str) -> None: """Queue a message for a running conversation""" assert self.conversation is not None, "Conversation should be running" diff --git a/openhands_cli/tui/core/conversation_switch_controller.py b/openhands_cli/tui/core/conversation_switch_controller.py index edf2deadc..370795e23 100644 --- a/openhands_cli/tui/core/conversation_switch_controller.py +++ b/openhands_cli/tui/core/conversation_switch_controller.py @@ -109,7 +109,10 @@ def _prepare_switch(self, target_id: uuid.UUID) -> None: self._state.reset_conversation_state() self._runners.clear_current() - self._runners.get_or_create(target_id) + runner = self._runners.get_or_create(target_id) + + # Replay persisted events so history is visible in the UI + runner.replay_events() self._state.finish_switching(target_id) self._state.set_switch_confirmation_target(None) diff --git a/openhands_cli/tui/textual_app.py b/openhands_cli/tui/textual_app.py index 37ba07c5b..240ec0e87 100644 --- a/openhands_cli/tui/textual_app.py +++ b/openhands_cli/tui/textual_app.py @@ -203,6 +203,9 @@ def __init__( get_conversations_dir(), initial_conversation_id ) + # Track whether this is a resumed conversation + self._is_resuming = resume_conversation_id is not None + # Store queued inputs (copy to prevent mutating caller's list) self.pending_inputs = list(queued_inputs) if queued_inputs else [] @@ -426,7 +429,8 @@ def _initialize_main_ui(self) -> None: 1. Checking if the agent has a critic configured 2. Collecting and displaying loaded resources (skills, hooks, MCPs) 3. Initializing the splash content (one-time setup) - 4. Processing any queued inputs + 4. Replaying conversation history when resuming + 5. Processing any queued inputs UI lifecycle is owned by OpenHandsApp, not ConversationContainer. The splash content initialization is a direct method call, not a reactive @@ -463,9 +467,25 @@ def _initialize_main_ui(self) -> None: # in SplashContent via data_bind self.conversation_state.set_loaded_resources(loaded_resources) + # When resuming, eagerly create the runner and replay persisted events. + # Skip when agent is unavailable (first-time user with no config yet). + if self._is_resuming and agent is not None: + self._replay_conversation_history() + # Process any queued inputs self._process_queued_inputs() + def _replay_conversation_history(self) -> None: + """Create the runner and replay persisted events for a resumed conversation.""" + conversation_id = self.conversation_state.conversation_id + if conversation_id is None: + return + + try: + self.conversation_manager.replay_history(conversation_id) + except Exception as e: + self.notify(f"Resume error: {e}", severity="error") + def _process_queued_inputs(self) -> None: """Process any queued inputs from --task or --file arguments. diff --git a/openhands_cli/tui/textual_app.tcss b/openhands_cli/tui/textual_app.tcss index 5fc7bbad5..e9688efc6 100644 --- a/openhands_cli/tui/textual_app.tcss +++ b/openhands_cli/tui/textual_app.tcss @@ -39,6 +39,13 @@ Screen { color: $primary; } +.replay-summary { + padding: 0 1; + margin-top: 1; + color: $primary; + text-style: underline; +} + .help-message, .error-message, .status-message { padding: 0 1; background: $background; diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index f24adde1b..563a18b61 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -8,7 +8,8 @@ from typing import TYPE_CHECKING from rich.text import Text -from textual.widgets import Markdown +from textual import events +from textual.widgets import Markdown, Static from openhands.sdk.conversation.visualizer.base import ConversationVisualizerBase from openhands.sdk.event import ( @@ -94,6 +95,19 @@ class ConversationVisualizer(ConversationVisualizerBase): container. Supports delegate visualization by tracking agent identity. """ + # Maximum number of events to load per "load more" click + MAX_REPLAY_EVENTS = 50 + + @staticmethod + def is_user_initiated_message(event: Event) -> bool: + """Return True if *event* is a user-typed message (not a delegation message).""" + return ( + isinstance(event, MessageEvent) + and event.llm_message is not None + and event.llm_message.role == "user" + and not event.sender + ) + def __init__( self, container: "VerticalScroll", @@ -118,6 +132,9 @@ def __init__( self._cli_settings: CliSettings | None = None # Track pending actions by tool_call_id for action-observation pairing self._pending_actions: dict[str, tuple[ActionEvent, Collapsible]] = {} + # Track replay state for load-more functionality + self._replay_offset = 0 + self._load_more_widget: Static | None = None @property def cli_settings(self) -> CliSettings: @@ -298,12 +315,24 @@ def on_event(self, event: Event) -> None: if critic_result is not None: self._handle_critic_result(critic_result) + def scroll_to_bottom(self) -> None: + """Schedule a scroll-to-bottom on the main thread.""" + self._run_on_main_thread(self._do_scroll_to_bottom) + + def _do_scroll_to_bottom(self) -> None: + """Scroll the container to the bottom (must be called from main thread).""" + self._container.scroll_end(animate=False) + def _add_widget_to_ui(self, widget: "Widget") -> None: """Add a widget to the UI (must be called from main thread).""" self._container.mount(widget) if self._container.is_vertical_scroll_end: self._container.scroll_end(animate=False) + def _add_widget_after(self, anchor: "Widget", widget: "Widget") -> None: + """Add a widget after an existing widget in the UI.""" + self._container.mount(widget, after=anchor) + def _handle_critic_result(self, critic_result: "CriticResult") -> None: """Handle a critic result by displaying widgets and notifying controller. @@ -318,47 +347,13 @@ def _handle_critic_result(self, critic_result: "CriticResult") -> None: Args: critic_result: The critic evaluation result to handle. """ - from openhands_cli.tui.messages import CriticResultReceived - from openhands_cli.tui.utils.critic import ( - create_critic_collapsible, - send_critic_inference_event, - ) - from openhands_cli.tui.utils.critic.feedback import CriticFeedbackWidget - - critic_settings = self.cli_settings.critic - - # Skip display if critic is disabled - if not critic_settings.enable_critic: + critic_widgets = self._build_critic_widgets(critic_result) + if not critic_widgets: return - # Get agent model for tracking - agent_model = self._get_agent_model() - conversation_id = str(self._app.conversation_id) - - # Send critic inference event to PostHog - send_critic_inference_event( - critic_result=critic_result, - conversation_id=conversation_id, - agent_model=agent_model, - ) - - # Display critic score collapsible - critic_widget = create_critic_collapsible(critic_result) - self._run_on_main_thread(self._add_widget_to_ui, critic_widget) - - # Add feedback widget after critic collapsible - feedback_widget = CriticFeedbackWidget( - critic_result=critic_result, - conversation_id=conversation_id, - agent_model=agent_model, - ) - self._run_on_main_thread(self._add_widget_to_ui, feedback_widget) - - # Notify RefinementController to evaluate and potentially trigger refinement - self._app.call_from_thread( - self._app.conversation_manager.post_message, - CriticResultReceived(critic_result), - ) + self._emit_critic_result_side_effects(critic_result) + for widget in critic_widgets: + self._run_on_main_thread(self._add_widget_to_ui, widget) def _dismiss_pending_feedback_widgets(self) -> None: """Dismiss any pending feedback widgets. @@ -377,13 +372,13 @@ def _render_message_widget(self, content: str) -> None: Args: content: The message text to display. """ - from textual.widgets import Static - user_message_widget = Static( - f"> {content}", classes="user-message", markup=False - ) + user_message_widget = self._create_user_message_widget(content) self._run_on_main_thread(self._add_widget_to_ui, user_message_widget) + def _create_user_message_widget(self, content: str) -> Static: + return Static(f"> {content}", classes="user-message", markup=False) + def render_user_message(self, content: str) -> None: """Render a user message to the UI. @@ -415,6 +410,174 @@ def render_refinement_message(self, content: str) -> None: self._dismiss_pending_feedback_widgets() self._render_message_widget(content) + def _replay_banner_text(self, remaining_hidden_count: int) -> str: + batch_count = min(self.MAX_REPLAY_EVENTS, remaining_hidden_count) + return f"[u]Load {batch_count:,} earlier events[/]" + + def render_replay_summary(self, hidden_count: int) -> None: + """Render a replay summary banner when history is truncated. + + Displays a clickable banner to load more events. + This is called during replay when the event history exceeds + MAX_REPLAY_EVENTS. + + Args: + hidden_count: Start index of the shown events (i.e. how many + events precede the displayed tail). + """ + if hidden_count <= 0: + return + + # Track replay state + self._replay_offset = hidden_count + + # Capture visualizer reference for the click handler + visualizer = self + + class ClickableBanner(Static): + """A clickable banner to load more history.""" + + async def _on_click(self, _event: events.Click) -> None: # type: ignore[override] + """Handle click to load more history.""" + visualizer.load_more_events() + + banner = ClickableBanner( + self._replay_banner_text(hidden_count), + id="replay-summary-banner", + classes="replay-summary", + markup=True, + ) + + self._load_more_widget = banner + self._run_on_main_thread(self._add_widget_to_ui, banner) + + def _build_critic_widgets(self, critic_result: "CriticResult") -> list["Widget"]: + from openhands_cli.tui.utils.critic import create_critic_collapsible + from openhands_cli.tui.utils.critic.feedback import CriticFeedbackWidget + + critic_settings = self.cli_settings.critic + if not critic_settings.enable_critic: + return [] + + conversation_id = str(self._app.conversation_id) + agent_model = self._get_agent_model() + return [ + create_critic_collapsible(critic_result), + CriticFeedbackWidget( + critic_result=critic_result, + conversation_id=conversation_id, + agent_model=agent_model, + ), + ] + + def _emit_critic_result_side_effects(self, critic_result: "CriticResult") -> None: + from openhands_cli.tui.messages import CriticResultReceived + from openhands_cli.tui.utils.critic import send_critic_inference_event + + critic_settings = self.cli_settings.critic + if not critic_settings.enable_critic: + return + + agent_model = self._get_agent_model() + conversation_id = str(self._app.conversation_id) + + send_critic_inference_event( + critic_result=critic_result, + conversation_id=conversation_id, + agent_model=agent_model, + ) + + self._app.call_from_thread( + self._app.conversation_manager.post_message, + CriticResultReceived(critic_result), + ) + + def _render_history_event( + self, + event: Event, + anchor: "Widget", + ) -> "Widget": + # Note: unlike on_event(), this intentionally skips TaskTrackerObservation + # plan-panel refresh since replayed history should not trigger side-effects. + if isinstance( + event, + ObservationEvent | UserRejectObservation | AgentErrorEvent, + ): + if self._handle_observation_event(event): + return anchor + + if self.is_user_initiated_message(event): + text = str(event.visualize) + if not text.strip(): + return anchor + widget: Widget | None = self._create_user_message_widget(text) + else: + widget = self._create_event_widget(event) + + if widget is None: + return anchor + + self._add_widget_after(anchor, widget) + current_anchor: Widget = widget + + critic_result = getattr(event, "critic_result", None) + if critic_result is None: + return current_anchor + + critic_widgets = self._build_critic_widgets(critic_result) + if not critic_widgets: + return current_anchor + + # Skip side effects (PostHog, CriticResultReceived) during replay + for critic_widget in critic_widgets: + self._add_widget_after(current_anchor, critic_widget) + current_anchor = critic_widget + return current_anchor + + def load_more_events(self) -> None: + """Load the next batch of events from history. + + Called when user clicks the 'load more' banner. + Loads exactly MAX_REPLAY_EVENTS events at a time. + """ + if self._load_more_widget is None: + return + + # Calculate how many more events to load + start = max(0, self._replay_offset - self.MAX_REPLAY_EVENTS) + end = self._replay_offset + + if start >= end: + return + + # Get conversation via current_runner (like other code does) + runner = self._app.conversation_manager.current_runner # type: ignore[union-attr] + if runner is None: + return + event_log = runner.conversation.state.events # type: ignore[union-attr] + + current_anchor: Widget = self._load_more_widget + for event in event_log[start:end]: + current_anchor = self._render_history_event(event, current_anchor) + + # Update replay state + self._replay_offset = start + + # Update or remove the banner + if start > 0: + # There are 'start' events remaining to load + remaining = start + # Update existing banner text instead of creating new one + if self._load_more_widget: + self._load_more_widget.update(self._replay_banner_text(remaining)) + else: + # No more to load - remove banner + if self._load_more_widget: + self._run_on_main_thread(self._load_more_widget.remove) + self._load_more_widget = None + + self._app.notify(f"Loaded {end - start} more events") + def _update_widget_in_ui( self, collapsible: Collapsible, new_title: str, new_content: str ) -> None: diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_after_load_all.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_after_load_all.svg new file mode 100644 index 000000000..1e45a3b8e --- /dev/null +++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_after_load_all.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LoadAllApp + + + + + + + + + + + +Load 2 earlier events + +> Hello + +> World + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_with_messages.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_with_messages.svg new file mode 100644 index 000000000..d4926fe03 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_with_messages.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ReplayBannerApp + + + + + + + + + + + +Load 50 earlier events + +> What is the status? + +> Please continue. + + + + + + + + + + diff --git a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg index 92c0e6c35..98dee0863 100644 --- a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg +++ b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg @@ -42,11 +42,13 @@ .terminal-r8 { fill: #dfd9c0 } .terminal-r9 { fill: #277dff } .terminal-r10 { fill: #fee165 } -.terminal-r11 { fill: #ffeb99;font-weight: bold } -.terminal-r12 { fill: #222222 } -.terminal-r13 { fill: #7a7a7a } -.terminal-r14 { fill: #277dff;font-weight: bold } -.terminal-r15 { fill: #4e4e4e } +.terminal-r11 { fill: #ffffff;font-weight: bold } +.terminal-r12 { fill: #6bff6b } +.terminal-r13 { fill: #ffeb99;font-weight: bold } +.terminal-r14 { fill: #222222 } +.terminal-r15 { fill: #7a7a7a } +.terminal-r16 { fill: #277dff;font-weight: bold } +.terminal-r17 { fill: #4e4e4e } @@ -180,7 +182,7 @@ - + Conversations ✕       ___                    _   _                 _       @@ -200,28 +202,28 @@ 3. Type /help for help, /feedback to leave anonymous feedback, or / to scroll through available commands - + Loaded: 6 tools, system prompt - +> echo hello world - +Print "hello world" to terminal: $ echo hello world - +hello world -New Conversation +New Conversation ╭─────────────────────────────────────────────────────────Started a new conversation -Type your message, @mention a file, or / for commands +Type your message, @mention a file, or / for commands ╰─────────────────────────────────────────────────────────────────────────────╯ [Ctrl+L for multi-line • Ctrl+X for custom editor] • -/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ Switched +/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ Switched Resumed conversation 00000000 - ^a Select all  ^x Open external editor  ^l Toggle single/multi-line input  ^j Submit multi-line input  ^r R^p palette + ^a Select all  ^x Open external editor  ^l Toggle single/multi-line input  ^j Submit multi-line input  ^r R^p palette diff --git a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg index 92c0e6c35..98dee0863 100644 --- a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg +++ b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg @@ -42,11 +42,13 @@ .terminal-r8 { fill: #dfd9c0 } .terminal-r9 { fill: #277dff } .terminal-r10 { fill: #fee165 } -.terminal-r11 { fill: #ffeb99;font-weight: bold } -.terminal-r12 { fill: #222222 } -.terminal-r13 { fill: #7a7a7a } -.terminal-r14 { fill: #277dff;font-weight: bold } -.terminal-r15 { fill: #4e4e4e } +.terminal-r11 { fill: #ffffff;font-weight: bold } +.terminal-r12 { fill: #6bff6b } +.terminal-r13 { fill: #ffeb99;font-weight: bold } +.terminal-r14 { fill: #222222 } +.terminal-r15 { fill: #7a7a7a } +.terminal-r16 { fill: #277dff;font-weight: bold } +.terminal-r17 { fill: #4e4e4e } @@ -180,7 +182,7 @@ - + Conversations ✕       ___                    _   _                 _       @@ -200,28 +202,28 @@ 3. Type /help for help, /feedback to leave anonymous feedback, or / to scroll through available commands - + Loaded: 6 tools, system prompt - +> echo hello world - +Print "hello world" to terminal: $ echo hello world - +hello world -New Conversation +New Conversation ╭─────────────────────────────────────────────────────────Started a new conversation -Type your message, @mention a file, or / for commands +Type your message, @mention a file, or / for commands ╰─────────────────────────────────────────────────────────────────────────────╯ [Ctrl+L for multi-line • Ctrl+X for custom editor] • -/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ Switched +/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ Switched Resumed conversation 00000000 - ^a Select all  ^x Open external editor  ^l Toggle single/multi-line input  ^j Submit multi-line input  ^r R^p palette + ^a Select all  ^x Open external editor  ^l Toggle single/multi-line input  ^j Submit multi-line input  ^r R^p palette diff --git a/tests/snapshots/test_visualizer_snapshots.py b/tests/snapshots/test_visualizer_snapshots.py index 6f469267a..771c2eef7 100644 --- a/tests/snapshots/test_visualizer_snapshots.py +++ b/tests/snapshots/test_visualizer_snapshots.py @@ -5,7 +5,7 @@ """ from typing import TYPE_CHECKING, Any, cast -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from textual.app import App, ComposeResult from textual.containers import VerticalScroll @@ -15,6 +15,7 @@ from openhands.sdk.llm import MessageToolCall from openhands.sdk.tool.builtins.finish import FinishAction from openhands.sdk.tool.builtins.think import ThinkAction +from openhands_cli.stores import CliSettings from openhands_cli.theme import OPENHANDS_THEME from openhands_cli.tui.panels.plan_side_panel import PlanSidePanel from openhands_cli.tui.widgets.richlog_visualizer import ConversationVisualizer @@ -49,6 +50,12 @@ class VisualizerTestApp(App): background: $background; color: $primary; } + .replay-summary { + padding: 0 1; + margin-top: 1; + color: $primary; + text-style: underline; + } """ def __init__(self, events: list[Any]) -> None: @@ -121,6 +128,63 @@ def _create_think_action_event(thought: str) -> ActionEvent: ) +class TestReplaySummaryBannerSnapshots: + """Snapshot tests for the replay summary banner.""" + + def test_replay_banner_with_messages(self, snap_compare): + """Verify replay banner appears above replayed user messages.""" + + class ReplayBannerApp(VisualizerTestApp): + def __init__(self): + super().__init__(events=[]) + + def compose(self) -> ComposeResult: + yield VerticalScroll(id="scroll_view") + + def on_mount(self) -> None: + container = self.query_one("#scroll_view", VerticalScroll) + self.visualizer = ConversationVisualizer( + container, cast("OpenHandsApp", self) + ) + settings = CliSettings(default_cells_expanded=True) + self.visualizer._cli_settings = settings + self.visualizer.render_replay_summary(100) + self.visualizer.render_user_message("What is the status?") + self.visualizer.render_user_message("Please continue.") + + assert snap_compare(ReplayBannerApp(), terminal_size=(80, 14)) + + def test_replay_banner_after_load_all(self, snap_compare): + """Verify banner is removed after all events are loaded.""" + + class LoadAllApp(VisualizerTestApp): + def __init__(self): + super().__init__(events=[]) + + def compose(self) -> ComposeResult: + yield VerticalScroll(id="scroll_view") + + def on_mount(self) -> None: + container = self.query_one("#scroll_view", VerticalScroll) + self.visualizer = ConversationVisualizer( + container, cast("OpenHandsApp", self) + ) + settings = CliSettings(default_cells_expanded=True) + self.visualizer._cli_settings = settings + with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2): + self.visualizer.render_replay_summary(2) + self.visualizer.render_user_message("Hello") + self.visualizer.render_user_message("World") + + async def load_all(pilot): + app = pilot.app + with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2): + app.visualizer.load_more_events() + await pilot.pause() + + assert snap_compare(LoadAllApp(), terminal_size=(80, 14), run_before=load_all) + + class TestVisualizerSnapshots: """Snapshot tests for the ConversationVisualizer.""" diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index acf5c455c..5004a4b46 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1,7 +1,9 @@ """Tests for ConversationVisualizer and Chinese character markup handling.""" +from collections.abc import Sequence +from types import SimpleNamespace from typing import TYPE_CHECKING, cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from rich.errors import MarkupError @@ -10,16 +12,22 @@ from textual.containers import VerticalScroll from textual.widgets import Static -from openhands.sdk import Action, MessageEvent, TextContent -from openhands.sdk.event import ActionEvent, AgentErrorEvent, UserRejectObservation +from openhands.sdk import Action, Message, MessageEvent, TextContent +from openhands.sdk.event import ( + ActionEvent, + AgentErrorEvent, + ObservationEvent, + UserRejectObservation, +) from openhands.sdk.event.conversation_error import ConversationErrorEvent from openhands.sdk.llm import MessageToolCall -from openhands.tools.terminal.definition import TerminalAction +from openhands.tools.terminal.definition import TerminalAction, TerminalObservation from openhands_cli.stores import CliSettings from openhands_cli.tui.textual_app import OpenHandsApp from openhands_cli.tui.widgets.richlog_visualizer import ( ELLIPSIS, MAX_LINE_LENGTH, + SUCCESS_ICON, ConversationVisualizer, ) @@ -110,6 +118,69 @@ def create_terminal_action_event( ) +def create_user_message_event(content: str) -> MessageEvent: + return MessageEvent( + llm_message=Message(role="user", content=[TextContent(text=content)]), + source="user", + ) + + +def create_terminal_observation_event( + action_event: ActionEvent, content: str = "done" +) -> ObservationEvent: + observation = TerminalObservation( + command=cast(TerminalAction, action_event.action).command, + exit_code=0, + timeout=False, + content=[TextContent(text=content)], + ) + return ObservationEvent( + tool_name=action_event.tool_name, + tool_call_id=action_event.tool_call_id, + observation=observation, + action_id=action_event.id, + ) + + +class ReplayHistoryTestApp(App): + def __init__(self, events: Sequence[object]) -> None: + super().__init__() + self.plan_panel = MagicMock() + self.plan_panel.is_on_screen = False + self.plan_panel.user_dismissed = True + self.conversation_id = "test-conversation" + self.conversation_manager = SimpleNamespace( + current_runner=SimpleNamespace( + conversation=SimpleNamespace(state=SimpleNamespace(events=events)) + ), + post_message=MagicMock(), + ) + self.notifications: list[str] = [] + + def compose(self): + yield VerticalScroll(id="scroll_view") + + def notify(self, message: str, *args, **kwargs) -> None: + self.notifications.append(message) + + +def history_widget_labels(container: VerticalScroll) -> list[str]: + labels: list[str] = [] + for child in container.children: + if child.id == "replay-summary-banner": + labels.append(str(child.render()).replace("\n", " ").strip()) + continue + + if isinstance(child, Static): + rendered = str(child.render()) + labels.append(rendered.replace("\n", " ").strip()) + continue + + title = getattr(child, "title", None) + labels.append(str(title) if title is not None else child.__class__.__name__) + return labels + + class TestChineseCharacterMarkupHandling: """Tests for handling Chinese characters with special markup symbols.""" @@ -227,6 +298,136 @@ def test_various_chinese_patterns_are_escaped(self, visualizer, test_content): assert rendered is not None +class TestReplayHistoryLoading: + @pytest.mark.asyncio + async def test_load_more_events_preserves_chronological_order( + self, mock_cli_settings + ): + events = [create_user_message_event(f"msg{i}") for i in range(6)] + app = ReplayHistoryTestApp(events) + + async with app.run_test() as pilot: + container = app.query_one("#scroll_view", VerticalScroll) + visualizer = ConversationVisualizer(container, cast(OpenHandsApp, app)) + + with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2): + with mock_cli_settings( + visualizer=visualizer, default_cells_expanded=True + ): + visualizer.render_replay_summary(4) + visualizer.render_user_message("msg4") + visualizer.render_user_message("msg5") + await pilot.pause() + + assert history_widget_labels(container) == [ + "Load 2 earlier events", + "> msg4", + "> msg5", + ] + + visualizer.load_more_events() + await pilot.pause() + + assert history_widget_labels(container) == [ + "Load 2 earlier events", + "> msg2", + "> msg3", + "> msg4", + "> msg5", + ] + + visualizer.load_more_events() + await pilot.pause() + + assert history_widget_labels(container) == [ + "> msg0", + "> msg1", + "> msg2", + "> msg3", + "> msg4", + "> msg5", + ] + + @pytest.mark.asyncio + async def test_load_more_events_pairs_action_and_observation( + self, mock_cli_settings + ): + from openhands_cli.tui.widgets.collapsible import Collapsible + + action_event = create_terminal_action_event("echo older", "Run older command") + observation_event = create_terminal_observation_event( + action_event, "older output" + ) + later_events = [ + create_user_message_event("msg2"), + create_user_message_event("msg3"), + ] + events = [action_event, observation_event, *later_events] + app = ReplayHistoryTestApp(events) + + async with app.run_test() as pilot: + container = app.query_one("#scroll_view", VerticalScroll) + visualizer = ConversationVisualizer(container, cast(OpenHandsApp, app)) + + with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2): + with mock_cli_settings( + visualizer=visualizer, default_cells_expanded=True + ): + visualizer.render_replay_summary(2) + visualizer.render_user_message("msg2") + visualizer.render_user_message("msg3") + await pilot.pause() + + visualizer.load_more_events() + await pilot.pause() + + collapsibles = list(container.query(Collapsible)) + assert len(collapsibles) == 1 + collapsible = cast(Collapsible, collapsibles[0]) + assert len(container.children) == 3 + assert "Run older command" in str(collapsible.title) + assert SUCCESS_ICON in str(collapsible.title) + assert history_widget_labels(container)[1:] == ["> msg2", "> msg3"] + + @pytest.mark.asyncio + async def test_replay_does_not_emit_critic_side_effects(self, mock_cli_settings): + """Replaying history should render critic widgets but NOT emit side effects. + + Side effects include PostHog analytics events and CriticResultReceived + messages. These must only fire on the live path, not during replay. + """ + from openhands.sdk.critic.result import CriticResult + + # Create a MessageEvent with a critic_result attached + message = Message(role="assistant", content=[TextContent(text="analysis")]) + event = MessageEvent(llm_message=message, source="agent") + event = event.model_copy( + update={"critic_result": CriticResult(score=0.85, message="Good: 0.85")} + ) + + events = [create_user_message_event("hi"), event] + app = ReplayHistoryTestApp(events) + + async with app.run_test() as pilot: + container = app.query_one("#scroll_view", VerticalScroll) + visualizer = ConversationVisualizer(container, cast(OpenHandsApp, app)) + + with mock_cli_settings(visualizer=visualizer, default_cells_expanded=True): + with patch.object( + visualizer, "_emit_critic_result_side_effects" + ) as mock_emit: + # Replay all events via _render_history_event + anchor = container + for ev in events: + if ConversationVisualizer.is_user_initiated_message(ev): + visualizer.render_user_message(str(ev.visualize).strip()) + else: + anchor = visualizer._render_history_event(ev, anchor) + await pilot.pause() + + mock_emit.assert_not_called() + + class TestVisualizerWithoutEscaping: """Tests that demonstrate what happens WITHOUT the escaping fix.