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:
+129
-36
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user