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.
657 lines
23 KiB
Python
657 lines
23 KiB
Python
"""Shared session-test helpers.
|
|
|
|
The minimal ``ChatSession`` factory, the ``SessionUIBase`` no-op/recording
|
|
subclasses, and the tree's standard streaming provider fakes
|
|
(``make_result`` / ``arm_session`` / ``scripted_provider`` /
|
|
``ArmedHandle``, at the bottom): every suite driving the streaming seam
|
|
imports them from here, so the eager-arming contract lives in one place.
|
|
The one deliberate exception, ``test_model_registry.py``'s
|
|
``_make_session``, takes a different signature (registry / model_alias /
|
|
reasoning_effort + ``_FakeUI``) and is NOT a candidate for sharing.
|
|
|
|
Module is named with a leading underscore so pytest doesn't try to
|
|
collect it as a test file — it's an importable utility, not a test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from turnstone.core.model_turn import ModelTurnResult, resolve_model_binding
|
|
from turnstone.core.providers import ModelCapabilities, StreamChunk, ToolCallDelta, UsageInfo
|
|
from turnstone.core.session import ChatSession
|
|
from turnstone.core.session_ui_base import SessionUIBase
|
|
from turnstone.core.trajectory import ProviderNative, ToolCall, Turn
|
|
|
|
|
|
class NullUI(SessionUIBase):
|
|
"""Bare-bones UI satisfying the SessionUIBase contract for tests
|
|
that don't care about UI side effects."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
|
|
|
|
_UNCHANGED = object()
|
|
|
|
|
|
def replace_session_lane(
|
|
session: Any,
|
|
*,
|
|
provider: Any = _UNCHANGED,
|
|
client: Any = _UNCHANGED,
|
|
model: Any = _UNCHANGED,
|
|
alias: Any = _UNCHANGED,
|
|
capabilities: Any = _UNCHANGED,
|
|
) -> Any:
|
|
"""Atomically replace selected facets of a test session's model lane.
|
|
|
|
Production sessions deliberately expose no mutable raw provider/client
|
|
slots. Tests that install a scripted provider use this one helper so their
|
|
setup follows the same whole-lane replacement rule as registry rebinding.
|
|
"""
|
|
binding = session._model_binding
|
|
old_lane = binding.lane
|
|
next_provider = old_lane.provider if provider is _UNCHANGED else provider
|
|
next_model = old_lane.model if model is _UNCHANGED else model
|
|
if capabilities is _UNCHANGED:
|
|
next_capabilities = old_lane.capabilities
|
|
if provider is not _UNCHANGED:
|
|
next_capabilities = next_provider.get_capabilities(next_model)
|
|
else:
|
|
next_capabilities = capabilities
|
|
lane = dataclasses.replace(
|
|
old_lane,
|
|
provider=next_provider,
|
|
client=old_lane.client if client is _UNCHANGED else client,
|
|
model=next_model,
|
|
alias=old_lane.alias if alias is _UNCHANGED else alias,
|
|
capabilities=next_capabilities,
|
|
)
|
|
session._model_binding = dataclasses.replace(binding, lane=lane)
|
|
return lane
|
|
|
|
|
|
def make_session(**kwargs: Any) -> ChatSession:
|
|
"""Build a ChatSession with minimal defaults; tests override
|
|
individual fields via kwargs."""
|
|
defaults: dict[str, Any] = {
|
|
"client": MagicMock(),
|
|
"model": "test-model",
|
|
"ui": NullUI(),
|
|
"instructions": None,
|
|
"temperature": 0.5,
|
|
"max_tokens": 4096,
|
|
"tool_timeout": 30,
|
|
}
|
|
defaults.update(kwargs)
|
|
registry = defaults.get("registry")
|
|
model_alias = defaults.get("model_alias")
|
|
if registry is not None and model_alias and defaults.get("model_binding") is None:
|
|
binding = resolve_model_binding(
|
|
registry,
|
|
model_alias,
|
|
config_store=defaults.get("config_store"),
|
|
)
|
|
defaults["client"] = binding.lane.client
|
|
defaults["model"] = binding.lane.model
|
|
defaults["registry_generation"] = binding.registry_generation
|
|
defaults["model_binding"] = binding
|
|
if "context_window" not in kwargs and binding.config is not None:
|
|
defaults["context_window"] = binding.config.context_window
|
|
return ChatSession(**defaults)
|
|
|
|
|
|
def mock_completion_result(
|
|
content: str = "",
|
|
tool_calls: list[dict[str, Any]] | None = None,
|
|
) -> MagicMock:
|
|
"""A provider result shaped like ``CompletionResult``.
|
|
|
|
Callers that route through ``model_turn`` (judges, task agents, and
|
|
every lane #827 migrates) hit its re-ingest, which iterates
|
|
``tool_calls``/``provider_blocks`` and joins ``reasoning`` — a bare
|
|
MagicMock attribute would TypeError deep inside the seam, so every
|
|
field the re-ingest reads is pinned to a real value here. ONE shared
|
|
definition: when the re-ingest starts reading a new CompletionResult
|
|
field, add it here and every suite moves together.
|
|
"""
|
|
result = MagicMock()
|
|
result.content = content
|
|
result.tool_calls = tool_calls
|
|
result.finish_reason = "stop"
|
|
result.usage = None
|
|
result.provider_blocks = []
|
|
result.reasoning = ""
|
|
return result
|
|
|
|
|
|
def fake_chat_stream(
|
|
*,
|
|
content: str | None = None,
|
|
tool_calls: list[dict[str, str]] | None = None,
|
|
finish_reason: str = "stop",
|
|
prompt_tokens: int = 10,
|
|
completion_tokens: int = 5,
|
|
reasoning_content: str | None = None,
|
|
reasoning: str | None = None,
|
|
) -> list[Any]:
|
|
"""Fake OpenAI Chat Completions SSE chunks for driving the REAL
|
|
``OpenAIChatCompletionsProvider`` through a fake SDK client::
|
|
|
|
client.chat.completions.create = lambda **kw: fake_chat_stream(...)
|
|
|
|
Exercises the adapter's ``_iter_stream`` plus ``drain_stream`` end to
|
|
end (the highest-fidelity fake lane), unlike ``as_stream`` which fakes
|
|
at the provider boundary. ``tool_calls`` entries are
|
|
``{"id", "name", "arguments"}`` dicts. ``SimpleNamespace`` (not
|
|
``MagicMock``) so absent SDK fields read as real ``None`` — an
|
|
auto-created mock attribute would leak into ``len()``/string paths.
|
|
|
|
Emits the realistic three-phase shape: data chunk(s), a finish-reason
|
|
chunk, then the ``stream_options.include_usage`` usage-only chunk with
|
|
empty ``choices``.
|
|
"""
|
|
|
|
def _delta(
|
|
content_val: str | None = None,
|
|
tcs: list[Any] | None = None,
|
|
rc: str | None = None,
|
|
rsn: str | None = None,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
content=content_val,
|
|
tool_calls=tcs,
|
|
reasoning=rsn,
|
|
reasoning_content=rc,
|
|
annotations=None,
|
|
)
|
|
|
|
chunks: list[Any] = []
|
|
if reasoning_content is not None or reasoning is not None:
|
|
chunks.append(
|
|
SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
finish_reason=None, delta=_delta(rc=reasoning_content, rsn=reasoning)
|
|
)
|
|
],
|
|
usage=None,
|
|
)
|
|
)
|
|
if content is not None:
|
|
chunks.append(
|
|
SimpleNamespace(
|
|
choices=[SimpleNamespace(finish_reason=None, delta=_delta(content))],
|
|
usage=None,
|
|
)
|
|
)
|
|
if tool_calls:
|
|
tcs = [
|
|
SimpleNamespace(
|
|
index=i,
|
|
id=tc.get("id", ""),
|
|
function=SimpleNamespace(
|
|
name=tc.get("name", ""), arguments=tc.get("arguments", "")
|
|
),
|
|
)
|
|
for i, tc in enumerate(tool_calls)
|
|
]
|
|
chunks.append(
|
|
SimpleNamespace(
|
|
choices=[SimpleNamespace(finish_reason=None, delta=_delta(None, tcs))],
|
|
usage=None,
|
|
)
|
|
)
|
|
chunks.append(
|
|
SimpleNamespace(
|
|
choices=[SimpleNamespace(finish_reason=finish_reason, delta=_delta())],
|
|
usage=None,
|
|
)
|
|
)
|
|
chunks.append(
|
|
SimpleNamespace(
|
|
choices=[],
|
|
usage=SimpleNamespace(
|
|
prompt_tokens=prompt_tokens,
|
|
completion_tokens=completion_tokens,
|
|
total_tokens=prompt_tokens + completion_tokens,
|
|
prompt_tokens_details=None,
|
|
input_tokens_details=None,
|
|
),
|
|
)
|
|
)
|
|
return chunks
|
|
|
|
|
|
class _ScriptedClient:
|
|
"""Callable client-method fake following a script of stream builders.
|
|
|
|
Call N returns the stream described by ``scripts[N]``; the last script
|
|
repeats for any further calls. Each script is a dict of kwargs for
|
|
the bound stream builder, or a pre-built return value. Records every
|
|
call's kwargs on ``.calls`` — read ``len(fn.calls)`` where a test
|
|
previously kept its own counter cell, and ``fn.calls[i]["messages"]``
|
|
where it captured request bodies.
|
|
"""
|
|
|
|
def __init__(self, scripts: tuple[Any, ...], to_stream: Any) -> None:
|
|
self._scripts = scripts
|
|
self._to_stream = to_stream
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
def __call__(self, **kwargs: Any) -> Any:
|
|
self.calls.append(kwargs)
|
|
script = self._scripts[min(len(self.calls) - 1, len(self._scripts) - 1)]
|
|
return self._to_stream(**script) if isinstance(script, dict) else script
|
|
|
|
|
|
def scripted_chat_client(*scripts: Any) -> _ScriptedClient:
|
|
"""A scripted ``client.chat.completions.create`` — dict scripts are
|
|
:func:`fake_chat_stream` kwargs."""
|
|
return _ScriptedClient(scripts, fake_chat_stream)
|
|
|
|
|
|
def scripted_anthropic_client(*scripts: Any) -> _ScriptedClient:
|
|
"""A scripted ``client.messages.stream`` — dict scripts are
|
|
:func:`fake_anthropic_stream` kwargs (``blocks`` plus optional
|
|
``stop_reason``/``usage``)."""
|
|
return _ScriptedClient(scripts, fake_anthropic_stream)
|
|
|
|
|
|
class FakeAnthropicBlock:
|
|
"""A full-content Anthropic content-block fake for
|
|
:func:`fake_anthropic_stream` — plain attributes plus the
|
|
``model_dump()`` the provider's block capture reads."""
|
|
|
|
def __init__(self, **fields: Any) -> None:
|
|
self._fields = fields
|
|
for key, value in fields.items():
|
|
setattr(self, key, value)
|
|
|
|
def model_dump(self, **_kw: Any) -> dict[str, Any]:
|
|
return dict(self._fields)
|
|
|
|
|
|
def fake_anthropic_stream(
|
|
blocks: list[Any],
|
|
*,
|
|
stop_reason: str | None = "end_turn",
|
|
usage: Any = None,
|
|
) -> Any:
|
|
"""Fake Anthropic SDK stream context manager for tests that drive the
|
|
REAL ``AnthropicProvider`` through a fake client::
|
|
|
|
client.messages.stream = lambda **kw: fake_anthropic_stream(...)
|
|
|
|
Accepts the same full-content block fakes the pre-#831
|
|
``get_final_message`` fixtures used (objects with ``.type`` + fields
|
|
and ``model_dump()``) and synthesizes the real event grammar the
|
|
streaming iterator consumes: ``content_block_start`` carries the block
|
|
with its text/thinking/signature EMPTIED and ``input`` as ``{}`` (the
|
|
SDK start shape), deltas carry the content, ``content_block_stop``
|
|
finalizes tool input, and the closing ``message_delta`` carries
|
|
``stop_reason`` (+ optional usage object). Without the stripping, the
|
|
provider's raw-block accumulator would double every text/thinking
|
|
field (start capture + delta append).
|
|
|
|
``stop_reason=None`` omits the closing ``message_delta`` entirely —
|
|
the terminal-signal-less lax-gateway shape ``finish_reason_optional``
|
|
exists for (content arrives, then the stream just ends).
|
|
"""
|
|
events: list[Any] = []
|
|
for idx, block in enumerate(blocks):
|
|
d = dict(block.model_dump()) if hasattr(block, "model_dump") else dict(vars(block))
|
|
btype = d.get("type", "")
|
|
start = dict(d)
|
|
if btype == "text":
|
|
start["text"] = ""
|
|
elif btype == "thinking":
|
|
start["thinking"] = ""
|
|
start["signature"] = ""
|
|
elif btype == "tool_use":
|
|
start["input"] = {}
|
|
events.append(
|
|
SimpleNamespace(
|
|
type="content_block_start", index=idx, content_block=SimpleNamespace(**start)
|
|
)
|
|
)
|
|
if btype == "text" and d.get("text"):
|
|
events.append(
|
|
SimpleNamespace(
|
|
type="content_block_delta",
|
|
index=idx,
|
|
delta=SimpleNamespace(type="text_delta", text=d["text"]),
|
|
)
|
|
)
|
|
elif btype == "thinking":
|
|
if d.get("thinking"):
|
|
events.append(
|
|
SimpleNamespace(
|
|
type="content_block_delta",
|
|
index=idx,
|
|
delta=SimpleNamespace(type="thinking_delta", thinking=d["thinking"]),
|
|
)
|
|
)
|
|
if d.get("signature"):
|
|
events.append(
|
|
SimpleNamespace(
|
|
type="content_block_delta",
|
|
index=idx,
|
|
delta=SimpleNamespace(type="signature_delta", signature=d["signature"]),
|
|
)
|
|
)
|
|
elif btype == "tool_use":
|
|
events.append(
|
|
SimpleNamespace(
|
|
type="content_block_delta",
|
|
index=idx,
|
|
delta=SimpleNamespace(
|
|
type="input_json_delta",
|
|
partial_json=json.dumps(d.get("input", {})),
|
|
),
|
|
)
|
|
)
|
|
events.append(SimpleNamespace(type="content_block_stop", index=idx))
|
|
if stop_reason is not None or usage is not None:
|
|
events.append(
|
|
SimpleNamespace(
|
|
type="message_delta", usage=usage, delta=SimpleNamespace(stop_reason=stop_reason)
|
|
)
|
|
)
|
|
|
|
mgr = MagicMock()
|
|
mgr.__enter__ = MagicMock(return_value=events)
|
|
mgr.__exit__ = MagicMock(return_value=False)
|
|
return mgr
|
|
|
|
|
|
def as_stream(result: Any) -> list[StreamChunk]:
|
|
"""Adapt a ``CompletionResult``-shaped fake to a ``create_streaming``
|
|
return value (single terminal chunk).
|
|
|
|
The #831 transport collapse routes every single-shot lane through
|
|
``drain_stream(provider.create_streaming(...))``, so provider fakes
|
|
return chunk iterables now. Tests keep building result-shaped fakes
|
|
(``mock_completion_result`` or hand-rolled) and wrap them at
|
|
assignment: ``provider.create_streaming.return_value =
|
|
as_stream(result)``. A list re-iterates on every call, so one
|
|
``return_value`` serves repeated-call tests; convert AFTER mutating
|
|
the fake's fields — the chunk snapshots them.
|
|
|
|
Multi-chunk accumulation semantics are exercised by the dedicated
|
|
``drain_stream`` unit tests, not through this helper.
|
|
"""
|
|
deltas = [
|
|
ToolCallDelta(
|
|
index=i,
|
|
id=tc.get("id", ""),
|
|
name=tc.get("function", {}).get("name", ""),
|
|
arguments_delta=tc.get("function", {}).get("arguments", ""),
|
|
)
|
|
for i, tc in enumerate(result.tool_calls or [])
|
|
]
|
|
return [
|
|
StreamChunk(
|
|
content_delta=result.content or "",
|
|
reasoning_delta=getattr(result, "reasoning", "") or "",
|
|
tool_call_deltas=deltas,
|
|
usage=result.usage,
|
|
finish_reason=result.finish_reason or "stop",
|
|
provider_blocks=list(result.provider_blocks or []),
|
|
)
|
|
]
|
|
|
|
|
|
def think_tag_stream(utterance: str) -> list[StreamChunk]:
|
|
"""``create_streaming`` return value simulating a passthrough server
|
|
that emits *utterance* — typically think-tag-bearing — as plain
|
|
streamed content.
|
|
|
|
The per-lane fixture for inline-reasoning dialect pins: lane tests
|
|
supply their own utterances (the dialect's SEMANTICS are specified
|
|
once, in ``tests._reasoning_dialect.CASES``, and pinned by the
|
|
one-shot suites — lane pins assert lane behavior, not tag grammar).
|
|
Routes through the real ``drain_stream`` seam exactly like
|
|
``as_stream``.
|
|
"""
|
|
return as_stream(mock_completion_result(content=utterance))
|
|
|
|
|
|
def seam_provider(utterance: str, *, provider_name: str = "openai-compatible") -> MagicMock:
|
|
"""Provider fake whose ``create_streaming`` replays *utterance* through
|
|
the REAL drain seam (``think_tag_stream``) — THE lane-suite seam fake.
|
|
|
|
One definition so the lane suites cannot drift when the provider
|
|
surface ``model_turn`` probes grows: real ``ModelCapabilities`` for
|
|
the clamp math, ``provider_name`` overridable per suite.
|
|
|
|
Assign the RETURNED fake to ``session._provider`` — never mutate the
|
|
provider a session resolved on its own: with a MagicMock client the
|
|
session resolves the process-wide ``create_provider(...)`` singleton,
|
|
and writing that shared instance's ``create_streaming`` poisons every
|
|
later session in the test run (the SSE-recovery e2e servers resolve
|
|
the same instance).
|
|
"""
|
|
provider = provider_shell(provider_name)
|
|
provider.create_streaming = MagicMock(return_value=think_tag_stream(utterance))
|
|
return provider
|
|
|
|
|
|
class RecordingUI:
|
|
"""UI adapter recording the ordered event stream ``send()`` emits."""
|
|
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
def _rec(self, kind, detail=""):
|
|
self.events.append((kind, detail))
|
|
|
|
def on_turn_start(self):
|
|
self._rec("turn_start")
|
|
|
|
def on_turn_committed(self):
|
|
self._rec("turn_committed")
|
|
|
|
def on_stream_discarded(self):
|
|
self._rec("stream_discarded")
|
|
|
|
def on_thinking_start(self):
|
|
self._rec("thinking_start")
|
|
|
|
def on_thinking_stop(self):
|
|
self._rec("thinking_stop")
|
|
|
|
def on_reasoning_token(self, text):
|
|
self._rec("reasoning", text)
|
|
|
|
def on_content_token(self, text):
|
|
self._rec("content", text)
|
|
|
|
def on_stream_end(self):
|
|
self._rec("stream_end")
|
|
|
|
def approve_tools(self, items):
|
|
return True, None
|
|
|
|
def on_tool_result(self, call_id, name, output, **kwargs):
|
|
pass
|
|
|
|
def on_tool_output_chunk(self, call_id, chunk):
|
|
pass
|
|
|
|
def on_status(self, usage, context_window, effort):
|
|
pass
|
|
|
|
def on_info(self, message):
|
|
self._rec("info", message)
|
|
|
|
def on_error(self, message):
|
|
self._rec("error", message)
|
|
|
|
def on_state_change(self, state):
|
|
self._rec("state", state)
|
|
|
|
def on_rename(self, name):
|
|
pass
|
|
|
|
def on_output_warning(self, call_id, assessment):
|
|
pass
|
|
|
|
def record_output_assessment(
|
|
self,
|
|
call_id,
|
|
assessment,
|
|
*,
|
|
tier="heuristic",
|
|
reasoning="",
|
|
judge_model="",
|
|
latency_ms=0,
|
|
confidence=0.0,
|
|
):
|
|
pass
|
|
|
|
def kinds(self):
|
|
return [k for k, _ in self.events]
|
|
|
|
def of(self, kind):
|
|
return [d for k, d in self.events if k == kind]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming provider fakes — the #832 seam contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def make_result(
|
|
content: str = "",
|
|
*,
|
|
tool_calls: list[dict[str, Any]] | None = None,
|
|
finish_reason: str = "stop",
|
|
usage: UsageInfo | None = None,
|
|
native_blocks: list[dict[str, Any]] | None = None,
|
|
producer: str = "openai-compatible",
|
|
wire_msgs: list[dict[str, Any]] | None = None,
|
|
) -> ModelTurnResult:
|
|
"""A ``ModelTurnResult`` shaped like the streaming wrapper's return,
|
|
for tests that only need "a turn happened" and patch
|
|
``_stream_response`` wholesale. Turn and ``tool_calls`` mirror are
|
|
built from the same dicts, preserving the #825 pairing invariant."""
|
|
calls = list(tool_calls or [])
|
|
tc_tuple = tuple(
|
|
ToolCall(
|
|
id=tc.get("id", ""),
|
|
name=tc.get("function", {}).get("name", ""),
|
|
arguments=tc.get("function", {}).get("arguments", ""),
|
|
)
|
|
for tc in calls
|
|
)
|
|
native = (
|
|
ProviderNative(producer=producer, blocks=tuple(native_blocks)) if native_blocks else None
|
|
)
|
|
return ModelTurnResult(
|
|
turn=Turn.assistant(content, tool_calls=tc_tuple, native=native),
|
|
finish_reason=finish_reason,
|
|
usage=usage,
|
|
tool_calls=calls,
|
|
wire_msgs=wire_msgs,
|
|
producer=producer,
|
|
)
|
|
|
|
|
|
class ArmedHandle:
|
|
"""Closeable sentinel standing in for the SDK stream handle."""
|
|
|
|
def __init__(self) -> None:
|
|
self.closed = False
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def provider_shell(
|
|
name: str = "openai-compatible",
|
|
retryable: frozenset[str] = frozenset({"IncompleteStreamError"}),
|
|
) -> MagicMock:
|
|
"""The armed-provider fake skeleton every streaming fake builds on:
|
|
provider_name / capabilities / retryable set. ONE spelling, so a new
|
|
attribute the seam starts probing lands in every fake at once."""
|
|
provider = MagicMock()
|
|
provider.provider_name = name
|
|
provider.get_capabilities.return_value = ModelCapabilities()
|
|
provider.retryable_error_names = retryable
|
|
return provider
|
|
|
|
|
|
def arm_session(
|
|
session: Any,
|
|
*streams: Any,
|
|
retryable: frozenset[str] = frozenset({"IncompleteStreamError"}),
|
|
name: str = "openai-compatible",
|
|
) -> MagicMock:
|
|
"""Install a sequential multi-turn armed provider fake on *session*.
|
|
|
|
Each ``create_streaming`` call serves the next element of *streams*:
|
|
an iterable/generator is armed (a closeable sentinel appended to
|
|
``cancel_ref`` — the eager append every real adapter performs, which
|
|
the creation-vs-midstream classifier keys on) and returned to be
|
|
consumed once; an EXCEPTION instance is raised at create time WITHOUT
|
|
arming, a creation-phase failure the per-lane ladder owns. Calls
|
|
beyond the script fail loudly: the strict finish gate rejects an
|
|
exhausted iterator rather than absorbing it as a silent empty turn,
|
|
so an under-scripted test must say so.
|
|
|
|
Title generation is latched off — with a provider-LEVEL fake the
|
|
best-effort title lane would otherwise consume the first script
|
|
before the main loop ran.
|
|
"""
|
|
session._title_generated = True
|
|
provider = provider_shell(name, retryable)
|
|
# One handle PER CREATE (the real adapters' rule): `handles` records
|
|
# them all, `_armed_handle` is the latest.
|
|
provider._armed_handle = None
|
|
provider.handles = []
|
|
remaining = list(streams)
|
|
|
|
def _create(**kwargs: Any):
|
|
assert remaining, "arm_session: script exhausted — send looped for more turns than scripted"
|
|
nxt = remaining.pop(0)
|
|
if isinstance(nxt, BaseException):
|
|
raise nxt
|
|
ref = kwargs.get("cancel_ref")
|
|
if ref is not None:
|
|
handle = ArmedHandle()
|
|
provider.handles.append(handle)
|
|
provider._armed_handle = handle
|
|
ref.append(handle)
|
|
return iter(nxt) if not hasattr(nxt, "__next__") else nxt
|
|
|
|
provider.create_streaming = MagicMock(side_effect=_create)
|
|
replace_session_lane(session, provider=provider)
|
|
return provider
|
|
|
|
|
|
def scripted_provider(chunks: list[StreamChunk]) -> MagicMock:
|
|
"""Provider fake replaying *chunks*, arming ``cancel_ref`` eagerly.
|
|
|
|
Install with :func:`replace_session_lane` (never mutate a resolved
|
|
provider — the create_provider singleton rule above). Each call returns a FRESH
|
|
iterator over the same script so ladder tests re-drive it; the armed
|
|
handle is appended per call, matching the one-handle-per-create
|
|
behavior of every real adapter.
|
|
"""
|
|
provider = provider_shell()
|
|
|
|
def _create(**kwargs: Any):
|
|
ref = kwargs.get("cancel_ref")
|
|
if ref is not None:
|
|
ref.append(ArmedHandle())
|
|
return iter(chunks)
|
|
|
|
provider.create_streaming = MagicMock(side_effect=_create)
|
|
return provider
|