diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index bcfb921..a94b8b7 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -193,6 +193,7 @@ def run_textual_interactive( from .widgets import ( LoadingWidget, ThinkingWidget, + SummarizationWidget, AssistantMessage, ToolCallWidget, SubAgentWidget, @@ -482,6 +483,7 @@ def run_textual_interactive( state = StreamState() loading_removed = False thinking_w: ThinkingWidget | None = None + summarization_w: SummarizationWidget | None = None assistant_w: AssistantMessage | None = None todo_w: TodoWidget | None = None tool_widgets: dict[str, ToolCallWidget] = {} @@ -655,7 +657,7 @@ def run_textual_interactive( # -- Remove loading spinner on first content event -- if not loading_removed and event_type in ( - "thinking", "text", "tool_call", + "thinking", "text", "tool_call", "summarization", ): await loading.cleanup() loading_removed = True @@ -667,6 +669,14 @@ def run_textual_interactive( await container.mount(thinking_w) thinking_w.append_text(event.get("content", "")) + elif event_type == "summarization": + content = event.get("content", "") + if content: + if summarization_w is None: + summarization_w = SummarizationWidget() + await container.mount(summarization_w) + summarization_w.set_content(content) + elif event_type == "text": if thinking_w is not None and thinking_w._is_active: thinking_w.finalize() diff --git a/EvoScientist/cli/widgets/__init__.py b/EvoScientist/cli/widgets/__init__.py index e9a6c33..668ed75 100644 --- a/EvoScientist/cli/widgets/__init__.py +++ b/EvoScientist/cli/widgets/__init__.py @@ -2,6 +2,7 @@ from .loading_widget import LoadingWidget from .thinking_widget import ThinkingWidget +from .summarization_widget import SummarizationWidget from .assistant_message import AssistantMessage from .tool_call_widget import ToolCallWidget from .subagent_widget import SubAgentWidget @@ -15,6 +16,7 @@ from .thread_selector import ThreadPickerWidget __all__ = [ "LoadingWidget", "ThinkingWidget", + "SummarizationWidget", "AssistantMessage", "ToolCallWidget", "SubAgentWidget", diff --git a/EvoScientist/cli/widgets/summarization_widget.py b/EvoScientist/cli/widgets/summarization_widget.py new file mode 100644 index 0000000..8e383e3 --- /dev/null +++ b/EvoScientist/cli/widgets/summarization_widget.py @@ -0,0 +1,92 @@ +"""Summarization panel widget for context compression display. + +Renders a Rich Panel showing that LangGraph's summarization middleware +has compressed older conversation history. Yellow/amber border to +distinguish from the blue thinking panel. Default collapsed; click to +expand/collapse. +""" + +from __future__ import annotations + +from rich.panel import Panel +from rich.text import Text + +from textual.events import Click +from textual.widgets import Static + +_MAX_COLLAPSED_CHARS = 80 +_MAX_EXPANDED_CHARS = 3000 + + +class SummarizationWidget(Static): + """Collapsible panel showing context summarization. + + Unlike ThinkingWidget this is not streamed — the full text arrives + in a single event. Defaults to collapsed with a one-line preview. + + Usage:: + + w = SummarizationWidget() + await container.mount(w) + w.set_content("The conversation covered ...") + """ + + DEFAULT_CSS = """ + SummarizationWidget { + height: auto; + margin: 0 0 1 0; + } + """ + + def __init__(self) -> None: + super().__init__("") + self._content = "" + self._collapsed = True + + def _char_count_label(self) -> str: + n = len(self._content) + if n >= 1000: + return f"{n / 1000:.1f}k chars" + return f"{n:,} chars" + + def _refresh_display(self) -> None: + if not self._content: + self.update("") + return + + if self._collapsed: + title = f"Context Summarized ({self._char_count_label()})" + first_line = self._content.strip().split("\n")[0].strip() + if len(first_line) > _MAX_COLLAPSED_CHARS: + first_line = first_line[:_MAX_COLLAPSED_CHARS - 3] + "\u2026" + preview = Text(first_line, style="dim italic") + preview.append(" [click to expand]", style="dim italic") + body = preview + else: + title = f"Context Summarized ({self._char_count_label()})" + display = self._content.rstrip() + if len(display) > _MAX_EXPANDED_CHARS: + half = _MAX_EXPANDED_CHARS // 2 + display = ( + display[:half] + + "\n\n... (truncated) ...\n\n" + + display[-half:] + ) + body = Text(display, style="dim italic") if display else Text( + "(empty)", style="dim" + ) + + self.update( + Panel(body, title=title, border_style="#f59e0b", padding=(0, 1)) + ) + + def set_content(self, text: str) -> None: + """Set the summarization text and refresh display.""" + self._content = text + self._refresh_display() + + def on_click(self, event: Click) -> None: + """Toggle collapsed/expanded state.""" + if self._content: + self._collapsed = not self._collapsed + self._refresh_display() diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 92b618f..d2d58c2 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -374,6 +374,7 @@ def create_streaming_display( response_markdown: Any = None, total_input_tokens: int = 0, total_output_tokens: int = 0, + summarization_text: str = "", ) -> Any: """Create Rich display layout for streaming output. @@ -412,6 +413,18 @@ def create_streaming_display( padding=(0, 1), )) + # Summarization panel (context was compressed by LangGraph middleware) + if summarization_text: + summary_display = summarization_text.rstrip() + if len(summary_display) > 300: + summary_display = summary_display[:300] + " ..." + elements.append(Panel( + Text(summary_display, style="dim italic"), + title="Context Summarized", + border_style="#f59e0b", + padding=(0, 1), + )) + # Tool calls and results paired display # Collapse older completed tools to prevent overflow in Live mode # Task tool calls are ALWAYS visible (they represent sub-agent delegations) @@ -621,6 +634,16 @@ def display_final_results( border_style="blue", )) + if state.summarization_text: + summary_display = state.summarization_text.rstrip() + if len(summary_display) > 500: + summary_display = summary_display[:500] + " ..." + console.print(Panel( + Text(summary_display, style="dim italic"), + title="Context Summarized", + border_style="#f59e0b", + )) + if show_tools and state.tool_calls: shown_sa_names: set[str] = set() @@ -1118,6 +1141,13 @@ async def _astream_to_console( dt = dt[:250] + "\n\u2026truncated\u2026\n" + dt[-250:] console.print(Panel(Text(dt, style="dim"), title="Thinking", border_style="blue")) + # Summarization + if state.summarization_text: + st = state.summarization_text.rstrip() + if len(st) > 500: + st = st[:500] + " ..." + console.print(Panel(Text(st, style="dim italic"), title="Context Summarized", border_style="#f59e0b")) + # 1) Regular (non-task) tools — above Task List for i, tc in enumerate(state.tool_calls): if tc.get("name", "").lower() == "task": diff --git a/EvoScientist/stream/emitter.py b/EvoScientist/stream/emitter.py index 5f65996..d5d6093 100644 --- a/EvoScientist/stream/emitter.py +++ b/EvoScientist/stream/emitter.py @@ -109,6 +109,11 @@ class StreamEventEmitter: "review_configs": review_configs or [], }) + @staticmethod + def summarization(content: str) -> StreamEvent: + """Context summarization event.""" + return StreamEvent("summarization", {"type": "summarization", "content": content}) + @staticmethod def error(message: str) -> StreamEvent: """Error event.""" diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index 145eda1..ada32e7 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -357,8 +357,13 @@ async def stream_agent_events( else: msg = data - # Filter summarization middleware synthetic messages + # Emit summarization middleware messages as a dedicated event if isinstance(metadata, dict) and metadata.get("lc_source") == "summarization": + content = "" + if hasattr(msg, "content"): + content = msg.content if isinstance(msg.content, str) else str(msg.content) + if content: + yield emitter.summarization(content).data continue subagent = _get_subagent_name(namespace, metadata) diff --git a/EvoScientist/stream/state.py b/EvoScientist/stream/state.py index b037786..95203a6 100644 --- a/EvoScientist/stream/state.py +++ b/EvoScientist/stream/state.py @@ -79,6 +79,7 @@ class StreamState: def __init__(self): self.thinking_text = "" + self.summarization_text = "" self.response_text = "" self.tool_calls = [] self.tool_results = [] @@ -259,6 +260,9 @@ class StreamState: elif event_type == "interrupt": self.pending_interrupt = event + elif event_type == "summarization": + self.summarization_text = event.get("content", "") + elif event_type == "usage_stats": self.total_input_tokens += event.get("input_tokens", 0) self.total_output_tokens += event.get("output_tokens", 0) @@ -281,6 +285,7 @@ class StreamState: """Get kwargs for create_streaming_display().""" return { "thinking_text": self.thinking_text, + "summarization_text": self.summarization_text, "response_text": self.response_text, "latest_text": self.latest_text, "tool_calls": self.tool_calls, diff --git a/tests/test_summarization.py b/tests/test_summarization.py new file mode 100644 index 0000000..3a5daea --- /dev/null +++ b/tests/test_summarization.py @@ -0,0 +1,169 @@ +"""Tests for the summarization event pipeline and display widgets.""" + +from EvoScientist.stream.emitter import StreamEventEmitter +from EvoScientist.stream.state import StreamState + + +# --------------------------------------------------------------------------- +# Emitter +# --------------------------------------------------------------------------- + + +class TestSummarizationEmitter: + """StreamEventEmitter.summarization().""" + + def test_event_type(self): + ev = StreamEventEmitter.summarization("hello") + assert ev.type == "summarization" + + def test_event_data(self): + ev = StreamEventEmitter.summarization("ctx compressed") + assert ev.data["type"] == "summarization" + assert ev.data["content"] == "ctx compressed" + + def test_empty_content(self): + ev = StreamEventEmitter.summarization("") + assert ev.data["content"] == "" + + +# --------------------------------------------------------------------------- +# StreamState +# --------------------------------------------------------------------------- + + +class TestSummarizationState: + """StreamState handling of summarization events.""" + + def test_initial_state(self): + state = StreamState() + assert state.summarization_text == "" + + def test_handle_summarization(self): + state = StreamState() + etype = state.handle_event({"type": "summarization", "content": "summary"}) + assert etype == "summarization" + assert state.summarization_text == "summary" + + def test_overwrites_previous(self): + """Each summarization replaces (not appends) the previous text.""" + state = StreamState() + state.handle_event({"type": "summarization", "content": "first"}) + state.handle_event({"type": "summarization", "content": "second"}) + assert state.summarization_text == "second" + + def test_get_display_args_includes_field(self): + state = StreamState() + state.handle_event({"type": "summarization", "content": "ctx"}) + args = state.get_display_args() + assert "summarization_text" in args + assert args["summarization_text"] == "ctx" + + def test_does_not_affect_thinking(self): + state = StreamState() + state.handle_event({"type": "thinking", "content": "think"}) + state.handle_event({"type": "summarization", "content": "sum"}) + assert state.thinking_text == "think" + assert state.summarization_text == "sum" + + def test_does_not_affect_response(self): + state = StreamState() + state.handle_event({"type": "text", "content": "hello"}) + state.handle_event({"type": "summarization", "content": "sum"}) + assert state.response_text == "hello" + assert state.summarization_text == "sum" + + +# --------------------------------------------------------------------------- +# Rich CLI display +# --------------------------------------------------------------------------- + + +def _render_group(group) -> str: + """Render a Rich Group to plain text for assertion checks.""" + from io import StringIO + + from rich.console import Console + + buf = StringIO() + console = Console(file=buf, width=120, force_terminal=True) + console.print(group) + return buf.getvalue() + + +class TestSummarizationRichDisplay: + """create_streaming_display() with summarization_text.""" + + def test_no_panel_when_empty(self): + from EvoScientist.stream.display import create_streaming_display + + group = create_streaming_display(summarization_text="") + rendered = _render_group(group) + assert "Context Summarized" not in rendered + + def test_panel_rendered(self): + from EvoScientist.stream.display import create_streaming_display + + group = create_streaming_display( + summarization_text="The conversation was about ML.", + response_text="ok", + ) + rendered = _render_group(group) + assert "Context Summarized" in rendered + + def test_long_text_truncated(self): + from EvoScientist.stream.display import create_streaming_display + + long_text = "x" * 500 + group = create_streaming_display( + summarization_text=long_text, + response_text="ok", + ) + rendered = _render_group(group) + assert "..." in rendered + + +# --------------------------------------------------------------------------- +# TUI SummarizationWidget +# --------------------------------------------------------------------------- + + +class TestSummarizationWidget: + """SummarizationWidget (Textual TUI).""" + + def test_init_collapsed(self): + from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget + + w = SummarizationWidget() + assert w._collapsed is True + assert w._content == "" + + def test_set_content(self): + from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget + + w = SummarizationWidget() + w._content = "test content" + assert w._content == "test content" + + def test_char_count_label_small(self): + from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget + + w = SummarizationWidget() + w._content = "hello" + assert w._char_count_label() == "5 chars" + + def test_char_count_label_large(self): + from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget + + w = SummarizationWidget() + w._content = "x" * 2500 + assert w._char_count_label() == "2.5k chars" + + def test_toggle_collapsed(self): + from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget + + w = SummarizationWidget() + assert w._collapsed is True + w._collapsed = not w._collapsed + assert w._collapsed is False + w._collapsed = not w._collapsed + assert w._collapsed is True