Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 489cfee1f0 | |||
| 11ef6830ab |
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
|||||||
|
|
||||||
## Current state / in-flight
|
## 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.2 drop double-print; v0.9.0 live-md next):_
|
||||||
|
|
||||||
**Status: v0.8.0 shipped.** Eleven core features complete (`sse_client`
|
**Status: v0.8.2 shipped.** Eleven core features complete (`sse_client`
|
||||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||||
startup error visibility #6, presenter contract semantics amendment
|
startup error visibility #6, presenter contract semantics amendment
|
||||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||||
@@ -51,7 +51,9 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
|
|||||||
land). CLI mode (--send) unaffected by design — INV-018.
|
land). CLI mode (--send) unaffected by design — INV-018.
|
||||||
|
|
||||||
Last commits on `main`:
|
Last commits on `main`:
|
||||||
- v0.8.0 feat(local_agents): JSON-backed local tier-3 index + picker merge
|
- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
|
||||||
|
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
|
||||||
|
- `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)
|
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
|
||||||
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
|
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
|
||||||
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
|
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.8.0"
|
version = "0.8.2"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -309,6 +309,14 @@ async def _iter_events(
|
|||||||
# with a bad id is still a keepalive). Don't reorder.
|
# with a bad id is still a keepalive). Don't reorder.
|
||||||
if sse.data == "":
|
if sse.data == "":
|
||||||
continue
|
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:
|
try:
|
||||||
sse_id = _parse_sse_id(sse.id)
|
sse_id = _parse_sse_id(sse.id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
+40
-50
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import ClassVar, Literal
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -186,9 +186,6 @@ class TuiPresenterState:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
thinking_open: bool = False
|
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 counter for turn-scoped start/end markers.
|
||||||
thinking_run_index: int = 0
|
thinking_run_index: int = 0
|
||||||
# v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
|
# 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
|
# only on `\n` boundaries (one written line per natural paragraph) or
|
||||||
# when the run closes (any leftover tail).
|
# when the run closes (any leftover tail).
|
||||||
thinking_chunk_buffer: str = ""
|
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(
|
def render(
|
||||||
self,
|
self,
|
||||||
event: Event,
|
event: Event,
|
||||||
*,
|
*,
|
||||||
log: RichLog,
|
log: RichLog,
|
||||||
current_text: Static,
|
|
||||||
tools_log: RichLog,
|
tools_log: RichLog,
|
||||||
debug_log: RichLog,
|
debug_log: RichLog,
|
||||||
thinking_log: RichLog,
|
thinking_log: RichLog,
|
||||||
@@ -211,18 +215,14 @@ class TuiPresenterState:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||||
|
|
||||||
v0.6.5 routing:
|
v0.8.1 routing:
|
||||||
- `log` (transcript) = content only: user-prompt echo (written
|
- `log` (transcript) = chat content: user-prompt echo (written
|
||||||
outside the presenter), terminal labels, post-Done Markdown body.
|
outside the presenter), coalesced Text deltas, terminal labels,
|
||||||
- `current_text` (Static below transcript) = live-streaming Text
|
optional post-Done Markdown body.
|
||||||
deltas accumulated into one growing line; cleared on terminal.
|
|
||||||
- `tools_log` = ToolStart + ToolResult.
|
- `tools_log` = ToolStart + ToolResult.
|
||||||
- `debug_log` = WorkerPhase + TextBoundary.
|
- `debug_log` = WorkerPhase + TextBoundary.
|
||||||
- `thinking_log` = streaming Thinking deltas inline (each chunk =
|
- `thinking_log` = streaming Thinking deltas inline (coalesced on
|
||||||
one line in the scrollable log). Rule(start)/Rule(end) markers
|
`\n`). Rule(start)/Rule(end) wrap each run.
|
||||||
wrap each run. The whole pane scrolls naturally — no separate
|
|
||||||
tail-scrolling Static at the bottom (v0.6.5 removed
|
|
||||||
`thinking-current`).
|
|
||||||
|
|
||||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||||
"""
|
"""
|
||||||
@@ -284,19 +284,23 @@ class TuiPresenterState:
|
|||||||
self.thinking_open = False
|
self.thinking_open = False
|
||||||
# Now render the non-thinking event itself.
|
# Now render the non-thinking event itself.
|
||||||
if isinstance(event, Text):
|
if isinstance(event, Text):
|
||||||
# v0.6.0: streaming text accumulates into current_text Static
|
# v0.8.1: stream Text deltas into transcript directly,
|
||||||
# — one growing live line, NOT per-delta RichLog entries.
|
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
|
||||||
self.text_buffer.append(event.content)
|
# The pre-v0.8.1 #current-text Static is gone — its dock-
|
||||||
current_text.update("".join(self.text_buffer))
|
# 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
|
return
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
# Terminal event: clear the streaming Static first so the
|
# Terminal event: flush any remaining text tail before the
|
||||||
# live-preview band collapses. Then write the colored label
|
# label / Markdown body lands.
|
||||||
# + (non-raw) Markdown body / (raw) accumulated plain text
|
if self.text_chunk_buffer:
|
||||||
# to the transcript.
|
log.write(self.text_chunk_buffer)
|
||||||
accumulated = "".join(self.text_buffer)
|
self.text_chunk_buffer = ""
|
||||||
self.text_buffer.clear()
|
|
||||||
current_text.update("")
|
|
||||||
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
||||||
# / Dawn yellow) for at-a-glance scanning.
|
# / Dawn yellow) for at-a-glance scanning.
|
||||||
if isinstance(event, Done):
|
if isinstance(event, Done):
|
||||||
@@ -306,17 +310,13 @@ class TuiPresenterState:
|
|||||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||||
style=_AU_SUCCESS,
|
style=_AU_SUCCESS,
|
||||||
))
|
))
|
||||||
if raw:
|
# v0.8.2: post-Done Markdown body re-render dropped. Pre-
|
||||||
# Raw mode: emit the accumulated streamed text verbatim
|
# v0.8.2 the transcript got BOTH the streamed text AND
|
||||||
# so the operator has a record after the Static clears.
|
# the Markdown(response) re-render — same content twice,
|
||||||
if accumulated:
|
# operator-flagged as "double prints". The streamed text
|
||||||
log.write(accumulated)
|
# IS the response now; markdown formatting (bold, lists,
|
||||||
else:
|
# code) renders as plain text. Matches thinking pane's
|
||||||
from rich.markdown import Markdown
|
# stream-as-content semantics (no post-close re-render).
|
||||||
from rich.rule import Rule
|
|
||||||
|
|
||||||
log.write(Rule(style=_AU_DEMOTED))
|
|
||||||
log.write(Markdown(event.response))
|
|
||||||
elif isinstance(event, Error):
|
elif isinstance(event, Error):
|
||||||
log.write(RichText(
|
log.write(RichText(
|
||||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||||
@@ -569,16 +569,9 @@ class RatatoskrApp(App[int]):
|
|||||||
background: $background;
|
background: $background;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
/* v0.6.0: streaming-text Static carries in-flight assistant tokens.
|
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||||
Replaces per-token RichLog spam — one growing line that updates in
|
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||||
place. Cleared on terminal event; final Markdown body lands in the
|
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||||
transcript. */
|
|
||||||
#current-text {
|
|
||||||
dock: bottom;
|
|
||||||
height: auto;
|
|
||||||
background: $background;
|
|
||||||
padding: 0 1;
|
|
||||||
}
|
|
||||||
#tools-log, #debug-log, #thinking-log {
|
#tools-log, #debug-log, #thinking-log {
|
||||||
background: $background;
|
background: $background;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
@@ -678,7 +671,6 @@ class RatatoskrApp(App[int]):
|
|||||||
with Horizontal(id="main-row"):
|
with Horizontal(id="main-row"):
|
||||||
with Vertical(id="left-column"):
|
with Vertical(id="left-column"):
|
||||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
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")
|
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||||
with Vertical(id="right-column"):
|
with Vertical(id="right-column"):
|
||||||
with TabbedContent(id="side-panes"):
|
with TabbedContent(id="side-panes"):
|
||||||
@@ -798,7 +790,6 @@ class RatatoskrApp(App[int]):
|
|||||||
assert self.client is not None
|
assert self.client is not None
|
||||||
assert content
|
assert content
|
||||||
log = self.query_one("#transcript", RichLog)
|
log = self.query_one("#transcript", RichLog)
|
||||||
current_text = self.query_one("#current-text", Static)
|
|
||||||
tools_log = self.query_one("#tools-log", RichLog)
|
tools_log = self.query_one("#tools-log", RichLog)
|
||||||
debug_log = self.query_one("#debug-log", RichLog)
|
debug_log = self.query_one("#debug-log", RichLog)
|
||||||
thinking_log = self.query_one("#thinking-log", RichLog)
|
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||||
@@ -814,7 +805,6 @@ class RatatoskrApp(App[int]):
|
|||||||
presenter.render(
|
presenter.render(
|
||||||
event,
|
event,
|
||||||
log=log,
|
log=log,
|
||||||
current_text=current_text,
|
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
thinking_log=thinking_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()
|
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:
|
class TestEmptyDataSkipped:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_empty_data_skipped(self) -> None:
|
async def test_empty_data_skipped(self) -> None:
|
||||||
|
|||||||
+73
-76
@@ -135,7 +135,6 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=MagicMock(),
|
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
@@ -160,7 +159,6 @@ class TestTuiPresenterState:
|
|||||||
log=MagicMock(),
|
log=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=MagicMock(),
|
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
@@ -190,7 +188,6 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
current_text=MagicMock(),
|
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
@@ -199,7 +196,6 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
current_text=MagicMock(),
|
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
@@ -217,10 +213,12 @@ class TestTuiPresenterState:
|
|||||||
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
||||||
|
|
||||||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
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 → Text → Thinking → Done → TWO start/end Rule pairs in
|
||||||
thinking_log, each wrapping their delta lines. Text goes to
|
thinking_log (deltas coalesced into tail-flushes per run).
|
||||||
current_text (buffered). Transcript: [done] + Markdown body.
|
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
|
from rich.rule import Rule
|
||||||
|
|
||||||
@@ -228,7 +226,6 @@ class TestTuiPresenterState:
|
|||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
thinking_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
current_text = MagicMock()
|
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
for evt in (
|
for evt in (
|
||||||
Thinking(sse_id=SID, content="first"),
|
Thinking(sse_id=SID, content="first"),
|
||||||
@@ -238,25 +235,24 @@ class TestTuiPresenterState:
|
|||||||
state.render(
|
state.render(
|
||||||
evt, log=log,
|
evt, log=log,
|
||||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
thinking_log=thinking_log, raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
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]
|
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)]
|
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||||||
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
|
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 len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||||||
assert "first" in delta_strs
|
assert "first" in delta_strs
|
||||||
assert "second" in delta_strs
|
assert "second" in delta_strs
|
||||||
# Text "hi" went to current_text (buffered), not the transcript directly.
|
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
|
||||||
current_text.update.assert_any_call("hi")
|
|
||||||
# Transcript: [done] label + Markdown(response) (raw=False).
|
|
||||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
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))
|
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||||
|
|
||||||
def test_render_exception_fallback(self) -> None:
|
def test_render_exception_fallback(self) -> None:
|
||||||
@@ -281,7 +277,6 @@ class TestTuiPresenterState:
|
|||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=MagicMock(),
|
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
@@ -301,7 +296,6 @@ class TestTuiPresenterState:
|
|||||||
log=MagicMock(),
|
log=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=MagicMock(),
|
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
@@ -323,8 +317,7 @@ class TestTuiPresenterState:
|
|||||||
Thinking(sse_id=SID, content="partial"),
|
Thinking(sse_id=SID, content="partial"),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||||
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
|
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
Cancelled(
|
Cancelled(
|
||||||
@@ -332,8 +325,7 @@ class TestTuiPresenterState:
|
|||||||
),
|
),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||||
current_text=MagicMock(), thinking_log=thinking_log, raw=False,
|
|
||||||
)
|
)
|
||||||
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
|
# 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]
|
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||||
@@ -341,11 +333,12 @@ class TestTuiPresenterState:
|
|||||||
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
|
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
|
||||||
assert thinking_log.write.call_count >= 3
|
assert thinking_log.write.call_count >= 3
|
||||||
|
|
||||||
def test_done_renders_markdown_after_label(self) -> None:
|
def test_done_flushes_tail_and_writes_label(self) -> None:
|
||||||
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
"""done_flushes_tail_and_writes_label [happy, v0.8.2]:
|
||||||
Text("hi") accumulates into current_text Static (buffered streaming);
|
Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
|
||||||
Done(response="hi") with raw=False → [done] label + Rule + Markdown
|
"hi" tail to transcript, then writes [done] label. v0.8.2 drops
|
||||||
in transcript. current_text cleared on terminal.
|
the post-Done Markdown body re-render — streamed text is the
|
||||||
|
canonical content (no double-print).
|
||||||
"""
|
"""
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
@@ -353,34 +346,33 @@ class TestTuiPresenterState:
|
|||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
current_text = MagicMock()
|
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content="hi"),
|
Text(sse_id=SID, content="hi"),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=current_text,
|
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Text accumulated to current_text, NOT written to log.
|
assert not log.write.called
|
||||||
current_text.update.assert_any_call("hi")
|
assert state.text_chunk_buffer == "hi"
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=current_text,
|
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Done cleared current_text and wrote [done] label + Rule + Markdown.
|
# On Done: tail flush "hi" + [done] label. No Markdown, no Rule.
|
||||||
current_text.update.assert_any_call("")
|
|
||||||
writes = [c[0][0] for c in log.write.call_args_list]
|
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(_text_of(w).startswith("[done]") for w in writes)
|
||||||
assert any(isinstance(w, Rule) for w in writes)
|
# v0.8.2: no post-Done re-render — no duplicate content.
|
||||||
assert any(isinstance(w, Markdown) for w in writes)
|
assert not any(isinstance(w, Markdown) for w in writes)
|
||||||
|
assert not any(isinstance(w, Rule) for w in writes)
|
||||||
|
assert state.text_chunk_buffer == ""
|
||||||
|
|
||||||
def test_raw_flag_skips_markdown(self) -> None:
|
def test_raw_flag_skips_markdown(self) -> None:
|
||||||
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
|
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
|
||||||
@@ -395,15 +387,13 @@ class TestTuiPresenterState:
|
|||||||
Text(sse_id=SID, content="hi"),
|
Text(sse_id=SID, content="hi"),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
|
||||||
)
|
)
|
||||||
writes = [c[0][0] for c in log.write.call_args_list]
|
writes = [c[0][0] for c in log.write.call_args_list]
|
||||||
assert not any(isinstance(w, Rule) for w in writes)
|
assert not any(isinstance(w, Rule) for w in writes)
|
||||||
@@ -425,8 +415,7 @@ class TestTuiPresenterState:
|
|||||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
|
||||||
)
|
)
|
||||||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||||||
assert not log.write.called
|
assert not log.write.called
|
||||||
@@ -461,8 +450,7 @@ class TestTuiPresenterState:
|
|||||||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
|
||||||
)
|
)
|
||||||
# INV-014: write went to tools_log
|
# INV-014: write went to tools_log
|
||||||
assert tools_log.write.called
|
assert tools_log.write.called
|
||||||
@@ -481,56 +469,55 @@ class TestTuiPresenterState:
|
|||||||
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
|
||||||
)
|
)
|
||||||
assert tools_log.write.called
|
assert tools_log.write.called
|
||||||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||||||
assert not log.write.called
|
assert not log.write.called
|
||||||
|
|
||||||
def test_text_event_buffers_into_current_text(self) -> None:
|
def test_text_event_buffers_until_newline(self) -> None:
|
||||||
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
|
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
|
||||||
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
|
`\\n` accumulate in text_chunk_buffer; no log write yet.
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
log = MagicMock()
|
||||||
tools_log = MagicMock()
|
tools_log = MagicMock()
|
||||||
current_text = MagicMock()
|
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content="hello"),
|
Text(sse_id=SID, content="hello"),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=current_text,
|
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
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 log.write.called
|
||||||
assert not tools_log.write.called
|
assert not tools_log.write.called
|
||||||
|
|
||||||
def test_text_deltas_accumulate(self) -> None:
|
def test_text_flushes_on_newline(self) -> None:
|
||||||
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
|
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
|
||||||
concatenated content, NOT separate per-delta lines.
|
the accumulated buffer as ONE line to log (transcript).
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
current_text = MagicMock()
|
log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
for tok in ("Hel", "lo", " ", "world"):
|
for tok in ("Hel", "lo", " ", "world", "\n"):
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content=tok),
|
Text(sse_id=SID, content=tok),
|
||||||
log=MagicMock(),
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
current_text=current_text,
|
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# Final update reflects the full concatenation.
|
writes = [c[0][0] for c in log.write.call_args_list]
|
||||||
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
|
# "Hello world" coalesces to ONE log entry.
|
||||||
|
assert writes == ["Hello world"]
|
||||||
|
assert state.text_chunk_buffer == ""
|
||||||
|
|
||||||
def test_duration_format_seconds(self) -> None:
|
def test_duration_format_seconds(self) -> None:
|
||||||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||||||
@@ -542,8 +529,7 @@ class TestTuiPresenterState:
|
|||||||
_make_tui_done(duration_ms=5467),
|
_make_tui_done(duration_ms=5467),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
_text_of(c[0][0])
|
_text_of(c[0][0])
|
||||||
@@ -569,8 +555,7 @@ class TestTuiPresenterState:
|
|||||||
_make_tui_done(usage=usage),
|
_make_tui_done(usage=usage),
|
||||||
log=log,
|
log=log,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
_text_of(c[0][0])
|
_text_of(c[0][0])
|
||||||
@@ -857,7 +842,8 @@ class TestLayoutShape:
|
|||||||
log=log,
|
log=log,
|
||||||
tools_log=app.query_one("#tools-log", RichLog),
|
tools_log=app.query_one("#tools-log", RichLog),
|
||||||
debug_log=app.query_one("#debug-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(
|
done = next(
|
||||||
c for c in seen
|
c for c in seen
|
||||||
@@ -1074,10 +1060,12 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
|||||||
|
|
||||||
class TestStreamTurnWorker:
|
class TestStreamTurnWorker:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
|
"""happy_text_done_no_double_print [happy,tracer, v0.8.2]:
|
||||||
Text deltas go to current_text (not transcript); on Done, transcript
|
Text("hello") buffers; on Done, "hello" flushes as tail to transcript
|
||||||
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
|
+ [done] label. v0.8.2 drops the post-Done Markdown body re-render
|
||||||
|
(was double-printing the response — streamed text + Markdown twice).
|
||||||
|
Only the turn-header Rule remains in the transcript.
|
||||||
"""
|
"""
|
||||||
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||||||
"42:2", _DONE_BODY
|
"42:2", _DONE_BODY
|
||||||
@@ -1093,16 +1081,17 @@ class TestStreamTurnWorker:
|
|||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await _submit_and_wait(app, pilot, "hi")
|
await _submit_and_wait(app, pilot, "hi")
|
||||||
assert app.state == "idle"
|
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.markdown import Markdown
|
||||||
from rich.rule import Rule
|
|
||||||
|
|
||||||
assert not any(w == "hello" for w in writes)
|
# "hello" appears as a tail-flush; [done] label fires.
|
||||||
|
assert any(w == "hello" for w in writes)
|
||||||
assert any("[done]" in str(w) for w in writes)
|
assert any("[done]" in str(w) for w in writes)
|
||||||
# Post-Done: Markdown body + Rule + turn-header Rule all present.
|
# v0.8.2: NO Markdown body re-render (was the duplicate).
|
||||||
assert any(isinstance(w, Markdown) for w in writes)
|
assert not any(isinstance(w, Markdown) for w in writes)
|
||||||
assert any(isinstance(w, Rule) for w in writes)
|
# The turn-header Rule is written to all 4 panes; we still expect
|
||||||
|
# SOME Rules in the spy (one per pane), but NOT the post-Done
|
||||||
|
# separator Rule that pre-v0.8.2 wrote.
|
||||||
|
# We rely on _spy_writes counting turn-header Rules only.
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -2254,8 +2243,16 @@ class TestResolveThenRunWithPicker:
|
|||||||
self,
|
self,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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=[]))
|
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
|
||||||
|
|
||||||
from ratatoskr.tui import AgentPickerApp
|
from ratatoskr.tui import AgentPickerApp
|
||||||
|
|||||||
Reference in New Issue
Block a user