feat(summarization): implement summarization feature with dedicated widget and event handling

This commit is contained in:
X-iZhang
2026-03-10 01:13:30 +00:00
parent 903ef4d158
commit 311fb08676
8 changed files with 320 additions and 2 deletions
+11 -1
View File
@@ -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()
+2
View File
@@ -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",
@@ -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()
+30
View File
@@ -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":
+5
View File
@@ -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."""
+6 -1
View File
@@ -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)
+5
View File
@@ -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,
+169
View File
@@ -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