Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
139771c8d8 | ||
|
|
489cfee1f0 |
@@ -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.1 text streams inline, no overlap):_
|
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
|
||||||
|
|
||||||
**Status: v0.8.1 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,8 @@ 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.1 fix(tui): kill current-text Static; Text streams inline via coalesce
|
- 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)
|
- `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)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.8.1"
|
version = "0.9.0"
|
||||||
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"
|
||||||
|
|||||||
+198
-93
@@ -16,7 +16,7 @@ from typing import ClassVar, Literal
|
|||||||
import httpx
|
import httpx
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
from textual.theme import Theme
|
from textual.theme import Theme
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
Footer,
|
Footer,
|
||||||
@@ -194,20 +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
|
# v0.9.0: Text accumulator for live Markdown rendering. Worldtree emits
|
||||||
# streamed into a dedicated #current-text Static below the transcript;
|
# Text deltas at token granularity; each delta appends to this buffer
|
||||||
# that Static (docked-bottom, height: auto) grew during streaming and
|
# and the current_response_widget re-renders Markdown(text_chunk_buffer)
|
||||||
# visually OVERLAPPED the transcript above (Textual didn't dynamically
|
# in place. On terminal event the widget is finalized + reference clears.
|
||||||
# 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 = ""
|
text_chunk_buffer: str = ""
|
||||||
|
# v0.9.0: reference to the Static widget holding the current turn's
|
||||||
|
# response Markdown Renderable. None between turns.
|
||||||
|
current_response_widget: object = None
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
self,
|
self,
|
||||||
event: Event,
|
event: Event,
|
||||||
*,
|
*,
|
||||||
log: RichLog,
|
transcript: "VerticalScroll",
|
||||||
tools_log: RichLog,
|
tools_log: RichLog,
|
||||||
debug_log: RichLog,
|
debug_log: RichLog,
|
||||||
thinking_log: RichLog,
|
thinking_log: RichLog,
|
||||||
@@ -215,14 +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.8.1 routing:
|
v0.9.0 routing:
|
||||||
- `log` (transcript) = chat content: user-prompt echo (written
|
- `transcript` (VerticalScroll) = chat content: each turn mounts
|
||||||
outside the presenter), coalesced Text deltas, terminal labels,
|
child widgets (turn-header / prompt-echo / response Markdown /
|
||||||
optional post-Done Markdown body.
|
done-label). Live Markdown rendering during Text streaming.
|
||||||
- `tools_log` = ToolStart + ToolResult.
|
- `tools_log` (RichLog) = ToolStart + ToolResult.
|
||||||
- `debug_log` = WorkerPhase + TextBoundary.
|
- `debug_log` (RichLog) = WorkerPhase + TextBoundary.
|
||||||
- `thinking_log` = streaming Thinking deltas inline (coalesced on
|
- `thinking_log` (RichLog) = streaming Thinking deltas inline
|
||||||
`\n`). Rule(start)/Rule(end) wrap each run.
|
(coalesced on `\n`); Rule(start)/Rule(end) wrap each run.
|
||||||
|
|
||||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||||
"""
|
"""
|
||||||
@@ -288,52 +288,73 @@ class TuiPresenterState:
|
|||||||
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
|
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
|
||||||
# The pre-v0.8.1 #current-text Static is gone — its dock-
|
# The pre-v0.8.1 #current-text Static is gone — its dock-
|
||||||
# bottom growth was overlapping the transcript visually.
|
# bottom growth was overlapping the transcript visually.
|
||||||
|
#
|
||||||
|
# v0.9.0: Text deltas accumulate in text_chunk_buffer and
|
||||||
|
# the current_response_widget renders Markdown(buffer) in
|
||||||
|
# place. First Text delta of the turn mounts a fresh Static
|
||||||
|
# holding the Markdown Renderable; subsequent deltas update
|
||||||
|
# the same widget. Live markdown rendering — no post-Done
|
||||||
|
# re-render needed.
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
|
||||||
self.text_chunk_buffer += event.content
|
self.text_chunk_buffer += event.content
|
||||||
while "\n" in self.text_chunk_buffer:
|
# --raw bypasses Markdown rendering — useful for debugging
|
||||||
line, _, rest = self.text_chunk_buffer.partition("\n")
|
# the raw text stream surface, and matches the pre-v0.9.0
|
||||||
if line:
|
# --raw semantics (which dropped the post-Done Markdown re-
|
||||||
log.write(line)
|
# render). In raw mode the response widget holds plain str.
|
||||||
self.text_chunk_buffer = rest
|
rendered = (
|
||||||
|
self.text_chunk_buffer if raw else Markdown(self.text_chunk_buffer)
|
||||||
|
)
|
||||||
|
if self.current_response_widget is None:
|
||||||
|
self.current_response_widget = Static(
|
||||||
|
rendered, classes="response-md"
|
||||||
|
)
|
||||||
|
transcript.mount(self.current_response_widget)
|
||||||
|
else:
|
||||||
|
self.current_response_widget.update(rendered)
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
return
|
return
|
||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
# Terminal event: flush any remaining text tail before the
|
# Terminal event: finalize the response widget (clear ref so
|
||||||
# label / Markdown body lands.
|
# the next turn mounts a fresh one). The accumulated text is
|
||||||
if self.text_chunk_buffer:
|
# already rendered as Markdown in the widget — no post-Done
|
||||||
log.write(self.text_chunk_buffer)
|
# re-render, no double-print.
|
||||||
self.text_chunk_buffer = ""
|
self.text_chunk_buffer = ""
|
||||||
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
self.current_response_widget = None
|
||||||
# / Dawn yellow) for at-a-glance scanning.
|
# Terminal labels mount as styled Statics. Tinted per outcome
|
||||||
|
# (Aurora green / Dawn red / Dawn yellow) for at-a-glance
|
||||||
|
# scanning.
|
||||||
if isinstance(event, Done):
|
if isinstance(event, Done):
|
||||||
log.write(RichText(
|
transcript.mount(Static(
|
||||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
RichText(
|
||||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
f"[done] turn_id={event.sse_id.turn_id} "
|
||||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
f"model={event.model} "
|
||||||
style=_AU_SUCCESS,
|
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||||
|
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||||
|
style=_AU_SUCCESS,
|
||||||
|
),
|
||||||
|
classes="done-label",
|
||||||
))
|
))
|
||||||
# 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
|
|
||||||
|
|
||||||
log.write(Rule(style=_AU_DEMOTED))
|
|
||||||
log.write(Markdown(event.response))
|
|
||||||
elif isinstance(event, Error):
|
elif isinstance(event, Error):
|
||||||
log.write(RichText(
|
transcript.mount(Static(
|
||||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
RichText(
|
||||||
f"message={event.message!r}",
|
f"[error] turn_id={event.sse_id.turn_id} "
|
||||||
style=_AU_ERROR,
|
f"code={event.error_code} message={event.message!r}",
|
||||||
|
style=_AU_ERROR,
|
||||||
|
),
|
||||||
|
classes="error-label",
|
||||||
))
|
))
|
||||||
else: # Cancelled
|
else: # Cancelled
|
||||||
log.write(RichText(
|
transcript.mount(Static(
|
||||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
RichText(
|
||||||
f"partial_message_id={event.partial_message_id}",
|
f"[cancelled] turn_id={event.turn_id} "
|
||||||
style=_AU_WARNING,
|
f"reason={event.reason!r} "
|
||||||
|
f"partial_message_id={event.partial_message_id}",
|
||||||
|
style=_AU_WARNING,
|
||||||
|
),
|
||||||
|
classes="cancelled-label",
|
||||||
))
|
))
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
return
|
return
|
||||||
if isinstance(event, WorkerPhase):
|
if isinstance(event, WorkerPhase):
|
||||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||||
@@ -365,22 +386,24 @@ class TuiPresenterState:
|
|||||||
# the original event AND a render_error line with the class name only
|
# the original event AND a render_error line with the class name only
|
||||||
# (NO exception message — security clause). Volva F1 fix.
|
# (NO exception message — security clause). Volva F1 fix.
|
||||||
#
|
#
|
||||||
# v0.6.0 routing-under-failure preservation — fallback writes go
|
# v0.9.0 routing-under-failure: panes (RichLog) still write Strip
|
||||||
# to the same destination the successful render would have used:
|
# lines; transcript (VerticalScroll) mounts a Static instead.
|
||||||
# - ToolStart/ToolResult → tools_log
|
|
||||||
# - Thinking → thinking_log
|
|
||||||
# - WorkerPhase/TextBoundary → debug_log
|
|
||||||
# - everything else → log
|
|
||||||
if isinstance(event, (ToolStart, ToolResult)):
|
if isinstance(event, (ToolStart, ToolResult)):
|
||||||
target = tools_log
|
tools_log.write(_plain_label(event))
|
||||||
|
tools_log.write(f"[render_error] {type(exc).__name__}")
|
||||||
elif isinstance(event, Thinking):
|
elif isinstance(event, Thinking):
|
||||||
target = thinking_log
|
thinking_log.write(_plain_label(event))
|
||||||
|
thinking_log.write(f"[render_error] {type(exc).__name__}")
|
||||||
elif isinstance(event, (WorkerPhase, TextBoundary)):
|
elif isinstance(event, (WorkerPhase, TextBoundary)):
|
||||||
target = debug_log
|
debug_log.write(_plain_label(event))
|
||||||
|
debug_log.write(f"[render_error] {type(exc).__name__}")
|
||||||
else:
|
else:
|
||||||
target = log
|
# Transcript-bound event (Text / Done / Error / Cancelled).
|
||||||
target.write(_plain_label(event))
|
transcript.mount(Static(_plain_label(event), classes="error-label"))
|
||||||
target.write(f"[render_error] {type(exc).__name__}")
|
transcript.mount(
|
||||||
|
Static(f"[render_error] {type(exc).__name__}", classes="error-label")
|
||||||
|
)
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
|
|
||||||
|
|
||||||
class AgentPickerApp(App[str | None]):
|
class AgentPickerApp(App[str | None]):
|
||||||
@@ -569,11 +592,40 @@ class RatatoskrApp(App[int]):
|
|||||||
}
|
}
|
||||||
/* v0.6.5: thinking-current Static removed; thinking now streams
|
/* v0.6.5: thinking-current Static removed; thinking now streams
|
||||||
directly into thinking-log so the whole pane scrolls naturally. */
|
directly into thinking-log so the whole pane scrolls naturally. */
|
||||||
#transcript {
|
/* v0.9.0: transcript is a VerticalScroll container holding dynamically
|
||||||
|
mounted Statics + Markdown widgets per turn. Live Markdown rendering
|
||||||
|
replaces the v0.8.x RichLog approach which couldn't render Markdown
|
||||||
|
in-flight (only on Done as a re-render → double-print bug). */
|
||||||
|
#transcript-scroll {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: $background;
|
background: $background;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
|
/* Per-turn mounted widgets carry id-prefix conventions:
|
||||||
|
- .turn-header "── turn N ──" (dim)
|
||||||
|
- .prompt-echo "❯ user input" (aurora bright cyan)
|
||||||
|
- .response-md Markdown(accumulated_text) — updated live
|
||||||
|
- .done-label "[done] turn_id=…" (aurora green)
|
||||||
|
- .error-label "[error] …" (dawn red)
|
||||||
|
- .cancelled-label "[cancelled] …" (dawn yellow)
|
||||||
|
*/
|
||||||
|
.turn-header {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
color: $au-dark-60;
|
||||||
|
}
|
||||||
|
.prompt-echo {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
.response-md {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
.done-label, .error-label, .cancelled-label {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||||
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||||
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||||
@@ -675,7 +727,12 @@ class RatatoskrApp(App[int]):
|
|||||||
# work without widget-level markup=True.
|
# work without widget-level markup=True.
|
||||||
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)
|
# v0.9.0: transcript is a VerticalScroll holding per-turn
|
||||||
|
# mounted widgets (turn header, prompt echo, response Markdown,
|
||||||
|
# done label). Live Markdown rendering happens via Static
|
||||||
|
# widgets holding `Markdown` Renderables, updated as Text
|
||||||
|
# deltas arrive.
|
||||||
|
yield VerticalScroll(id="transcript-scroll")
|
||||||
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"):
|
||||||
@@ -738,20 +795,30 @@ class RatatoskrApp(App[int]):
|
|||||||
self._set_hint(self.HINT_IDLE)
|
self._set_hint(self.HINT_IDLE)
|
||||||
|
|
||||||
def _write_turn_headers(self, turn_id: int) -> None:
|
def _write_turn_headers(self, turn_id: int) -> None:
|
||||||
"""v0.6.0: Write `── turn N ──` Rule headers across every pane so
|
"""v0.6.0: turn-ID headers across every pane for cross-pane
|
||||||
operators can visually correlate sections during cross-pane
|
correlation. v0.9.0: transcript is a VerticalScroll; mounts a
|
||||||
debugging. Called from `_stream_turn_worker` on first event of
|
Static with rule-style text instead of writing a Rule Renderable
|
||||||
each new turn (idempotent per turn via active_turn_id guard).
|
to RichLog. Other panes still use RichLog.write(Rule).
|
||||||
"""
|
"""
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
|
from rich.text import Text as RichText
|
||||||
|
|
||||||
title = f"turn {turn_id}"
|
title = f"turn {turn_id}"
|
||||||
rule = Rule(title=title, style=_AU_DEMOTED)
|
rule = Rule(title=title, style=_AU_DEMOTED)
|
||||||
try:
|
try:
|
||||||
self.query_one("#transcript", RichLog).write(rule)
|
# Transcript (VerticalScroll): mount a styled Static.
|
||||||
|
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||||
|
transcript.mount(
|
||||||
|
Static(
|
||||||
|
RichText(f"── turn {turn_id} ──", style=_AU_DEMOTED),
|
||||||
|
classes="turn-header",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Other panes (RichLog): write the Rule Renderable.
|
||||||
self.query_one("#tools-log", RichLog).write(rule)
|
self.query_one("#tools-log", RichLog).write(rule)
|
||||||
self.query_one("#debug-log", RichLog).write(rule)
|
self.query_one("#debug-log", RichLog).write(rule)
|
||||||
self.query_one("#thinking-log", RichLog).write(rule)
|
self.query_one("#thinking-log", RichLog).write(rule)
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Defensive: widget tree may be tearing down — never let a
|
# Defensive: widget tree may be tearing down — never let a
|
||||||
# turn-header write block the SSE consumer.
|
# turn-header write block the SSE consumer.
|
||||||
@@ -767,12 +834,19 @@ class RatatoskrApp(App[int]):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
"""Echo user prompt, spawn stream worker; busy notice if not idle."""
|
"""Echo user prompt, spawn stream worker; busy notice if not idle.
|
||||||
|
|
||||||
|
v0.9.0: prompt echo mounts as a Static in the transcript VerticalScroll
|
||||||
|
(was log.write to RichLog).
|
||||||
|
"""
|
||||||
if event.input.id != "prompt":
|
if event.input.id != "prompt":
|
||||||
return
|
return
|
||||||
log = self.query_one("#transcript", RichLog)
|
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||||
if self.state != "idle":
|
if self.state != "idle":
|
||||||
log.write("[busy] turn in flight; input ignored")
|
transcript.mount(
|
||||||
|
Static("[busy] turn in flight; input ignored", classes="error-label")
|
||||||
|
)
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
event.input.value = ""
|
event.input.value = ""
|
||||||
return
|
return
|
||||||
content = event.input.value.strip()
|
content = event.input.value.strip()
|
||||||
@@ -781,7 +855,13 @@ class RatatoskrApp(App[int]):
|
|||||||
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it
|
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it
|
||||||
# stands out against the default-foreground assistant text below it.
|
# stands out against the default-foreground assistant text below it.
|
||||||
from rich.text import Text as RichText
|
from rich.text import Text as RichText
|
||||||
log.write(RichText(f"❯ {content}", style=_AU_USER_ECHO)) # noqa: RUF001
|
transcript.mount(
|
||||||
|
Static(
|
||||||
|
RichText(f"❯ {content}", style=_AU_USER_ECHO), # noqa: RUF001
|
||||||
|
classes="prompt-echo",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
event.input.value = ""
|
event.input.value = ""
|
||||||
self.state = "streaming"
|
self.state = "streaming"
|
||||||
self._set_hint(self.HINT_STREAMING)
|
self._set_hint(self.HINT_STREAMING)
|
||||||
@@ -790,26 +870,37 @@ class RatatoskrApp(App[int]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _stream_turn_worker(self, content: str) -> None:
|
async def _stream_turn_worker(self, content: str) -> None:
|
||||||
"""Drive stream_turn, render events via TuiPresenterState (issue #12)."""
|
"""Drive stream_turn, render events via TuiPresenterState.
|
||||||
|
|
||||||
|
v0.9.0: transcript is a VerticalScroll; the presenter's `transcript`
|
||||||
|
argument is the container, and the presenter mounts Static / Markdown-
|
||||||
|
backed widgets directly. Wire-error labels mount as `error-label`
|
||||||
|
Statics into the transcript-scroll.
|
||||||
|
"""
|
||||||
assert self.state == "streaming"
|
assert self.state == "streaming"
|
||||||
assert self.client is not None
|
assert self.client is not None
|
||||||
assert content
|
assert content
|
||||||
log = self.query_one("#transcript", RichLog)
|
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||||
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)
|
||||||
presenter = TuiPresenterState()
|
presenter = TuiPresenterState()
|
||||||
|
|
||||||
|
def _mount_wire_error(label: str) -> None:
|
||||||
|
try:
|
||||||
|
transcript.mount(Static(label, classes="error-label"))
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn(self.client, self.session_id, content):
|
async for event in stream_turn(self.client, self.session_id, content):
|
||||||
if self.active_turn_id is None:
|
if self.active_turn_id is None:
|
||||||
self.active_turn_id = event.sse_id.turn_id
|
self.active_turn_id = event.sse_id.turn_id
|
||||||
# v0.6.0: turn-ID headers across all panes so the
|
|
||||||
# operator can visually correlate sections during
|
|
||||||
# cross-pane debugging.
|
|
||||||
self._write_turn_headers(self.active_turn_id)
|
self._write_turn_headers(self.active_turn_id)
|
||||||
presenter.render(
|
presenter.render(
|
||||||
event,
|
event,
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
@@ -818,15 +909,15 @@ class RatatoskrApp(App[int]):
|
|||||||
if isinstance(event, (Done, Error, Cancelled)):
|
if isinstance(event, (Done, Error, Cancelled)):
|
||||||
break
|
break
|
||||||
except SseConnectFailed as exc:
|
except SseConnectFailed as exc:
|
||||||
log.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
|
_mount_wire_error(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
|
||||||
except SseConnectionDropped as exc:
|
except SseConnectionDropped as exc:
|
||||||
log.write(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
|
_mount_wire_error(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
|
||||||
except MalformedSseId as exc:
|
except MalformedSseId as exc:
|
||||||
log.write(f"[malformed_sse_id] raw={exc.raw!r}")
|
_mount_wire_error(f"[malformed_sse_id] raw={exc.raw!r}")
|
||||||
except MalformedSseData as exc:
|
except MalformedSseData as exc:
|
||||||
log.write(f"[malformed_sse_data] raw={exc.raw!r}")
|
_mount_wire_error(f"[malformed_sse_data] raw={exc.raw!r}")
|
||||||
except TurnIdFlip as exc:
|
except TurnIdFlip as exc:
|
||||||
log.write(f"[turn_id_flip] expected={exc.established} got={exc.got}")
|
_mount_wire_error(f"[turn_id_flip] expected={exc.established} got={exc.got}")
|
||||||
finally:
|
finally:
|
||||||
self.state = "idle"
|
self.state = "idle"
|
||||||
self.active_turn_id = None
|
self.active_turn_id = None
|
||||||
@@ -849,9 +940,12 @@ class RatatoskrApp(App[int]):
|
|||||||
return
|
return
|
||||||
self.state = "cancelling"
|
self.state = "cancelling"
|
||||||
self._set_hint(self.HINT_CANCELLING)
|
self._set_hint(self.HINT_CANCELLING)
|
||||||
log = self.query_one("#transcript", RichLog)
|
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||||
self.run_worker(
|
self.run_worker(
|
||||||
_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=log)
|
_cancel_via_sse(
|
||||||
|
self.client, self.session_id, self.active_turn_id,
|
||||||
|
transcript=transcript,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
elif self.state == "cancelling":
|
elif self.state == "cancelling":
|
||||||
if self.stream_worker is not None:
|
if self.stream_worker is not None:
|
||||||
@@ -1000,12 +1094,23 @@ async def _cancel_via_sse(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
turn_id: int,
|
turn_id: int,
|
||||||
*,
|
*,
|
||||||
log: RichLog,
|
transcript: VerticalScroll,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009)."""
|
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
|
||||||
|
|
||||||
|
v0.9.0: mounts a `[cancel_failed]` Static into the transcript-scroll
|
||||||
|
container on failure (was log.write to RichLog).
|
||||||
|
"""
|
||||||
assert client is not None
|
assert client is not None
|
||||||
assert isinstance(turn_id, int) and turn_id > 0
|
assert isinstance(turn_id, int) and turn_id > 0
|
||||||
try:
|
try:
|
||||||
await cancel_turn(client, session_id, turn_id)
|
await cancel_turn(client, session_id, turn_id)
|
||||||
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
|
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
|
||||||
log.write(f"[cancel_failed] {type(exc).__name__}: {exc}")
|
try:
|
||||||
|
transcript.mount(Static(
|
||||||
|
f"[cancel_failed] {type(exc).__name__}: {exc}",
|
||||||
|
classes="error-label",
|
||||||
|
))
|
||||||
|
transcript.scroll_end(animate=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
+232
-145
@@ -61,20 +61,43 @@ def _args_existing(session_id: str = "s-1existing", **overrides) -> ParsedArgs:
|
|||||||
|
|
||||||
|
|
||||||
def _spy_writes(monkeypatch) -> list:
|
def _spy_writes(monkeypatch) -> list:
|
||||||
"""Patch RichLog.write to record every arg into a list (returned).
|
"""Patch RichLog.write AND VerticalScroll.mount to record every renderable
|
||||||
|
or mounted-widget content into a single list (returned).
|
||||||
|
|
||||||
Accepts *args/**kwargs so Textual's internal deferred-render path
|
v0.9.0: transcript content is mounted into a VerticalScroll, not written
|
||||||
(which calls write positionally with width/expand/shrink/scroll_end)
|
to a RichLog. The spy captures both shapes — for each mounted Static, the
|
||||||
still works after a write-during-mount + Resize sequence.
|
Static's `renderable` (Markdown / RichText / str) lands in the list,
|
||||||
|
indistinguishably from RichLog.write entries. Integration tests assert
|
||||||
|
on substrings or types in `writes` so the merged shape is the right
|
||||||
|
abstraction.
|
||||||
|
|
||||||
|
Accepts *args/**kwargs so Textual's internal deferred-render paths still
|
||||||
|
work after a write-during-mount + Resize sequence.
|
||||||
"""
|
"""
|
||||||
|
from textual.containers import VerticalScroll
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
writes: list = []
|
writes: list = []
|
||||||
original = RichLog.write
|
|
||||||
|
|
||||||
def spy(self, content, *args, **kw):
|
original_write = RichLog.write
|
||||||
|
|
||||||
|
def spy_write(self, content, *args, **kw):
|
||||||
writes.append(content)
|
writes.append(content)
|
||||||
return original(self, content, *args, **kw)
|
return original_write(self, content, *args, **kw)
|
||||||
|
|
||||||
monkeypatch.setattr(RichLog, "write", spy)
|
monkeypatch.setattr(RichLog, "write", spy_write)
|
||||||
|
|
||||||
|
original_mount = VerticalScroll.mount
|
||||||
|
|
||||||
|
def spy_mount(self, *children, **kw):
|
||||||
|
for child in children:
|
||||||
|
if isinstance(child, Static):
|
||||||
|
writes.append(child.content)
|
||||||
|
else:
|
||||||
|
writes.append(child)
|
||||||
|
return original_mount(self, *children, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(VerticalScroll, "mount", spy_mount)
|
||||||
return writes
|
return writes
|
||||||
|
|
||||||
|
|
||||||
@@ -126,13 +149,13 @@ class TestTuiPresenterState:
|
|||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
thinking_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
for chunk in ("Let", " me", " think"):
|
for chunk in ("Let", " me", " think"):
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content=chunk),
|
Thinking(sse_id=SID, content=chunk),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
@@ -143,7 +166,7 @@ class TestTuiPresenterState:
|
|||||||
assert len(writes) == 1
|
assert len(writes) == 1
|
||||||
assert isinstance(writes[0], Rule)
|
assert isinstance(writes[0], Rule)
|
||||||
assert state.thinking_chunk_buffer == "Let me think"
|
assert state.thinking_chunk_buffer == "Let me think"
|
||||||
assert log.write.call_count == 0
|
assert transcript.mount.call_count == 0
|
||||||
|
|
||||||
def test_thinking_flushes_on_newline(self) -> None:
|
def test_thinking_flushes_on_newline(self) -> None:
|
||||||
"""thinking_flushes_on_newline [happy, v0.7.1]:
|
"""thinking_flushes_on_newline [happy, v0.7.1]:
|
||||||
@@ -156,7 +179,7 @@ class TestTuiPresenterState:
|
|||||||
for chunk in ("Hello", " world", "\n"):
|
for chunk in ("Hello", " world", "\n"):
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content=chunk),
|
Thinking(sse_id=SID, content=chunk),
|
||||||
log=MagicMock(),
|
transcript=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
@@ -178,14 +201,14 @@ class TestTuiPresenterState:
|
|||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
debug_log = MagicMock()
|
debug_log = MagicMock()
|
||||||
thinking_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
for content in ("a", "b"):
|
for content in ("a", "b"):
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content=content),
|
Thinking(sse_id=SID, content=content),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
@@ -193,7 +216,7 @@ class TestTuiPresenterState:
|
|||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log,
|
debug_log=debug_log,
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
@@ -207,7 +230,7 @@ class TestTuiPresenterState:
|
|||||||
assert isinstance(thinking_writes[2], Rule)
|
assert isinstance(thinking_writes[2], Rule)
|
||||||
# worker_phase still goes to debug_log; transcript untouched.
|
# worker_phase still goes to debug_log; transcript untouched.
|
||||||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
||||||
assert not log.write.called
|
assert not transcript.mount.called
|
||||||
|
|
||||||
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
|
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
|
||||||
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
||||||
@@ -224,7 +247,7 @@ class TestTuiPresenterState:
|
|||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
thinking_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
for evt in (
|
for evt in (
|
||||||
@@ -233,13 +256,13 @@ class TestTuiPresenterState:
|
|||||||
Thinking(sse_id=SID, content="second"),
|
Thinking(sse_id=SID, content="second"),
|
||||||
):
|
):
|
||||||
state.render(
|
state.render(
|
||||||
evt, log=log,
|
evt, transcript=transcript,
|
||||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||||
thinking_log=thinking_log, raw=False,
|
thinking_log=thinking_log, raw=False,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||||
thinking_log=thinking_log, raw=False,
|
thinking_log=thinking_log, raw=False,
|
||||||
)
|
)
|
||||||
@@ -251,9 +274,9 @@ class TestTuiPresenterState:
|
|||||||
assert "first" in delta_strs
|
assert "first" in delta_strs
|
||||||
assert "second" in delta_strs
|
assert "second" in delta_strs
|
||||||
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
|
# 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]
|
transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)]
|
||||||
assert "hi" in log_writes
|
assert "hi" in transcript_renderables
|
||||||
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
assert any(w.startswith("[done]") for w in transcript_renderables if isinstance(w, str))
|
||||||
|
|
||||||
def test_render_exception_fallback(self) -> None:
|
def test_render_exception_fallback(self) -> None:
|
||||||
"""render_exception_fallback [adversarial, v0.6.5]:
|
"""render_exception_fallback [adversarial, v0.6.5]:
|
||||||
@@ -263,7 +286,7 @@ class TestTuiPresenterState:
|
|||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
thinking_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
# First call (Rule write) raises; subsequent calls succeed for fallback.
|
# First call (Rule write) raises; subsequent calls succeed for fallback.
|
||||||
thinking_log.write.side_effect = [
|
thinking_log.write.side_effect = [
|
||||||
@@ -274,7 +297,7 @@ class TestTuiPresenterState:
|
|||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content="x"),
|
Thinking(sse_id=SID, content="x"),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=thinking_log,
|
thinking_log=thinking_log,
|
||||||
@@ -284,7 +307,7 @@ class TestTuiPresenterState:
|
|||||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
assert any(w.startswith("[thinking]") for w in writes), writes
|
||||||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||||||
assert not any("rule write failed" in w for w in writes), writes
|
assert not any("rule write failed" in w for w in writes), writes
|
||||||
assert not log.write.called
|
assert not transcript.mount.called
|
||||||
|
|
||||||
def test_state_reset_per_worker(self) -> None:
|
def test_state_reset_per_worker(self) -> None:
|
||||||
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
"""state_reset_per_worker [trace]: fresh TuiPresenterState() starts no thinking open."""
|
||||||
@@ -293,7 +316,7 @@ class TestTuiPresenterState:
|
|||||||
s1 = TuiPresenterState()
|
s1 = TuiPresenterState()
|
||||||
s1.render(
|
s1.render(
|
||||||
Thinking(sse_id=SID, content="x"),
|
Thinking(sse_id=SID, content="x"),
|
||||||
log=MagicMock(),
|
transcript=MagicMock(),
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
@@ -310,12 +333,12 @@ class TestTuiPresenterState:
|
|||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
thinking_log = MagicMock()
|
thinking_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Thinking(sse_id=SID, content="partial"),
|
Thinking(sse_id=SID, content="partial"),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||||
)
|
)
|
||||||
@@ -323,80 +346,88 @@ class TestTuiPresenterState:
|
|||||||
Cancelled(
|
Cancelled(
|
||||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
||||||
),
|
),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=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.
|
# 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]
|
transcript_renderables = [_text_of(r) for r in _mounted_renderables(transcript)]
|
||||||
assert any(w.startswith("[cancelled]") for w in log_writes)
|
assert any(w.startswith("[cancelled]") for w in transcript_renderables)
|
||||||
# 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_text_then_done_mounts_widget_and_finalizes(self) -> None:
|
||||||
"""done_renders_markdown_after_label [happy, v0.8.1]:
|
"""text_then_done_mounts_widget_and_finalizes [happy, v0.9.0]:
|
||||||
Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
|
First Text delta mounts a Static(Markdown(buffer)) into the transcript;
|
||||||
"hi" as a tail line in transcript, then writes [done] + Rule +
|
Done finalizes the widget reference and mounts a styled [done] label.
|
||||||
Markdown body (non-raw).
|
No duplicate content (v0.9.0 replaces v0.8.x's flush-on-Done with
|
||||||
|
live in-place Markdown updates).
|
||||||
"""
|
"""
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.rule import Rule
|
|
||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content="hi"),
|
Text(sse_id=SID, content="hi"),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# v0.8.1: Text "hi" stays buffered (no `\n` yet) — no log write yet.
|
# v0.9.0: response widget mounted on first Text delta with Markdown wrapper.
|
||||||
assert not log.write.called
|
assert transcript.mount.called
|
||||||
|
first_widget = transcript.mount.call_args_list[0][0][0]
|
||||||
|
assert isinstance(first_widget.content, Markdown)
|
||||||
|
assert first_widget.content.markup == "hi"
|
||||||
assert state.text_chunk_buffer == "hi"
|
assert state.text_chunk_buffer == "hi"
|
||||||
|
# Done finalizes: text_chunk_buffer cleared, widget ref released, label mounted.
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# On Done: tail flush + [done] + Rule + Markdown body.
|
writes = _mounted_renderables(transcript)
|
||||||
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.9.0: response Markdown rendered live during stream — only ONE
|
||||||
assert any(isinstance(w, Markdown) for w in writes)
|
# Markdown renderable lands in the transcript (no post-Done re-render).
|
||||||
|
markdowns = [w for w in writes if isinstance(w, Markdown)]
|
||||||
|
assert len(markdowns) == 1
|
||||||
assert state.text_chunk_buffer == ""
|
assert state.text_chunk_buffer == ""
|
||||||
|
assert state.current_response_widget is None
|
||||||
|
|
||||||
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 [v0.9.0]: raw=True → response widget holds
|
||||||
|
plain str instead of Markdown. Live in-place update still happens;
|
||||||
|
only the wrapper differs.
|
||||||
|
"""
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.rule import Rule
|
|
||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content="hi"),
|
Text(sse_id=SID, content="hi"),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=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]
|
writes = _mounted_renderables(transcript)
|
||||||
assert not any(isinstance(w, Rule) for w in writes)
|
# Raw mode bypasses Markdown entirely — content lives as plain str.
|
||||||
assert not any(isinstance(w, Markdown) for w in writes)
|
assert not any(isinstance(w, Markdown) for w in writes)
|
||||||
|
assert "hi" in writes
|
||||||
|
|
||||||
def test_worker_phase_demoted_to_debug_log(self) -> None:
|
def test_worker_phase_demoted_to_debug_log(self) -> None:
|
||||||
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
|
"""worker_phase_demoted_to_debug_log [trace, v0.5.0]: WorkerPhase → debug_log
|
||||||
@@ -407,17 +438,17 @@ class TestTuiPresenterState:
|
|||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
debug_log = MagicMock()
|
debug_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
debug_log=debug_log, 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 transcript.mount.called
|
||||||
renderable = debug_log.write.call_args[0][0]
|
renderable = debug_log.write.call_args[0][0]
|
||||||
# INV-003: must be a styled Rich Text renderable, not a plain str.
|
# INV-003: must be a styled Rich Text renderable, not a plain str.
|
||||||
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
|
# v0.4.1 retheme: style is now Australis Sea dark-60 ("#86929d") instead
|
||||||
@@ -442,12 +473,12 @@ class TestTuiPresenterState:
|
|||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
tools_log = MagicMock()
|
tools_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||||
)
|
)
|
||||||
@@ -455,85 +486,95 @@ class TestTuiPresenterState:
|
|||||||
assert tools_log.write.called
|
assert tools_log.write.called
|
||||||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
|
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_start:")
|
||||||
# INV-014: transcript was NOT written to
|
# INV-014: transcript was NOT written to
|
||||||
assert not log.write.called
|
assert not transcript.mount.called
|
||||||
|
|
||||||
def test_tool_result_routes_to_tools_log(self) -> None:
|
def test_tool_result_routes_to_tools_log(self) -> None:
|
||||||
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
|
"""tool_result_routes_to_tools_log [INV-014]: ToolResult → tools_log, NOT transcript."""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
tools_log = MagicMock()
|
tools_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
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,
|
transcript=transcript,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
debug_log=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 transcript.mount.called
|
||||||
|
|
||||||
def test_text_event_buffers_until_newline(self) -> None:
|
def test_text_first_delta_mounts_response_widget(self) -> None:
|
||||||
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
|
"""text_first_delta_mounts_response_widget [v0.9.0]: first Text delta
|
||||||
`\\n` accumulate in text_chunk_buffer; no log write yet.
|
mounts a Static carrying Markdown(buffer) into the transcript. The
|
||||||
|
text_chunk_buffer holds the accumulated content for the next delta's
|
||||||
|
in-place update.
|
||||||
"""
|
"""
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
tools_log = MagicMock()
|
tools_log = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content="hello"),
|
Text(sse_id=SID, content="hello"),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=tools_log,
|
tools_log=tools_log,
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
# v0.8.1: buffered, not written until `\n` or Done.
|
|
||||||
assert state.text_chunk_buffer == "hello"
|
assert state.text_chunk_buffer == "hello"
|
||||||
assert not log.write.called
|
assert transcript.mount.call_count == 1
|
||||||
|
widget = transcript.mount.call_args[0][0]
|
||||||
|
assert isinstance(widget.content, Markdown)
|
||||||
|
assert widget.content.markup == "hello"
|
||||||
|
assert state.current_response_widget is widget
|
||||||
assert not tools_log.write.called
|
assert not tools_log.write.called
|
||||||
|
|
||||||
def test_text_flushes_on_newline(self) -> None:
|
def test_text_subsequent_deltas_update_in_place(self) -> None:
|
||||||
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
|
"""text_subsequent_deltas_update_in_place [v0.9.0]: deltas after the
|
||||||
the accumulated buffer as ONE line to log (transcript).
|
first do NOT mount a new widget — they update the existing widget's
|
||||||
|
Markdown content in place. The text_chunk_buffer accumulates.
|
||||||
"""
|
"""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
for tok in ("Hel", "lo", " ", "world", "\n"):
|
for tok in ("Hel", "lo", " ", "world"):
|
||||||
state.render(
|
state.render(
|
||||||
Text(sse_id=SID, content=tok),
|
Text(sse_id=SID, content=tok),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(),
|
debug_log=MagicMock(),
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=False,
|
raw=False,
|
||||||
)
|
)
|
||||||
writes = [c[0][0] for c in log.write.call_args_list]
|
# Exactly ONE mount (the first delta); subsequent deltas update.
|
||||||
# "Hello world" coalesces to ONE log entry.
|
assert transcript.mount.call_count == 1
|
||||||
assert writes == ["Hello world"]
|
assert state.text_chunk_buffer == "Hello world"
|
||||||
assert state.text_chunk_buffer == ""
|
# Widget reference held; buffer is the source of truth re-rendered
|
||||||
|
# into Markdown(...) for each Static.update call.
|
||||||
|
assert state.current_response_widget is not None
|
||||||
|
|
||||||
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"."""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(duration_ms=5467),
|
_make_tui_done(duration_ms=5467),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
_text_of(c[0][0])
|
_text_of(r)
|
||||||
for c in log.write.call_args_list
|
for r in _mounted_renderables(transcript)
|
||||||
if _text_of(c[0][0]).startswith("[done]")
|
if _text_of(r).startswith("[done]")
|
||||||
)
|
)
|
||||||
assert "duration=5.5s" in done_line
|
assert "duration=5.5s" in done_line
|
||||||
assert "duration_ms=5467" not in done_line
|
assert "duration_ms=5467" not in done_line
|
||||||
@@ -542,7 +583,7 @@ class TestTuiPresenterState:
|
|||||||
"""usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII)."""
|
"""usage_format_unicode_arrow [trace]: TUI Done label uses → (Unicode), not -> (ASCII)."""
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
|
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
usage = {
|
usage = {
|
||||||
"prompt_tokens": 6756,
|
"prompt_tokens": 6756,
|
||||||
@@ -552,14 +593,14 @@ class TestTuiPresenterState:
|
|||||||
}
|
}
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(usage=usage),
|
_make_tui_done(usage=usage),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
tools_log=MagicMock(),
|
tools_log=MagicMock(),
|
||||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||||
)
|
)
|
||||||
done_line = next(
|
done_line = next(
|
||||||
_text_of(c[0][0])
|
_text_of(r)
|
||||||
for c in log.write.call_args_list
|
for r in _mounted_renderables(transcript)
|
||||||
if _text_of(c[0][0]).startswith("[done]")
|
if _text_of(r).startswith("[done]")
|
||||||
)
|
)
|
||||||
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
assert "usage 6756 in → 126 out (6882 total, 0 cached)" in done_line
|
||||||
|
|
||||||
@@ -570,14 +611,38 @@ def _text_of(write_arg: object) -> str:
|
|||||||
Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")`
|
Issue #12 wraps demoted-telemetry entries in `rich.text.Text(..., style="dim")`
|
||||||
so the RichLog can apply dim styling; non-demoted writes stay as plain str.
|
so the RichLog can apply dim styling; non-demoted writes stay as plain str.
|
||||||
Tests that want to assert against content need both shapes flattened.
|
Tests that want to assert against content need both shapes flattened.
|
||||||
|
|
||||||
|
v0.9.0: also extracts plain text from Markdown wrappers (the streaming-text
|
||||||
|
response path uses Markdown(buffer) now; tests assert against the source
|
||||||
|
markup, which lives in `Markdown.markup`).
|
||||||
"""
|
"""
|
||||||
|
from rich.markdown import Markdown
|
||||||
from rich.text import Text as RichText
|
from rich.text import Text as RichText
|
||||||
|
|
||||||
if isinstance(write_arg, RichText):
|
if isinstance(write_arg, RichText):
|
||||||
return write_arg.plain
|
return write_arg.plain
|
||||||
|
if isinstance(write_arg, Markdown):
|
||||||
|
return write_arg.markup
|
||||||
if isinstance(write_arg, str):
|
if isinstance(write_arg, str):
|
||||||
return write_arg
|
return write_arg
|
||||||
return "" # Markdown / Rule / etc. — not text content
|
return "" # Rule / etc. — not text content
|
||||||
|
|
||||||
|
|
||||||
|
def _mounted_renderables(transcript_mock: MagicMock) -> list:
|
||||||
|
"""v0.9.0: TuiPresenterState now mounts Static widgets into the transcript
|
||||||
|
VerticalScroll instead of writing renderables to a RichLog. Tests using a
|
||||||
|
MagicMock transcript inspect `transcript.mount.call_args_list`; each call's
|
||||||
|
first positional arg is the Static child whose `.content` carries the
|
||||||
|
Markdown / RichText / str that pre-v0.9.0 would have been the write arg.
|
||||||
|
Returns those renderables in mount-call order so tests can assert on them
|
||||||
|
with the same shape they used for `log.write.call_args_list` previously.
|
||||||
|
"""
|
||||||
|
out: list = []
|
||||||
|
for call in transcript_mock.mount.call_args_list:
|
||||||
|
for child in call.args:
|
||||||
|
renderable = getattr(child, "content", child)
|
||||||
|
out.append(renderable)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
|
def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
|
||||||
@@ -601,15 +666,15 @@ def _make_tui_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None)
|
|||||||
class TestCancelViaSse:
|
class TestCancelViaSse:
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_happy_cancel(self) -> None:
|
async def test_happy_cancel(self) -> None:
|
||||||
"""happy_cancel [happy,tracer]: 200 OK → returns None; log has no [cancel_failed]."""
|
"""happy_cancel [happy,tracer]: 200 OK → returns None; transcript has no [cancel_failed]."""
|
||||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||||
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
return_value=httpx.Response(200, json=_CANCEL_OK_RESP)
|
||||||
)
|
)
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
result = await _cancel_via_sse(client, "s-1", 42, log=log)
|
result = await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||||||
assert result is None
|
assert result is None
|
||||||
log.write.assert_not_called()
|
transcript.mount.assert_not_called()
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_cancel_failed_500(self) -> None:
|
async def test_cancel_failed_500(self) -> None:
|
||||||
@@ -617,10 +682,10 @@ class TestCancelViaSse:
|
|||||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||||
return_value=httpx.Response(500, content=b"boom")
|
return_value=httpx.Response(500, content=b"boom")
|
||||||
)
|
)
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||||||
line = log.write.call_args[0][0]
|
line = transcript.mount.call_args[0][0].content
|
||||||
assert "[cancel_failed]" in line
|
assert "[cancel_failed]" in line
|
||||||
assert "CancelFailed" in line
|
assert "CancelFailed" in line
|
||||||
|
|
||||||
@@ -630,10 +695,10 @@ class TestCancelViaSse:
|
|||||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||||
return_value=httpx.Response(409)
|
return_value=httpx.Response(409)
|
||||||
)
|
)
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||||||
line = log.write.call_args[0][0]
|
line = transcript.mount.call_args[0][0].content
|
||||||
assert "[cancel_failed]" in line
|
assert "[cancel_failed]" in line
|
||||||
assert "CancelAlreadyCompleted" in line
|
assert "CancelAlreadyCompleted" in line
|
||||||
|
|
||||||
@@ -643,10 +708,10 @@ class TestCancelViaSse:
|
|||||||
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
|
||||||
side_effect=httpx.ConnectError("network down")
|
side_effect=httpx.ConnectError("network down")
|
||||||
)
|
)
|
||||||
log = MagicMock()
|
transcript = MagicMock()
|
||||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||||
await _cancel_via_sse(client, "s-1", 42, log=log)
|
await _cancel_via_sse(client, "s-1", 42, transcript=transcript)
|
||||||
line = log.write.call_args[0][0]
|
line = transcript.mount.call_args[0][0].content
|
||||||
assert "[cancel_failed]" in line
|
assert "[cancel_failed]" in line
|
||||||
assert "ConnectError" in line
|
assert "ConnectError" in line
|
||||||
|
|
||||||
@@ -713,18 +778,18 @@ class TestLayoutShape:
|
|||||||
assert row is not None
|
assert row is not None
|
||||||
|
|
||||||
async def test_left_column_content_only(self) -> None:
|
async def test_left_column_content_only(self) -> None:
|
||||||
"""left_column_content_only [v0.6.5]: left column = transcript + prompt
|
"""left_column_content_only [v0.9.0]: left column = transcript-scroll
|
||||||
+ current-text (streaming text Static). thinking-current Static
|
VerticalScroll + prompt Input. thinking-current Static removed in
|
||||||
removed entirely as of v0.6.5.
|
v0.6.5; transcript RichLog replaced by VerticalScroll in v0.9.0.
|
||||||
"""
|
"""
|
||||||
from textual.containers import Vertical
|
from textual.containers import Vertical, VerticalScroll
|
||||||
from textual.widgets import Input, RichLog
|
from textual.widgets import Input
|
||||||
|
|
||||||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
left = app.query_one("#left-column", Vertical)
|
left = app.query_one("#left-column", Vertical)
|
||||||
transcript = app.query_one("#transcript", RichLog)
|
transcript = app.query_one("#transcript-scroll", VerticalScroll)
|
||||||
prompt = app.query_one("#prompt", Input)
|
prompt = app.query_one("#prompt", Input)
|
||||||
assert transcript in left.walk_children()
|
assert transcript in left.walk_children()
|
||||||
assert prompt in left.walk_children()
|
assert prompt in left.walk_children()
|
||||||
@@ -749,7 +814,7 @@ class TestLayoutShape:
|
|||||||
assert tools_tab is not None
|
assert tools_tab is not None
|
||||||
|
|
||||||
async def test_tools_log_inside_tools_tab(self) -> None:
|
async def test_tools_log_inside_tools_tab(self) -> None:
|
||||||
"""tools_log_inside_tools_tab: tools-log RichLog is a descendant of tools-tab TabPane."""
|
"""tools_log_inside_tools_tab: tools-transcript RichLog is a descendant of tools-tab TabPane."""
|
||||||
from textual.widgets import RichLog, TabPane
|
from textual.widgets import RichLog, TabPane
|
||||||
|
|
||||||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
@@ -800,7 +865,7 @@ class TestLayoutShape:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def test_debug_tab_exists(self) -> None:
|
async def test_debug_tab_exists(self) -> None:
|
||||||
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-log RichLog."""
|
"""debug_tab_exists [v0.5.0]: right column has Debug TabPane + #debug-transcript RichLog."""
|
||||||
from textual.widgets import RichLog, TabPane
|
from textual.widgets import RichLog, TabPane
|
||||||
|
|
||||||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
@@ -822,36 +887,45 @@ class TestLayoutShape:
|
|||||||
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
assert app.query_one("#side-panes", TabbedContent).active == "debug-tab"
|
||||||
|
|
||||||
async def test_done_label_styled_success(self) -> None:
|
async def test_done_label_styled_success(self) -> None:
|
||||||
"""done_label_styled_success [v0.5.1]: [done] label renders in Aurora green."""
|
"""done_label_styled_success [v0.9.0]: [done] label mounts as Static
|
||||||
|
carrying a RichText with Aurora green style. Inspect the mounted
|
||||||
|
Static's `.content`.
|
||||||
|
"""
|
||||||
from rich.text import Text as RichText
|
from rich.text import Text as RichText
|
||||||
|
from textual.containers import VerticalScroll
|
||||||
from textual.widgets import RichLog
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
# Probe the presenter directly — write a Done via state.render.
|
|
||||||
from ratatoskr.tui import TuiPresenterState
|
from ratatoskr.tui import TuiPresenterState
|
||||||
log = app.query_one("#transcript", RichLog)
|
transcript = app.query_one("#transcript-scroll", VerticalScroll)
|
||||||
state = TuiPresenterState()
|
state = TuiPresenterState()
|
||||||
seen: list = []
|
mounted: list = []
|
||||||
orig = log.write
|
orig_mount = transcript.mount
|
||||||
log.write = lambda c, *a, **kw: (seen.append(c), orig(c, *a, **kw))[1]
|
|
||||||
|
def spy_mount(*ch, **kw):
|
||||||
|
mounted.extend(ch)
|
||||||
|
return orig_mount(*ch, **kw)
|
||||||
|
|
||||||
|
transcript.mount = spy_mount # type: ignore[method-assign]
|
||||||
state.render(
|
state.render(
|
||||||
_make_tui_done(),
|
_make_tui_done(),
|
||||||
log=log,
|
transcript=transcript,
|
||||||
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),
|
||||||
thinking_log=MagicMock(),
|
thinking_log=MagicMock(),
|
||||||
raw=True,
|
raw=True,
|
||||||
)
|
)
|
||||||
done = next(
|
done = next(
|
||||||
c for c in seen
|
w.content for w in mounted
|
||||||
if isinstance(c, RichText) and _text_of(c).startswith("[done]")
|
if isinstance(getattr(w, "content", None), RichText)
|
||||||
|
and _text_of(w.content).startswith("[done]")
|
||||||
)
|
)
|
||||||
assert done.style == "#16B866" # Aurora green
|
assert done.style == "#16B866" # Aurora green
|
||||||
|
|
||||||
async def test_empty_state_placeholders_present(self) -> None:
|
async def test_empty_state_placeholders_present(self) -> None:
|
||||||
"""empty_state_placeholders_present [v0.5.1]: tools-log + debug-log show
|
"""empty_state_placeholders_present [v0.5.1]: tools-transcript + debug-transcript show
|
||||||
placeholder lines before any turn fires."""
|
placeholder lines before any turn fires."""
|
||||||
from textual.widgets import RichLog
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
@@ -1059,11 +1133,14 @@ 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.8.1]:
|
"""happy_text_done_no_double_print [happy,tracer, v0.9.0]:
|
||||||
Text("hello") buffers in text_chunk_buffer (no `\\n`); on Done,
|
Text("hello") mounts a Static(Markdown("hello")) into the transcript;
|
||||||
flushes "hello" tail to transcript, then [done] label, then Rule
|
Done mounts a [done] label Static. The Markdown is rendered live (one
|
||||||
+ Markdown body.
|
widget for the whole stream, updated in place), so there is NO
|
||||||
|
post-Done re-render — exactly ONE Markdown renderable lands in the
|
||||||
|
transcript for the response body. v0.9.0 supersedes v0.8.2's
|
||||||
|
drop-Markdown patch with proper live rendering.
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -1080,22 +1157,26 @@ class TestStreamTurnWorker:
|
|||||||
await _submit_and_wait(app, pilot, "hi")
|
await _submit_and_wait(app, pilot, "hi")
|
||||||
assert app.state == "idle"
|
assert app.state == "idle"
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.rule import Rule
|
|
||||||
|
|
||||||
# v0.8.1: "hello" appears in transcript as a tail-flush on Done.
|
# The response body lives as ONE Markdown renderable mounted into
|
||||||
assert any(w == "hello" for w in writes)
|
# the transcript; live updates happen via Static.update, not via
|
||||||
assert any("[done]" in str(w) for w in writes)
|
# re-mount, so there's exactly one Markdown in the spy stream.
|
||||||
# Post-Done: Markdown body + Rule + turn-header Rule all present.
|
markdowns = [w for w in writes if isinstance(w, Markdown)]
|
||||||
assert any(isinstance(w, Markdown) for w in writes)
|
assert len(markdowns) == 1, (
|
||||||
assert any(isinstance(w, Rule) for w in writes)
|
f"v0.9.0: expected exactly ONE Markdown mounted, got {len(markdowns)}"
|
||||||
|
)
|
||||||
|
assert markdowns[0].markup == "hello"
|
||||||
|
# [done] label fires too.
|
||||||
|
assert any("[done]" in _text_of(w) for w in writes)
|
||||||
|
|
||||||
@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:
|
||||||
"""raw_flag_skips_markdown_render [trace, v0.6.0]:
|
"""raw_flag_skips_markdown_render [trace, v0.9.0]:
|
||||||
With --raw, no Markdown render. A turn-header Rule IS still written
|
With --raw, the response widget holds plain str instead of Markdown.
|
||||||
(v0.6.0 INV — turn correlation lives in every pane). The post-Done
|
Turn-header markers still appear in every pane: 3 RichLog panes
|
||||||
Rule(separator) is suppressed; accumulated streamed text is written
|
receive a Rule, the transcript-scroll receives a Static-wrapped
|
||||||
as a plain string instead.
|
RichText (mounted, not written), giving 3 Rules in the captured
|
||||||
|
writes list.
|
||||||
"""
|
"""
|
||||||
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
stream = _sse_chunk("42:1", {"type": "text", "content": "hi"}) + _sse_chunk(
|
||||||
"42:2", _DONE_BODY
|
"42:2", _DONE_BODY
|
||||||
@@ -1113,11 +1194,11 @@ class TestStreamTurnWorker:
|
|||||||
|
|
||||||
# No Markdown in raw mode.
|
# No Markdown in raw mode.
|
||||||
assert not any(isinstance(w, Markdown) for w in writes)
|
assert not any(isinstance(w, Markdown) for w in writes)
|
||||||
# Only turn-header Rules — one per pane (transcript + tools +
|
# 3 Rules — one per RichLog pane (tools / debug / thinking).
|
||||||
# debug + thinking = 4). No post-Done separator Rule.
|
# Transcript-scroll uses a Static turn-header Markdown alternative.
|
||||||
rules = [w for w in writes if isinstance(w, Rule)]
|
rules = [w for w in writes if isinstance(w, Rule)]
|
||||||
assert len(rules) == 4, f"expected 4 turn-header Rules, got {len(rules)}"
|
assert len(rules) == 3, f"expected 3 turn-header Rules, got {len(rules)}"
|
||||||
# Accumulated text "hi" written as plain string post-Done.
|
# Accumulated text "hi" mounted as plain str into transcript.
|
||||||
assert "hi" in writes
|
assert "hi" in writes
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@@ -1491,8 +1572,14 @@ class TestActionInterrupt:
|
|||||||
await pilot.pause(0.02)
|
await pilot.pause(0.02)
|
||||||
# Give _cancel_via_sse time to write the [cancel_failed] line
|
# Give _cancel_via_sse time to write the [cancel_failed] line
|
||||||
await pilot.pause(0.05)
|
await pilot.pause(0.05)
|
||||||
log = app.query_one("#transcript", RichLog)
|
from textual.containers import VerticalScroll
|
||||||
rendered = "\n".join(str(strip.text) for strip in log.lines)
|
from textual.widgets import Static
|
||||||
|
transcript = app.query_one("#transcript-scroll", VerticalScroll)
|
||||||
|
rendered = "\n".join(
|
||||||
|
str(child.content)
|
||||||
|
for child in transcript.children
|
||||||
|
if isinstance(child, Static)
|
||||||
|
)
|
||||||
assert "[cancel_failed]" in rendered
|
assert "[cancel_failed]" in rendered
|
||||||
assert app.state == "cancelling"
|
assert app.state == "cancelling"
|
||||||
stream_gate.set() # let stream finish for teardown
|
stream_gate.set() # let stream finish for teardown
|
||||||
|
|||||||
Reference in New Issue
Block a user