feat(cli): display model reasoning content during streaming

Add show_reasoning config (default: False) to display model
thinking/reasoning content in the TUI during streaming.  Reasoning
is emitted via a new emit_reasoning hook on AgentHook, gated by the
channels config.  Display uses ✻ prefix with dim italic styling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Flinn Xie
2026-05-11 01:02:49 +08:00
co-authored by Claude Opus 4.7
parent d630ac90d1
commit 3a27af0018
9 changed files with 182 additions and 13 deletions
+22 -1
View File
@@ -13,6 +13,17 @@ def _ctx() -> AgentHookContext:
return AgentHookContext(iteration=0, messages=[])
# ---------------------------------------------------------------------------
# Base AgentHook emit_reasoning: no-op
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_base_hook_emit_reasoning_is_noop():
hook = AgentHook()
await hook.emit_reasoning("should not raise")
# ---------------------------------------------------------------------------
# Fan-out: every hook is called in order
# ---------------------------------------------------------------------------
@@ -45,6 +56,9 @@ async def test_composite_fans_out_all_async_methods():
async def before_iteration(self, context: AgentHookContext) -> None:
events.append("before_iteration")
async def emit_reasoning(self, reasoning_content: str | None) -> None:
events.append(f"emit_reasoning:{reasoning_content}")
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
events.append(f"on_stream:{delta}")
@@ -61,6 +75,7 @@ async def test_composite_fans_out_all_async_methods():
ctx = _ctx()
await hook.before_iteration(ctx)
await hook.emit_reasoning("thinking...")
await hook.on_stream(ctx, "hi")
await hook.on_stream_end(ctx, resuming=True)
await hook.before_execute_tools(ctx)
@@ -68,6 +83,7 @@ async def test_composite_fans_out_all_async_methods():
assert events == [
"before_iteration", "before_iteration",
"emit_reasoning:thinking...", "emit_reasoning:thinking...",
"on_stream:hi", "on_stream:hi",
"on_stream_end:True", "on_stream_end:True",
"before_execute_tools", "before_execute_tools",
@@ -120,6 +136,8 @@ async def test_composite_error_isolation_all_async():
calls: list[str] = []
class Bad(AgentHook):
async def emit_reasoning(self, reasoning_content):
raise RuntimeError("err")
async def on_stream_end(self, context, *, resuming):
raise RuntimeError("err")
async def before_execute_tools(self, context):
@@ -128,6 +146,8 @@ async def test_composite_error_isolation_all_async():
raise RuntimeError("err")
class Good(AgentHook):
async def emit_reasoning(self, reasoning_content):
calls.append("emit_reasoning")
async def on_stream_end(self, context, *, resuming):
calls.append("on_stream_end")
async def before_execute_tools(self, context):
@@ -137,10 +157,11 @@ async def test_composite_error_isolation_all_async():
hook = CompositeHook([Bad(), Good()])
ctx = _ctx()
await hook.emit_reasoning("test")
await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx)
await hook.after_iteration(ctx)
assert calls == ["on_stream_end", "before_execute_tools", "after_iteration"]
assert calls == ["emit_reasoning", "on_stream_end", "before_execute_tools", "after_iteration"]
# ---------------------------------------------------------------------------
+51 -3
View File
@@ -156,17 +156,65 @@ def test_stream_renderer_stop_for_input_stops_spinner():
# Create renderer with mocked console
with patch.object(stream_mod, "_make_console", return_value=mock_console):
renderer = stream_mod.StreamRenderer(show_spinner=True)
# Verify spinner started
spinner.start.assert_called_once()
# Stop for input
renderer.stop_for_input()
# Verify spinner stopped
spinner.stop.assert_called_once()
@pytest.mark.asyncio
async def test_on_end_writes_final_content_to_stdout_after_stopping_live():
"""on_end should stop Live (transient erases it) then print final content to stdout."""
mock_live = MagicMock()
mock_console = MagicMock()
mock_console.capture.return_value.__enter__ = MagicMock(
return_value=MagicMock(get=lambda: "final output\n")
)
mock_console.capture.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(stream_mod, "_make_console", return_value=mock_console):
renderer = stream_mod.StreamRenderer(show_spinner=False)
renderer._live = mock_live
renderer._buf = "final output"
written: list[str] = []
with patch("sys.stdout") as mock_stdout:
mock_stdout.write = lambda s: written.append(s)
mock_stdout.flush = MagicMock()
await renderer.on_end()
mock_live.stop.assert_called_once()
assert renderer._live is None
assert written == ["final output\n"]
@pytest.mark.asyncio
async def test_on_end_resuming_clears_buffer_and_restarts_spinner():
"""on_end(resuming=True) should reset state for the next iteration."""
spinner = MagicMock()
mock_console = MagicMock()
mock_console.status.return_value = spinner
mock_console.capture.return_value.__enter__ = MagicMock(
return_value=MagicMock(get=lambda: "")
)
mock_console.capture.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(stream_mod, "_make_console", return_value=mock_console):
renderer = stream_mod.StreamRenderer(show_spinner=True)
renderer._buf = "some content"
await renderer.on_end(resuming=True)
assert renderer._buf == ""
# Spinner should have been restarted (start called twice: __init__ + resuming)
assert spinner.start.call_count == 2
def test_make_console_force_terminal_when_stdout_is_tty():
"""Console should set force_terminal=True when stdout is a TTY (rich output)."""
import sys
+59
View File
@@ -29,3 +29,62 @@ async def test_interactive_retry_wait_is_rendered_as_progress_even_when_progress
assert handled is True
assert calls == [("Model request failed, retry in 2s (attempt 1).", thinking)]
@pytest.mark.asyncio
async def test_reasoning_displayed_when_show_reasoning_enabled():
"""Reasoning content should be displayed when show_reasoning is True."""
calls: list[str] = []
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=True,
)
msg = SimpleNamespace(
content="Let me think about this...",
metadata={"_progress": True, "_reasoning": True},
)
with patch("nanobot.cli.commands._print_cli_reasoning", side_effect=lambda t, th, r=None: calls.append(t)):
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
assert calls == ["Let me think about this..."]
@pytest.mark.asyncio
async def test_reasoning_hidden_when_show_reasoning_disabled():
"""Reasoning content should be suppressed when show_reasoning is False."""
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=False,
)
msg = SimpleNamespace(
content="Let me think about this...",
metadata={"_progress": True, "_reasoning": True},
)
with patch("nanobot.cli.commands._print_cli_reasoning") as mock_reasoning:
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
mock_reasoning.assert_not_called()
@pytest.mark.asyncio
async def test_non_reasoning_progress_not_affected_by_show_reasoning():
"""Regular progress lines should display regardless of show_reasoning."""
calls: list[str] = []
channels_config = SimpleNamespace(
send_progress=True, send_tool_hints=False, show_reasoning=False,
)
msg = SimpleNamespace(
content="working on it...",
metadata={"_progress": True},
)
async def fake_print(text: str, thinking=None, renderer=None):
calls.append(text)
with patch("nanobot.cli.commands._print_interactive_progress_line", side_effect=fake_print):
handled = await commands._maybe_print_interactive_progress(msg, None, channels_config)
assert handled is True
assert calls == ["working on it..."]