4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
1311 lines
51 KiB
Python
1311 lines
51 KiB
Python
"""Tests for the StreamRenderer."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from collections.abc import Iterator
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
from core.domain.stream import StreamEvent
|
||
from surfaces.cli.ui.renderer import StreamRenderer, _canonical_node_name
|
||
|
||
|
||
def _make_event(
|
||
event_type: str,
|
||
node: str = "",
|
||
data: dict | None = None,
|
||
*,
|
||
kind: str = "",
|
||
tags: list[str] | None = None,
|
||
) -> StreamEvent:
|
||
return StreamEvent(
|
||
event_type=event_type,
|
||
node_name=node,
|
||
data=data or {},
|
||
kind=kind,
|
||
tags=tags or [],
|
||
)
|
||
|
||
|
||
def _investigation_events() -> Iterator[StreamEvent]:
|
||
"""Simulate a minimal investigation stream (updates mode)."""
|
||
yield _make_event("metadata", data={"run_id": "r-1"})
|
||
yield _make_event(
|
||
"updates",
|
||
"extract_alert",
|
||
{
|
||
"extract_alert": {
|
||
"alert_name": "test-alert",
|
||
"pipeline_name": "etl",
|
||
"severity": "critical",
|
||
}
|
||
},
|
||
)
|
||
yield _make_event(
|
||
"updates",
|
||
"resolve_integrations",
|
||
{"resolve_integrations": {"resolved_integrations": {"grafana": {}}}},
|
||
)
|
||
yield _make_event(
|
||
"updates",
|
||
"plan_actions",
|
||
{"plan_actions": {"planned_actions": ["query_grafana_logs"]}},
|
||
)
|
||
yield _make_event(
|
||
"updates",
|
||
"investigate",
|
||
{"investigate": {"evidence": {"logs": "error found"}}},
|
||
)
|
||
yield _make_event(
|
||
"updates",
|
||
"diagnose",
|
||
{"diagnose": {"root_cause": "Schema mismatch", "validity_score": 0.85}},
|
||
)
|
||
yield _make_event(
|
||
"updates",
|
||
"publish",
|
||
{"publish": {"report": "Investigation complete."}},
|
||
)
|
||
yield _make_event("end")
|
||
|
||
|
||
def _events_mode_stream() -> Iterator[StreamEvent]:
|
||
"""Simulate an events-mode investigation stream with tool calls."""
|
||
yield _make_event("metadata", data={"run_id": "r-3"})
|
||
|
||
yield _make_event(
|
||
"events",
|
||
"extract_alert",
|
||
{"name": "extract_alert", "data": {}, "metadata": {"pipeline_node": "extract_alert"}},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:1"],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"extract_alert",
|
||
{
|
||
"name": "extract_alert",
|
||
"data": {"output": {"alert_name": "test", "severity": "high"}},
|
||
"metadata": {"pipeline_node": "extract_alert"},
|
||
},
|
||
kind="on_chain_end",
|
||
tags=["graph:step:1"],
|
||
)
|
||
|
||
yield _make_event(
|
||
"events",
|
||
"investigate",
|
||
{"name": "investigate", "data": {}, "metadata": {"pipeline_node": "investigate"}},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:3"],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"investigate",
|
||
{
|
||
"name": "query_datadog_logs",
|
||
"data": {"input": {"query": "error"}},
|
||
"metadata": {"pipeline_node": "investigate"},
|
||
},
|
||
kind="on_tool_start",
|
||
tags=[],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"investigate",
|
||
{
|
||
"name": "query_datadog_logs",
|
||
"data": {"output": "42 entries"},
|
||
"metadata": {"pipeline_node": "investigate"},
|
||
},
|
||
kind="on_tool_end",
|
||
tags=[],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"investigate",
|
||
{
|
||
"name": "investigate",
|
||
"data": {"output": {"root_cause": "Schema error"}},
|
||
"metadata": {"pipeline_node": "investigate"},
|
||
},
|
||
kind="on_chain_end",
|
||
tags=["graph:step:3"],
|
||
)
|
||
|
||
yield _make_event("end")
|
||
|
||
|
||
class TestCanonicalNodeName:
|
||
def test_diagnose_maps_to_diagnose_root_cause(self) -> None:
|
||
assert _canonical_node_name("diagnose") == "diagnose_root_cause"
|
||
|
||
def test_publish_maps_to_publish_findings(self) -> None:
|
||
assert _canonical_node_name("publish") == "publish_findings"
|
||
|
||
def test_extract_alert_unchanged(self) -> None:
|
||
assert _canonical_node_name("extract_alert") == "extract_alert"
|
||
|
||
def test_unknown_node_unchanged(self) -> None:
|
||
assert _canonical_node_name("custom_node") == "custom_node"
|
||
|
||
|
||
class TestStreamRendererUpdatesMode:
|
||
"""Tests for legacy updates-mode rendering (backward compat)."""
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_renders_full_investigation(self) -> None:
|
||
renderer = StreamRenderer()
|
||
final = renderer.render_stream(_investigation_events())
|
||
|
||
assert renderer.events_received == 8
|
||
assert "extract_alert" in renderer.node_names_seen
|
||
assert "diagnose_root_cause" in renderer.node_names_seen
|
||
assert "publish_findings" in renderer.node_names_seen
|
||
assert final.get("root_cause") == "Schema mismatch"
|
||
assert final.get("report") == "Investigation complete."
|
||
assert renderer.stream_completed is True
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_accumulates_state(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_investigation_events())
|
||
state = renderer.final_state
|
||
|
||
assert state["alert_name"] == "test-alert"
|
||
assert state["planned_actions"] == ["query_grafana_logs"]
|
||
assert state["validity_score"] == 0.85
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_handles_empty_stream(self) -> None:
|
||
renderer = StreamRenderer()
|
||
final = renderer.render_stream(iter([]))
|
||
|
||
assert renderer.events_received == 0
|
||
assert renderer.node_names_seen == []
|
||
assert final == {}
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_handles_noise_alert(self) -> None:
|
||
def noise_events() -> Iterator[StreamEvent]:
|
||
yield _make_event("metadata", data={"run_id": "r-2"})
|
||
yield _make_event(
|
||
"updates",
|
||
"extract_alert",
|
||
{"extract_alert": {"is_noise": True, "alert_name": "noise"}},
|
||
)
|
||
yield _make_event("end")
|
||
|
||
renderer = StreamRenderer()
|
||
final = renderer.render_stream(noise_events())
|
||
|
||
assert final.get("is_noise") is True
|
||
assert renderer.events_received == 3
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_node_message_for_plan_actions(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {"planned_actions": ["query_logs", "get_metrics"]}
|
||
msg = renderer._build_node_message("plan_actions")
|
||
assert msg is not None
|
||
assert "query_logs" in msg
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_node_message_for_diagnose(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {"validity_score": 0.92}
|
||
msg = renderer._build_node_message("diagnose_root_cause")
|
||
assert msg is not None
|
||
assert "92%" in msg
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_node_message_for_diagnose_skips_non_numeric_validity(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {"validity_score": "0.9"}
|
||
assert renderer._build_node_message("diagnose_root_cause") is None
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_node_message_for_diagnose_skips_non_finite_validity(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {"validity_score": float("nan")}
|
||
assert renderer._build_node_message("diagnose_root_cause") is None
|
||
|
||
|
||
class TestStreamRendererEventsMode:
|
||
"""Tests for events-mode rendering (fine-grained tool/LLM events)."""
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_tracks_nodes_from_events(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_events_mode_stream())
|
||
|
||
assert "extract_alert" in renderer.node_names_seen
|
||
assert "investigate" in renderer.node_names_seen
|
||
assert renderer.stream_completed is True
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_merges_chain_end_output_into_state(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_events_mode_stream())
|
||
state = renderer.final_state
|
||
|
||
assert state.get("root_cause") == "Schema error"
|
||
assert state.get("alert_name") == "test"
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_tool_events_count(self) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_events_mode_stream())
|
||
assert renderer.events_received == 8
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_tool_details_hidden_by_default_and_grouped_inline(self, capfd) -> None:
|
||
def tool_trace_events() -> Iterator[StreamEvent]:
|
||
yield _make_event(
|
||
"events",
|
||
"investigation_agent",
|
||
{
|
||
"id": "call-1",
|
||
"name": "query_grafana_logs",
|
||
"input": {
|
||
"service_name": "checkout-api",
|
||
"grafana_api_key": "secret-key",
|
||
},
|
||
},
|
||
kind="on_tool_start",
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"investigation_agent",
|
||
{
|
||
"id": "call-1",
|
||
"name": "query_grafana_logs",
|
||
"output": {"available": True, "logs": [{"message": "boom"}]},
|
||
},
|
||
kind="on_tool_end",
|
||
)
|
||
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(tool_trace_events())
|
||
|
||
out, _ = capfd.readouterr()
|
||
assert "Input:" not in out
|
||
assert "Output:" not in out
|
||
assert "checkout-api" not in out
|
||
assert "boom" not in out
|
||
assert "secret-key" not in out
|
||
assert renderer._format_tool_summary() == "Grafana: Loki"
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_tool_detail_toggle_prints_redacted_input_and_output(self, capfd) -> None:
|
||
def tool_trace_events() -> Iterator[StreamEvent]:
|
||
yield _make_event(
|
||
"events",
|
||
"investigation_agent",
|
||
{
|
||
"id": "call-1",
|
||
"name": "query_grafana_logs",
|
||
"input": {
|
||
"service_name": "checkout-api",
|
||
"grafana_api_key": "secret-key",
|
||
},
|
||
},
|
||
kind="on_tool_start",
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"investigation_agent",
|
||
{
|
||
"id": "call-1",
|
||
"name": "query_grafana_logs",
|
||
"output": {"available": True, "logs": [{"message": "boom"}]},
|
||
},
|
||
kind="on_tool_end",
|
||
)
|
||
|
||
renderer = StreamRenderer()
|
||
renderer._toggle_tool_details()
|
||
renderer.render_stream(tool_trace_events())
|
||
|
||
out, _ = capfd.readouterr()
|
||
assert "Tool details shown" in out
|
||
assert "Input:" in out
|
||
assert "Output:" in out
|
||
assert "checkout-api" in out
|
||
assert "boom" in out
|
||
assert "secret-key" not in out
|
||
assert "[redacted]" in out
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_tool_summary_groups_repeated_tools_by_source(self) -> None:
|
||
renderer = StreamRenderer()
|
||
for tool_name in (
|
||
"query_grafana_alert_rules",
|
||
"query_grafana_metrics",
|
||
"query_grafana_metrics",
|
||
"query_grafana_logs",
|
||
):
|
||
renderer._record_tool_summary(tool_name)
|
||
|
||
assert renderer._format_tool_summary() == "Grafana: alerts, Mimir x2, Loki"
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_ignores_events_without_node(self) -> None:
|
||
def nodeless_events() -> Iterator[StreamEvent]:
|
||
yield _make_event(
|
||
"events",
|
||
"",
|
||
{"event": "on_chain_start", "name": "RunnableSequence"},
|
||
kind="on_chain_start",
|
||
)
|
||
yield _make_event("end")
|
||
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(nodeless_events())
|
||
assert renderer.node_names_seen == []
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_is_graph_node_event_with_step_tag(self) -> None:
|
||
evt = _make_event(
|
||
"events",
|
||
"investigate",
|
||
{"name": "investigate"},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:3"],
|
||
)
|
||
assert StreamRenderer._is_graph_node_event(evt) is True
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_is_graph_node_event_name_match(self) -> None:
|
||
evt = _make_event(
|
||
"events",
|
||
"investigate",
|
||
{"name": "investigate"},
|
||
kind="on_chain_start",
|
||
)
|
||
assert StreamRenderer._is_graph_node_event(evt) is True
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_sub_chain_not_graph_node(self) -> None:
|
||
evt = _make_event(
|
||
"events",
|
||
"investigate",
|
||
{"name": "RunnableSequence"},
|
||
kind="on_chain_start",
|
||
tags=["tracing:hidden"],
|
||
)
|
||
assert StreamRenderer._is_graph_node_event(evt) is False
|
||
|
||
|
||
class TestStreamRendererCleanupOnException:
|
||
"""Tests for spinner + report cleanup when the stream raises mid-iteration.
|
||
|
||
The stream may raise (LLM quota, network, cancel). The renderer must always
|
||
stop the spinner thread AND flush whatever final state was accumulated, so
|
||
the user sees the partial report they were watching stream live before the
|
||
exception propagates.
|
||
"""
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_partial_state_flushed_when_stream_raises(self) -> None:
|
||
"""_print_report must run on the error path, not just the happy path."""
|
||
|
||
def stream_raises_after_extract() -> Iterator[StreamEvent]:
|
||
yield _make_event("metadata", data={"run_id": "r-x"})
|
||
yield _make_event(
|
||
"updates",
|
||
"extract_alert",
|
||
{"extract_alert": {"alert_name": "partial-alert"}},
|
||
)
|
||
raise RuntimeError("simulated upstream stream failure")
|
||
|
||
renderer = StreamRenderer()
|
||
print_report_calls: list[None] = []
|
||
original_print_report = renderer._print_report
|
||
finish_calls: list[None] = []
|
||
original_finish = renderer._finish_active_node
|
||
|
||
def spy_print_report() -> None:
|
||
print_report_calls.append(None)
|
||
original_print_report()
|
||
|
||
def spy_finish() -> None:
|
||
finish_calls.append(None)
|
||
original_finish()
|
||
|
||
renderer._print_report = spy_print_report # type: ignore[method-assign]
|
||
renderer._finish_active_node = spy_finish # type: ignore[method-assign]
|
||
|
||
try:
|
||
renderer.render_stream(stream_raises_after_extract())
|
||
raise AssertionError("expected stream exception to propagate")
|
||
except RuntimeError as exc:
|
||
assert "simulated upstream stream failure" in str(exc)
|
||
|
||
assert finish_calls, "spinner cleanup must run on stream failure"
|
||
assert print_report_calls, (
|
||
"partial report must be flushed on stream failure — "
|
||
"_print_report() must run from the finally block"
|
||
)
|
||
assert renderer.final_state.get("alert_name") == "partial-alert", (
|
||
"accumulated state from before the failure must be retained"
|
||
)
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_print_report_skipped_on_keyboard_interrupt(self) -> None:
|
||
"""_print_report must NOT run when the user presses Ctrl+C."""
|
||
|
||
def stream_raises_keyboard_interrupt() -> Iterator[StreamEvent]:
|
||
yield _make_event("metadata", data={"run_id": "r-ki"})
|
||
yield _make_event(
|
||
"updates",
|
||
"extract_alert",
|
||
{"extract_alert": {"alert_name": "interrupted-alert"}},
|
||
)
|
||
raise KeyboardInterrupt
|
||
|
||
renderer = StreamRenderer()
|
||
print_report_calls: list[None] = []
|
||
finish_calls: list[None] = []
|
||
|
||
renderer._print_report = lambda: print_report_calls.append(None) # type: ignore[method-assign]
|
||
renderer._finish_active_node = lambda: finish_calls.append(None) # type: ignore[method-assign]
|
||
|
||
with pytest.raises(KeyboardInterrupt):
|
||
renderer.render_stream(stream_raises_keyboard_interrupt())
|
||
|
||
assert finish_calls, "spinner cleanup must run on Ctrl+C"
|
||
assert not print_report_calls, (
|
||
"_print_report() must be skipped when the user interrupts the stream"
|
||
)
|
||
assert renderer.final_state.get("alert_name") == "interrupted-alert"
|
||
|
||
|
||
class TestStreamRendererStdinWatcher:
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_uses_tracker_owned_stdin_watcher(self) -> None:
|
||
renderer = StreamRenderer(local=True)
|
||
watcher = renderer._tracker._toggle_watcher
|
||
|
||
assert watcher is not None
|
||
renderer.render_stream(_investigation_events())
|
||
|
||
assert renderer._tracker._toggle_watcher is None
|
||
assert renderer._toggle_unregister is None
|
||
|
||
|
||
def _diagnose_streaming_events() -> Iterator[StreamEvent]:
|
||
"""Simulate the diagnose node emitting token deltas before chain end."""
|
||
yield _make_event("metadata", data={"run_id": "r-d"})
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{"name": "diagnose", "data": {}, "metadata": {"pipeline_node": "diagnose"}},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:1"],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"chunk": {"content": "OpenSRE "}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"chunk": {"content": "identified the schema mismatch."}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"output": {"root_cause": "Schema mismatch", "validity_score": 0.85}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chain_end",
|
||
tags=["graph:step:1"],
|
||
)
|
||
yield _make_event("end")
|
||
|
||
|
||
class TestStreamRendererDiagnoseStreaming:
|
||
"""The diagnose node streams the LLM's reasoning live as Markdown.
|
||
|
||
Other nodes keep the compact spinner UX from ``ProgressTracker``; only
|
||
diagnose is special-cased because it is where user-facing root-cause
|
||
reasoning is generated (#1263).
|
||
"""
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_diagnose_token_chunks_accumulate_into_buffer(self) -> None:
|
||
"""Each on_chat_model_stream chunk for diagnose appends to the buffer."""
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_diagnose_streaming_events())
|
||
|
||
assert "diagnose_root_cause" in renderer.node_names_seen
|
||
# Final state still picks up the chain_end output.
|
||
assert renderer.final_state.get("root_cause") == "Schema mismatch"
|
||
assert renderer.final_state.get("validity_score") == 0.85
|
||
assert renderer.stream_completed is True
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_diagnose_skips_rich_live_when_repl_safe(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
from surfaces.cli.ui.renderer import diagnose as diagnose_module
|
||
from surfaces.cli.ui.renderer import renderer as renderer_module
|
||
|
||
monkeypatch.setattr(diagnose_module, "_repl_progress_active", lambda: True)
|
||
monkeypatch.setattr(renderer_module, "_repl_progress_active", lambda: True)
|
||
renderer = StreamRenderer(local=True)
|
||
renderer.render_stream(_diagnose_streaming_events())
|
||
|
||
assert renderer._diagnose._live is None
|
||
assert renderer._toggle_unregister is None
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_diagnose_text_mode_replays_buffer_at_finish(self, capfd) -> None:
|
||
"""In text mode the buffered token text is printed when the node ends."""
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_diagnose_streaming_events())
|
||
|
||
out, _ = capfd.readouterr()
|
||
# Tokens are visible verbatim — not truncated to the 60-char preview
|
||
# the spinner subtext path would use.
|
||
assert "OpenSRE identified the schema mismatch." in out
|
||
# The resolved-dot line uses the canonical node name and timing.
|
||
assert "diagnose_root_cause" in out
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_other_nodes_bypass_diagnose_streaming(self) -> None:
|
||
"""Non-diagnose nodes go through the tracker; diagnose buffer stays empty."""
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_events_mode_stream())
|
||
|
||
assert renderer._diagnose.buffer == []
|
||
assert renderer._diagnose._live is None
|
||
assert "diagnose_root_cause" not in renderer.node_names_seen
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_root_cause_section_suppressed_when_diagnose_streamed(self, capfd) -> None:
|
||
"""When the user has seen the diagnose reasoning stream live, the
|
||
condensed Root Cause one-liner is redundant and gets dropped."""
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_diagnose_streaming_events())
|
||
|
||
out, _ = capfd.readouterr()
|
||
# Root Cause header is suppressed; the streamed body and final state
|
||
# carry the same information.
|
||
assert "Root Cause" not in out
|
||
# State is still populated so callers (tests, programmatic users)
|
||
# can read it from final_state.
|
||
assert renderer.final_state.get("root_cause") == "Schema mismatch"
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_root_cause_section_printed_when_diagnose_did_not_stream(self, capfd) -> None:
|
||
"""Updates-mode renders the report via the terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(_investigation_events())
|
||
|
||
out, _ = capfd.readouterr()
|
||
# The terminal renderer now outputs the report content (not a labelled
|
||
# Root Cause section) — "Investigation complete." comes from the publish event.
|
||
assert "Investigation complete" in out
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_diagnose_handles_anthropic_content_block_lists(self, capfd) -> None:
|
||
"""Anthropic-style adapters emit AIMessageChunk.content as a list of blocks.
|
||
|
||
Each block can be a dict ``{"type": "text", "text": "..."}`` or an
|
||
object with ``.text``. The renderer must flatten both shapes; calling
|
||
``str()`` on the list would render its Python repr instead of the
|
||
actual reasoning text.
|
||
"""
|
||
|
||
class _AnthropicTextBlock:
|
||
def __init__(self, text: str) -> None:
|
||
self.type = "text"
|
||
self.text = text
|
||
|
||
def anthropic_diagnose_events() -> Iterator[StreamEvent]:
|
||
yield _make_event("metadata", data={"run_id": "r-anthropic"})
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{"name": "diagnose", "data": {}, "metadata": {"pipeline_node": "diagnose"}},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:1"],
|
||
)
|
||
# Object-form block (Anthropic-style typical shape).
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"chunk": {"content": [_AnthropicTextBlock("Schema ")]}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
# Dict-form block (alternate shape).
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {
|
||
"chunk": {"content": [{"type": "text", "text": "mismatch detected."}]}
|
||
},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
# Tool-use block (non-text) interleaved — must be skipped.
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {
|
||
"chunk": {"content": [{"type": "tool_use", "name": "search", "input": {}}]}
|
||
},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"output": {"root_cause": "Schema mismatch"}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chain_end",
|
||
tags=["graph:step:1"],
|
||
)
|
||
yield _make_event("end")
|
||
|
||
renderer = StreamRenderer()
|
||
renderer.render_stream(anthropic_diagnose_events())
|
||
|
||
out, _ = capfd.readouterr()
|
||
# Real reasoning text appears, not Python repr of the block list.
|
||
assert "Schema mismatch detected." in out
|
||
# Tool-use block contributed no garbage.
|
||
assert "tool_use" not in out
|
||
assert "_AnthropicTextBlock" not in out
|
||
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "text"})
|
||
def test_diagnose_live_closed_on_mid_stream_exception(self) -> None:
|
||
"""If the stream raises during diagnose, the cleanup finish runs."""
|
||
|
||
def diagnose_then_raise() -> Iterator[StreamEvent]:
|
||
yield _make_event("metadata", data={"run_id": "r-x"})
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{"name": "diagnose", "data": {}, "metadata": {"pipeline_node": "diagnose"}},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:1"],
|
||
)
|
||
yield _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"chunk": {"content": "partial reasoning..."}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
raise RuntimeError("LLM quota exhausted")
|
||
|
||
renderer = StreamRenderer()
|
||
try:
|
||
renderer.render_stream(diagnose_then_raise())
|
||
raise AssertionError("expected RuntimeError to propagate")
|
||
except RuntimeError as exc:
|
||
assert "LLM quota exhausted" in str(exc)
|
||
|
||
# _finish_active_node runs in the finally block and routes diagnose
|
||
# through _end_diagnose, which closes the Live region and clears
|
||
# _active_node.
|
||
assert renderer._diagnose._live is None
|
||
assert renderer._active_node is None
|
||
|
||
|
||
class TestStreamRendererFocusedUXAndParsing:
|
||
"""Focused tests for deterministic report parsing."""
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parsing_with_structured_sections(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Report content is rendered via the terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Database connection pool saturated",
|
||
"validity_score": 0.95,
|
||
"slack_message": (
|
||
"### Supporting Evidence\n"
|
||
"• Active connections reached 100 max limit\n"
|
||
"• Thread pool starvation observed in logs\n"
|
||
"\n"
|
||
"### Next Actions\n"
|
||
"• Scale database connections to 200\n"
|
||
"• Restart connection pool gracefully\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
|
||
assert "Active connections" in out
|
||
assert "Scale database connections to 200" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parsing_with_numbered_lists(self, _mock_display, _mock_live, capfd) -> None:
|
||
"""Report content with numbered lists is rendered via the terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Database connection pool saturated",
|
||
"validity_score": 0.95,
|
||
"slack_message": (
|
||
"### Supporting Evidence\n"
|
||
"1. Active connections reached 100 max limit\n"
|
||
"2) Thread pool starvation observed in logs\n"
|
||
"\n"
|
||
"### Next Actions\n"
|
||
"1. Scale database connections to 200\n"
|
||
"2) Restart connection pool gracefully\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
|
||
assert "Active connections" in out
|
||
assert "Scale database connections to 200" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parsing_fallback_to_verbs_if_no_section(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Report content with bullet points is passed through to the terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Deadlock in database",
|
||
"validity_score": 0.88,
|
||
"slack_message": (
|
||
"The system experienced a major deadlock.\n"
|
||
"• Check transaction isolation levels.\n"
|
||
"• Restart the backend container.\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
|
||
assert "Check transaction isolation levels" in out
|
||
assert "Restart the backend container" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parsing_ignores_prose_evidence(self, _mock_display, _mock_live, capfd) -> None:
|
||
"""Report content including evidence items is rendered via the terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Database connection pool saturated",
|
||
"validity_score": 0.95,
|
||
"slack_message": (
|
||
"There is no evidence of database hardware failure.\n"
|
||
"### Supporting Evidence\n"
|
||
"• Saturated pool connections count is 100\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Saturated pool connections count" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parsing_mid_sentence_prose_not_misclassified(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Report content with prose and evidence is rendered via the terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Database connection pool saturated",
|
||
"validity_score": 0.95,
|
||
"slack_message": (
|
||
"There is no supporting evidence of DB hardware failure\n"
|
||
"Investigating root cause further to be absolutely sure\n"
|
||
"Skip next steps for now until confirmed\n"
|
||
"### Supporting Evidence\n"
|
||
"• Saturated pool connections count is 100\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Saturated pool connections count" in out
|
||
assert "There is no supporting evidence of DB hardware failure" in out
|
||
assert "Investigating root cause further" in out
|
||
assert "Skip next steps for now" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_verb_fallback_ignores_consumed_lines(self, _mock_display, _mock_live, capfd) -> None:
|
||
"""Verb-fallback does not pick up diagnostic prose that was already consumed by another section."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "High memory consumption",
|
||
"validity_score": 0.90,
|
||
"report": (
|
||
"### Supporting Evidence\n• We run the check on CPU and confirm it is stable.\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Next Actions" not in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_rich_rca_includes_parsed_report_root_cause_body(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Lines under a report \"Root Cause\" section are shown in the RCA output (rich)."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Connection pool exhausted",
|
||
"validity_score": 0.9,
|
||
"slack_message": (
|
||
"# Root Cause\n"
|
||
"Connection pool exhausted\n"
|
||
"• Stale transactions hold connections open.\n"
|
||
"• Idle timeout was set too high for burst traffic.\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Connection pool exhausted" in out
|
||
assert "Stale transactions" in out
|
||
assert "Idle timeout" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parses_markdown_hash_and_emphasis_headers(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Report content is rendered through terminal renderer."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Incident summary line",
|
||
"validity_score": 0.88,
|
||
"report": (
|
||
"*Non-Validated Claims (Inferred):*\n"
|
||
"Insufficient evidence gathered yet.\n"
|
||
"## Recommended Actions\n"
|
||
"- Enable debug logging for the workload\n"
|
||
"*Cited Evidence:*\n"
|
||
"- Queries: synthetic lookup\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Insufficient evidence gathered" in out
|
||
assert "synthetic lookup" in out.lower()
|
||
assert "Enable debug logging" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_root_cause_verbs_do_not_promote_to_next_actions(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Action verbs under Root Cause belong in RCA detail, never verb-fallback Next Actions."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Connection pool exhaustion",
|
||
"validity_score": 0.9,
|
||
"report": (
|
||
"# Root Cause\n"
|
||
"• Review the pool settings before scaling.\n"
|
||
"• Check idle timeout versus burst traffic.\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Next Actions" not in out
|
||
assert "Review the pool settings" in out
|
||
assert "idle timeout" in out.lower()
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_rich_rca_confidence_invalid_score_shows_na(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "Incident summary",
|
||
"validity_score": float("nan"),
|
||
"slack_message": "Incident summary\nNo root cause found.",
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Incident summary" in out
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_report_parsing_mid_list_transition_guard(
|
||
self, _mock_display, _mock_live, capfd
|
||
) -> None:
|
||
"""Section keywords inside a bullet item do not cause mid-list section transitions."""
|
||
renderer = StreamRenderer()
|
||
renderer._final_state = {
|
||
"root_cause": "High memory consumption",
|
||
"validity_score": 0.90,
|
||
"report": (
|
||
"### Supporting Evidence\n"
|
||
"• The evidence suggests memory leak in container.\n"
|
||
"• Check the heap dump next.\n"
|
||
),
|
||
}
|
||
renderer._print_report()
|
||
out, _ = capfd.readouterr()
|
||
assert "Supporting Evidence" in out
|
||
assert "The evidence suggests memory leak" in out
|
||
|
||
def test_strip_outer_quotes_does_not_fire_on_linux(self) -> None:
|
||
"""Windows-only outer quote normalization does not alter POSIX shlex tokens."""
|
||
from tools.interactive_shell.shell.parsing import (
|
||
parse_shell_command,
|
||
)
|
||
|
||
parsed = parse_shell_command('run "cat /tmp/file.txt"', is_windows=False)
|
||
assert parsed.argv == ["run", "cat /tmp/file.txt"]
|
||
|
||
|
||
class TestStreamRendererDiagnoseThrottle:
|
||
"""Pin the throttle: ``Markdown(buffer)`` is constructed at most once
|
||
per refresh window plus a final flush, even on long streams.
|
||
|
||
The diagnose node previously called ``live.update(Markdown(buffer))`` on
|
||
every token chunk, so a 10k-token reasoning trace caused ~10k full
|
||
Markdown re-parses (each O(n) on a growing buffer) — visibly stalling
|
||
long streams. These tests use a fake clock + Markdown spy so the parse
|
||
count is deterministic.
|
||
"""
|
||
|
||
@staticmethod
|
||
def _make_diagnose_chunk(content: object) -> StreamEvent:
|
||
return _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"chunk": {"content": content}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chat_model_stream",
|
||
tags=[],
|
||
)
|
||
|
||
@staticmethod
|
||
def _make_diagnose_start() -> StreamEvent:
|
||
return _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{"name": "diagnose", "data": {}, "metadata": {"pipeline_node": "diagnose"}},
|
||
kind="on_chain_start",
|
||
tags=["graph:step:1"],
|
||
)
|
||
|
||
@staticmethod
|
||
def _make_diagnose_end() -> StreamEvent:
|
||
return _make_event(
|
||
"events",
|
||
"diagnose",
|
||
{
|
||
"name": "diagnose",
|
||
"data": {"output": {"root_cause": "x"}},
|
||
"metadata": {"pipeline_node": "diagnose"},
|
||
},
|
||
kind="on_chain_end",
|
||
tags=["graph:step:1"],
|
||
)
|
||
|
||
def _install_clock_and_spy(self, monkeypatch) -> tuple[list[float], list[int]]:
|
||
"""Patch ``time.monotonic`` and ``Markdown`` in the renderer module.
|
||
|
||
Returns ``(fake_time, parse_count)`` mutable cells the test drives.
|
||
"""
|
||
from surfaces.cli.ui.renderer import diagnose as renderer_module
|
||
|
||
fake_time = [0.0]
|
||
parse_count = [0]
|
||
real_markdown = renderer_module.Markdown
|
||
|
||
class _SpyMarkdown(real_markdown):
|
||
def __init__(self, text: str) -> None:
|
||
parse_count[0] += 1
|
||
super().__init__(text)
|
||
|
||
monkeypatch.setattr(renderer_module.time, "monotonic", lambda: fake_time[0])
|
||
monkeypatch.setattr(renderer_module, "Markdown", _SpyMarkdown)
|
||
return fake_time, parse_count
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_chunks_in_one_window_collapse_to_a_single_final_flush(
|
||
self, _mock_display, _mock_live, monkeypatch
|
||
) -> None:
|
||
"""100 chunks while the clock is stuck → exactly one Markdown parse."""
|
||
fake_time, parse_count = self._install_clock_and_spy(monkeypatch)
|
||
# Force rich mode so the Live region opens and the throttle gates
|
||
# actual ``live.update`` calls. In text mode (pytest default — stdout
|
||
# isn't a tty) ``_diagnose._live`` stays None and the throttle never
|
||
# fires, which is the wrong path to test here.
|
||
renderer = StreamRenderer()
|
||
|
||
renderer._handle_event(self._make_diagnose_start())
|
||
for i in range(100):
|
||
renderer._handle_event(self._make_diagnose_chunk(f"c{i} "))
|
||
renderer._handle_event(self._make_diagnose_end())
|
||
|
||
# Clock never advanced past 0.0 — every per-chunk render was gated.
|
||
# Only the unconditional final flush in _DiagnoseStreamRenderer.finish
|
||
# fires, producing exactly one Markdown parse.
|
||
assert parse_count[0] == 1, (
|
||
f"expected 1 parse (final flush only), got {parse_count[0]}; "
|
||
"throttle is letting intra-window updates through"
|
||
)
|
||
# Buffer still contains all chunks even though we only rendered once.
|
||
assert "c0 " in "".join(renderer._diagnose.buffer)
|
||
assert "c99 " in "".join(renderer._diagnose.buffer)
|
||
# silence unused-var while keeping the fixture wired.
|
||
assert fake_time[0] == 0.0
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_chunks_across_multiple_windows_render_periodically(
|
||
self, _mock_display, _mock_live, monkeypatch
|
||
) -> None:
|
||
"""Chunks spaced past the throttle interval render multiple times."""
|
||
from surfaces.cli.ui.renderer import diagnose as renderer_module
|
||
|
||
fake_time, parse_count = self._install_clock_and_spy(monkeypatch)
|
||
interval = renderer_module._DIAGNOSE_RENDER_INTERVAL_S
|
||
renderer = StreamRenderer()
|
||
|
||
renderer._handle_event(self._make_diagnose_start())
|
||
# 10 chunks, each crossing a fresh render window (2× interval apart).
|
||
for i in range(10):
|
||
fake_time[0] = (i + 1) * (interval * 2)
|
||
renderer._handle_event(self._make_diagnose_chunk(f"c{i} "))
|
||
renderer._handle_event(self._make_diagnose_end())
|
||
|
||
# 10 in-loop renders + 1 final flush. Tolerance for boundary effects.
|
||
assert 9 <= parse_count[0] <= 12, (
|
||
f"expected ~10–11 parses across 10 windows, got {parse_count[0]}"
|
||
)
|
||
# Throttle's purpose: parse count must stay << total chunks.
|
||
assert parse_count[0] < 50
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_final_flush_renders_chunks_pending_in_last_window(
|
||
self, _mock_display, _mock_live, monkeypatch
|
||
) -> None:
|
||
"""Chunks arriving in the trailing throttle window must still appear."""
|
||
from surfaces.cli.ui.renderer import diagnose as renderer_module
|
||
|
||
fake_time, parse_count = self._install_clock_and_spy(monkeypatch)
|
||
interval = renderer_module._DIAGNOSE_RENDER_INTERVAL_S
|
||
renderer = StreamRenderer()
|
||
|
||
renderer._handle_event(self._make_diagnose_start())
|
||
# First chunk crosses one window — renders.
|
||
fake_time[0] = interval * 2
|
||
renderer._handle_event(self._make_diagnose_chunk("early "))
|
||
# Remaining chunks fall within the same window — throttle blocks them.
|
||
renderer._handle_event(self._make_diagnose_chunk("tail-1 "))
|
||
renderer._handle_event(self._make_diagnose_chunk("tail-2"))
|
||
renderer._handle_event(self._make_diagnose_end())
|
||
|
||
# All chunks must be in the buffer at finish (final flush picks them up).
|
||
assert "".join(renderer._diagnose.buffer) == "early tail-1 tail-2"
|
||
# Two parses: one in-loop render at "early " + one final flush.
|
||
assert parse_count[0] == 2
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_anthropic_block_chunks_throttle_correctly(
|
||
self, _mock_display, _mock_live, monkeypatch
|
||
) -> None:
|
||
"""List-shaped Anthropic content blocks honor the same throttle gate."""
|
||
fake_time, parse_count = self._install_clock_and_spy(monkeypatch)
|
||
renderer = StreamRenderer()
|
||
|
||
renderer._handle_event(self._make_diagnose_start())
|
||
# Mixed: dict-form text blocks + tool-use (skipped) + object-form.
|
||
# Clock stuck → all gated, only final flush fires.
|
||
for i in range(20):
|
||
content = [{"type": "text", "text": f"c{i} "}]
|
||
renderer._handle_event(self._make_diagnose_chunk(content))
|
||
renderer._handle_event(self._make_diagnose_end())
|
||
|
||
# Exactly one Markdown parse — the final flush.
|
||
assert parse_count[0] == 1
|
||
# Block list shapes flatten correctly through the throttle path too.
|
||
assert "".join(renderer._diagnose.buffer).startswith("c0 ")
|
||
assert "c19" in "".join(renderer._diagnose.buffer)
|
||
assert fake_time[0] == 0.0
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_diagnose_start_stops_progress_tracker_display(self, _mock_display, _mock_live) -> None:
|
||
"""Calling _begin_diagnose safely stops the active ProgressTracker display and sets it to None."""
|
||
renderer = StreamRenderer()
|
||
# Initialize active display
|
||
renderer._tracker._display = renderer._tracker._display or True
|
||
assert renderer._tracker._display is not None
|
||
|
||
renderer._begin_diagnose("diagnose_root_cause")
|
||
assert renderer._tracker._display is None
|
||
|
||
|
||
class TestStreamRendererPrintAboveRenderable:
|
||
"""Tests for the _print_above_renderable safety mechanism.
|
||
|
||
Ensures that when the diagnose Live region is active, print_above_renderable
|
||
is routed through the Live console to prevent terminal corruption, and
|
||
otherwise falls back to the tracker.
|
||
"""
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_print_above_renderable_routes_to_live_console_when_started(
|
||
self, _mock_display, _mock_live
|
||
) -> None:
|
||
renderer = StreamRenderer()
|
||
renderer._begin_diagnose("diagnose_root_cause")
|
||
|
||
# Mock the active Live object's console
|
||
from unittest.mock import MagicMock
|
||
|
||
mock_console = MagicMock()
|
||
renderer._diagnose._live.console = mock_console
|
||
renderer._diagnose._live.is_started = True
|
||
|
||
panel = "test-panel"
|
||
renderer._print_above_renderable(panel)
|
||
|
||
# Should print directly via active Live console
|
||
mock_console.print.assert_called_once_with(panel)
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_print_above_renderable_falls_back_to_tracker_when_live_not_started(
|
||
self, _mock_display, _mock_live
|
||
) -> None:
|
||
renderer = StreamRenderer()
|
||
# Mock tracker's print_above_renderable
|
||
from unittest.mock import MagicMock
|
||
|
||
mock_tracker_print = MagicMock()
|
||
renderer._tracker.print_above_renderable = mock_tracker_print
|
||
|
||
panel = "test-panel"
|
||
renderer._print_above_renderable(panel)
|
||
|
||
# Should fall back to tracker
|
||
mock_tracker_print.assert_called_once_with(panel)
|
||
|
||
@patch("surfaces.cli.ui.renderer.diagnose.Live")
|
||
@patch("surfaces.interactive_shell.ui.output.tracker._EventLogDisplay")
|
||
@patch.dict(os.environ, {"TRACER_OUTPUT_FORMAT": "rich"})
|
||
def test_print_above_renderable_falls_back_to_console_when_tracker_stopped(
|
||
self, _mock_display, _mock_live
|
||
) -> None:
|
||
from surfaces.cli.ui.renderer import StreamRenderer
|
||
|
||
renderer = StreamRenderer()
|
||
|
||
# Simulate tracker being stopped
|
||
renderer._tracker.stop()
|
||
assert renderer._tracker._display is None
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
mock_console = MagicMock()
|
||
renderer._console = mock_console
|
||
|
||
panel = "test-panel"
|
||
renderer._print_above_renderable(panel)
|
||
|
||
# Should fall back to internal console
|
||
mock_console.print.assert_called_once_with(panel)
|
||
|
||
def test_merge_chain_start_input_eagerly_updates_metadata(self) -> None:
|
||
"""_merge_chain_start_input should pull 'input' payload from data into _final_state."""
|
||
from core.domain.stream import StreamEvent
|
||
from surfaces.cli.ui.renderer import StreamRenderer
|
||
|
||
renderer = StreamRenderer()
|
||
assert "alert_name" not in renderer._final_state
|
||
|
||
# Construct on_chain_start style event carrying metadata in input
|
||
event = StreamEvent(
|
||
event_type="events",
|
||
node_name="diagnose",
|
||
kind="on_chain_start",
|
||
data={
|
||
"name": "diagnose",
|
||
"data": {
|
||
"input": {"alert_name": "late-breaking-alert", "pipeline_name": "test-pipeline"}
|
||
},
|
||
},
|
||
)
|
||
|
||
renderer._merge_chain_start_input(event)
|
||
|
||
assert renderer._final_state.get("alert_name") == "late-breaking-alert"
|
||
assert renderer._final_state.get("pipeline_name") == "test-pipeline"
|