diff --git a/persistent-memory.md b/persistent-memory.md index 908b8f3..bd66682 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 6e986c1..05f487f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/ratatoskr/sse_client.py b/src/ratatoskr/sse_client.py index 0c99495..9f034ed 100644 --- a/src/ratatoskr/sse_client.py +++ b/src/ratatoskr/sse_client.py @@ -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: diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index b50cde1..5d73afe 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -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, diff --git a/tests/test_sse_client.py b/tests/test_sse_client.py index e93359f..095067a 100644 --- a/tests/test_sse_client.py +++ b/tests/test_sse_client.py @@ -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: diff --git a/tests/test_tui.py b/tests/test_tui.py index 78f5214..b6c8dc0 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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 diff --git a/uv.lock b/uv.lock index f469df0..10ad214 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.8.0" +version = "0.8.1" source = { editable = "." } dependencies = [ { name = "httpx" },