562 lines
21 KiB
Python
562 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from opensquilla.cli.chat.turn import UsageSummary
|
|
from opensquilla.cli.tui.backend.render_summary import summarize_args, summarize_result
|
|
from opensquilla.cli.tui.opentui.renderer import OpenTuiStreamRenderer, _format_tokens
|
|
from opensquilla.engine.usage import SessionTotalsSnapshot
|
|
|
|
|
|
def test_web_search_args_render_query_summary() -> None:
|
|
assert summarize_args("web_search", {"query": "OpenSquilla canonical search"}) == (
|
|
"OpenSquilla canonical search"
|
|
)
|
|
assert summarize_args("web_discover", {"query": "OpenSquilla discover links"}) == (
|
|
"OpenSquilla discover links"
|
|
)
|
|
|
|
|
|
class _RecordingHandle:
|
|
def __init__(self) -> None:
|
|
self.sent: list[tuple[str, dict]] = []
|
|
|
|
async def send_message(self, message_type: str, payload: dict) -> None:
|
|
self.sent.append((message_type, payload))
|
|
|
|
|
|
class _ToolbarRecordingHandle(_RecordingHandle):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.toolbar: dict[str, object] = {}
|
|
self.toolbar_updates: list[tuple[str, object | None]] = []
|
|
self.invalidated = 0
|
|
|
|
def set_toolbar(self, key: str, value: object | None) -> None:
|
|
self.toolbar_updates.append((key, value))
|
|
if value is None:
|
|
self.toolbar.pop(key, None)
|
|
return
|
|
self.toolbar[key] = value
|
|
|
|
def invalidate(self) -> None:
|
|
self.invalidated += 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_intermediate_text_is_thinking_final_text_is_answer_card() -> None:
|
|
"""Intermediate narration before a tool (presentation="intermediate") opens
|
|
a purple thinking block; the final answer (presentation="answer") opens a
|
|
cyan answer card. Each is the right kind from its first delta — no retype."""
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("Let me check", presentation="intermediate")
|
|
await r.atool_start("web_search", {"query": "x"}, "c1")
|
|
await r.atool_finished("c1", success=True, result="result line")
|
|
await r.aappend_text("Final answer", presentation="answer")
|
|
await r.afinalize(None)
|
|
|
|
assert not [t for t, _ in handle.sent if t == "block.retype"]
|
|
begins = [
|
|
(p["kind"], p["id"])
|
|
for t, p in handle.sent
|
|
if t == "block.begin" and p.get("kind") in {"thinking", "answer"}
|
|
]
|
|
# intermediate -> thinking block, final -> answer card, in that order
|
|
assert [kind for kind, _id in begins] == ["thinking", "answer"]
|
|
tool_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "tool"]
|
|
assert tool_begins and tool_begins[0]["meta"]["name"] == "web_search"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_answer_is_a_card_from_the_first_delta() -> None:
|
|
"""A pure-answer turn opens an answer card on the very first delta and
|
|
streams into it — never a thinking block, never a retype."""
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("The ", presentation="answer")
|
|
await r.aappend_text("answer.", presentation="answer")
|
|
await r.afinalize(None)
|
|
|
|
assert not [t for t, _ in handle.sent if t == "block.retype"]
|
|
answer_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"]
|
|
assert len(answer_begins) == 1
|
|
assert not [
|
|
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "thinking"
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reasoning_streams_into_its_own_block_and_closes_before_text() -> None:
|
|
"""Reasoning (the model's extended-thinking process) streams live into a
|
|
dedicated 'reasoning' block — the host shows a dim rolling peek and
|
|
collapses it to 'Thought for Ns' on block.end — and the block closes
|
|
before the answer text opens so the two never interleave."""
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_reasoning("let me think ")
|
|
await r.aappend_reasoning("step by step about the internals")
|
|
await r.aappend_text("the answer")
|
|
await r.afinalize(None)
|
|
|
|
# the reasoning block is its own kind, distinct from intermediate "thinking"
|
|
# text and from the "answer" card
|
|
reasoning_begins = [
|
|
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "reasoning"
|
|
]
|
|
assert len(reasoning_begins) == 1
|
|
reasoning_id = reasoning_begins[0]["id"]
|
|
# every reasoning delta streams into that block (the host renders the peek)
|
|
reasoning_appends = [
|
|
p["delta"] for t, p in handle.sent if t == "block.append" and p["id"] == reasoning_id
|
|
]
|
|
assert reasoning_appends == ["let me think ", "step by step about the internals"]
|
|
# the reasoning block closes before the answer block opens
|
|
events = [
|
|
(t, p.get("id"), p.get("kind"))
|
|
for t, p in handle.sent
|
|
if t in ("block.begin", "block.end")
|
|
]
|
|
end_reasoning = events.index(("block.end", reasoning_id, None))
|
|
begin_answer = next(
|
|
i for i, (t, _i, kind) in enumerate(events) if t == "block.begin" and kind == "answer"
|
|
)
|
|
assert end_reasoning < begin_answer
|
|
assert not [t for t, _ in handle.sent if t == "block.retype"]
|
|
# the reasoning marker is closed before the answer block opens
|
|
ends = [p["id"] for t, p in handle.sent if t == "block.end"]
|
|
assert reasoning_id in ends
|
|
answer_begins = [
|
|
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"
|
|
]
|
|
assert len(answer_begins) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_answer_only_turn_has_no_retype() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("Direct answer")
|
|
await r.afinalize(None)
|
|
assert not [t for t, _ in handle.sent if t == "block.retype"]
|
|
answer_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"]
|
|
assert len(answer_begins) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_renderer_marks_tool_error_and_cancel() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.atool_start("grep", {"pattern": "x"}, "c2")
|
|
await r.atool_finished("c2", success=False, error="boom")
|
|
await r.aerror("turn-level failure")
|
|
await r.afinalize(None, cancelled=True)
|
|
updates = [p for t, p in handle.sent if t == "block.update"]
|
|
assert any(p["patch"].get("status") == "error" for p in updates)
|
|
error_begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "error"]
|
|
assert error_begins and error_begins[0]["meta"]["text"] == "turn-level failure"
|
|
end = [p for t, p in handle.sent if t == "turn.end"][0]
|
|
assert end["cancelled"] is True
|
|
# the failed tool's detail line was appended into the tool block
|
|
appends = [p for t, p in handle.sent if t == "block.append" and p["id"] == "c2"]
|
|
assert any("boom" in p["delta"] for p in appends)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_midtool_closes_open_tool_block() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.atool_start("grep", {"pattern": "x"}, "c9")
|
|
# NO atool_finished — simulate cancellation
|
|
await r.afinalize(None, cancelled=True)
|
|
# the open tool must be force-closed: an error update + an end for its id
|
|
updates = [p for t, p in handle.sent if t == "block.update" and p["id"] == "c9"]
|
|
ends = [p for t, p in handle.sent if t == "block.end" and p["id"] == "c9"]
|
|
assert updates and updates[-1]["patch"].get("status") == "error"
|
|
assert ends, "cancelled in-flight tool block was never closed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_without_finalize_tears_down_errored_turn() -> None:
|
|
"""Error paths end the turn without afinalize; the guaranteed aclose must
|
|
still end the turn, idle the pill, and re-enable the composer so the UI
|
|
never stays busy and the next turn never merges into the errored card."""
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("partial answer")
|
|
await r.aerror("provider exploded")
|
|
await r.aclose()
|
|
|
|
types = [t for t, _ in handle.sent]
|
|
assert "turn.end" in types
|
|
statuses = [p for t, p in handle.sent if t == "turn.status"]
|
|
assert statuses[-1]["phase"] == "idle"
|
|
assert statuses[-1]["active"] is False
|
|
composer_sets = [p for t, p in handle.sent if t == "composer.set"]
|
|
assert composer_sets[-1] == {"disabled": False}
|
|
# the open answer block was force-closed
|
|
begins = {p["id"] for t, p in handle.sent if t == "block.begin" and p.get("kind") == "answer"}
|
|
ends = {p["id"] for t, p in handle.sent if t == "block.end"}
|
|
assert begins <= ends
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_force_closes_inflight_tool_block() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.atool_start("grep", {"pattern": "x"}, "c7")
|
|
# NO atool_finished, NO afinalize — e.g. a provider timeout mid-tool
|
|
await r.aclose()
|
|
|
|
updates = [p for t, p in handle.sent if t == "block.update" and p["id"] == "c7"]
|
|
ends = [p for t, p in handle.sent if t == "block.end" and p["id"] == "c7"]
|
|
assert updates and updates[-1]["patch"].get("status") == "error"
|
|
assert ends
|
|
assert [t for t, _ in handle.sent if t == "turn.end"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_after_afinalize_emits_no_second_teardown() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(None)
|
|
await r.aclose()
|
|
|
|
assert len([t for t, _ in handle.sent if t == "turn.end"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_before_any_output_is_a_noop() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aclose()
|
|
assert handle.sent == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aclose_tolerates_dead_output_handle() -> None:
|
|
class _DeadHandle:
|
|
async def send_message(self, message_type: str, payload: dict) -> None:
|
|
raise RuntimeError("OpenTUI bridge is not started")
|
|
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("partial")
|
|
# the bridge dies before teardown; aclose must not raise from its emits
|
|
r.output_handle = _DeadHandle()
|
|
await r.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_pill_returns_to_output_when_text_resumes_after_tool() -> None:
|
|
"""In the narrate-then-act flow the pill must not stay stuck on the
|
|
finished tool's name while the final answer streams."""
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("Let me look", presentation="intermediate")
|
|
await r.atool_start("grep", {"pattern": "x"}, "c1")
|
|
await r.atool_finished("c1", success=True, result="hit")
|
|
await r.aappend_text("Final answer", presentation="answer")
|
|
await r.afinalize(None)
|
|
|
|
phases = [p["phase"] for t, p in handle.sent if t == "turn.status"]
|
|
assert phases == ["thinking", "output", "tool", "output", "idle"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_astatus_updates_pill_and_renders_dim_status_line() -> None:
|
|
"""Status messages (artifact saved, task-group progress) must be visible:
|
|
a transient pill label plus a dim in-card status line."""
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("working")
|
|
await r.astatus("artifact written: report.md")
|
|
await r.aappend_text(" more")
|
|
await r.afinalize(None)
|
|
|
|
status_blocks = [
|
|
p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "status"
|
|
]
|
|
assert status_blocks and status_blocks[0]["meta"]["text"] == "artifact written: report.md"
|
|
assert status_blocks[0]["meta"]["style"] == "dim"
|
|
ends = {p["id"] for t, p in handle.sent if t == "block.end"}
|
|
assert status_blocks[0]["id"] in ends
|
|
|
|
labels = [(p["phase"], p["label"]) for t, p in handle.sent if t == "turn.status"]
|
|
assert ("output", "artifact written: report.md") in labels
|
|
# the pill label is transient: the next text delta restores the phase label
|
|
status_index = labels.index(("output", "artifact written: report.md"))
|
|
assert ("output", "output") in labels[status_index + 1 :]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_astatus_ignores_blank_messages() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.astatus(" ")
|
|
assert not [p for t, p in handle.sent if t == "block.begin"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_usage_block_emitted_before_turn_end() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(None)
|
|
types = [t for t, _ in handle.sent]
|
|
usage_begin = next(
|
|
i
|
|
for i, (t, p) in enumerate(handle.sent)
|
|
if t == "block.begin" and p.get("kind") == "usage"
|
|
)
|
|
turn_end = types.index("turn.end")
|
|
assert usage_begin < turn_end, "usage block must render in the active turn, before turn.end"
|
|
# answer card still closes (its block.end) before usage
|
|
answer_end = next(
|
|
i
|
|
for i, (t, p) in enumerate(handle.sent)
|
|
if t == "block.end" and p["id"].endswith("-b1")
|
|
)
|
|
assert answer_end < usage_begin
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_anonymous_tools_each_close_independently() -> None:
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.atool_start("a", {}, None)
|
|
await r.atool_finished(None, success=True, result="ra")
|
|
await r.atool_start("b", {}, None)
|
|
await r.atool_finished(None, success=True, result="rb")
|
|
begins = [p for t, p in handle.sent if t == "block.begin" and p.get("kind") == "tool"]
|
|
ends = [p for t, p in handle.sent if t == "block.end"]
|
|
assert len(begins) == 2
|
|
# each tool block gets its own end (distinct ids), so neither overwrites
|
|
# the other and no dangling block is left without a close.
|
|
begin_ids = {p["id"] for p in begins}
|
|
end_ids = {p["id"] for p in ends}
|
|
assert len(begin_ids) == 2
|
|
assert begin_ids <= end_ids
|
|
|
|
|
|
def test_format_tokens_abbreviates_thousands() -> None:
|
|
assert _format_tokens(856) == "856"
|
|
assert _format_tokens(1234) == "1.2k"
|
|
assert _format_tokens(0) == "0"
|
|
assert _format_tokens(None) == "0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_afinalize_writes_usage_to_toolbar_and_invalidates() -> None:
|
|
handle = _ToolbarRecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(UsageSummary(input_tokens=1234, output_tokens=856))
|
|
assert handle.toolbar.get("router_usage") == "1.2k/856"
|
|
assert handle.invalidated == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_afinalize_writes_session_input_to_toolbar() -> None:
|
|
handle = _ToolbarRecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(
|
|
UsageSummary(
|
|
input_tokens=1,
|
|
output_tokens=2,
|
|
session_totals=SessionTotalsSnapshot(input_tokens=84_000),
|
|
)
|
|
)
|
|
|
|
assert handle.toolbar.get("router_usage") == "1/2"
|
|
assert handle.toolbar.get("router_session_input") == 84_000
|
|
assert handle.invalidated == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_afinalize_clears_stale_session_input_when_snapshot_missing() -> None:
|
|
handle = _ToolbarRecordingHandle()
|
|
handle.toolbar["router_session_input"] = 84_000
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(UsageSummary(input_tokens=1, output_tokens=2))
|
|
|
|
assert handle.toolbar.get("router_usage") == "1/2"
|
|
assert "router_session_input" not in handle.toolbar
|
|
assert handle.invalidated == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_begin_clears_stale_router_usage_before_finalize_writes_current_usage() -> None:
|
|
handle = _ToolbarRecordingHandle()
|
|
handle.toolbar["router_usage"] = "999/888"
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.astatus("thinking")
|
|
|
|
assert ("router_usage", None) in handle.toolbar_updates
|
|
assert "router_usage" not in handle.toolbar
|
|
assert handle.invalidated == 1
|
|
|
|
await r.afinalize(UsageSummary(input_tokens=5, output_tokens=7))
|
|
|
|
assert handle.toolbar.get("router_usage") == "5/7"
|
|
assert handle.toolbar_updates[-1] == ("router_usage", "5/7")
|
|
assert handle.invalidated == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_usage_turn_keeps_router_usage_cleared() -> None:
|
|
handle = _ToolbarRecordingHandle()
|
|
handle.toolbar["router_usage"] = "999/888"
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(None)
|
|
assert "router_usage" not in handle.toolbar
|
|
assert handle.invalidated == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_afinalize_tolerates_handle_without_set_toolbar() -> None:
|
|
# The plain recording handle has no set_toolbar/invalidate — afinalize must
|
|
# not crash when wiring usage into the router toolbar.
|
|
handle = _RecordingHandle()
|
|
r = OpenTuiStreamRenderer(output_handle=handle)
|
|
r.__enter__()
|
|
await r.aappend_text("done")
|
|
await r.afinalize(UsageSummary(input_tokens=5, output_tokens=7))
|
|
assert [t for t, _ in handle.sent if t == "turn.end"]
|
|
|
|
|
|
def test_tool_result_summary_keeps_meaningful_lines_without_banners() -> None:
|
|
summary = summarize_result(
|
|
"exit_code=0\n"
|
|
".\n"
|
|
"·\n"
|
|
"...\n"
|
|
"═══ 一级模块 ═══\n"
|
|
"agents\n"
|
|
"────────\n"
|
|
"exit_code=1\n"
|
|
"================\n"
|
|
"src/opensquilla/main.py\n"
|
|
)
|
|
|
|
assert summary == "agents\nexit_code=1\nsrc/opensquilla/main.py"
|
|
assert "exit_code=0" not in summary
|
|
assert " / " not in summary
|
|
assert "═══" not in summary
|
|
|
|
|
|
def test_tool_result_summary_stringifies_single_structured_msg_payload() -> None:
|
|
summary = summarize_result(
|
|
{
|
|
"type": "msg",
|
|
"msg": [
|
|
{"kind": "text", "text": "first"},
|
|
{"kind": "data", "value": {"rows": [1, 2]}},
|
|
],
|
|
}
|
|
)
|
|
|
|
assert summary.startswith("[")
|
|
assert '"type": "msg"' not in summary
|
|
assert '"rows": [1, 2]' in summary
|
|
|
|
|
|
def test_tool_result_summary_stringifies_structured_msg_payloads() -> None:
|
|
summary = summarize_result(
|
|
[
|
|
{"type": "msg", "msg": {"files": ["main.py"], "count": 1}},
|
|
{"type": "msg", "msg": ["ok", {"status": "done"}]},
|
|
]
|
|
)
|
|
|
|
assert summary == (
|
|
'{"count": 1, "files": ["main.py"]}\n'
|
|
'["ok", {"status": "done"}]'
|
|
)
|
|
|
|
|
|
async def test_aturn_started_announces_thinking_before_any_provider_event() -> None:
|
|
"""The stream loop announces the turn as soon as it starts, so the pill
|
|
pulses "thinking" through the silent model-thinking window instead of the
|
|
UI sitting on "ready" until the first token."""
|
|
handle = _RecordingHandle()
|
|
renderer = OpenTuiStreamRenderer(output_handle=handle)
|
|
|
|
await renderer.aturn_started()
|
|
|
|
types = [message_type for message_type, _payload in handle.sent]
|
|
assert "turn.begin" in types
|
|
status = next(p for t, p in handle.sent if t == "turn.status")
|
|
assert status["phase"] == "thinking"
|
|
assert status["active"] is True
|
|
# Idempotent: the first real event must not begin a second turn.
|
|
await renderer.aturn_started()
|
|
assert types.count("turn.begin") == 1
|
|
|
|
|
|
async def test_answer_stream_strips_routing_directive_tags() -> None:
|
|
handle = _RecordingHandle()
|
|
renderer = OpenTuiStreamRenderer(output_handle=handle)
|
|
|
|
# Tag split across deltas, then the real answer.
|
|
await renderer.aappend_text("[[reply_to", presentation="answer")
|
|
await renderer.aappend_text("_current]]\n", presentation="answer")
|
|
await renderer.aappend_text("My name is OpenSquilla.", presentation="answer")
|
|
await renderer.afinalize()
|
|
|
|
appends = [p["delta"] for t, p in handle.sent if t == "block.append"]
|
|
joined = "".join(appends)
|
|
assert "reply_to_current" not in joined
|
|
assert joined == "My name is OpenSquilla."
|
|
# The raw logical buffer (TurnResult text) keeps the model's exact output.
|
|
assert "[[reply_to_current]]" in renderer.buffer
|
|
|
|
|
|
async def test_tag_only_delta_opens_no_answer_block() -> None:
|
|
handle = _RecordingHandle()
|
|
renderer = OpenTuiStreamRenderer(output_handle=handle)
|
|
|
|
await renderer.aappend_text("[[reply_to_current]]", presentation="answer")
|
|
await renderer.afinalize()
|
|
|
|
kinds = [p["kind"] for t, p in handle.sent if t == "block.begin"]
|
|
assert "answer" not in kinds
|
|
|
|
|
|
async def test_held_bracket_prefix_flushes_into_the_block_on_close() -> None:
|
|
handle = _RecordingHandle()
|
|
renderer = OpenTuiStreamRenderer(output_handle=handle)
|
|
|
|
await renderer.aappend_text("see ", presentation="answer")
|
|
# A bracket run that never completes into a directive is ordinary text.
|
|
await renderer.aappend_text("[[re", presentation="answer")
|
|
await renderer.afinalize()
|
|
|
|
appends = [p["delta"] for t, p in handle.sent if t == "block.append"]
|
|
assert "".join(appends) == "see [[re"
|