Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

232 lines
8.5 KiB
Python

"""Live Markdown renderer for diagnose-node token streams."""
from __future__ import annotations
import sys
import time
from collections.abc import Callable, Mapping
from typing import Any
from rich.console import Console
from rich.live import Live
from rich.markdown import Markdown
from rich.spinner import Spinner
from rich.text import Text
from core.domain.stream import StreamEvent
from platform.analytics.cli import capture_investigation_lifecycle_event
from platform.analytics.events import Event
from surfaces.cli.ui.renderer.constants import (
_BOLD,
_DIAGNOSE_LIVE_REFRESH,
_DIAGNOSE_NODE,
_DIAGNOSE_RENDER_INTERVAL_S,
_DIAGNOSE_SPINNER_COLOR,
_DIAGNOSE_SPINNER_NAME,
_DIM,
_GREEN,
_RESET,
_WHITE,
_render_source,
)
from surfaces.interactive_shell.ui.output import (
ProgressTracker,
_repl_progress_active,
get_output_format,
set_live_console,
stop_display,
unregister_live_console,
)
class _DiagnoseStreamRenderer:
"""Owns the diagnose-node live-streaming state machine.
Encapsulates the buffer of incoming token deltas, the lazy Rich Console
+ Live region, and the throttled Markdown re-parse cadence. Exists so
:class:`StreamRenderer` keeps a single responsibility (event dispatch
+ node lifecycle + final report) while diagnose-specific streaming
concerns live in one focused place.
Lifecycle: :meth:`start` → :meth:`append_chunk` (per token-delta event)
→ :meth:`finish`. The same instance can be reused across multiple
investigation runs — :meth:`start` resets all state.
"""
def __init__(
self,
console: Console | None = None,
tracker: ProgressTracker | None = None,
*,
local: bool = False,
state_provider: Callable[[], Mapping[str, object]] | None = None,
) -> None:
self.buffer: list[str] = []
self._live: Live | None = None
self._started: float = 0.0
# Last time we re-rendered ``Markdown(buffer)`` into the Live region.
# Throttled to ``_DIAGNOSE_RENDER_INTERVAL_S`` so long streams don't
# incur O(n²) parsing.
self._last_render: float = 0.0
self._console: Console | None = console
self._tracker: ProgressTracker | None = tracker
self._local = local
self._state_provider = state_provider
@property
def streamed(self) -> bool:
"""True if any chunks were buffered during the run.
Callers (specifically :meth:`StreamRenderer._print_report`) use this
to decide whether the final ``Root Cause`` summary should be
suppressed — it would duplicate text the user just watched stream.
"""
return bool(self.buffer)
def start(self) -> None:
"""Reset state and open the Live region (rich) or print a placeholder (text)."""
self.buffer = []
self._started = time.monotonic()
# 0.0 sentinel forces the first chunk past the throttle gate so the
# user sees something rendered as soon as tokens arrive.
self._last_render = 0.0
if _repl_progress_active():
return
if get_output_format() != "rich":
sys.stdout.write(f" … {_DIAGNOSE_NODE}\n")
sys.stdout.flush()
return
if self._console is None:
self._console = Console(highlight=False)
spinner = Spinner(
_DIAGNOSE_SPINNER_NAME,
text=Text(
f"{_DIAGNOSE_NODE} reasoning…",
style=f"bold {_DIAGNOSE_SPINNER_COLOR}",
),
style=f"bold {_DIAGNOSE_SPINNER_COLOR}",
)
self._live = Live(
spinner,
console=self._console,
refresh_per_second=_DIAGNOSE_LIVE_REFRESH,
transient=False,
)
# Shrink the gap: stop previous display immediately before starting new one
if self._tracker is not None:
self._tracker.stop()
else:
stop_display()
# Register console globally so that print_above_renderable fallbacks
# correctly print above this live region during the diagnose phase.
set_live_console(self._console)
self._live.start()
def append_chunk(self, event: StreamEvent) -> None:
"""Append a token delta to the buffer; refresh the Live region (throttled).
The chunk's ``content`` shape varies by provider: OpenAI emits a
plain string; some Anthropic SDK paths emit a list of content blocks.
:func:`_flatten_chunk_content` handles both — calling ``str()`` on
the list shape would render its Python repr instead of reasoning.
"""
chunk = event.data.get("data", {}).get("chunk", {})
content = chunk.get("content", "") if isinstance(chunk, dict) else ""
if not content:
return
text = _flatten_chunk_content(content)
if not text:
return
self.buffer.append(text)
if len(self.buffer) == 1:
latency_ms = (time.monotonic() - self._started) * 1000
state = dict(self._state_provider()) if self._state_provider is not None else None
capture_investigation_lifecycle_event(
Event.INVESTIGATION_FIRST_HYPOTHESIS_RENDERED,
{
"latency_ms": int(latency_ms),
"stage": _DIAGNOSE_NODE,
"source": _render_source(local=self._local),
},
state=state,
)
if self._live is None:
if _repl_progress_active() and self._tracker is not None:
preview = "".join(self.buffer)
if len(preview) > 80:
preview = "…" + preview[-77:]
self._tracker.update_subtext(_DIAGNOSE_NODE, preview, duration=30.0)
return
# Throttle Markdown re-parse to once per refresh window; the final
# flush in :meth:`finish` guarantees the latest buffer is rendered
# before the Live region closes.
now = time.monotonic()
if now - self._last_render >= _DIAGNOSE_RENDER_INTERVAL_S:
self._live.update(Markdown("".join(self.buffer)))
self._last_render = now
def finish(self, message: str | None = None) -> None:
"""Close the Live region (or text-mode flush) and print the resolved-dot line.
``message`` is appended dim-styled to the resolution line — typically
a validity-score summary built by ``_build_node_message``.
"""
elapsed = time.monotonic() - self._started
if self._live is not None:
# Final flush: any chunks pending in the last throttle window
# render here so the user sees the complete reasoning.
if self.buffer:
self._live.update(Markdown("".join(self.buffer)))
try:
self._live.stop()
finally:
self._live = None
# Unregister only if we own it (safeguard against subsequent activations)
unregister_live_console(self._console)
sys.stdout.write(
f" {_GREEN}{_RESET} {_BOLD}{_WHITE}{_DIAGNOSE_NODE}{_RESET}"
f" {_DIM}{elapsed:.1f}s{_RESET}"
)
if message:
sys.stdout.write(f" {_DIM}{message}{_RESET}")
sys.stdout.write("\n")
sys.stdout.flush()
else:
if self.buffer:
for line in "".join(self.buffer).strip().splitlines():
print(f" {line}")
tail = f" ● {_DIAGNOSE_NODE} {elapsed:.1f}s"
if message:
tail += f" {message}"
print(tail)
def _flatten_chunk_content(content: Any) -> str:
"""Resolve a chat-model chunk's ``content`` to plain text.
OpenAI emits a string. Anthropic-style adapters may emit a list of content
blocks where each block may be an object with ``.text`` or a dict
with a ``"text"`` key. Non-text blocks (tool-use, image) are skipped.
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, dict):
text_value = block.get("text")
if isinstance(text_value, str):
parts.append(text_value)
continue
text_value = getattr(block, "text", None)
if isinstance(text_value, str):
parts.append(text_value)
return "".join(parts)