diff --git a/docs/contracts/donut_voiced_interview.contract.md b/docs/contracts/donut_voiced_interview.contract.md index 6db9955..00220b0 100644 --- a/docs/contracts/donut_voiced_interview.contract.md +++ b/docs/contracts/donut_voiced_interview.contract.md @@ -40,6 +40,19 @@ confidence: 0.8 # Contract: Donut voiced interview (auto-TTS + KB-recall bridge) +> **⚠ SLICE 3 (KB-recall bridge) RETIRED 2026-08-02.** The `kb_bridge.py` module + +> its single `web/server.py` call-site were deleted per INV-KB-1 when Worldtree #383 +> shipped native Tier-3 `reference_knowledge` (v1.0.0b167, live on :8081 + demo). +> Donut now searches the fiction wing (DCC corpus) natively, in-turn, with evidence +> packets (note_id + path provenance, confidence bucket) and a server-side grounding +> rule — strictly better than the consumer-side memory_context pinning it replaced +> (no separate consult round-trip, not gated by our out-of-band prompt). Retirement +> live-verified: Donut called `reference_knowledge` and grounded in DCC in-voice +> before deletion. `wt.stream_turn`'s `memory_context` param was KEPT (inert SDK +> parity). The DEC-6 / INV-KB-* / FN pin_kb_context sections below are retained as +> historical record of what was built and why it retired. **Slices 1 (persona) + 2 +> (auto-TTS) remain LIVE.** + Migration-style contract: three separable slices (persona / TTS / KB-bridge), each independently shippable. Slice order is chosen for fastest visible result. diff --git a/src/ratatoskr/kb_bridge.py b/src/ratatoskr/kb_bridge.py deleted file mode 100644 index 0a74cef..0000000 --- a/src/ratatoskr/kb_bridge.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Retire-ready KB-recall bridge (slice 3 of the Donut voiced-interview contract). - -Tier-3 agents are DELIBERATELY tool-less in Worldtree v1 (build_tier3_agent_context -hard-codes tool_schemas=[]); a character cannot search the corpus herself. This is -the consumer-side workaround (DEC-6, worldtree-dev ruling, wyrd-proven): per interview -turn, ratatoskr consults Mimir (the Well-of-Knowledge agent) out-of-band, extracts the -grounded passages, and pins them as `memory_context` on the character's turn. She -answers in-voice, framing the pinned corpus as her own memory (the persona prompt -instructs exactly this). - -RETIRE-READY (INV-KB-1): isolated behind ONE seam — `pin_kb_context` — called from -exactly one call-site (web/server.py's turn path). Retiring the bridge = delete this -module + that call-site; `wt.stream_turn`'s `memory_context` param stays (SDK-parity, -inert). This module imports nothing from the TTS or SDK-adapter core -(`ratatoskr.wt` / `ratatoskr.tts`); it drives the passed-in WorldtreeClient directly. -Delete when Worldtree #361 `reference_knowledge` extends native retrieval to Tier-3. - -Foot-gun (verified live 2026-08-02): an UNCONSTRAINED Mimir consult loops -read_note↔search_library and can run >60s without converging. The CONSTRAINED -"one search, passages verbatim, no iterative reading" prompt converges in ~10s. The -consult is HARD-bounded by a timeout and degrades to [] (INV-KB-3), so a slow/looping -consult never hangs the character's turn. -""" - -from __future__ import annotations - -import asyncio -from contextlib import aclosing - -from worldtree_sdk import DoneEvent, TextEvent, WorldtreeClient - -# INV-KB-4: only interview characters that need corpus recall pay the retrieval -# round-trip. Maps each opted-in agent_id → the corpus name that scopes the search. -_KB_INTERVIEW_CORPORA = {"ratatoskr:donut": "Dungeon Crawler Carl"} - -# The KB agent driven for retrieval (Worldtree's Well-of-Knowledge custodian). -_MIMIR_AGENT_ID = "mimir" -# A fixed partition for the out-of-band consult, isolated from the character's own -# end-user partition. The retrieval channel is never persisted (INV-KB-2): the -# consult is throwaway and its result rides only the character's turn POST body. -_KB_BRIDGE_END_USER = "ratatoskr-kb-bridge" -# Hard ceiling on the consult (degrade to [] past it). The single-search prompt -# below converges in ~3-4s live; the headroom absorbs gateway variance. An -# unconstrained consult loops read_note↔search_library and blows well past this. -_CONSULT_TIMEOUT_S = 20.0 -# Bound the pinned extract; a whole-note dump would bloat the character's prompt. -_MAX_EXTRACT_CHARS = 2000 -# A no-hit sentinel the consult prompt asks Mimir to emit when the search finds nothing -# relevant, so a non-empty "no results found" answer isn't pinned as corpus evidence and -# voiced as the character's own memory (heid-code-review F7). The token is artificial — -# no real corpus passage contains it — so a substring check is safe, not a fragile heuristic. -_NO_MATCH_SENTINEL = "NO_CORPUS_MATCH" - - -def _consult_prompt(question: str, corpus: str) -> str: - """The CONSTRAINED consult (the foot-gun mitigation, tuned live 2026-08-02): - force EXACTLY ONE search_library call and forbid read_note. This is what makes - Mimir converge in ~3-4s — the softer "do one search" phrasing still let it loop - read_note↔search past the 25s ceiling on conversational (non-keyword) questions.""" - return ( - f"You may call search_library EXACTLY ONCE and call no other tool " - f"(do NOT call read_note). Query: {question} (corpus: {corpus}). " - f"After the single search returns, immediately output the most relevant " - f"result excerpts verbatim and stop. If the search returns nothing relevant, " - f"reply with exactly {_NO_MATCH_SENTINEL} and nothing else." - ) - - -async def _run_consult(client: WorldtreeClient, prompt: str, created: list[str]) -> str: - """Drive one throwaway Mimir turn and return its grounded answer text. Prefers - the DoneEvent's authoritative full `response`, falling back to the accumulated - text deltas if the terminal event carried none. - - `created` is a caller-owned list this appends the new session id to as soon as it - exists, so the caller can delete the session even when a timeout cancels this - coroutine mid-stream (the id would otherwise be lost in this frame).""" - session = await client.sessions.create( - {"agent_id": _MIMIR_AGENT_ID, "end_user_id": _KB_BRIDGE_END_USER} - ) - session_id = session["session_id"] - created.append(session_id) - parts: list[str] = [] - final = "" - # aclosing: we break on DoneEvent without exhausting the stream; deterministically - # close the SDK generator so it can't finalize mid-flight against a closing - # transport (the "async generator already running" race the live smoke surfaced). - async with aclosing(client.sessions.stream_turn(session_id, prompt)) as stream: - async for ev in stream: - if isinstance(ev, TextEvent) and isinstance(ev.content, str): - parts.append(ev.content) - elif isinstance(ev, DoneEvent): - if isinstance(ev.response, str) and ev.response.strip(): - final = ev.response - break - return (final or "".join(parts)).strip() - - -async def pin_kb_context( - question: str, agent_id: str | None, *, client: WorldtreeClient -) -> list[dict]: - """Consumer-side retrieval + memory_context pinning (FN pin_kb_context / DEC-6). - - Returns memory_context items for the character's turn, or [] on any miss. Total: - an allowlist miss, blank question, empty/failed/timed-out consult, or ANY - exception → [] — never raises to the turn path (INV-KB-3), never blocks the turn. - CancelledError (a browser disconnect) is intentionally NOT caught — it propagates. - """ - corpus = _KB_INTERVIEW_CORPORA.get(agent_id or "") - if corpus is None: # INV-KB-4: not an opted-in interview character - return [] - if not isinstance(question, str) or not question.strip(): - return [] - created: list[str] = [] - try: - extract = await asyncio.wait_for( - _run_consult(client, _consult_prompt(question, corpus), created), - timeout=_CONSULT_TIMEOUT_S, - ) - except Exception: - # timeout / transport / protocol / any consult failure → degrade silently - # (INV-KB-3). CancelledError is a BaseException, so it is NOT swallowed here. - extract = "" - finally: - # Delete the throwaway consult session so they don't accumulate server-side under - # the fixed partition (heid bug-hunt). Runs on success, error, AND timeout — the - # timeout is caught above, so this executes in a non-cancelled context and the - # delete completes. Best-effort; a delete failure never masks the result. On a - # propagating CancelledError (browser disconnect) the session may outlive us — - # acceptable, Worldtree GCs it; the invariant is that CancelledError still propagates. - for sid in created: - try: - await client.sessions.delete(sid) - except Exception: - pass - if not extract or _NO_MATCH_SENTINEL in extract: - # Empty, OR Mimir signalled no relevant hit — don't pin a non-answer as memory - # (heid-code-review F7). The sentinel is artificial, so `in` can't false-positive - # on a genuine passage. - return [] - return [{"kind": "corpus_reference", "text": extract[:_MAX_EXTRACT_CHARS]}] diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py index 9698ddb..661294c 100644 --- a/src/ratatoskr/web/server.py +++ b/src/ratatoskr/web/server.py @@ -37,7 +37,6 @@ from worldtree_sdk import ( from ratatoskr import local_agents as _local_agents from ratatoskr import wt from ratatoskr.first_message import seed_preset_first_message -from ratatoskr.kb_bridge import pin_kb_context from ratatoskr.sessions import ( AgentNotAvailable, AgentNotFound, @@ -272,10 +271,6 @@ class TurnHandle: content: str status: str = "queued" # queued | streaming | done | error | cancelled upstream_turn_id: int | None = None - # The browser-selected agent for this session — carried so the turn path can gate - # the retire-ready KB-recall bridge on the interview-character allowlist (INV-KB-4). - # Optional: absent (older client / non-character turn) simply means no KB consult. - agent_id: str | None = None # Process-local monotonic turn_id counter. Per FN submit_turn_endpoint @@ -294,19 +289,13 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse: body = await request.json() content = body.get("content") if isinstance(body, dict) else None if not content or not isinstance(content, str): - # Require a non-blank STRING: a truthy non-str (e.g. {"content": {...}}) would - # pass a bare falsy check, then crash pin_kb_context's question.strip() mid-stream - # instead of a deterministic 400 (heid bug-hunt: non-str content). + # Require a non-blank STRING; a truthy non-str (e.g. {"content": {...}}) gets a + # deterministic 400 rather than reaching the SDK turn call as a bad type. return JSONResponse({"error_code": "missing_content"}, status_code=400) session_id = request.path_params["session_id"] - # The browser may include agent_id so the stream path can gate the KB-recall - # bridge (INV-KB-4); optional — absent → no consult (a non-str is ignored). - agent_id = body.get("agent_id") if isinstance(body, dict) else None - if not isinstance(agent_id, str): - agent_id = None turn_id = next(_TURN_COUNTER) request.app.state.turn_registry[(session_id, turn_id)] = TurnHandle( - session_id=session_id, turn_id=turn_id, content=content, agent_id=agent_id, + session_id=session_id, turn_id=turn_id, content=content, ) return JSONResponse({"turn_id": turn_id}, status_code=200) @@ -377,24 +366,8 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse: wt_client = _wt_client(client) try: handle.status = "streaming" - # Open the SSE stream immediately with a comment frame BEFORE the KB consult - # (which can take up to _CONSULT_TIMEOUT_S). Without a first byte, a reverse - # proxy or the browser EventSource can drop a silent connection → a false - # "WIRE LOST" before the real turn even starts (heid bug-hunt). SSE comment - # frames (":"-prefixed) are ignored by EventSource, so this is inert to the UI. - yield b": keepalive\n\n" - # Retire-ready KB-recall bridge (INV-KB-1: THE single call-site). Consumer-side - # Mimir retrieval for opted-in interview characters (INV-KB-4); [] for everyone - # else. Bounded + degrade-to-[] internally (INV-KB-3), so it never blocks or - # fails the turn — a miss just streams the turn with no pinned corpus context. - memory_context = await pin_kb_context( - handle.content, handle.agent_id, client=wt_client - ) try: - async for event in wt.stream_turn( - wt_client, session_id, handle.content, - memory_context=memory_context or None, - ): + async for event in wt.stream_turn(wt_client, session_id, handle.content): # v0.16.0: capture the upstream (Worldtree-assigned) turn_id from # the first event so cancel paths target the real upstream turn, # not our local counter — parsed from the composite sse_id. diff --git a/src/ratatoskr/web/static/index.html b/src/ratatoskr/web/static/index.html index da56797..788e2a9 100644 --- a/src/ratatoskr/web/static/index.html +++ b/src/ratatoskr/web/static/index.html @@ -1715,7 +1715,7 @@ async function submitPrompt() { try { r = await fetch("/api/turns/" + encodeURIComponent(state.sessionId), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content, agent_id: state.agentId || null }), + body: JSON.stringify({ content }), }); } catch (e) { tickerAdd("err", "submit failed", "network_error"); return; } if (r.status !== 200) { tickerAdd("err", "submit failed", "status=" + r.status); return; } diff --git a/tests/test_kb_bridge.py b/tests/test_kb_bridge.py deleted file mode 100644 index 57ea40f..0000000 --- a/tests/test_kb_bridge.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Tests for ratatoskr.kb_bridge per docs/contracts/donut_voiced_interview.contract.md -(slice 3 — the retire-ready KB-recall bridge, FN pin_kb_context). - -The bridge drives a passed-in WorldtreeClient; unit tests use a fake client + real -SDK event instances. No live network. - -Invariants exercised: - - INV-KB-3: any miss / failure / timeout / exception → [], never raises to the turn. - - INV-KB-4: only the opted-in interview character(s) consult; others short-circuit. - - The consult prompt is the CONSTRAINED shape (the >60s-loop foot-gun mitigation). -""" - -import asyncio - -import pytest -from worldtree_sdk import DoneEvent, TextEvent - -from ratatoskr import kb_bridge -from ratatoskr.kb_bridge import pin_kb_context - -_DONUT = "ratatoskr:donut" - - -def _text(s: str) -> TextEvent: - return TextEvent(type="text", sse_id="2:1", turn_id=2, raw={"content": s}, content=s) - - -def _done(response: str = "") -> DoneEvent: - return DoneEvent( - type="done", sse_id="2:9", turn_id=2, raw={}, phase="Finishing", - response=response, model="gen", duration_ms=1, usage={}, - ) - - -class _FakeSessions: - def __init__(self, *, events=None, create_result=None, stream_error=None, - create_error=None, stream_delay=0.0): - self.events = events or [] - self.create_result = create_result or {"session_id": "kb-sess-1"} - self.stream_error = stream_error - self.create_error = create_error - self.stream_delay = stream_delay - self.calls: list = [] - self.deleted: list = [] - - async def create(self, body=None, **kw): - self.calls.append(("create", body, kw)) - if self.create_error is not None: - raise self.create_error - return self.create_result - - async def delete(self, session_id): - self.deleted.append(session_id) - - def stream_turn(self, *args, **kw): - self.calls.append(("stream_turn", args, kw)) - return self._gen() - - async def _gen(self): - if self.stream_delay: - await asyncio.sleep(self.stream_delay) - for e in self.events: - yield e - if self.stream_error is not None: - raise self.stream_error - - -class _FakeClient: - def __init__(self, sessions: _FakeSessions): - self.sessions = sessions - - -class TestAllowlistGate: - async def test_unlisted_agent_returns_empty_without_consulting(self) -> None: - fake = _FakeSessions(events=[_text("x"), _done("x")]) - out = await pin_kb_context("who is Carl?", "mimir", client=_FakeClient(fake)) - assert out == [] - assert fake.calls == [] # INV-KB-4: no consult for a non-interview agent - - async def test_none_agent_returns_empty(self) -> None: - fake = _FakeSessions() - out = await pin_kb_context("q", None, client=_FakeClient(fake)) - assert out == [] and fake.calls == [] - - async def test_blank_question_returns_empty(self) -> None: - fake = _FakeSessions() - out = await pin_kb_context(" ", _DONUT, client=_FakeClient(fake)) - assert out == [] and fake.calls == [] - - async def test_non_string_question_returns_empty(self) -> None: - # A non-str question (e.g. a dict slipping past an upstream guard) must not - # crash on .strip() — it degrades to [] like any other miss. - fake = _FakeSessions() - out = await pin_kb_context({"x": 1}, _DONUT, client=_FakeClient(fake)) - assert out == [] and fake.calls == [] - - -class TestConsult: - async def test_happy_pins_done_response_as_corpus_reference(self) -> None: - fake = _FakeSessions(events=[_text("partial "), _done("The full grounded passage.")]) - out = await pin_kb_context("what happened?", _DONUT, client=_FakeClient(fake)) - assert out == [{"kind": "corpus_reference", "text": "The full grounded passage."}] - - async def test_falls_back_to_text_deltas_when_no_done_response(self) -> None: - fake = _FakeSessions(events=[_text("alpha "), _text("beta"), _done("")]) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert out == [{"kind": "corpus_reference", "text": "alpha beta"}] - - async def test_constrained_consult_prompt_and_mimir_target(self) -> None: - fake = _FakeSessions(events=[_done("p")]) - await pin_kb_context("who is Princess Donut?", _DONUT, client=_FakeClient(fake)) - create_body = fake.calls[0][1] - assert create_body["agent_id"] == "mimir" - stream_prompt = fake.calls[1][1][1] # ("stream_turn", (sid, prompt), {}) - # The foot-gun mitigation: EXACTLY one search_library call, and read_note FORBIDDEN. - # Assert the distinguishing phrase, not the bare token "read_note" — a mitigation- - # inverted prompt ("DO call read_note") would still contain the token (heid-code-review). - assert "EXACTLY ONCE" in stream_prompt - assert "do NOT call read_note" in stream_prompt # forbids the loop tool (negation) - assert "Dungeon Crawler Carl" in stream_prompt # corpus scoping - assert "who is Princess Donut?" in stream_prompt # the question - assert "NO_CORPUS_MATCH" in stream_prompt # the no-hit sentinel instruction - - async def test_empty_consult_returns_empty(self) -> None: - fake = _FakeSessions(events=[_text(" "), _done(" ")]) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert out == [] - - async def test_no_match_sentinel_pins_nothing(self) -> None: - # A non-empty "no results" answer carrying the sentinel must NOT be pinned as - # corpus memory (heid-code-review F7) — even wrapped in other prose. - fake = _FakeSessions(events=[_done("I searched but found NO_CORPUS_MATCH here.")]) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert out == [] - - async def test_extract_bounded_to_max_chars(self) -> None: - long = "x" * 5000 - fake = _FakeSessions(events=[_done(long)]) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - # Assert the concrete bound (2000), not the impl constant it's sliced by — a - # mutated constant must fail this (heid-code-review: non-circular assertion). - assert len(out[0]["text"]) == 2000 - - -class TestDegradation: - async def test_stream_error_degrades_to_empty(self) -> None: - fake = _FakeSessions(events=[_text("x")], stream_error=RuntimeError("boom")) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert out == [] # INV-KB-3: never raises to the turn path - - async def test_create_error_degrades_to_empty(self) -> None: - fake = _FakeSessions(create_error=RuntimeError("no session")) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert out == [] - - async def test_timeout_degrades_to_empty(self, monkeypatch) -> None: - monkeypatch.setattr(kb_bridge, "_CONSULT_TIMEOUT_S", 0.05) - fake = _FakeSessions(events=[_done("late")], stream_delay=5.0) - out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert out == [] # a slow/looping consult never hangs the turn - - async def test_cancellation_is_not_swallowed(self) -> None: - # CancelledError (a browser disconnect) MUST propagate, not degrade to []. - fake = _FakeSessions(create_error=asyncio.CancelledError()) - with pytest.raises(asyncio.CancelledError): - await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - - -class TestSessionCleanup: - """The throwaway Mimir consult session is deleted so consults don't accumulate - server-side (heid bug-hunt) — on success, error, AND timeout.""" - - async def test_deletes_session_on_success(self) -> None: - fake = _FakeSessions(events=[_done("grounded")]) - await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert fake.deleted == ["kb-sess-1"] - - async def test_deletes_session_on_stream_error(self) -> None: - fake = _FakeSessions(events=[_text("x")], stream_error=RuntimeError("boom")) - await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert fake.deleted == ["kb-sess-1"] # cleaned up despite the failure - - async def test_deletes_session_on_timeout(self, monkeypatch) -> None: - monkeypatch.setattr(kb_bridge, "_CONSULT_TIMEOUT_S", 0.05) - fake = _FakeSessions(events=[_done("late")], stream_delay=5.0) - await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - # The holder pattern makes the id available even though the timeout cancelled - # the consult mid-stream; the finally deletes it in the caught (non-cancelled) path. - assert fake.deleted == ["kb-sess-1"] - - async def test_no_delete_when_create_failed(self) -> None: - # Nothing was created → nothing to delete (created list stays empty). - fake = _FakeSessions(create_error=RuntimeError("no session")) - await pin_kb_context("q", _DONUT, client=_FakeClient(fake)) - assert fake.deleted == [] diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 0e813a4..1b44f58 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -1575,85 +1575,3 @@ class TestTtsEndpoint: ) assert resp.status_code == 200 - -class TestKbBridgeWiring: - """The KB-recall bridge seam (slice 3, INV-KB-1): the turn path calls - pin_kb_context and forwards its result as memory_context on the turn POST. - Proves the ONE call-site is wired end-to-end (the consult itself is unit-tested - in test_kb_bridge.py; here we stub it to assert the plumbing).""" - - @respx.mock - def test_interview_agent_pins_context_into_turn_post(self, monkeypatch) -> None: - from ratatoskr.web import server - from ratatoskr.web.server import create_app - - seen = {} - - async def _fake_pin(question, agent_id, *, client): - seen["question"] = question - seen["agent_id"] = agent_id - return [{"kind": "corpus_reference", "text": "grounded passage"}] - - monkeypatch.setattr(server, "pin_kb_context", _fake_pin) - route = respx.post("https://w.example/sessions/s-1/messages").mock( - return_value=_sse_resp(_sse_chunk("42:1", _DONE_BODY)) - ) - c = TestClient(create_app(_mock_client_factory())) - tid = c.post( - "/api/turns/s-1", - json={"content": "who is Carl?", "agent_id": "ratatoskr:donut"}, - ).json()["turn_id"] - with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp: - b"".join(resp.iter_bytes()) - # The browser-sent agent_id reached the bridge, and its result rode the POST. - assert seen == {"question": "who is Carl?", "agent_id": "ratatoskr:donut"} - body = json.loads(route.calls.last.request.content) - assert body["memory_context"] == [ - {"kind": "corpus_reference", "text": "grounded passage"} - ] - - @respx.mock - def test_non_interview_agent_pins_nothing(self) -> None: - # Real pin_kb_context: a non-allowlisted agent short-circuits to [] (no consult), - # so memory_context is absent from the turn POST body (SDK omits None). - from ratatoskr.web.server import create_app - - route = respx.post("https://w.example/sessions/s-1/messages").mock( - return_value=_sse_resp(_sse_chunk("42:1", _DONE_BODY)) - ) - c = TestClient(create_app(_mock_client_factory())) - tid = c.post( - "/api/turns/s-1", json={"content": "hi", "agent_id": "mimir"} - ).json()["turn_id"] - with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp: - b"".join(resp.iter_bytes()) - body = json.loads(route.calls.last.request.content) - assert "memory_context" not in body or body["memory_context"] is None - - @respx.mock - def test_failed_consult_still_streams_the_turn(self, monkeypatch) -> None: - # A degraded/failed KB consult (returns []) must NOT block or fail the character - # turn — it streams to `done` with no pinned context (INV-KB-3, server-level; - # heid-code-review F9: the turn-path failure seam was untested end to end). - from ratatoskr.web import server - from ratatoskr.web.server import create_app - - async def _degraded_pin(question, agent_id, *, client): - return [] # consult failed/timed-out internally → degraded to [] - - monkeypatch.setattr(server, "pin_kb_context", _degraded_pin) - respx.post("https://w.example/sessions/s-1/messages").mock( - return_value=_sse_resp( - _sse_chunk("42:1", {"type": "text", "content": "hi"}) - + _sse_chunk("42:2", _DONE_BODY) - ) - ) - c = TestClient(create_app(_mock_client_factory())) - tid = c.post( - "/api/turns/s-1", - json={"content": "who is Carl?", "agent_id": "ratatoskr:donut"}, - ).json()["turn_id"] - with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp: - raw = b"".join(resp.iter_bytes()) - events = [e["event"] for e in _parse_browser_sse(raw)] - assert "text" in events and "done" in events # the turn completed normally