mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 16:02:24 -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.
238 lines
7.8 KiB
Python
238 lines
7.8 KiB
Python
"""Tests for InteractiveAdapter.
|
|
|
|
Focus: the interactive lifecycle transport contract and ``cleanup_ui``
|
|
behavior (unblock pending events, broadcast ``ws_closed`` to per-UI
|
|
listeners, cancel + close session). The SessionManager-level tests
|
|
in ``test_session_manager.py`` cover the adapter-agnostic lifecycle.
|
|
|
|
``emit_created`` now owns the bounded global-queue publication after the
|
|
HTTP handler has prepared and validated the create. ``emit_state`` and
|
|
``emit_rehydrated`` remain out-of-band/no-op on interactive.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from turnstone.core.adapters.interactive_adapter import InteractiveAdapter
|
|
from turnstone.core.workstream import Workstream, WorkstreamKind
|
|
|
|
|
|
class _StubUI:
|
|
"""Stub matching the subset of WebUI the adapter touches."""
|
|
|
|
def __init__(self) -> None:
|
|
self._approval_event = threading.Event()
|
|
self._approval_result: tuple[bool, str | None] = (True, "initial")
|
|
self._fg_event = threading.Event()
|
|
self._listeners_lock = threading.Lock()
|
|
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
|
|
|
|
|
class _StubSession:
|
|
def __init__(self) -> None:
|
|
self.cancelled = False
|
|
self.closed = False
|
|
self.model = "gpt-5"
|
|
self.model_alias = "default"
|
|
|
|
def cancel(self) -> None:
|
|
self.cancelled = True
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def _make_adapter(
|
|
*,
|
|
ui_factory: Any = None,
|
|
session_factory: Any = None,
|
|
) -> tuple[InteractiveAdapter, queue.Queue[dict[str, Any]]]:
|
|
gq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=100)
|
|
adapter = InteractiveAdapter(
|
|
global_queue=gq,
|
|
ui_factory=ui_factory or (lambda ws: _StubUI()),
|
|
session_factory=session_factory or (lambda *a, **kw: _StubSession()),
|
|
)
|
|
return adapter, gq
|
|
|
|
|
|
def _make_ws(**overrides: Any) -> Workstream:
|
|
ws = Workstream(id="ws-1", name="hello")
|
|
ws.kind = WorkstreamKind.INTERACTIVE
|
|
ws.user_id = "u1"
|
|
ws.ui = _StubUI()
|
|
ws.session = _StubSession()
|
|
for k, v in overrides.items():
|
|
setattr(ws, k, v)
|
|
return ws
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transport
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_emit_closed_defaults_to_closed_reason() -> None:
|
|
adapter, gq = _make_adapter()
|
|
adapter.emit_closed("ws-1", name="my-ws")
|
|
event = gq.get_nowait()
|
|
assert event == {
|
|
"type": "ws_closed",
|
|
"ws_id": "ws-1",
|
|
"reason": "closed",
|
|
"name": "my-ws",
|
|
}
|
|
|
|
|
|
def test_emit_closed_propagates_evicted_reason_and_name() -> None:
|
|
adapter, gq = _make_adapter()
|
|
adapter.emit_closed("ws-1", reason="evicted", name="my-ws")
|
|
event = gq.get_nowait()
|
|
assert event["reason"] == "evicted"
|
|
assert event["name"] == "my-ws"
|
|
|
|
|
|
def test_emit_closed_default_name_is_empty_string() -> None:
|
|
adapter, gq = _make_adapter()
|
|
adapter.emit_closed("ws-1")
|
|
assert gq.get_nowait()["name"] == ""
|
|
|
|
|
|
def test_emit_swallows_queue_full_without_raising() -> None:
|
|
gq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1)
|
|
gq.put({"type": "filler"})
|
|
adapter = InteractiveAdapter(
|
|
global_queue=gq,
|
|
ui_factory=lambda ws: _StubUI(),
|
|
session_factory=lambda *a, **kw: _StubSession(),
|
|
)
|
|
adapter.emit_closed("ws-1") # must not raise even though queue is full
|
|
assert gq.qsize() == 1 # nothing added on a full queue
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cleanup_ui
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cleanup_ui_unblocks_pending_approval_fg_events() -> None:
|
|
adapter, _ = _make_adapter()
|
|
ws = _make_ws()
|
|
# Simulate pending events
|
|
ws.ui._approval_event.clear() # type: ignore[attr-defined]
|
|
ws.ui._fg_event.clear() # type: ignore[attr-defined]
|
|
|
|
adapter.cleanup_ui(ws)
|
|
|
|
assert ws.ui._approval_event.is_set() # type: ignore[attr-defined]
|
|
assert ws.ui._fg_event.is_set() # type: ignore[attr-defined]
|
|
# Approval result flipped to "deny" so the waiter sees a sensible value.
|
|
assert ws.ui._approval_result == (False, None) # type: ignore[attr-defined]
|
|
|
|
|
|
def test_cleanup_ui_broadcasts_ws_closed_to_listener_queues() -> None:
|
|
adapter, _ = _make_adapter()
|
|
ws = _make_ws()
|
|
lq1: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=10)
|
|
lq2: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=10)
|
|
ws.ui._listeners.extend([lq1, lq2]) # type: ignore[attr-defined]
|
|
|
|
adapter.cleanup_ui(ws)
|
|
|
|
assert lq1.get_nowait() == {"type": "ws_closed"}
|
|
assert lq2.get_nowait() == {"type": "ws_closed"}
|
|
# Listeners cleared so subsequent events don't fan out to dead generators.
|
|
assert ws.ui._listeners == [] # type: ignore[attr-defined]
|
|
|
|
|
|
def test_cleanup_ui_broadcast_evicts_stale_head_when_listener_queue_full() -> None:
|
|
"""Per the old _cleanup_ui fallback: when a listener queue is full,
|
|
drop the oldest event and put ws_closed. Ensures an unresponsive
|
|
browser tab doesn't block close."""
|
|
adapter, _ = _make_adapter()
|
|
ws = _make_ws()
|
|
lq: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1)
|
|
lq.put_nowait({"type": "stale"})
|
|
ws.ui._listeners.append(lq) # type: ignore[attr-defined]
|
|
|
|
adapter.cleanup_ui(ws)
|
|
|
|
assert lq.get_nowait() == {"type": "ws_closed"}
|
|
assert lq.empty()
|
|
|
|
|
|
def test_cleanup_ui_cancels_and_closes_session() -> None:
|
|
adapter, _ = _make_adapter()
|
|
ws = _make_ws()
|
|
adapter.cleanup_ui(ws)
|
|
assert ws.session.cancelled is True # type: ignore[attr-defined]
|
|
assert ws.session.closed is True # type: ignore[attr-defined]
|
|
|
|
|
|
def test_cleanup_ui_tolerates_missing_session_and_ui() -> None:
|
|
"""A placeholder workstream whose session build failed may arrive
|
|
at cleanup_ui with session=None or ui=None. Must not crash."""
|
|
adapter, _ = _make_adapter()
|
|
ws = _make_ws()
|
|
ws.session = None
|
|
ws.ui = None
|
|
adapter.cleanup_ui(ws) # no crash
|
|
|
|
|
|
def test_cleanup_ui_tolerates_stub_ui_without_events() -> None:
|
|
"""A stub UI missing _approval_event / etc. (test scaffolding
|
|
code) must not crash cleanup_ui — the hasattr guards matter."""
|
|
adapter, _ = _make_adapter()
|
|
ws = _make_ws()
|
|
ws.ui = MagicMock(spec=[]) # empty spec — attribute accesses miss
|
|
adapter.cleanup_ui(ws) # no crash
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Construction passthrough
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_ui_delegates_to_ui_factory() -> None:
|
|
captured_ws: list[Workstream] = []
|
|
|
|
def _ui_factory(ws: Workstream) -> Any:
|
|
captured_ws.append(ws)
|
|
return _StubUI()
|
|
|
|
adapter, _ = _make_adapter(ui_factory=_ui_factory)
|
|
ws = _make_ws()
|
|
result = adapter.build_ui(ws)
|
|
assert captured_ws == [ws]
|
|
assert isinstance(result, _StubUI)
|
|
|
|
|
|
def test_build_session_forwards_all_kwargs_to_session_factory() -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _sf(ui: Any, model: str | None, ws_id: str, **kwargs: Any) -> Any:
|
|
captured["ui"] = ui
|
|
captured["model"] = model
|
|
captured["ws_id"] = ws_id
|
|
captured.update(kwargs)
|
|
return _StubSession()
|
|
|
|
adapter, _ = _make_adapter(session_factory=_sf)
|
|
ws = _make_ws()
|
|
adapter.build_session(
|
|
ws, skill="coder", model="gpt-5", client_type="web", judge_model="gpt-4.1"
|
|
)
|
|
assert captured["ui"] is ws.ui
|
|
assert captured["model"] == "gpt-5"
|
|
assert captured["ws_id"] == ws.id
|
|
assert captured["skill"] == "coder"
|
|
assert captured["client_type"] == "web"
|
|
assert captured["kind"] == WorkstreamKind.INTERACTIVE
|
|
assert captured["parent_ws_id"] is None
|
|
# Kind-specific passthrough — interactive session_factory accepts judge_model.
|
|
assert captured["judge_model"] == "gpt-4.1"
|