mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
480a1426b3
* fix(session): fail-closed history-commit handoff (#981) The deleted-workstream discovery is now a terminal, ws_id-keyed latch: keyed conversation commits refuse admission once the durable parent is gone (convergence finalizers and force-abandon are exempt), history handoff refuses to mint a proof token so /history fails closed with a 503 instead of silently wiping the pane, and the SSE stream carries a workstream_gone resync reason. Discarded commits leave a forensic log of commit keys and roles, never content. Conversation rows gain a commit_key (migration 071): keyed saves are idempotent under retry, validated against the full commit identity, and refused when they would cross a workstream deletion. The prune orphan category now requires a NULL alias plus a two-hour updated grace, with cutoffs computed at discovery time and carried into both dialects' rechecks. The mid-turn interjection queue is owner-partitioned with no per-site mode flags: pops take the acting principal's and unowned rows, other participants' rows are structurally retained, and enforcement lives at queue admission plus the shared before_spawn gates. The retraction ledger is bounded by open pop windows: pops open a window atomically with the queue delete, restores close their ids atomically with the ledger consume, every other exit closes through one helper, and misses for unheld ids record nothing. The workstream-gone latch refuses unattended wakes at all three gates (watcher spawn, claim, delivery pre-pop), and the retry dispatcher regained its pre-envelope cancel/error convergence net. Persistence-state reporting derives through the session bound to each UI instead of a registry lookup by id that failed open to healthy during tombstone retention. The dashboard roster no longer re-inserts ghost entries from trailing activity events, the history tool-outcome scan tolerates interleaved non-turn rows, and the shared handoff-deadline handle owns its own retirement. Single-sourced across call sites: keyed-commit row values, attachment save wrappers, tail-truncation and conflict-resolution bodies for both storage dialects; worker-slot lifecycle field sets; the direct-commit admission frame; queued-row layout accessors; the string-aware comment stripper shared by every JS harness suite. Refs #981 #964 * fix(session): sweep handoff fixes to their sibling surfaces The interactive replay loop treated a system row as a tool-batch boundary, so every tool result after an interleaved row vanished from that pane while the coordinator rendered the same history correctly. Only a conversational turn ends the batch window now, matching the shared outcome index. Accepted user turns clear the composer's attachment chips on the same viewer policy that settles optimistic bubbles rather than on having matched a local bubble, so a workstream created with an upload no longer keeps a chip for an attachment the create dispatch already consumed. The coordinator's raced-Stop arm emits the stream-end hook it inherits alongside the idle state, leaving no unfinalized bubble or unflushed tool output. Ending a session surfaces a failure toast when the request never lands or answers with a non-JSON body. The per-second persistence reconcile now probes each session without blocking: a workstream whose generation and handoff locks are held is skipped until the next pass instead of contending the locks every commit needs. The one-shot repair that gates workstream creation at capacity keeps a definite probe — it has no next pass, and the sessions likeliest to be contended are the ones whose unresolved journals emptied its candidate list. Single-sourced: the attachment lane builds its conversation row through the shared commit-identity builder; the ordinary worker exit releases its slot through the lifecycle owner; both operator surfaces snapshot their counters through one non-consuming helper; the replay preamble loses its per-kind wrappers and its config hook; the browser harness suites share one brace walker; and each in-flight history attempt is one record carrying both its abort controller and its deadline. Refs #981 #964
1212 lines
50 KiB
Python
1212 lines
50 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)
|
|
# Production creates the durable workstream before a live session may
|
|
# admit keyed conversation rows. These direct-session tests must mirror
|
|
# that ordering: bypassing it would weaken the hard-delete parent fence.
|
|
from turnstone.core.storage import get_storage
|
|
|
|
get_storage().register_workstream(
|
|
session.ws_id,
|
|
user_id=session._user_id,
|
|
kind=session._kind,
|
|
)
|
|
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
|
|
refresh_calls = 0
|
|
|
|
def cancel_in_window():
|
|
nonlocal refresh_calls
|
|
refresh_calls += 1
|
|
real_refresh()
|
|
# send() performs one preflight refresh before the first attempt;
|
|
# the second refresh is the retry re-create seam this test owns.
|
|
if refresh_calls == 2:
|
|
session._cancel_event.set()
|
|
|
|
with patch.object(session, "_refresh_model_from_registry", side_effect=cancel_in_window):
|
|
session.send("test")
|
|
|
|
assert refresh_calls == 2
|
|
# 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
|