feat(#20): rewire the CLI turn path onto the wt adapter (slice-2, part 2b-i)

The --send turn path (_amain create + _run_turn stream + _cancel_and_log) now goes
through ratatoskr.wt over the worldtree-sdk; external CLI behavior (output, exit
codes) is preserved. No hand-rolled path is deleted yet — web/server.py still uses
them (part 2b-ii), so the deletions + live smoke come after web is rewired.

- _amain builds one WorldtreeClient via wt.build_client over a ratatoskr-owned
  transport (INV-CUT-1); create → wt.create_session (reads the SDK's open create
  dict); the transport keeps the default bearer so the not-yet-migrated hand-rolled
  seed_preset_first_message (slice-3) still authenticates.
- _run_turn drives wt.stream_turn and consumes SDK TurnEvents; the mid-stream cancel
  target is parsed from the composite sse_id ("{turn}:{seq}") — the SDK's top-level
  turn_id is the body field and is absent on text/thinking frames.
- CliPresenterState.render consumes the SDK TurnEvent union with None-hardening on
  the now-optional fields (usage degrades to "(n/a)" rather than crashing).
- The SDK normalizes a pre-response transport failure to ConnectFailed(status=0);
  _amain (network → exit 21) and _cancel_and_log (swallow, INV-009) catch it.
- build_client gains max_reconnects (SDK default 5; tests pass 0 to surface drops
  immediately). test_cli: SDK-event factories keep the render-test bodies intact;
  client constructions wrap in build_client; cancel-race mocks carry the SDK's
  (status, error_code) pair.

Suite 570 green; cli.py + wt.py mypy + ruff clean (the pre-existing send_content
arg-type note is unchanged). Patch (internal; external CLI behavior preserved).
This commit is contained in:
vh
2026-07-19 06:07:50 -07:00
parent b907a7b8a5
commit e3a10ad80e
5 changed files with 272 additions and 104 deletions
+129 -36
View File
@@ -7,8 +7,10 @@ import json
import httpx
import pytest
import respx
from worldtree_sdk.events import build_event
from ratatoskr import cli as cli_mod
from ratatoskr import wt
from ratatoskr.cli import (
ParsedArgs,
UsageError,
@@ -20,18 +22,7 @@ from ratatoskr.cli import (
main,
)
from ratatoskr.sessions import BifrostBinding
from ratatoskr.sse_client import (
Cancelled,
Done,
Error,
SseId,
Text,
TextBoundary,
Thinking,
ToolResult,
ToolStart,
WorkerPhase,
)
from ratatoskr.sse_client import SseId
class _FlushCountingIO(io.StringIO):
@@ -333,6 +324,83 @@ SID = SseId(42, 5)
SID42 = SseId(42, 1)
# ── SDK-event factories ──────────────────────────────────────────────────────
# The presenter now consumes worldtree-sdk `TurnEvent`s. These build them exactly
# as the SDK's parser does (via `build_event` from the raw envelope), preserving
# the old dataclass call shapes so the render-test bodies stay unchanged. `sse_id`
# is a parsed `SseId` here purely to keep the terse SID42 idiom; the SDK carries the
# composite id as a string and turn_id top-level.
def _sid_str(sse_id: SseId) -> str:
return f"{sse_id.turn_id}:{sse_id.seq}"
def Thinking(*, sse_id: SseId, content: str) -> object:
return build_event("thinking", _sid_str(sse_id), sse_id.turn_id, {"content": content})
def Text(*, sse_id: SseId, content: str) -> object:
return build_event("text", _sid_str(sse_id), sse_id.turn_id, {"content": content})
def WorkerPhase(*, sse_id: SseId, phase: str, turn_id: int) -> object:
return build_event("worker_phase", _sid_str(sse_id), turn_id, {"phase": phase})
def TextBoundary(*, sse_id: SseId, kind: str, char_offset: int, ts: str) -> object:
return build_event(
"text_boundary", _sid_str(sse_id), sse_id.turn_id,
{"kind": kind, "char_offset": char_offset, "ts": ts},
)
def ToolStart(*, sse_id: SseId, name: str, arguments: object) -> object:
return build_event(
"tool_start", _sid_str(sse_id), sse_id.turn_id, {"name": name, "arguments": arguments}
)
def ToolResult(*, sse_id: SseId, name: str, result: object, duration_ms: int) -> object:
return build_event(
"tool_result", _sid_str(sse_id), sse_id.turn_id,
{"name": name, "result": result, "duration_ms": duration_ms},
)
def Done(
*, sse_id: SseId, phase: str, response: str, model: str, duration_ms: int, usage: object
) -> object:
return build_event(
"done", _sid_str(sse_id), sse_id.turn_id,
{"phase": phase, "response": response, "model": model,
"duration_ms": duration_ms, "usage": usage},
)
def Error(*, sse_id: SseId, phase: str, message: str, error_code: str) -> object:
return build_event(
"error", _sid_str(sse_id), sse_id.turn_id,
{"phase": phase, "message": message, "error_code": error_code},
)
def Cancelled(
*, sse_id: SseId, phase: str, turn_id: int, reason: object, partial_message_id: object
) -> object:
return build_event(
"cancelled", _sid_str(sse_id), turn_id,
{"phase": phase, "reason": reason, "partial_message_id": partial_message_id},
)
def _wtc(transport: httpx.AsyncClient) -> object:
"""The adapter's WorldtreeClient over a respx-mocked transport. Reconnects are
disabled (max_reconnects=0) so a transport drop surfaces immediately instead of
burning the resilient retry budget with real backoff sleeps."""
return wt.build_client(
"https://w.example", api_key="k", transport=transport, max_reconnects=0
)
class TestCliPresenterState:
"""Tests for the new CliPresenterState — per issue #12 contract."""
@@ -687,7 +755,7 @@ _USAGE_ZERO: dict[str, int] = {
}
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> Done:
def _make_done(*, duration_ms: int = 1, usage: dict[str, int] | None = None) -> object:
return Done(
sse_id=SID42,
phase="succeeded",
@@ -709,7 +777,8 @@ class TestCancelAndLog:
)
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
result = await _cancel_and_log(client, "s-1", 42, stderr=stderr)
assert result is None
assert stderr.getvalue() == ""
@@ -721,7 +790,8 @@ class TestCancelAndLog:
return_value=httpx.Response(500, content=b"boom")
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -730,11 +800,14 @@ class TestCancelAndLog:
@respx.mock
async def test_cancel_already_completed(self) -> None:
"""cancel_already_completed [scenario]: …"""
# SDK gates the race on the (status, error_code) PAIR (B-CAN-3): 409 alone is
# a generic CancelFailed; 409 + turn_finished is the double-cancel race.
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(409)
return_value=httpx.Response(409, json={"error_code": "turn_finished"})
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -743,11 +816,13 @@ class TestCancelAndLog:
@respx.mock
async def test_cancel_turn_not_found(self) -> None:
"""cancel_turn_not_found [scenario]: 404 → returns None; stderr CancelTurnNotFound."""
# 404 + turn_not_found is the benign "finished before cancel arrived" race.
respx.post("https://w.example/sessions/s-1/turns/42/cancel").mock(
return_value=httpx.Response(404)
return_value=httpx.Response(404, json={"error_code": "turn_not_found"})
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
assert "[cancel_failed]" in out
@@ -760,11 +835,14 @@ class TestCancelAndLog:
side_effect=httpx.ConnectError("network down")
)
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
await _cancel_and_log(client, "s-1", 42, stderr=stderr)
out = stderr.getvalue()
# The SDK normalizes a transport drop to ConnectFailed(status=0); _cancel_and_log
# swallows it (INV-009) and logs the normalized type.
assert "[cancel_failed]" in out
assert "ConnectError" in out
assert "ConnectFailed" in out
class _GatedStream(httpx.AsyncByteStream):
@@ -797,7 +875,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout = io.StringIO()
stderr = io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 0
assert stdout.getvalue() == "hello\n"
@@ -820,7 +899,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 2
assert "[error]" in stderr.getvalue()
@@ -836,7 +916,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 3
assert "[cancelled]" in stderr.getvalue()
@@ -849,7 +930,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(
client, "missing", "hi", sigint, stdout=stdout, stderr=stderr
)
@@ -882,7 +964,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 21
assert "[connection_dropped]" in stderr.getvalue()
@@ -896,7 +979,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
assert "[malformed_sse_id]" in stderr.getvalue()
@@ -913,7 +997,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -933,7 +1018,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -955,7 +1041,8 @@ class TestRunTurn:
)
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 22
out = stderr.getvalue()
@@ -978,7 +1065,8 @@ class TestRunTurn:
sigint = asyncio.Event()
sigint.set() # SIGINT before _run_turn even starts
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await asyncio.wait_for(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr), timeout=2.0
)
@@ -1007,7 +1095,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1045,7 +1134,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1091,7 +1181,8 @@ class TestRunTurn:
monkeypatch.setattr(sigint, "wait", counting_wait)
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1129,7 +1220,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
task = asyncio.create_task(
_run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
)
@@ -1176,7 +1268,8 @@ class TestRunTurn:
sigint = asyncio.Event()
stdout, stderr = io.StringIO(), io.StringIO()
async with httpx.AsyncClient(base_url="https://w.example") as client:
async with httpx.AsyncClient(base_url="https://w.example") as _tp:
client = _wtc(_tp)
exit_code = await _run_turn(client, "s-1", "hi", sigint, stdout=stdout, stderr=stderr)
assert exit_code == 0
assert call_count == 3