mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
7a06f5e8bc
* refactor(session): make ModelLane the provider boundary (#979) ## Summary This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot. - Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding. - Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call. - Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references. - Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results. - Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts. ## Validation - SQLite suite: 11,188 passed, 9 skipped, 10 deselected - PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected - Live backend: 3 passed - SSE recovery: 6 passed; browser recovery harness passed all scenarios - Ruff: clean; 595 files correctly formatted - mypy: 243 source files clean - TypeScript: typecheck/build and 35 tests passed - OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte - `git diff --check` and Git LFS integrity clean Closes #979. * fix(deps): update nanoid for GHSA-2v37-7h3g-55p8 Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation. Validation: - npm ci - npm audit --audit-level=moderate: 0 vulnerabilities - TypeScript typecheck and build - TypeScript tests: 35 passed * fix(test): assert canonical model registry URLs Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation. Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
1195 lines
49 KiB
Python
1195 lines
49 KiB
Python
"""Session-level tests for the mid-stream transport-retry loop (#937).
|
|
|
|
A wire death DURING body iteration surfaces after the request already
|
|
returned its stream handle, so neither the SDK's ``max_retries`` nor the
|
|
creation-time per-lane ladder (``_model_turn_with_retry``) ever sees it.
|
|
These tests drive ``ChatSession.send()`` with scripted provider streams
|
|
and pin the retry loop's contract: bounded re-issue on the normalized
|
|
retryable shape, the dead attempt finalized client-side before the retry
|
|
notice (``stream_end``) and discarded from the server buffers once the
|
|
backoff survives the Stop window (``stream_discarded`` — never
|
|
``turn_committed``, whose semantics keep the turn buffer),
|
|
cancel-in-retry-window abort, exhaustion surfacing the stream-death
|
|
wording, and the ``_assistant_pending_tokens`` reset that keeps a
|
|
post-finish blip from recycling the prior turn's token count.
|
|
|
|
Fixture note: tests zero ``_RETRY_BASE_DELAY`` per instance (else each
|
|
retry pays real exponential backoff).
|
|
"""
|
|
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from tests._session_helpers import (
|
|
NullUI,
|
|
RecordingUI,
|
|
arm_session,
|
|
make_session,
|
|
replace_session_lane,
|
|
)
|
|
from turnstone.core.memory import load_last_error
|
|
from turnstone.core.model_turn import WirePreparationError
|
|
from turnstone.core.providers import IncompleteStreamError, StreamChunk, UsageInfo
|
|
from turnstone.core.session import BackendAuthUnavailableError, GenerationCancelled
|
|
from turnstone.core.streaming_text import ThinkTagSplitter
|
|
from turnstone.core.trajectory import Turn, dicts_from_turns
|
|
|
|
|
|
def _make_session(ui=None, **kwargs):
|
|
"""Wrap the shared session factory with this suite's two retry knobs.
|
|
|
|
The defaults live in tests/_session_helpers.make_session — duplicating
|
|
them here is exactly the drift its docstring warns about.
|
|
"""
|
|
session = make_session(ui=ui or RecordingUI(), **kwargs)
|
|
session._RETRY_BASE_DELAY = 0
|
|
# Latch the auto-title trigger: its background thread would drive a
|
|
# second model call through the MagicMock client (drain retries, real
|
|
# backoff) alongside the send under test.
|
|
session._title_generated = True
|
|
return session
|
|
|
|
|
|
def _dying_stream(*texts, exc):
|
|
"""Content chunks, then a mid-body death — no finish reason seen."""
|
|
|
|
def gen():
|
|
for t in texts:
|
|
yield StreamChunk(content_delta=t)
|
|
raise exc
|
|
|
|
return gen()
|
|
|
|
|
|
def _good_stream(text):
|
|
return iter(
|
|
[
|
|
StreamChunk(content_delta=text),
|
|
StreamChunk(
|
|
finish_reason="stop",
|
|
usage=UsageInfo(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
def _assistant_msgs(session):
|
|
return [m for m in dicts_from_turns(session.messages) if m["role"] == "assistant"]
|
|
|
|
|
|
class TestMidStreamRetry:
|
|
def test_death_retries_and_commits_one_clean_turn(self, tmp_db, caplog):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
first_text = "streaming from the dead attempt"
|
|
streams = [
|
|
_dying_stream(first_text, exc=httpx.ReadError("[SSL] record layer failure")),
|
|
_good_stream("Hello world"),
|
|
]
|
|
create = arm_session(session, *streams).create_streaming
|
|
with (
|
|
caplog.at_level(logging.WARNING, logger="turnstone.core.session"),
|
|
):
|
|
session.send("test")
|
|
|
|
assert create.call_count == 2
|
|
# ONE committed turn, carrying only the successful attempt's text.
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "Hello world"
|
|
# The dead attempt's tokens streamed live (minus the trailing
|
|
# MAX_TAG_LEN chars the tag-scan buffer held back — never
|
|
# displayed, so nothing needs to finalize them); the retried text
|
|
# is a fresh bubble, not an append onto the dead attempt's.
|
|
flushed = first_text[: len(first_text) - ThinkTagSplitter.MAX_TAG_LEN]
|
|
assert ui.of("content") == [flushed, "Hello world"]
|
|
# The dead attempt is finalized client-side before the notice, and
|
|
# DISCARDED only after the backoff survives the Stop window (a
|
|
# Stop during backoff must find the dead text still in the turn
|
|
# buffer, matching what the cancel handler persists): stream_end
|
|
# -> notice -> (backoff) -> stream_discarded -> spinner, then the
|
|
# retried stream's own end-of-turn pair (a real commit).
|
|
seq = [
|
|
(k, d)
|
|
for k, d in ui.events
|
|
if k in ("stream_end", "stream_discarded", "turn_committed", "thinking_start")
|
|
or (k == "info" and "stream died mid-response" in d)
|
|
]
|
|
assert [k for k, _ in seq] == [
|
|
"thinking_start",
|
|
"stream_end",
|
|
"info",
|
|
"stream_discarded",
|
|
"thinking_start",
|
|
"stream_end",
|
|
"turn_committed",
|
|
]
|
|
notice = seq[2][1]
|
|
assert "(ReadError)" in notice
|
|
assert "(1/2)" in notice
|
|
assert ("state", "idle") in ui.events
|
|
assert not ui.of("error")
|
|
assert any("stream.retry" in r.message for r in caplog.records)
|
|
|
|
def test_exhaustion_surfaces_stream_death_wording(self, tmp_db, caplog):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream("a", exc=httpx.ReadError("[SSL] record layer failure")) for _ in range(3)
|
|
]
|
|
create = arm_session(session, *streams).create_streaming
|
|
with (
|
|
caplog.at_level(logging.INFO, logger="turnstone.core.session"),
|
|
pytest.raises(IncompleteStreamError, match="ReadError"),
|
|
):
|
|
session.send("test")
|
|
|
|
# Initial attempt + _MID_STREAM_RETRIES re-creates, then fatal.
|
|
assert create.call_count == 1 + session._MID_STREAM_RETRIES
|
|
assert ("state", "error") in ui.events
|
|
errors = ui.of("error")
|
|
assert errors and "Backend stream died mid-response" in errors[-1]
|
|
assert "retries did not recover it" in errors[-1]
|
|
persisted = load_last_error(session._ws_id)
|
|
assert persisted and "Backend stream died mid-response" in persisted
|
|
fatal = [r for r in caplog.records if "session.fatal.recorded" in r.message]
|
|
assert fatal and any(r.levelno == logging.ERROR for r in fatal)
|
|
# Every dead attempt is finalized AND discarded — the terminal arm
|
|
# included (keeping its buffers bought nothing: the fatal path's
|
|
# error-state drain wipes them anyway, and an overflow recovered by
|
|
# compact-and-retry would otherwise concatenate dead text into the
|
|
# idle payload). No commit ever happens.
|
|
assert ui.kinds().count("stream_end") == 3
|
|
assert ui.kinds().count("stream_discarded") == 3
|
|
assert ui.kinds().count("turn_committed") == 0
|
|
|
|
def test_cancel_during_backoff_stops_without_recreate(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._RETRY_BASE_DELAY = 30.0
|
|
real_backoff = session._backoff_or_cancelled
|
|
|
|
def cancel_then_backoff(delay, my_generation=0):
|
|
# A Stop landing during the backoff window: the event-wait
|
|
# returns immediately instead of burning the 30s delay.
|
|
session.cancel()
|
|
real_backoff(delay, my_generation)
|
|
|
|
create = arm_session(
|
|
session, _dying_stream("Hel", exc=httpx.ReadError("wire died"))
|
|
).create_streaming
|
|
with (
|
|
patch.object(session, "_backoff_or_cancelled", side_effect=cancel_then_backoff),
|
|
):
|
|
session.send("test")
|
|
|
|
assert create.call_count == 1 # no further API call after the Stop
|
|
assert ("state", "idle") in ui.events
|
|
assert ("state", "error") not in ui.events
|
|
assert any("cancelled" in d.lower() for d in ui.of("info"))
|
|
# The dead attempt's partial survives the Stop with the cancel
|
|
# marker — same disposition a cancel DURING the attempt gets. "Hel"
|
|
# is shorter than the splitter's carry window, so this also pins the
|
|
# carry-tail inclusion in the stash.
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "Hel\n\n[generation cancelled before completion]"
|
|
|
|
def test_rebind_reprepares_wire_messages(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream("x", exc=httpx.ReadError("wire died")),
|
|
_good_stream("ok"),
|
|
]
|
|
|
|
def swap_binding():
|
|
# A registry reload that rebinds mid-retry replaces the client
|
|
# object (the identity signal the wrapper keys on).
|
|
replace_session_lane(session, client=MagicMock())
|
|
|
|
create = arm_session(session, *streams).create_streaming
|
|
with (
|
|
patch.object(session, "_refresh_model_from_registry", side_effect=swap_binding),
|
|
patch.object(
|
|
session,
|
|
"_prepare_lowered_wire_messages",
|
|
wraps=session._prepare_lowered_wire_messages,
|
|
) as prep,
|
|
):
|
|
session.send("test")
|
|
|
|
# send() prepares once; the rebind forces exactly one re-prepare
|
|
# (capability-sensitive wire fold) before the re-issue.
|
|
assert prep.call_count == 2
|
|
assert create.call_count == 2
|
|
assert _assistant_msgs(session)[-1]["content"] == "ok"
|
|
|
|
def test_refresh_fires_per_reissue(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream("x", exc=httpx.ReadError("wire died")),
|
|
_dying_stream("y", exc=httpx.ReadError("wire died again")),
|
|
_good_stream("ok"),
|
|
]
|
|
create = arm_session(session, *streams).create_streaming
|
|
with (
|
|
patch.object(
|
|
session,
|
|
"_refresh_model_from_registry",
|
|
wraps=session._refresh_model_from_registry,
|
|
) as refresh,
|
|
):
|
|
session.send("test")
|
|
|
|
assert create.call_count == 3
|
|
# Once at the top of send() (the per-send driver) plus once per
|
|
# re-issue — a regression that drops the mid-retry refresh would
|
|
# show 1 here and stream a reload-closed client (#937 recreate
|
|
# hardening).
|
|
assert refresh.call_count == 3
|
|
|
|
def test_retry_window_restarts_spinner(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream(exc=httpx.ReadError("pre-token wire death")),
|
|
_good_stream("ok"),
|
|
]
|
|
arm_session(session, *streams)
|
|
session.send("test")
|
|
|
|
# A pre-first-token death leaves the spinner RUNNING; the CLI's
|
|
# on_thinking_start is idempotent at the callee (it stops a live
|
|
# spinner before replacing it), so the retry arm restarts with a
|
|
# single call — after the backoff-gated discard.
|
|
notice = [i for i, (k, d) in enumerate(ui.events) if k == "info" and "stream died" in d]
|
|
assert notice
|
|
after = [k for k, _ in ui.events[notice[0] + 1 : notice[0] + 3]]
|
|
assert after == ["stream_discarded", "thinking_start"]
|
|
|
|
def test_pretoken_death_stop_persists_marker_row(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._RETRY_BASE_DELAY = 30.0
|
|
real_backoff = session._backoff_or_cancelled
|
|
|
|
def cancel_then_backoff(delay, my_generation=0):
|
|
session.cancel()
|
|
real_backoff(delay, my_generation)
|
|
|
|
arm_session(session, _dying_stream(exc=httpx.ReadError("died before first token")))
|
|
with (
|
|
patch.object(session, "_backoff_or_cancelled", side_effect=cancel_then_backoff),
|
|
):
|
|
session.send("test")
|
|
|
|
# No content ever streamed, but a cancelled streaming turn must
|
|
# still persist the marker row — an absent assistant row is the
|
|
# ambiguous shape the marker branch exists to prevent.
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "[generation cancelled before completion]"
|
|
assert ("state", "idle") in ui.events
|
|
|
|
def test_ttft_window_stop_backfills_previous_partial(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
|
|
def second_stream():
|
|
# A Stop lands after the re-create, before the first chunk:
|
|
# cancel() closes the fresh stream and the read dies.
|
|
session.cancel()
|
|
raise httpx.ReadError("closed by cancel")
|
|
yield # pragma: no cover — generator marker
|
|
|
|
streams = [
|
|
_dying_stream("Hel", exc=httpx.ReadError("wire died")),
|
|
second_stream(),
|
|
]
|
|
arm_session(session, *streams)
|
|
session.send("test")
|
|
|
|
# The replacement attempt recorded an EMPTY partial; the wrapper
|
|
# backfills it with the previous attempt's text — the user saw it,
|
|
# and a Stop one second earlier (during backoff) preserves it too.
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "Hel\n\n[generation cancelled before completion]"
|
|
|
|
def test_trailing_window_stop_aborts_with_marker(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
|
|
def gen():
|
|
yield StreamChunk(content_delta="done-ish")
|
|
yield StreamChunk(finish_reason="stop")
|
|
# Stop races the trailing-metadata window; the closed stream
|
|
# ends cleanly under transport_guarded's post-finish tolerance.
|
|
session.cancel()
|
|
|
|
arm_session(session, gen())
|
|
session.send("test")
|
|
|
|
# The post-loop cancel re-check converts the Stop — the turn must
|
|
# NOT commit as complete (its tool calls would execute despite the
|
|
# Stop); it aborts with the marker like any observed cancel.
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "done-ish\n\n[generation cancelled before completion]"
|
|
assert ("state", "idle") in ui.events
|
|
assert ("state", "error") not in ui.events
|
|
|
|
def test_keyboard_interrupt_finalizes_dead_attempt(self, tmp_db, caplog):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
arm_session(session, _dying_stream("Hel", exc=KeyboardInterrupt()))
|
|
with (
|
|
caplog.at_level(logging.INFO, logger="turnstone.core.session"),
|
|
pytest.raises(KeyboardInterrupt),
|
|
):
|
|
session.send("test")
|
|
|
|
# Ctrl-C is BaseException — the retry gate never sees it, but the
|
|
# dead attempt still needs its client-side finalize (the CLI's
|
|
# markdown fence resets only in on_stream_end).
|
|
assert ui.kinds().count("stream_end") == 1
|
|
assert ("state", "error") in ui.events
|
|
|
|
def test_gate_consults_serving_lane_provider(self, tmp_db):
|
|
class FlakyError(Exception):
|
|
pass
|
|
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
# The retry gate reads the SERVING lane's provider. FlakyError is
|
|
# retryable only per THIS provider's set, so the re-issue proves
|
|
# the gate consulted the lane that armed the stream, not a global
|
|
# default. (The distinct-fallback-lane variant rides the real
|
|
# walk in test_model_registry's TestSessionFallback.)
|
|
provider = arm_session(
|
|
session,
|
|
_dying_stream("x", exc=FlakyError("in-band transient")),
|
|
_good_stream("ok"),
|
|
retryable=frozenset({"FlakyError"}),
|
|
)
|
|
session.send("test")
|
|
|
|
assert provider.create_streaming.call_count == 2
|
|
assert _assistant_msgs(session)[-1]["content"] == "ok"
|
|
|
|
def test_recreate_overflow_falls_through_to_compact_retry(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
provider = arm_session(
|
|
session,
|
|
_dying_stream("x", exc=httpx.ReadError("wire died")),
|
|
# A mid-retry rebind landed on a smaller-window model: the
|
|
# re-create overflows deterministically (a creation-phase
|
|
# raise — unarmed).
|
|
RuntimeError("maximum context length exceeded"),
|
|
_good_stream("recovered"),
|
|
)
|
|
with (
|
|
patch.object(session, "_compact_messages") as compact,
|
|
):
|
|
session.send("test")
|
|
|
|
# The overflow surfaced as ITSELF (not masked behind the stream
|
|
# death), so send()'s compact-and-retry arm recovered the turn.
|
|
assert provider.create_streaming.call_count == 3
|
|
compact.assert_called_once()
|
|
assert _assistant_msgs(session)[-1]["content"] == "recovered"
|
|
|
|
def test_postcompaction_failure_surfaces_as_itself(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
arm_session(
|
|
session,
|
|
RuntimeError("maximum context length exceeded"),
|
|
ValueError("backend exploded"),
|
|
)
|
|
with (
|
|
patch.object(session, "_compact_messages"),
|
|
pytest.raises(ValueError, match="backend exploded"),
|
|
):
|
|
session.send("test")
|
|
|
|
# Compaction already succeeded — the post-compaction failure must
|
|
# not be re-labeled as a context overflow.
|
|
persisted = load_last_error(session._ws_id)
|
|
assert persisted and "ValueError" in persisted
|
|
assert "Context window exceeded" not in persisted
|
|
|
|
def test_model_only_rebind_reprepares(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream("x", exc=httpx.ReadError("wire died")),
|
|
_good_stream("ok"),
|
|
]
|
|
|
|
refreshes = {"n": 0}
|
|
|
|
def swap_model():
|
|
# reload() keeps the pooled client when only the alias's model
|
|
# id changed — the binding triple must still trigger the
|
|
# re-prepare. The swap fires on the RETRY's refresh (call 2);
|
|
# call 1 is send()'s per-send driver at the top of the turn.
|
|
refreshes["n"] += 1
|
|
if refreshes["n"] == 2:
|
|
replace_session_lane(session, model="swapped-model")
|
|
|
|
arm_session(session, *streams)
|
|
with (
|
|
patch.object(session, "_refresh_model_from_registry", side_effect=swap_model),
|
|
patch.object(
|
|
session,
|
|
"_prepare_lowered_wire_messages",
|
|
wraps=session._prepare_lowered_wire_messages,
|
|
) as prep,
|
|
):
|
|
session.send("test")
|
|
|
|
assert prep.call_count == 2
|
|
|
|
def test_orphan_attempt_touches_neither_ui_nor_partial_slot(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._generation = 7 # a successor generation owns the session
|
|
|
|
def chunks():
|
|
yield StreamChunk(content_delta="orphan text")
|
|
yield StreamChunk(content_delta="more")
|
|
|
|
arm_session(session, chunks())
|
|
with pytest.raises(GenerationCancelled):
|
|
session._stream_response(3)
|
|
|
|
# The superseded thread's cancel arm must touch neither the UI
|
|
# (its stream_end would reset the successor's inflight buffers)
|
|
# nor the shared partial slot the successor's handler consumes.
|
|
assert "stream_end" not in ui.kinds()
|
|
assert session._cancelled_partial_msg is None
|
|
|
|
def test_superseded_backoff_cancel_does_not_write_partial_slot(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
|
|
def supersede_then_cancel(delay, my_generation=0):
|
|
session._generation += 1 # force-cancel claimed a successor
|
|
raise GenerationCancelled()
|
|
|
|
arm_session(session, _dying_stream("Hel", exc=httpx.ReadError("wire died")))
|
|
with (
|
|
patch.object(session, "_backoff_or_cancelled", side_effect=supersede_then_cancel),
|
|
):
|
|
session.send("test") # the orphaned turn returns silently
|
|
|
|
# An orphan waking in the backoff window must not write the
|
|
# successor's partial slot or persist anything.
|
|
assert session._cancelled_partial_msg is None
|
|
assert not _assistant_msgs(session)
|
|
|
|
def test_dead_attempt_text_absent_from_turn_buffer(self, tmp_db):
|
|
# Real SessionUIBase buffers — a recording fake cannot see the
|
|
# multi-segment turn buffer the IDLE payload drains, which
|
|
# on_turn_committed deliberately does NOT clear. on_state_change
|
|
# is a web-fanout concern narrowed off the shared base.
|
|
class _BufferUI(NullUI):
|
|
def on_state_change(self, state):
|
|
pass
|
|
|
|
ui = _BufferUI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream(
|
|
"dead attempt text that must not surface",
|
|
exc=httpx.ReadError("wire died"),
|
|
),
|
|
_good_stream("final answer"),
|
|
]
|
|
arm_session(session, *streams)
|
|
session.send("test")
|
|
|
|
# The dead segment was truncated at the watermark; only the
|
|
# retried attempt's text reaches the IDLE payload.
|
|
assert "".join(ui._ws_turn_content) == "final answer"
|
|
|
|
def test_stop_in_backoff_keeps_dead_text_in_turn_buffer(self, tmp_db):
|
|
# The discard is gated on the backoff SURVIVING: a Stop during the
|
|
# window persists the promoted partial to history, and the idle
|
|
# payload (drained from the turn buffer) must carry the same text
|
|
# — discarding first rendered the cancelled turn empty on the
|
|
# dashboard while the transcript had it.
|
|
class _BufferUI(NullUI):
|
|
def on_state_change(self, state):
|
|
pass
|
|
|
|
ui = _BufferUI()
|
|
session = _make_session(ui)
|
|
session._RETRY_BASE_DELAY = 30.0
|
|
real_backoff = session._backoff_or_cancelled
|
|
|
|
def cancel_then_backoff(delay, my_generation=0):
|
|
session.cancel()
|
|
real_backoff(delay, my_generation)
|
|
|
|
dead_text = "a dead attempt long enough to flush past the carry window"
|
|
arm_session(session, _dying_stream(dead_text, exc=httpx.ReadError("wire died")))
|
|
with (
|
|
patch.object(session, "_backoff_or_cancelled", side_effect=cancel_then_backoff),
|
|
):
|
|
session.send("test")
|
|
|
|
assert "a dead attempt" in "".join(ui._ws_turn_content)
|
|
assistant = _assistant_msgs(session)
|
|
assert assistant and assistant[-1]["content"].startswith("a dead attempt")
|
|
|
|
def test_pre937_ui_without_discard_hook_survives_retry(self, tmp_db):
|
|
# A duck-typed UI predating on_stream_discarded must degrade to
|
|
# "no server-buffer truncate", not crash the retry arm with an
|
|
# AttributeError that replaces the stream death being handled.
|
|
class _Pre937UI(RecordingUI):
|
|
# property() with no getter raises AttributeError on access —
|
|
# simulating the hook's absence on an inheriting fake.
|
|
on_stream_discarded = property()
|
|
|
|
ui = _Pre937UI()
|
|
session = _make_session(ui)
|
|
streams = [
|
|
_dying_stream("x", exc=httpx.ReadError("wire died")),
|
|
_good_stream("ok"),
|
|
]
|
|
arm_session(session, *streams)
|
|
session.send("test")
|
|
|
|
assert _assistant_msgs(session)[-1]["content"] == "ok"
|
|
assert ("state", "idle") in ui.events
|
|
|
|
def test_overflow_recovery_discards_dead_text_from_turn_buffer(self, tmp_db):
|
|
# A mid-consumption overflow is TERMINAL for the retry ladder but
|
|
# RECOVERED by send()'s compact-and-retry — the dead attempt's text
|
|
# must not concatenate with the recovered answer in the idle
|
|
# payload (the terminal arm discards, same as the retry arm).
|
|
class _BufferUI(NullUI):
|
|
def on_state_change(self, state):
|
|
pass
|
|
|
|
ui = _BufferUI()
|
|
session = _make_session(ui)
|
|
provider = arm_session(
|
|
session,
|
|
_dying_stream(
|
|
"dead overflow text",
|
|
exc=RuntimeError("maximum context length exceeded"),
|
|
),
|
|
_good_stream("recovered"),
|
|
)
|
|
with (
|
|
patch.object(session, "_compact_messages"),
|
|
):
|
|
session.send("test")
|
|
|
|
assert provider.create_streaming.call_count == 2
|
|
assert "".join(ui._ws_turn_content) == "recovered"
|
|
|
|
def test_orphan_death_records_no_fatal_over_successor(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
|
|
def dying_superseded():
|
|
yield StreamChunk(content_delta="Hel")
|
|
# A force-cancel claims a successor generation before the
|
|
# orphan's death propagates (its cancel event was replaced, so
|
|
# cancel conversion cannot fire).
|
|
session._generation += 1
|
|
raise ValueError("orphan death")
|
|
|
|
arm_session(session, dying_superseded())
|
|
# RULED (#832, orphan-exit): a superseded generation's death
|
|
# converts at the ladder's generation check and send() ends
|
|
# SILENTLY as cancelled — no arbitrary exception class escapes
|
|
# into the thread runner.
|
|
session.send("test")
|
|
|
|
# The orphan must not flash an error banner over the live
|
|
# successor turn or persist a wrong last_error for the coord.
|
|
assert ("state", "error") not in ui.events
|
|
assert not ui.of("error")
|
|
assert not load_last_error(session._ws_id)
|
|
assert not _assistant_msgs(session)
|
|
|
|
def test_non_retryable_error_is_immediately_fatal(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
create = arm_session(
|
|
session, _dying_stream("Hel", exc=ValueError("model exploded"))
|
|
).create_streaming
|
|
with (
|
|
pytest.raises(ValueError, match="model exploded"),
|
|
):
|
|
session.send("test")
|
|
|
|
assert create.call_count == 1 # zero retries
|
|
assert ("state", "error") in ui.events
|
|
assert not any("stream died mid-response" in d for d in ui.of("info"))
|
|
# The terminal arm finalizes and discards even a zero-retry death.
|
|
assert ui.kinds().count("stream_end") == 1
|
|
assert ui.kinds().count("stream_discarded") == 1
|
|
assert ui.kinds().count("turn_committed") == 0
|
|
|
|
def test_recreate_failure_surfaces_original_stream_death(self, tmp_db, caplog):
|
|
"""A failing re-create (closed client, registry chaos) must not
|
|
replace the operator-actionable stream-death error with its own."""
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
create = arm_session(
|
|
session,
|
|
_dying_stream("He", exc=httpx.ReadError("[SSL] record layer failure")),
|
|
RuntimeError("Cannot send a request, as the client has been closed."),
|
|
).create_streaming
|
|
with (
|
|
caplog.at_level(logging.WARNING, logger="turnstone.core.session"),
|
|
pytest.raises(IncompleteStreamError, match="ReadError"),
|
|
):
|
|
session.send("test")
|
|
|
|
assert create.call_count == 2
|
|
assert any("stream.retry.recreate_failed" in r.message for r in caplog.records)
|
|
errors = ui.of("error")
|
|
assert errors and "Backend stream died mid-response" in errors[-1]
|
|
assert "client has been closed" not in errors[-1]
|
|
|
|
def test_post_finish_blip_appends_fresh_token_estimate(self, tmp_db):
|
|
"""The post-finish tolerance can end a turn with the trailing usage
|
|
chunk lost; the estimate appended for that turn must be a fresh
|
|
char-based one, never the PREVIOUS turn's completion count."""
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._assistant_pending_tokens = 777 # stale prior-turn count
|
|
|
|
def blipping():
|
|
yield StreamChunk(content_delta="Hello world")
|
|
yield StreamChunk(finish_reason="stop")
|
|
raise httpx.ReadError("late blip") # the usage chunk is lost
|
|
|
|
arm_session(session, blipping())
|
|
session.send("test")
|
|
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "Hello world"
|
|
# Clean end, no retry — the finish reason had already passed.
|
|
assert not any("stream died mid-response" in d for d in ui.of("info"))
|
|
assert ("state", "idle") in ui.events
|
|
expected = max(1, int(session._msg_char_count(assistant[0]) / session._chars_per_token))
|
|
assert session._msg_tokens[-1] == expected
|
|
assert session._msg_tokens[-1] != 777
|
|
|
|
|
|
class TestRecreateWindowClassification:
|
|
"""Between a mid-stream death and the next attempt's ``begin_attempt``
|
|
there is NO live attempt — ``end_attempt`` pronounces the dead one
|
|
dead the moment its partial is captured. These pins hold the two
|
|
failure modes of reading the dead attempt's armed state in that
|
|
window (a Stop re-finalizing discarded display state; a walk-preamble
|
|
error replacing the stream death), the two error classes the
|
|
re-issue mask must forward verbatim, and the saw-chunk classifier
|
|
fallback for adapters that never arm."""
|
|
|
|
def test_stop_in_recreate_window_emits_no_stale_display_state(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
arm_session(
|
|
session,
|
|
_dying_stream("Half an answer", exc=httpx.ReadError("wire died")),
|
|
_good_stream("never reached"),
|
|
)
|
|
# The Stop lands AFTER backoff+discard, BEFORE the next attempt
|
|
# arms: _refresh_model_from_registry is the last step of the
|
|
# re-create sequence, so a cancel fired there raises at the next
|
|
# walk's loop-top _check_cancelled — the exact window.
|
|
real_refresh = session._refresh_model_from_registry
|
|
|
|
def cancel_in_window():
|
|
real_refresh()
|
|
session._cancel_event.set()
|
|
|
|
with patch.object(session, "_refresh_model_from_registry", side_effect=cancel_in_window):
|
|
session.send("test")
|
|
|
|
# The dead attempt streamed only its safe-flush prefix ("Ha");
|
|
# the splitter carry ("lf an answer") must NEVER surface as a late
|
|
# content token behind a duplicate stream_end.
|
|
assert ui.of("content") == ["Ha"]
|
|
assert ui.kinds().count("stream_end") == 1
|
|
# The full partial (flushed + carry) still reaches history via
|
|
# the promotion path — display suppression must not cost text.
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert (
|
|
assistant[0]["content"] == "Half an answer\n\n[generation cancelled before completion]"
|
|
)
|
|
assert ("state", "idle") in ui.events
|
|
assert ("state", "error") not in ui.events
|
|
|
|
def test_recreate_preamble_error_surfaces_original_death(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
arm_session(
|
|
session,
|
|
_dying_stream("text", exc=httpx.ReadError("wire died")),
|
|
_good_stream("never reached"),
|
|
)
|
|
# The re-issue walk's PREAMBLE (before any begin_attempt) raises:
|
|
# with the dead attempt's ref retired this is a creation-phase
|
|
# failure, so the ORIGINAL stream death is the error the operator
|
|
# sees — not the preamble's.
|
|
real_tracker = session._get_health_tracker
|
|
calls: list[int] = []
|
|
|
|
def tracker_then_boom():
|
|
calls.append(1)
|
|
if len(calls) >= 2:
|
|
raise RuntimeError("registry blew up mid-rebind")
|
|
return real_tracker()
|
|
|
|
with (
|
|
patch.object(session, "_get_health_tracker", side_effect=tracker_then_boom),
|
|
pytest.raises(IncompleteStreamError, match="ReadError"),
|
|
):
|
|
session.send("test")
|
|
|
|
errors = ui.of("error")
|
|
assert errors and "Backend stream died mid-response" in errors[-1]
|
|
assert not any("registry blew up" in e for e in errors)
|
|
|
|
def test_auth_refusal_at_reissue_surfaces_as_itself(self, tmp_db):
|
|
# An OBO mint refusal during the re-create is a config outage
|
|
# with its own remediation branch — masking it behind the earlier
|
|
# transport death would misdiagnose it as a network flap.
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
arm_session(
|
|
session,
|
|
_dying_stream("text", exc=httpx.ReadError("wire died")),
|
|
BackendAuthUnavailableError("obo mint refused for alias 'gpt': no cached grant"),
|
|
)
|
|
with pytest.raises(BackendAuthUnavailableError):
|
|
session.send("test")
|
|
|
|
errors = ui.of("error")
|
|
assert errors and "configured to mint a credential per call" in errors[-1]
|
|
assert not any("Backend stream died mid-response" in e for e in errors)
|
|
|
|
def test_never_arming_adapter_death_classifies_midstream(self, tmp_db):
|
|
"""An adapter that ignores ``cancel_ref`` forfeits the
|
|
health/usage hook duties, but its mid-stream death must STILL
|
|
classify as mid-stream: chunks reached the display, so a
|
|
creation-classified death would silently re-issue the same lane
|
|
and double-render them."""
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
provider = arm_session(session) # install the provider shell only
|
|
scripts = [
|
|
_dying_stream("First words from the dying wire", exc=httpx.ReadError("wire died")),
|
|
_good_stream("Recovered"),
|
|
]
|
|
|
|
def create_no_arm(**kwargs):
|
|
assert scripts, "script exhausted"
|
|
return scripts.pop(0)
|
|
|
|
provider.create_streaming = MagicMock(side_effect=create_no_arm)
|
|
session.send("test")
|
|
|
|
# The mid-stream ladder ran its full theater — finalize, notice,
|
|
# discard — and the retried attempt committed ALONE.
|
|
assert any("stream died mid-response" in d for d in ui.of("info"))
|
|
assert ui.kinds().count("stream_discarded") == 1
|
|
assistant = _assistant_msgs(session)
|
|
assert len(assistant) == 1
|
|
assert assistant[0]["content"] == "Recovered"
|
|
# The dead attempt's flushed prefix rendered exactly once.
|
|
flushed = "First words from the dying wire"[: -ThinkTagSplitter.MAX_TAG_LEN]
|
|
assert ui.of("content").count(flushed) == 1
|
|
|
|
def test_wire_preparation_failure_never_touches_backend_health(self, tmp_db):
|
|
"""A lowering failure is a session-data fault: no dispatch and no
|
|
health record on ANY lane — but it DOES walk the fallbacks, since
|
|
prepare is lane-variant and another lane's posture may serve the
|
|
turn. When none does, the typed ``WirePreparationError`` rides to
|
|
the fatal formatter's dedicated branch."""
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
provider = arm_session(session, _good_stream("unreached"))
|
|
tracker = MagicMock()
|
|
registry = MagicMock()
|
|
registry.fallback = ["fb1"]
|
|
session._registry = registry
|
|
with (
|
|
patch.object(session, "_get_health_tracker", return_value=tracker),
|
|
patch.object(session, "_try_fallback_lane", return_value=None) as fb_spy,
|
|
patch.object(
|
|
session,
|
|
"_prepare_lowered_wire_messages",
|
|
side_effect=ValueError("malformed turn 7"),
|
|
),
|
|
pytest.raises(WirePreparationError) as excinfo,
|
|
):
|
|
session.send("test")
|
|
|
|
# The wrapper carries the cause's CLASS, not its text. Callers that
|
|
# render ``str(exc)`` directly — the interactive retry arm's dashboard
|
|
# line — would otherwise re-emit the stored-history text the fatal
|
|
# formatter just withheld, on a surface that reaches the operator and
|
|
# the persisted error row.
|
|
assert str(excinfo.value) == "ValueError"
|
|
assert "malformed turn 7" not in str(excinfo.value)
|
|
|
|
tracker.record_failure.assert_not_called()
|
|
fb_spy.assert_called_once()
|
|
provider.create_streaming.assert_not_called()
|
|
errors = ui.of("error")
|
|
assert errors and "stored history" in errors[-1]
|
|
# Reached the dedicated branch — identified by the cause's CLASS.
|
|
# Its message is withheld all the way through the live send path:
|
|
# it is our lowering's text over stored history, and this string
|
|
# is persisted (see TestWirePrepFaultRedaction).
|
|
assert "ValueError" in errors[-1]
|
|
assert "malformed turn 7" not in errors[-1]
|
|
|
|
def test_fallback_prep_fault_continues_walk(self, tmp_db):
|
|
"""A prep fault on one lane must not abort the walk: the next
|
|
alias may still serve the turn (prepare is lane-variant)."""
|
|
from tests._session_helpers import make_result
|
|
|
|
session = _make_session(RecordingUI())
|
|
registry = MagicMock()
|
|
registry.fallback = ["a", "b"]
|
|
session._registry = registry
|
|
served = make_result(content="ok")
|
|
tracker = MagicMock()
|
|
with (
|
|
patch.object(session, "_get_health_tracker", return_value=tracker),
|
|
patch.object(
|
|
session,
|
|
"_model_turn_with_retry",
|
|
side_effect=WirePreparationError("primary prep fault"),
|
|
),
|
|
patch.object(session, "_try_fallback_lane", side_effect=[None, served]) as fb,
|
|
):
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 0)
|
|
result = session._model_turn_with_fallback(consumer, lambda w, lane: w, 0)
|
|
|
|
assert result is served
|
|
assert fb.call_count == 2
|
|
tracker.record_failure.assert_not_called()
|
|
|
|
def test_fallback_prep_fault_records_no_health(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
registry = MagicMock()
|
|
registry.resolve_binding.return_value = (MagicMock(), "m", None, MagicMock(), None)
|
|
fb_tracker = MagicMock()
|
|
session._registry = registry
|
|
session._health_registry = MagicMock()
|
|
session._health_registry.get_tracker_for_alias.return_value = fb_tracker
|
|
with (
|
|
patch.object(session, "_build_main_lane", return_value=MagicMock()),
|
|
patch.object(
|
|
session,
|
|
"_model_turn_with_retry",
|
|
side_effect=WirePreparationError("fold blew up"),
|
|
),
|
|
):
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 0)
|
|
out = session._try_fallback_lane("fb", consumer, lambda w, lane: w, 0)
|
|
|
|
assert out is None
|
|
fb_tracker.record_failure.assert_not_called()
|
|
assert any("Fallback fb also failed: WirePreparationError" in i for i in ui.of("info"))
|
|
|
|
|
|
class TestGenerationFencedCreationNotices:
|
|
"""Retry/fallback theater belongs only to the generation that earned it."""
|
|
|
|
def test_force_successor_before_retry_notice_suppresses_notice_and_redispatch(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._generation = 7
|
|
|
|
def supersede_before_notice(*args):
|
|
session._generation += 1
|
|
return False
|
|
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 7)
|
|
with (
|
|
patch(
|
|
"turnstone.core.session.model_turn", side_effect=httpx.ConnectError("down")
|
|
) as dispatch,
|
|
patch.object(session, "_stop_retrying", side_effect=supersede_before_notice),
|
|
pytest.raises(GenerationCancelled),
|
|
):
|
|
session._model_turn_with_retry(
|
|
session._primary_lane(),
|
|
None,
|
|
consumer,
|
|
lambda wire, lane: wire,
|
|
7,
|
|
)
|
|
|
|
dispatch.assert_called_once()
|
|
assert not any("Retrying in" in info for info in ui.of("info"))
|
|
|
|
def test_stop_before_primary_fallback_notice_suppresses_notice_and_dispatch(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._generation = 8
|
|
session._registry = MagicMock()
|
|
|
|
def stop_during_resolution(*args, **kwargs):
|
|
session._cancel_event.set()
|
|
return MagicMock()
|
|
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 8)
|
|
with (
|
|
patch(
|
|
"turnstone.core.session.resolve_model_binding",
|
|
side_effect=stop_during_resolution,
|
|
),
|
|
patch.object(session, "_build_main_lane", return_value=MagicMock()),
|
|
patch.object(session, "_model_turn_with_retry") as dispatch,
|
|
pytest.raises(GenerationCancelled),
|
|
):
|
|
session._try_fallback_lane("fb", consumer, lambda wire, lane: wire, 8)
|
|
|
|
dispatch.assert_not_called()
|
|
assert not any("falling back to fb" in info for info in ui.of("info"))
|
|
|
|
def test_stop_before_degraded_fallback_notice_suppresses_notice_and_dispatch(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._generation = 9
|
|
registry = MagicMock()
|
|
registry.fallback = ["fb"]
|
|
session._registry = registry
|
|
|
|
class _StopOnDegradedRead:
|
|
@property
|
|
def is_degraded(self):
|
|
session._cancel_event.set()
|
|
return True
|
|
|
|
health_registry = MagicMock()
|
|
health_registry.get_tracker_for_alias.return_value = _StopOnDegradedRead()
|
|
session._health_registry = health_registry
|
|
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 9)
|
|
with (
|
|
patch.object(session, "_get_health_tracker", return_value=None),
|
|
patch.object(
|
|
session,
|
|
"_model_turn_with_retry",
|
|
side_effect=RuntimeError("primary failed"),
|
|
),
|
|
patch.object(session, "_try_fallback_lane") as dispatch,
|
|
pytest.raises(GenerationCancelled),
|
|
):
|
|
session._model_turn_with_fallback(consumer, lambda wire, lane: wire, 9)
|
|
|
|
dispatch.assert_not_called()
|
|
assert not any("degraded, trying anyway" in info for info in ui.of("info"))
|
|
|
|
def test_force_successor_before_fallback_failed_notice_suppresses_stale_notice(self, tmp_db):
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
session._generation = 10
|
|
session._registry = MagicMock()
|
|
|
|
def fail_after_successor_claim(*args, **kwargs):
|
|
session._generation += 1
|
|
raise httpx.ConnectError("fallback failed")
|
|
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 10)
|
|
with (
|
|
patch("turnstone.core.session.resolve_model_binding", return_value=MagicMock()),
|
|
patch.object(session, "_build_main_lane", return_value=MagicMock()),
|
|
patch.object(
|
|
session,
|
|
"_model_turn_with_retry",
|
|
side_effect=fail_after_successor_claim,
|
|
) as dispatch,
|
|
pytest.raises(GenerationCancelled),
|
|
):
|
|
session._try_fallback_lane("fb", consumer, lambda wire, lane: wire, 10)
|
|
|
|
dispatch.assert_called_once()
|
|
infos = ui.of("info")
|
|
assert any("falling back to fb" in info for info in infos)
|
|
assert not any("Fallback fb also failed" in info for info in infos)
|
|
|
|
|
|
class TestDebugDumpLatch:
|
|
"""The debug request dump prints once per ``_stream_response``
|
|
invocation. RULED (#832): send()'s overflow-recovery re-invocation
|
|
prints the RE-PREPARED wire — the dump that diagnoses the recovery —
|
|
where the pre-fold behavior printed only the first. Within one
|
|
invocation, re-issues and fallback lanes re-run the passes but never
|
|
re-dump."""
|
|
|
|
def test_reissue_never_redumps(self, tmp_db):
|
|
session = _make_session(RecordingUI())
|
|
session.debug = True
|
|
session.messages.append(Turn.user("hi"))
|
|
arm_session(
|
|
session,
|
|
_dying_stream("x" * 20, exc=httpx.ReadError("wire died")),
|
|
_good_stream("ok"),
|
|
)
|
|
with patch.object(session, "_debug_print_request") as dump:
|
|
session._stream_response(0)
|
|
assert dump.call_count == 1
|
|
|
|
def test_second_invocation_reprints(self, tmp_db):
|
|
session = _make_session(RecordingUI())
|
|
session.debug = True
|
|
session.messages.append(Turn.user("hi"))
|
|
arm_session(session, _good_stream("a"), _good_stream("b"))
|
|
with patch.object(session, "_debug_print_request") as dump:
|
|
session._stream_response(0)
|
|
session._stream_response(0)
|
|
assert dump.call_count == 2
|
|
|
|
|
|
class TestFallbackFailureRedaction:
|
|
def test_fallback_ui_line_carries_class_name_only(self, tmp_db):
|
|
"""The fallback-failure info line lands in the browser transcript
|
|
and persisted event stream — it carries the exception CLASS, never
|
|
its text (a ConnectError's str can embed a credential-bearing
|
|
base_url)."""
|
|
ui = RecordingUI()
|
|
session = _make_session(ui)
|
|
registry = MagicMock()
|
|
registry.resolve_binding.side_effect = httpx.ConnectError(
|
|
"dial http://user:SECRETKEY@gw.example/v1 failed"
|
|
)
|
|
session._registry = registry
|
|
from turnstone.core.session import _StreamTurnConsumer
|
|
|
|
consumer = _StreamTurnConsumer(session, 0)
|
|
result = session._try_fallback_lane("fb", consumer, lambda w, lane: w, 0)
|
|
|
|
assert result is None
|
|
infos = ui.of("info")
|
|
assert any("Fallback fb also failed: ConnectError" in i for i in infos)
|
|
assert not any("SECRETKEY" in i for i in infos)
|
|
|
|
|
|
class TestWirePrepFaultRedaction:
|
|
def test_operator_text_carries_the_cause_class_only(self, tmp_db):
|
|
"""A wire-prep fault renders its cause's CLASS, never its message.
|
|
|
|
Every other branch of the formatter tails the backend's own
|
|
diagnostic text, which is what the operator needs. This one is
|
|
different in kind: ``prepare_wire`` is our lowering over the
|
|
session's STORED HISTORY, so its exception message can quote that
|
|
history — and this string is both shown to the operator and
|
|
persisted to ``last_error``, which a coordinating agent reads.
|
|
``redact_credentials`` is a best-effort regex by its own
|
|
docstring, so it is no floor for arbitrary conversation text.
|
|
"""
|
|
from turnstone.core.model_turn import WirePreparationError
|
|
|
|
session = _make_session(RecordingUI())
|
|
cause = ValueError("malformed block in turn 4: {'text': 'the user's private notes'}")
|
|
exc = WirePreparationError(str(cause))
|
|
exc.__cause__ = cause
|
|
|
|
rendered = session._format_backend_error(exc)
|
|
|
|
assert rendered is not None
|
|
assert "ValueError" in rendered
|
|
assert "private notes" not in rendered
|
|
assert "malformed block" not in rendered
|
|
# Still actionable: the operator learns what failed and what to try.
|
|
assert "stored history" in rendered
|
|
assert "/compact" in rendered
|
|
|
|
|
|
class TestPrepareWireLaneCaps:
|
|
"""The per-attempt wire prep folds with the SERVING lane's
|
|
capabilities — a fallback whose template rejects mid-conversation
|
|
system roles gets the folded shape even when the primary keeps them
|
|
inline."""
|
|
|
|
def test_caps_override_controls_fold_posture(self, tmp_db):
|
|
from turnstone.core.providers import ModelCapabilities
|
|
|
|
session = _make_session(RecordingUI())
|
|
msgs = [
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "assistant", "content": "yo"},
|
|
{"role": "system", "content": "nudge", "_source": "idle_nudge"},
|
|
{"role": "user", "content": "next"},
|
|
]
|
|
native = session._prepare_wire_messages(
|
|
list(msgs), caps=ModelCapabilities(supports_mid_conversation_system=True)
|
|
)
|
|
folded = session._prepare_wire_messages(
|
|
list(msgs), caps=ModelCapabilities(supports_mid_conversation_system=False)
|
|
)
|
|
assert any(m["role"] == "system" for m in native[1:])
|
|
assert not any(m["role"] == "system" for m in folded[1:])
|
|
|
|
def test_stream_prepare_passes_serving_lane_caps(self, tmp_db):
|
|
session = _make_session(RecordingUI())
|
|
arm_session(session, _good_stream("ok"))
|
|
with patch.object(
|
|
session,
|
|
"_prepare_lowered_wire_messages",
|
|
wraps=session._prepare_lowered_wire_messages,
|
|
) as prep:
|
|
session.send("test")
|
|
assert prep.call_args.kwargs["caps"] is session._get_capabilities()
|
|
|
|
def test_interactive_history_legalizes_each_tool_call_once(self, tmp_db):
|
|
"""The hot prepare suffix must not repeat model_turn's sanitizer."""
|
|
import turnstone.core.lowering as lowering
|
|
from turnstone.core.trajectory import ToolCall
|
|
|
|
session = _make_session(RecordingUI())
|
|
session.messages = [
|
|
Turn.assistant(
|
|
tool_calls=(ToolCall(id="call-bad", name="lookup", arguments="not-json"),)
|
|
),
|
|
Turn.tool("call-bad", "handled"),
|
|
]
|
|
arm_session(session, _good_stream("ok"))
|
|
|
|
with patch.object(
|
|
lowering,
|
|
"wire_valid_arguments",
|
|
wraps=lowering.wire_valid_arguments,
|
|
) as validity_scan:
|
|
session.send("next")
|
|
|
|
assert validity_scan.call_count == 1
|