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
984 lines
37 KiB
Python
984 lines
37 KiB
Python
"""Tests for the shared live-streaming renderer used by interactive-shell handlers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import re
|
|
import threading
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from surfaces.interactive_shell.ui.streaming import (
|
|
format_token_count_short,
|
|
render_response_header,
|
|
stream_to_console,
|
|
)
|
|
|
|
|
|
def _strip_ansi(text: str) -> str:
|
|
"""Drop ANSI escapes so assertions check the visible output."""
|
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text)
|
|
|
|
|
|
def _tty_console() -> tuple[Console, io.StringIO]:
|
|
"""Build a Console that thinks it is a terminal so Rich.Live actually renders."""
|
|
buf = io.StringIO()
|
|
return (
|
|
Console(file=buf, force_terminal=True, color_system=None, width=80, highlight=False),
|
|
buf,
|
|
)
|
|
|
|
|
|
def _non_tty_console() -> tuple[Console, io.StringIO]:
|
|
buf = io.StringIO()
|
|
return Console(file=buf, force_terminal=False, color_system=None, width=80), buf
|
|
|
|
|
|
def _yield_chunks(chunks: list[str]) -> Iterator[str]:
|
|
yield from chunks
|
|
|
|
|
|
class TestNonTtyFallback:
|
|
"""On a non-terminal console the helper drains, prints, and returns the full text."""
|
|
|
|
def test_drains_stream_and_prints_without_live_artifacts(self) -> None:
|
|
console, buf = _non_tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["Hel", "lo, ", "world"]),
|
|
)
|
|
|
|
output = buf.getvalue()
|
|
assert result == "Hello, world"
|
|
# Bullet header + label + text reach piped output so captured
|
|
# logs are useful. ``●`` is the row marker; ``assistant`` is the
|
|
# dim label alongside it.
|
|
assert "●" in output
|
|
assert "assistant" in output
|
|
assert "Hello, world" in output
|
|
# No spinner / Live cursor-movement artifacts in non-TTY captures.
|
|
assert "thinking" not in output
|
|
|
|
def test_suppression_drains_silently_in_non_tty(self) -> None:
|
|
"""Suppressed payloads (machine-readable payloads) must not appear in piped output."""
|
|
console, buf = _non_tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(['{"actions"', ":[]}"]),
|
|
suppress_if_starts_with="{",
|
|
)
|
|
|
|
assert result == '{"actions":[]}'
|
|
output = buf.getvalue()
|
|
# No bullet header for suppressed responses.
|
|
assert "●" not in output
|
|
assert '{"actions"' not in output
|
|
|
|
|
|
class TestTtyParagraphRender:
|
|
"""On a terminal console paragraphs render as Markdown the moment
|
|
each ``\\n\\n`` boundary closes them; the final paragraph is
|
|
force-flushed at end-of-stream. Code blocks are kept whole (we
|
|
don't split mid-fence). The spinner indicator drives the live
|
|
streaming feedback within a paragraph.
|
|
"""
|
|
|
|
def test_renders_label_and_streamed_content_as_markdown(self) -> None:
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["Run **opensre", " investigate** to start."]),
|
|
)
|
|
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert result == "Run **opensre investigate** to start."
|
|
# Bullet row marker pinned above the rendered paragraph.
|
|
assert "●" in output
|
|
# End-of-stream force-flush rendered Markdown — ``**`` stripped.
|
|
assert "**opensre" not in output
|
|
assert "opensre investigate" in output
|
|
|
|
def test_renders_first_paragraph_before_second_completes(self) -> None:
|
|
"""A complete paragraph (``\\n\\n``) flushes immediately, even
|
|
when more chunks would still arrive after it. The second
|
|
paragraph stays buffered until its own boundary or EOS."""
|
|
chunks: list[str] = []
|
|
|
|
def _capture_chunks() -> Iterator[str]:
|
|
for c in ["First **para**.\n\n", "Second **para**."]:
|
|
chunks.append(c)
|
|
yield c
|
|
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_capture_chunks(),
|
|
)
|
|
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert result == "First **para**.\n\nSecond **para**."
|
|
# Both paragraphs are rendered (``**`` stripped).
|
|
assert "First para." in output
|
|
assert "Second para." in output
|
|
assert "**para**" not in output
|
|
|
|
def test_paragraph_break_across_chunk_boundary_flushes(self) -> None:
|
|
"""The cross-chunk seam — chunk N ends with ``\\n``, chunk N+1
|
|
starts with ``\\n`` — must be detected as a paragraph break.
|
|
|
|
Without the seam check the fast-path skips the join (no
|
|
``\\n\\n`` *inside* either chunk) and the boundary is missed
|
|
until end-of-stream.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
# Patch only on this thread; restored by the test fixture's GC.
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, _ = _tty_console()
|
|
# ``"first.\n"`` then ``"\nsecond."`` — neither chunk
|
|
# contains ``\n\n`` standalone, but joined they form a
|
|
# paragraph break at the seam.
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["first.\n", "\nsecond."]),
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 2 parses: first paragraph flushed at the seam, then second
|
|
# tail force-flushed at end-of-stream.
|
|
assert parse_count[0] == 2, (
|
|
f"seam check missed the cross-chunk break — got {parse_count[0]} parses"
|
|
)
|
|
|
|
def test_peeked_chunks_seed_prev_chunk_for_seam_detection(self) -> None:
|
|
"""When ``suppress_if_starts_with`` peeks chunks but doesn't
|
|
suppress, those peeked chunks become history for the seam
|
|
check on the very first main-loop chunk.
|
|
|
|
Concretely: suppression-peek pulls ``"hello\\n"`` (didn't match
|
|
``"{"``); main loop starts with ``"\\nworld"``. The seam should
|
|
be detected — ``peeked[-1]`` is the initial ``prev_chunk``.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, _ = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["hello\n", "\nworld"]),
|
|
suppress_if_starts_with="{",
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 2 parses — peeked chunk + first main-loop chunk form a seam,
|
|
# producing one paragraph; tail is force-flushed at EOS.
|
|
assert parse_count[0] == 2
|
|
|
|
def test_open_code_block_is_not_split_mid_fence(self) -> None:
|
|
"""``\\n\\n`` inside an open code block must NOT trigger a
|
|
flush — splitting would render a partial fenced block whose
|
|
formatting breaks. The fence stays whole until it closes."""
|
|
chunks_with_open_fence = [
|
|
"Header\n\n",
|
|
"```python\n",
|
|
"x = 1\n\n", # blank line inside code block — must not flush
|
|
"y = 2\n",
|
|
"```\n\n",
|
|
"Trailing.",
|
|
]
|
|
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(chunks_with_open_fence),
|
|
)
|
|
|
|
# Full text returned unchanged.
|
|
assert "x = 1" in result
|
|
assert "y = 2" in result
|
|
# Both code lines must appear in the rendered output (i.e. the
|
|
# fence wasn't split before its closing ``` was seen).
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "x = 1" in output
|
|
assert "y = 2" in output
|
|
assert "Trailing" in output
|
|
|
|
def test_post_fence_paragraph_renders_after_block_with_embedded_blank_line(
|
|
self,
|
|
) -> None:
|
|
"""A code block containing a blank line must not bury later
|
|
paragraphs at EOS. Once the closing fence arrives, the
|
|
completed code-block paragraph flushes and any subsequent
|
|
``\\n\\n``-terminated paragraph flushes mid-stream too.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, _ = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(
|
|
[
|
|
"Intro.\n\n",
|
|
"```python\n",
|
|
"x = 1\n\n",
|
|
"y = 2\n",
|
|
"```\n\n",
|
|
"Conclusion.\n\n",
|
|
]
|
|
),
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 3 parses — intro flushes on its own ``\n\n``; the fenced block
|
|
# flushes once the closing fence + ``\n\n`` arrive (skipping the
|
|
# embedded blank line); conclusion flushes on its own ``\n\n``
|
|
# before EOS. Without the skip-past-open-fence logic, the fenced
|
|
# block + conclusion would defer to a single force-flush at EOS
|
|
# → 2 parses.
|
|
assert parse_count[0] == 3, (
|
|
f"post-fence paragraph deferred to EOS — got {parse_count[0]} parses"
|
|
)
|
|
|
|
def test_multiple_blank_lines_inside_single_fence_render_as_one_block(
|
|
self,
|
|
) -> None:
|
|
"""A single code block with several embedded ``\\n\\n`` must render
|
|
once when its fence closes. Exercises ``search_from`` advancing
|
|
repeatedly within a single ``_flush_paragraphs`` call.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, _ = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(
|
|
[
|
|
"Intro.\n\n",
|
|
"```python\n",
|
|
"a\n\n", # first embedded blank
|
|
"b\n\n", # second embedded blank
|
|
"c\n\n", # third embedded blank
|
|
"d\n",
|
|
"```\n\n",
|
|
"After.\n\n",
|
|
]
|
|
),
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 3 parses with the fix: intro + fenced block (rendered once when
|
|
# fence closes, after 3 skip iterations advance search_from past
|
|
# each embedded blank) + after. Without the fix, the inner loop
|
|
# would break on the first odd-fence boundary and defer the block
|
|
# + after to a single EOS force-flush → 2 parses.
|
|
assert parse_count[0] == 3, (
|
|
f"expected 3 parses (intro + block + after), got {parse_count[0]}"
|
|
)
|
|
|
|
def test_two_consecutive_fences_each_with_blank_line_render_independently(
|
|
self,
|
|
) -> None:
|
|
"""Two fenced blocks back-to-back, each containing an embedded
|
|
``\\n\\n``. Each block must render as its own paragraph when its
|
|
fence closes — ``search_from`` is reset to 0 after each render so
|
|
the second block isn't blocked by stale state from the first.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, _ = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(
|
|
[
|
|
"Intro.\n\n",
|
|
"```py\nfoo\n\nbar\n```\n\n", # block 1, embedded blank
|
|
"```py\nbaz\n\nqux\n```\n\n", # block 2, embedded blank
|
|
"End.\n\n",
|
|
]
|
|
),
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 4 parses with the fix: intro + block1 + block2 + end. Without
|
|
# the fix, block1's leading embedded ``\n\n`` would lock the inner
|
|
# loop on the odd-fence break for every subsequent chunk, so the
|
|
# entire tail (block1 + block2 + end) collapses into one EOS
|
|
# force-flush → 2 parses total.
|
|
assert parse_count[0] == 4, (
|
|
f"expected 4 parses (intro + 2 blocks + end), got {parse_count[0]}"
|
|
)
|
|
|
|
def test_inline_triple_backtick_mention_does_not_block_paragraph(self) -> None:
|
|
"""Single inline ``\\`\\`\\``` mention inside flowing text must not
|
|
be miscounted as an open fence. The substring count would flip to
|
|
odd (1), skipping the paragraph's ``\\n\\n`` boundary and deferring
|
|
rendering to EOS. Only line-start fences count, so two paragraphs
|
|
each render incrementally as their ``\\n\\n`` arrives.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, _ = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(
|
|
[
|
|
"The ``` marker opens a code block in markdown.\n\n",
|
|
"Use it whenever you want to fence example code.\n\n",
|
|
]
|
|
),
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 2 parses: each paragraph flushes on its own ``\n\n``. Without
|
|
# the line-start fence check, the single inline ``` would flip
|
|
# the count to odd, both ``\n\n`` boundaries would be skipped,
|
|
# and the whole stream would force-flush as 1 parse at EOS.
|
|
assert parse_count[0] == 2, (
|
|
f"inline ``` mention blocked paragraph flush — got {parse_count[0]} parses"
|
|
)
|
|
|
|
def test_mid_line_triple_backtick_does_not_count_as_fence(self) -> None:
|
|
"""A ``\\`\\`\\``` that appears mid-line (not at line start) must
|
|
NOT be counted as a fence boundary by the parity check. Real
|
|
code blocks must keep accumulating across paragraphs until a
|
|
line-start closing fence arrives — the mid-line backticks are
|
|
inline content (often quoted/embedded in prose), not Markdown
|
|
syntax.
|
|
|
|
Regression for the drive-by review point: a chunk like
|
|
``\\nresult: ok\\`\\`\\`\\nmore text`` (closing-fence-shaped
|
|
characters mid-line because the chunk boundary fell there)
|
|
used to be a worry. The ``^\\`\\`\\``` regex with
|
|
``re.MULTILINE`` matches only line-start fences, so this
|
|
scenario stays correct.
|
|
"""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
original_markdown = streaming_module.Markdown
|
|
streaming_module.Markdown = _SpyMarkdown
|
|
try:
|
|
console, buf = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(
|
|
[
|
|
# Real fence opens at line start.
|
|
"```py\n",
|
|
"x = 1\n",
|
|
# Mid-line backticks inside the still-open fence.
|
|
# MUST be ignored by the parity check — fence
|
|
# stays open until a real closing fence at line
|
|
# start.
|
|
"result: ok```\n",
|
|
"y = 2\n",
|
|
# Now the real closing fence at line start.
|
|
"```\n\n",
|
|
"After the block.\n\n",
|
|
]
|
|
),
|
|
)
|
|
finally:
|
|
streaming_module.Markdown = original_markdown
|
|
|
|
# 2 parses: (1) the entire fenced code block as one Markdown
|
|
# parse — proves the mid-line ``` didn't prematurely flush it;
|
|
# (2) the "After the block." paragraph. Without the line-anchor,
|
|
# the mid-line ``` would flip the parity, close the fence
|
|
# early, and we'd see 3+ parses with broken code rendering.
|
|
assert parse_count[0] == 2, f"mid-line ``` was miscounted — got {parse_count[0]} parses"
|
|
# And the mid-line backticks must reach the rendered output as
|
|
# plain text (inside the code block), not get eaten as syntax.
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "result: ok" in output
|
|
|
|
def test_unclosed_fence_with_embedded_blank_line_renders_at_eos(self) -> None:
|
|
"""Unclosed fence containing an embedded ``\\n\\n`` must not hang
|
|
the inner loop and must surface the partial buffer at end-of-stream.
|
|
|
|
With the skip-past-open-fence logic, the inner loop advances
|
|
``search_from`` past each embedded ``\\n\\n``, eventually returns
|
|
``-1`` from ``find``, and exits cleanly. The outer ``finally``
|
|
then force-flushes the partial buffer so the user sees the
|
|
truncated response rather than nothing.
|
|
"""
|
|
chunks = [
|
|
"```py\n",
|
|
"a = 1\n\n", # blank inside fence
|
|
"b = 2\n", # stream ends without closing the fence
|
|
]
|
|
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(chunks),
|
|
)
|
|
|
|
# Both code lines must appear in the rendered output — the
|
|
# partial fence is force-flushed at EOS so the user sees what
|
|
# was streamed before the LLM cut off.
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "a = 1" in output
|
|
assert "b = 2" in output
|
|
assert "a = 1" in result
|
|
assert "b = 2" in result
|
|
|
|
def test_returns_empty_string_when_stream_is_empty(self) -> None:
|
|
"""An empty stream must not leave a frozen spinner on screen."""
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks([]),
|
|
)
|
|
|
|
assert result == ""
|
|
# Bullet still printed (header fires before chunk processing),
|
|
# but no spinner residue at finalize.
|
|
assert "●" in _strip_ansi(buf.getvalue())
|
|
|
|
|
|
class TestMidStreamError:
|
|
"""Errors inside the stream propagate while the partial buffer stays on screen."""
|
|
|
|
def test_exception_propagates_with_partial_visible(self) -> None:
|
|
def _broken_stream() -> Iterator[str]:
|
|
yield "partial "
|
|
yield "answer"
|
|
raise RuntimeError("upstream 503")
|
|
|
|
console, buf = _tty_console()
|
|
|
|
with pytest.raises(RuntimeError, match="upstream 503"):
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_broken_stream(),
|
|
)
|
|
|
|
# The partial response was rendered before the exception propagated,
|
|
# so the caller can surface an error label below it.
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "partial answer" in output
|
|
|
|
def test_keyboard_interrupt_propagates_with_partial_visible(self) -> None:
|
|
"""KeyboardInterrupt mid-stream propagates after the partial renders.
|
|
|
|
The double-press absorption logic that used to live here was moved
|
|
to the prompt_toolkit cancel key bindings (see
|
|
:func:`interactive_shell.ui.input_prompt.key_bindings.build_cancel_key_bindings`)
|
|
— the streaming code just lets ``KeyboardInterrupt`` propagate,
|
|
and the ``finally`` block in :func:`stream_to_console` ensures
|
|
the partial buffer is rendered.
|
|
"""
|
|
|
|
class _ChunksThenKbd:
|
|
__slots__ = ("_i",)
|
|
|
|
def __init__(self) -> None:
|
|
self._i = 0
|
|
|
|
def __iter__(self) -> Iterator[str]:
|
|
return self
|
|
|
|
def __next__(self) -> str:
|
|
parts = ("partial ", "answer")
|
|
if self._i < len(parts):
|
|
c = parts[self._i]
|
|
self._i += 1
|
|
return c
|
|
raise KeyboardInterrupt
|
|
|
|
console, buf = _tty_console()
|
|
with pytest.raises(KeyboardInterrupt):
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=iter(_ChunksThenKbd()),
|
|
)
|
|
|
|
output = _strip_ansi(buf.getvalue())
|
|
# Partial is rendered before the KI propagates — the ``finally``
|
|
# in stream_to_console fires the Markdown render of the buffer.
|
|
assert "partial answer" in output
|
|
|
|
|
|
class TestTimingFooter:
|
|
"""A small dim ``· Ns`` footer appears after a rendered live response."""
|
|
|
|
def test_footer_printed_after_streamed_response(self) -> None:
|
|
console, buf = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["hello"]),
|
|
)
|
|
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert re.search(r"·\s+\d+\.\d+s", output) is not None
|
|
|
|
def test_footer_skipped_when_stream_is_empty(self) -> None:
|
|
"""Empty stream must not print a timing footer under nothing."""
|
|
console, buf = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks([]),
|
|
)
|
|
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert re.search(r"·\s+\d+\.\d+s", output) is None
|
|
|
|
def test_footer_skipped_when_response_is_suppressed(self) -> None:
|
|
"""Suppressed machine-readable payloads should not get a timing footer either."""
|
|
console, buf = _tty_console()
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(['{"actions"', ":[]}"]),
|
|
suppress_if_starts_with="{",
|
|
)
|
|
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert re.search(r"·\s+\d+\.\d+s", output) is None
|
|
|
|
|
|
class TestRenderResponseHeader:
|
|
"""``render_response_header`` is the bullet-row marker shared with
|
|
``action_turn.run_action_tool_turn`` — three call sites collapsed
|
|
to one helper, so we lock in the visible output here.
|
|
"""
|
|
|
|
def test_emits_bullet_glyph_and_label(self) -> None:
|
|
console, buf = _tty_console()
|
|
render_response_header(console, "assistant")
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "●" in output
|
|
assert "assistant" in output
|
|
|
|
def test_label_is_passthrough(self) -> None:
|
|
"""The function takes the label verbatim — callers pass either
|
|
``STREAM_LABEL_ANSWER`` or ``STREAM_LABEL_ASSISTANT`` (or any
|
|
free-form word). No filtering, no defaults."""
|
|
console, buf = _tty_console()
|
|
render_response_header(console, "answer")
|
|
assert "answer" in _strip_ansi(buf.getvalue())
|
|
|
|
|
|
class TestFormatTokenCountShort:
|
|
"""Shared helper used by both the streaming footer and the live spinner."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("count", "expected"),
|
|
[
|
|
(0, "0"),
|
|
(1, "1"),
|
|
(999, "999"),
|
|
(1000, "1.0k"),
|
|
(1234, "1.2k"),
|
|
(10000, "10.0k"),
|
|
(123456, "123.5k"),
|
|
],
|
|
)
|
|
def test_formats_at_boundaries(self, count: int, expected: str) -> None:
|
|
assert format_token_count_short(count) == expected
|
|
|
|
|
|
class _ProgressConsole(Console):
|
|
"""Console with the loop's :class:`StreamingConsole` shape — exposes
|
|
``update_streaming_progress`` and ``cancel_requested`` for the
|
|
streaming layer's ``getattr`` dispatch.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
cancel_event: threading.Event | None = None,
|
|
cancel_after_n_progress_calls: int | None = None,
|
|
**kwargs: object,
|
|
) -> None:
|
|
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
self.progress_calls: list[int] = []
|
|
self._cancel_event = cancel_event or threading.Event()
|
|
self._cancel_after = cancel_after_n_progress_calls
|
|
|
|
def update_streaming_progress(self, bytes_received: int) -> None:
|
|
self.progress_calls.append(bytes_received)
|
|
if self._cancel_after is not None and len(self.progress_calls) >= self._cancel_after:
|
|
self._cancel_event.set()
|
|
|
|
@property
|
|
def cancel_requested(self) -> bool:
|
|
return self._cancel_event.is_set()
|
|
|
|
|
|
class TestProgressHook:
|
|
"""``stream_to_console`` invokes the optional ``update_streaming_progress``
|
|
hook on the console and throttles the call rate so worker-thread → UI
|
|
cross-thread queueing isn't flooded on long streams.
|
|
"""
|
|
|
|
def test_progress_hook_called_with_running_byte_count(self) -> None:
|
|
buf = io.StringIO()
|
|
console = _ProgressConsole(file=buf, force_terminal=True, color_system=None, width=80)
|
|
chunks = ["Hello, ", "world", "!"]
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(chunks),
|
|
)
|
|
|
|
assert result == "Hello, world!"
|
|
assert console.progress_calls, "progress hook never fired"
|
|
# Counts must be monotonically non-decreasing — the streaming
|
|
# layer pushes a *running* byte total, never a per-chunk delta.
|
|
assert console.progress_calls == sorted(console.progress_calls)
|
|
# Each reported count must reflect bytes that *had* arrived by
|
|
# that point in the stream — never exceed the final total.
|
|
assert console.progress_calls[-1] <= len(result)
|
|
|
|
def test_progress_hook_throttled_on_burst_streams(self) -> None:
|
|
"""A burst of 200 small chunks must not produce 200 hook calls.
|
|
|
|
Throttling target is ~10/s; the test stream finishes well under
|
|
a second so we expect a small handful of calls (not one per
|
|
chunk). The exact count is timing-dependent — assert ``<= 50``
|
|
as a generous upper bound that still proves throttling fires.
|
|
"""
|
|
buf = io.StringIO()
|
|
console = _ProgressConsole(file=buf, force_terminal=True, color_system=None, width=80)
|
|
burst = ["x"] * 200
|
|
stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(burst),
|
|
)
|
|
|
|
assert len(console.progress_calls) <= 50, (
|
|
f"throttle did not fire — got {len(console.progress_calls)} calls"
|
|
)
|
|
|
|
def test_no_hook_when_console_lacks_method(self) -> None:
|
|
"""Plain ``Console`` (no progress method) must stream cleanly."""
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["alpha", "beta"]),
|
|
)
|
|
assert result == "alphabeta"
|
|
|
|
def test_progress_hook_failure_does_not_truncate_response(self) -> None:
|
|
"""A flaky status widget must never lose response content."""
|
|
|
|
class _BrokenConsole(Console):
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
file=io.StringIO(),
|
|
force_terminal=True,
|
|
color_system=None,
|
|
width=80,
|
|
)
|
|
|
|
def update_streaming_progress(self, bytes_received: int) -> None: # noqa: ARG002
|
|
raise RuntimeError("widget gone")
|
|
|
|
console = _BrokenConsole()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["full ", "answer"]),
|
|
)
|
|
assert result == "full answer"
|
|
|
|
|
|
class TestParagraphFlushThrottle:
|
|
"""Long single-paragraph streams must not pay O(n²) work re-joining
|
|
the buffer on every chunk. The fast-path skips the join when no
|
|
paragraph boundary could possibly land in the new chunk.
|
|
"""
|
|
|
|
def _spy_markdown_parses(self, monkeypatch: pytest.MonkeyPatch) -> list[int]:
|
|
"""Wrap ``streaming.Markdown`` so each construction increments a counter."""
|
|
from surfaces.interactive_shell.ui import streaming as streaming_module
|
|
|
|
parse_count = [0]
|
|
real_markdown = streaming_module.Markdown
|
|
|
|
class _SpyMarkdown(real_markdown): # type: ignore[misc, valid-type]
|
|
def __init__(self, text: str, **kwargs) -> None:
|
|
parse_count[0] += 1
|
|
super().__init__(text, **kwargs)
|
|
|
|
monkeypatch.setattr(streaming_module, "Markdown", _SpyMarkdown)
|
|
return parse_count
|
|
|
|
def test_long_single_paragraph_renders_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No ``\\n\\n`` in any chunk → the only Markdown parse is the
|
|
end-of-stream force-flush. Proves the fast-path skips the join
|
|
on every intermediate chunk."""
|
|
parse_count = self._spy_markdown_parses(monkeypatch)
|
|
console, _ = _tty_console()
|
|
|
|
# 500 chunks, each a few words, no paragraph breaks.
|
|
chunks = [f"word{i} " for i in range(500)]
|
|
result = stream_to_console(console, label="assistant", chunks=_yield_chunks(chunks))
|
|
|
|
assert "word0" in result
|
|
assert "word499" in result
|
|
# End-of-stream force-flush is the only Markdown construction.
|
|
assert parse_count[0] == 1, f"expected 1 parse (force-flush), got {parse_count[0]}"
|
|
|
|
def test_paragraph_boundary_per_chunk_renders_once_per_paragraph(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Each ``\\n\\n`` boundary triggers exactly one Markdown
|
|
parse — the trailing tail is force-flushed at end."""
|
|
parse_count = self._spy_markdown_parses(monkeypatch)
|
|
console, _ = _tty_console()
|
|
|
|
chunks = [
|
|
"para 1.\n\n",
|
|
"para 2.\n\n",
|
|
"para 3.\n\n",
|
|
"trailing tail",
|
|
]
|
|
stream_to_console(console, label="assistant", chunks=_yield_chunks(chunks))
|
|
|
|
# 3 in-loop renders + 1 force-flush at end = 4 total.
|
|
assert parse_count[0] == 4
|
|
|
|
def test_chunks_with_only_single_newlines_skip_flush(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Lists / code with single ``\\n`` separators don't trigger
|
|
flush until a real ``\\n\\n`` boundary closes the block.
|
|
Keeping multi-line list/table syntax intact is required for
|
|
Rich's Markdown renderer to produce a proper Table / bullet
|
|
list rather than rendering each row as a standalone block."""
|
|
parse_count = self._spy_markdown_parses(monkeypatch)
|
|
console, _ = _tty_console()
|
|
|
|
chunks = [
|
|
"- item 1\n",
|
|
"- item 2\n",
|
|
"- item 3\n",
|
|
# No \n\n — only force-flush at end.
|
|
]
|
|
stream_to_console(console, label="assistant", chunks=_yield_chunks(chunks))
|
|
|
|
assert parse_count[0] == 1
|
|
|
|
|
|
class TestCancelPolling:
|
|
"""``stream_to_console`` polls ``console.cancel_requested`` between
|
|
chunks so an Esc-driven cancel signal stops the worker-thread stream
|
|
before it drains the iterator.
|
|
"""
|
|
|
|
def test_cancel_set_before_stream_returns_empty_partial(self) -> None:
|
|
buf = io.StringIO()
|
|
cancel_event = threading.Event()
|
|
cancel_event.set() # cancel before any chunk is pulled
|
|
console = _ProgressConsole(
|
|
cancel_event=cancel_event,
|
|
file=buf,
|
|
force_terminal=True,
|
|
color_system=None,
|
|
width=80,
|
|
)
|
|
|
|
# If the cancel poll didn't work, the iterator below would
|
|
# raise (it's a single-use generator).
|
|
chunks_iter = _yield_chunks(["a", "b", "c"])
|
|
result = stream_to_console(console, label="assistant", chunks=chunks_iter)
|
|
assert result == ""
|
|
|
|
def test_cancel_mid_stream_truncates_buffer(self) -> None:
|
|
"""Cancel signalled mid-stream stops further chunk reads.
|
|
|
|
Uses a generator that flips the cancel flag from inside its own
|
|
yield loop — that's deterministic regardless of throttling, since
|
|
the next iteration of ``stream_to_console``'s loop checks the
|
|
cancel flag *before* pulling the next chunk.
|
|
"""
|
|
buf = io.StringIO()
|
|
cancel_event = threading.Event()
|
|
console = _ProgressConsole(
|
|
cancel_event=cancel_event,
|
|
file=buf,
|
|
force_terminal=True,
|
|
color_system=None,
|
|
width=80,
|
|
)
|
|
|
|
chunks_yielded: list[int] = []
|
|
|
|
def _chunks_with_cancel() -> Iterator[str]:
|
|
for i in range(20):
|
|
chunks_yielded.append(i)
|
|
if i == 3:
|
|
cancel_event.set()
|
|
yield f"chunk{i} "
|
|
|
|
result = stream_to_console(console, label="assistant", chunks=_chunks_with_cancel())
|
|
|
|
# The generator should not have been pumped through to chunk 19 —
|
|
# ``stream_to_console`` should have broken out of its loop once
|
|
# the cancel event was visible.
|
|
assert max(chunks_yielded) < 19, (
|
|
f"generator yielded too many chunks — got up to {max(chunks_yielded)}"
|
|
)
|
|
# The result must include chunks read before the cancel was
|
|
# observed and must not include the trailing chunks.
|
|
assert result.startswith("chunk0 ")
|
|
assert "chunk19" not in result
|
|
|
|
def test_no_cancel_attr_means_stream_runs_to_completion(self) -> None:
|
|
"""A console without ``cancel_requested`` must drain normally."""
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["one ", "two ", "three"]),
|
|
)
|
|
assert result == "one two three"
|
|
|
|
|
|
class TestSuppressionPeek:
|
|
"""``suppress_if_starts_with`` skips live rendering for content the caller will handle."""
|
|
|
|
def test_suppresses_and_drains_when_first_char_matches(self) -> None:
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(['{"actions"', ":[]", "}"]),
|
|
suppress_if_starts_with="{",
|
|
)
|
|
|
|
assert result == '{"actions":[]}'
|
|
# No bullet header, no markdown, no live-region artifacts.
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "●" not in output
|
|
assert '{"actions"' not in output
|
|
|
|
def test_renders_normally_when_first_char_does_not_match(self) -> None:
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks(["Hello, ", "world"]),
|
|
suppress_if_starts_with="{",
|
|
)
|
|
|
|
assert result == "Hello, world"
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "●" in output
|
|
assert "Hello, world" in output
|
|
|
|
def test_skips_leading_whitespace_before_deciding(self) -> None:
|
|
"""Leading whitespace must not block the suppression peek."""
|
|
console, buf = _tty_console()
|
|
result = stream_to_console(
|
|
console,
|
|
label="assistant",
|
|
chunks=_yield_chunks([" \n", '{"action"', ':"slash"}']),
|
|
suppress_if_starts_with="{",
|
|
)
|
|
|
|
assert result == ' \n{"action":"slash"}'
|
|
output = _strip_ansi(buf.getvalue())
|
|
assert "●" not in output
|