Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ef6830ab |
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-25 (post-v0.8.0 local tier-3 agent index in picker):_
|
||||
_As of 2026-05-25 (post-v0.8.1 text streams inline, no overlap):_
|
||||
|
||||
**Status: v0.8.0 shipped.** Eleven core features complete (`sse_client`
|
||||
**Status: v0.8.1 shipped.** Eleven core features complete (`sse_client`
|
||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||
startup error visibility #6, presenter contract semantics amendment
|
||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||
@@ -51,7 +51,8 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
|
||||
land). CLI mode (--send) unaffected by design — INV-018.
|
||||
|
||||
Last commits on `main`:
|
||||
- v0.8.0 feat(local_agents): JSON-backed local tier-3 index + picker merge
|
||||
- v0.8.1 fix(tui): kill current-text Static; Text streams inline via coalesce
|
||||
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
|
||||
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
|
||||
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
|
||||
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -309,6 +309,14 @@ async def _iter_events(
|
||||
# with a bad id is still a keepalive). Don't reorder.
|
||||
if sse.data == "":
|
||||
continue
|
||||
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
|
||||
# SOMETIMES emits events without an `id:` line (observed mid-stream
|
||||
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
|
||||
# RFC, events without ids are legitimate (they just don't update
|
||||
# Last-Event-ID); the previous strict behavior crashed every turn
|
||||
# on the offending agent. Treat same as empty-data: skip silently.
|
||||
if sse.id == "":
|
||||
continue
|
||||
try:
|
||||
sse_id = _parse_sse_id(sse.id)
|
||||
except ValueError as exc:
|
||||
|
||||
+40
-45
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import httpx
|
||||
@@ -186,9 +186,6 @@ class TuiPresenterState:
|
||||
"""
|
||||
|
||||
thinking_open: bool = False
|
||||
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
|
||||
# and update `current_text` Static in place — no per-token RichLog spam.
|
||||
text_buffer: list[str] = field(default_factory=list)
|
||||
# Thinking-run counter for turn-scoped start/end markers.
|
||||
thinking_run_index: int = 0
|
||||
# v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
|
||||
@@ -197,13 +194,20 @@ class TuiPresenterState:
|
||||
# only on `\n` boundaries (one written line per natural paragraph) or
|
||||
# when the run closes (any leftover tail).
|
||||
thinking_chunk_buffer: str = ""
|
||||
# v0.8.1: same pattern for Text deltas. Pre-v0.8.1 the Text deltas
|
||||
# streamed into a dedicated #current-text Static below the transcript;
|
||||
# that Static (docked-bottom, height: auto) grew during streaming and
|
||||
# visually OVERLAPPED the transcript above (Textual didn't dynamically
|
||||
# resize the 1fr transcript while the dock-bottom child expanded).
|
||||
# The Static is gone in v0.8.1 — Text deltas coalesce on `\n` and write
|
||||
# directly to `log` (transcript), the same shape thinking uses.
|
||||
text_chunk_buffer: str = ""
|
||||
|
||||
def render(
|
||||
self,
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
current_text: Static,
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
thinking_log: RichLog,
|
||||
@@ -211,18 +215,14 @@ class TuiPresenterState:
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
v0.6.5 routing:
|
||||
- `log` (transcript) = content only: user-prompt echo (written
|
||||
outside the presenter), terminal labels, post-Done Markdown body.
|
||||
- `current_text` (Static below transcript) = live-streaming Text
|
||||
deltas accumulated into one growing line; cleared on terminal.
|
||||
v0.8.1 routing:
|
||||
- `log` (transcript) = chat content: user-prompt echo (written
|
||||
outside the presenter), coalesced Text deltas, terminal labels,
|
||||
optional post-Done Markdown body.
|
||||
- `tools_log` = ToolStart + ToolResult.
|
||||
- `debug_log` = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` = streaming Thinking deltas inline (each chunk =
|
||||
one line in the scrollable log). Rule(start)/Rule(end) markers
|
||||
wrap each run. The whole pane scrolls naturally — no separate
|
||||
tail-scrolling Static at the bottom (v0.6.5 removed
|
||||
`thinking-current`).
|
||||
- `thinking_log` = streaming Thinking deltas inline (coalesced on
|
||||
`\n`). Rule(start)/Rule(end) wrap each run.
|
||||
|
||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
@@ -284,19 +284,23 @@ class TuiPresenterState:
|
||||
self.thinking_open = False
|
||||
# Now render the non-thinking event itself.
|
||||
if isinstance(event, Text):
|
||||
# v0.6.0: streaming text accumulates into current_text Static
|
||||
# — one growing live line, NOT per-delta RichLog entries.
|
||||
self.text_buffer.append(event.content)
|
||||
current_text.update("".join(self.text_buffer))
|
||||
# v0.8.1: stream Text deltas into transcript directly,
|
||||
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
|
||||
# The pre-v0.8.1 #current-text Static is gone — its dock-
|
||||
# bottom growth was overlapping the transcript visually.
|
||||
self.text_chunk_buffer += event.content
|
||||
while "\n" in self.text_chunk_buffer:
|
||||
line, _, rest = self.text_chunk_buffer.partition("\n")
|
||||
if line:
|
||||
log.write(line)
|
||||
self.text_chunk_buffer = rest
|
||||
return
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
# Terminal event: clear the streaming Static first so the
|
||||
# live-preview band collapses. Then write the colored label
|
||||
# + (non-raw) Markdown body / (raw) accumulated plain text
|
||||
# to the transcript.
|
||||
accumulated = "".join(self.text_buffer)
|
||||
self.text_buffer.clear()
|
||||
current_text.update("")
|
||||
# Terminal event: flush any remaining text tail before the
|
||||
# label / Markdown body lands.
|
||||
if self.text_chunk_buffer:
|
||||
log.write(self.text_chunk_buffer)
|
||||
self.text_chunk_buffer = ""
|
||||
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
||||
# / Dawn yellow) for at-a-glance scanning.
|
||||
if isinstance(event, Done):
|
||||
@@ -306,12 +310,13 @@ class TuiPresenterState:
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
))
|
||||
if raw:
|
||||
# Raw mode: emit the accumulated streamed text verbatim
|
||||
# so the operator has a record after the Static clears.
|
||||
if accumulated:
|
||||
log.write(accumulated)
|
||||
else:
|
||||
# v0.8.1: in non-raw mode, ALSO write Rule + Markdown body
|
||||
# as the canonical rendered version. The streamed lines
|
||||
# above are plain text; the Markdown body re-renders the
|
||||
# same content with proper formatting (lists, bold, code
|
||||
# blocks). Some duplication is acceptable — the streamed
|
||||
# content gave live progress; the Markdown is the final.
|
||||
if not raw:
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
@@ -569,16 +574,9 @@ class RatatoskrApp(App[int]):
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.6.0: streaming-text Static carries in-flight assistant tokens.
|
||||
Replaces per-token RichLog spam — one growing line that updates in
|
||||
place. Cleared on terminal event; final Markdown body lands in the
|
||||
transcript. */
|
||||
#current-text {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||
#tools-log, #debug-log, #thinking-log {
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
@@ -678,7 +676,6 @@ class RatatoskrApp(App[int]):
|
||||
with Horizontal(id="main-row"):
|
||||
with Vertical(id="left-column"):
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
yield Static("", id="current-text")
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
with Vertical(id="right-column"):
|
||||
with TabbedContent(id="side-panes"):
|
||||
@@ -798,7 +795,6 @@ class RatatoskrApp(App[int]):
|
||||
assert self.client is not None
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
current_text = self.query_one("#current-text", Static)
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
debug_log = self.query_one("#debug-log", RichLog)
|
||||
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||
@@ -814,7 +810,6 @@ class RatatoskrApp(App[int]):
|
||||
presenter.render(
|
||||
event,
|
||||
log=log,
|
||||
current_text=current_text,
|
||||
tools_log=tools_log,
|
||||
debug_log=debug_log,
|
||||
thinking_log=thinking_log,
|
||||
|
||||
@@ -711,6 +711,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
|
||||
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
|
||||
|
||||
|
||||
def _sse_no_id_chunk(data: str) -> bytes:
|
||||
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
|
||||
return f"data: {data}\n\n".encode()
|
||||
|
||||
|
||||
class TestEmptyIdSkipped:
|
||||
@respx.mock
|
||||
async def test_empty_id_on_first_event_skipped(self) -> None:
|
||||
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
|
||||
event carrying NO `id:` line → httpx_sse exposes sse.id == ''
|
||||
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
|
||||
the turn. v0.8.1: treat same as empty-data keepalive — skip silently.
|
||||
|
||||
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
|
||||
the first stream frame had no id line, every turn died with
|
||||
`[malformed_sse_id] raw=''`.
|
||||
"""
|
||||
from ratatoskr.sse_client import Done as _Done
|
||||
from ratatoskr.sse_client import Text as _Text
|
||||
|
||||
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
|
||||
# Subsequent frames have ids; normal processing resumes.
|
||||
stream = (
|
||||
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
|
||||
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
|
||||
+ _sse_chunk("42:2", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
|
||||
assert len(events) == 2
|
||||
assert isinstance(events[0], _Text)
|
||||
assert events[0].content == "first"
|
||||
assert isinstance(events[1], _Done)
|
||||
|
||||
|
||||
class TestEmptyDataSkipped:
|
||||
@respx.mock
|
||||
async def test_empty_data_skipped(self) -> None:
|
||||
|
||||
+61
-68
@@ -135,7 +135,6 @@ class TestTuiPresenterState:
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
@@ -160,7 +159,6 @@ class TestTuiPresenterState:
|
||||
log=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
@@ -190,7 +188,6 @@ class TestTuiPresenterState:
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
@@ -199,7 +196,6 @@ class TestTuiPresenterState:
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
@@ -217,10 +213,12 @@ class TestTuiPresenterState:
|
||||
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
||||
|
||||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||||
"""multiple_thinking_runs_each_get_section [scenario, v0.6.5]:
|
||||
"""multiple_thinking_runs_each_get_section [scenario, v0.8.1]:
|
||||
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
|
||||
thinking_log, each wrapping their delta lines. Text goes to
|
||||
current_text (buffered). Transcript: [done] + Markdown body.
|
||||
thinking_log (deltas coalesced into tail-flushes per run).
|
||||
Text deltas now stream into the transcript via coalesce-on-newline
|
||||
(no current-text Static); "hi" with no `\\n` stays buffered until
|
||||
Done's tail-flush.
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
|
||||
@@ -228,7 +226,6 @@ class TestTuiPresenterState:
|
||||
|
||||
log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for evt in (
|
||||
Thinking(sse_id=SID, content="first"),
|
||||
@@ -238,25 +235,24 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
evt, log=log,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.6.5: thinking_log holds 4 Rules (start + end per run) + 2 delta lines.
|
||||
# thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||||
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
|
||||
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||||
assert "first" in delta_strs
|
||||
assert "second" in delta_strs
|
||||
# Text "hi" went to current_text (buffered), not the transcript directly.
|
||||
current_text.update.assert_any_call("hi")
|
||||
# Transcript: [done] label + Markdown(response) (raw=False).
|
||||
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert "hi" in log_writes
|
||||
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||
|
||||
def test_render_exception_fallback(self) -> None:
|
||||
@@ -281,7 +277,6 @@ class TestTuiPresenterState:
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
@@ -301,7 +296,6 @@ class TestTuiPresenterState:
|
||||
log=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
@@ -323,8 +317,7 @@ class TestTuiPresenterState:
|
||||
Thinking(sse_id=SID, content="partial"),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
Cancelled(
|
||||
@@ -332,8 +325,7 @@ class TestTuiPresenterState:
|
||||
),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
@@ -342,10 +334,10 @@ class TestTuiPresenterState:
|
||||
assert thinking_log.write.call_count >= 3
|
||||
|
||||
def test_done_renders_markdown_after_label(self) -> None:
|
||||
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
||||
Text("hi") accumulates into current_text Static (buffered streaming);
|
||||
Done(response="hi") with raw=False → [done] label + Rule + Markdown
|
||||
in transcript. current_text cleared on terminal.
|
||||
"""done_renders_markdown_after_label [happy, v0.8.1]:
|
||||
Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
|
||||
"hi" as a tail line in transcript, then writes [done] + Rule +
|
||||
Markdown body (non-raw).
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
@@ -353,34 +345,33 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Text accumulated to current_text, NOT written to log.
|
||||
current_text.update.assert_any_call("hi")
|
||||
# v0.8.1: Text "hi" stays buffered (no `\n` yet) — no log write yet.
|
||||
assert not log.write.called
|
||||
assert state.text_chunk_buffer == "hi"
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Done cleared current_text and wrote [done] label + Rule + Markdown.
|
||||
current_text.update.assert_any_call("")
|
||||
# On Done: tail flush + [done] + Rule + Markdown body.
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
assert "hi" in writes
|
||||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
assert state.text_chunk_buffer == ""
|
||||
|
||||
def test_raw_flag_skips_markdown(self) -> None:
|
||||
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
|
||||
@@ -395,15 +386,13 @@ class TestTuiPresenterState:
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
assert not any(isinstance(w, Rule) for w in writes)
|
||||
@@ -425,8 +414,7 @@ class TestTuiPresenterState:
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||||
assert not log.write.called
|
||||
@@ -461,8 +449,7 @@ class TestTuiPresenterState:
|
||||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||||
log=log,
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# INV-014: write went to tools_log
|
||||
assert tools_log.write.called
|
||||
@@ -481,56 +468,55 @@ class TestTuiPresenterState:
|
||||
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
||||
log=log,
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert tools_log.write.called
|
||||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||||
assert not log.write.called
|
||||
|
||||
def test_text_event_buffers_into_current_text(self) -> None:
|
||||
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
|
||||
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
|
||||
def test_text_event_buffers_until_newline(self) -> None:
|
||||
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
|
||||
`\\n` accumulate in text_chunk_buffer; no log write yet.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
tools_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hello"),
|
||||
log=log,
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
current_text.update.assert_called_once_with("hello")
|
||||
# v0.8.1: buffered, not written until `\n` or Done.
|
||||
assert state.text_chunk_buffer == "hello"
|
||||
assert not log.write.called
|
||||
assert not tools_log.write.called
|
||||
|
||||
def test_text_deltas_accumulate(self) -> None:
|
||||
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
|
||||
concatenated content, NOT separate per-delta lines.
|
||||
def test_text_flushes_on_newline(self) -> None:
|
||||
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
|
||||
the accumulated buffer as ONE line to log (transcript).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
current_text = MagicMock()
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for tok in ("Hel", "lo", " ", "world"):
|
||||
for tok in ("Hel", "lo", " ", "world", "\n"):
|
||||
state.render(
|
||||
Text(sse_id=SID, content=tok),
|
||||
log=MagicMock(),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Final update reflects the full concatenation.
|
||||
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
# "Hello world" coalesces to ONE log entry.
|
||||
assert writes == ["Hello world"]
|
||||
assert state.text_chunk_buffer == ""
|
||||
|
||||
def test_duration_format_seconds(self) -> None:
|
||||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||||
@@ -542,8 +528,7 @@ class TestTuiPresenterState:
|
||||
_make_tui_done(duration_ms=5467),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
_text_of(c[0][0])
|
||||
@@ -569,8 +554,7 @@ class TestTuiPresenterState:
|
||||
_make_tui_done(usage=usage),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
_text_of(c[0][0])
|
||||
@@ -857,7 +841,8 @@ class TestLayoutShape:
|
||||
log=log,
|
||||
tools_log=app.query_one("#tools-log", RichLog),
|
||||
debug_log=app.query_one("#debug-log", RichLog),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
thinking_log=MagicMock(),
|
||||
raw=True,
|
||||
)
|
||||
done = next(
|
||||
c for c in seen
|
||||
@@ -1075,9 +1060,10 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
||||
class TestStreamTurnWorker:
|
||||
@respx.mock
|
||||
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
|
||||
Text deltas go to current_text (not transcript); on Done, transcript
|
||||
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
|
||||
"""happy_text_done_renders_markdown [happy,tracer, v0.8.1]:
|
||||
Text("hello") buffers in text_chunk_buffer (no `\\n`); on Done,
|
||||
flushes "hello" tail to transcript, then [done] label, then Rule
|
||||
+ Markdown body.
|
||||
"""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
@@ -1093,12 +1079,11 @@ class TestStreamTurnWorker:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "hi")
|
||||
assert app.state == "idle"
|
||||
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
|
||||
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
assert not any(w == "hello" for w in writes)
|
||||
# v0.8.1: "hello" appears in transcript as a tail-flush on Done.
|
||||
assert any(w == "hello" for w in writes)
|
||||
assert any("[done]" in str(w) for w in writes)
|
||||
# Post-Done: Markdown body + Rule + turn-header Rule all present.
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
@@ -2254,8 +2239,16 @@ class TestResolveThenRunWithPicker:
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
|
||||
"""list_agents returns [] AND no local tier-3 entries → stderr
|
||||
[no_agents]; exit 13; picker NOT opened. Isolate
|
||||
$RATATOSKR_LOCAL_AGENTS so the operator's real local index
|
||||
doesn't merge in and turn this into a non-empty list."""
|
||||
# v0.8.0 isolation: point local agents at an empty tmp file.
|
||||
monkeypatch.setenv(
|
||||
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json")
|
||||
)
|
||||
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
Reference in New Issue
Block a user