fix(memory): preserve replay identity and validation

This commit is contained in:
Patrick Buckley
2026-08-11 21:26:27 -07:00
parent cc84f9d176
commit 3252f3fd95
12 changed files with 302 additions and 115 deletions
+3 -5
View File
@@ -1,10 +1,8 @@
"""Shared mock factory for ``events_replay`` tests. """Shared mock factory for ``events_replay`` tests.
Both interactive (:func:`turnstone.server._interactive_events_replay`) Interactive and coordinator replay tests exercise the same shared preamble at
and coord (:func:`turnstone.console.server._coord_events_replay`) drive :func:`turnstone.core.session_replay.session_replay_preamble` plus their
the same shared preamble at kind-specific tails. Their test suites share the underlying mock surface (session.model,
:func:`turnstone.core.session_replay.session_replay_preamble`. Their
test suites share the underlying mock surface (session.model,
session.model_alias, session._last_usage, ui._pending_*, ui._ws_lock, session.model_alias, session._last_usage, ui._pending_*, ui._ws_lock,
counters); this module is the single home for that shape so a future counters); this module is the single home for that shape so a future
field add lands once. field add lands once.
+14 -27
View File
@@ -2102,39 +2102,35 @@ def test_coord_cancel_cascade_failure_does_not_fail_owner_cancel(storage):
from tests._replay_helpers import make_replay_mocks as _make_coord_replay_mocks # noqa: E402 from tests._replay_helpers import make_replay_mocks as _make_coord_replay_mocks # noqa: E402
def test_coord_events_replay_yields_connected_first(): def test_coord_shared_preamble_yields_connected_first():
"""Pre-status-bar coord replay only re-injected pending_approval + """The shared preamble gives coordinator streams the same bootstrap."""
pending_plan_review. Post-status-bar parity with interactive from turnstone.core.session_replay import session_replay_preamble
yields ``connected`` first so the dashboard's status bar populates
the model cell before any history arrives — mirrors the
interactive replay (turnstone/server.py:_interactive_events_replay)."""
from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks() ws, ui, _request = _make_coord_replay_mocks()
out = list(_coord_events_replay(ws, ui, request)) out = list(session_replay_preamble(ws.session, ui))
assert out[0]["type"] == "connected" assert out[0]["type"] == "connected"
assert out[0]["model"] == "gpt-5" assert out[0]["model"] == "gpt-5"
assert out[0]["model_alias"] == "default" assert out[0]["model_alias"] == "default"
assert out[0]["skip_permissions"] is False assert out[0]["skip_permissions"] is False
def test_coord_events_replay_includes_status_only_when_last_usage_present(): def test_coord_shared_preamble_includes_status_only_when_last_usage_present():
"""The ``status`` event populates the per-tab token-usage bar on """The ``status`` event populates the per-tab token-usage bar on
resume. Skipped when ``session._last_usage`` is None (a freshly- resume. Skipped when ``session._last_usage`` is None (a freshly-
created coordinator that hasn't completed a turn) — matches created coordinator that hasn't completed a turn) — matches
interactive behaviour.""" interactive behaviour."""
from turnstone.console.server import _coord_events_replay from turnstone.core.session_replay import session_replay_preamble
ws, ui, request = _make_coord_replay_mocks() ws, ui, _request = _make_coord_replay_mocks()
out = list(_coord_events_replay(ws, ui, request)) out = list(session_replay_preamble(ws.session, ui))
assert "status" not in {ev["type"] for ev in out} assert "status" not in {ev["type"] for ev in out}
def test_coord_events_replay_status_payload_shape(): def test_coord_shared_preamble_status_payload_shape():
"""When ``last_usage`` exists, the replayed ``status`` event carries """When ``last_usage`` exists, the replayed ``status`` event carries
every field the dashboard's updateStatusBar() reads — same shape every field the dashboard's updateStatusBar() reads — same shape
SessionUI.on_status emits live.""" SessionUI.on_status emits live."""
from turnstone.console.server import _coord_events_replay from turnstone.core.session_replay import session_replay_preamble
ws, ui, request = _make_coord_replay_mocks( ws, ui, request = _make_coord_replay_mocks(
last_usage={ last_usage={
@@ -2146,7 +2142,7 @@ def test_coord_events_replay_status_payload_shape():
_ws_turn_tool_calls=3, _ws_turn_tool_calls=3,
_ws_messages=7, _ws_messages=7,
) )
out = list(_coord_events_replay(ws, ui, request)) out = list(session_replay_preamble(ws.session, ui))
status = next(ev for ev in out if ev["type"] == "status") status = next(ev for ev in out if ev["type"] == "status")
assert status["prompt_tokens"] == 40000 assert status["prompt_tokens"] == 40000
assert status["completion_tokens"] == 6310 assert status["completion_tokens"] == 6310
@@ -2174,12 +2170,7 @@ def test_coord_events_replay_skips_session_block_when_no_session():
def test_coord_events_replay_yields_pending_approval(): def test_coord_events_replay_yields_pending_approval():
"""The lifted coord ``events_replay`` callback yields, after the """The coord replay tail yields pending approval without mutation."""
connected preamble, the pending approval (if any). Pre-lift coord
pushed it onto the listener queue via ``put_nowait``; the lift
restructures as a generator the lifted body iterates and yields as
``data:`` lines, but the payload identity is preserved. Pure-read —
never mutates ``ui``."""
from turnstone.console.server import _coord_events_replay from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks( ws, ui, request = _make_coord_replay_mocks(
@@ -2188,8 +2179,6 @@ def test_coord_events_replay_yields_pending_approval():
out = list(_coord_events_replay(ws, ui, request)) out = list(_coord_events_replay(ws, ui, request))
types = [ev["type"] for ev in out] types = [ev["type"] for ev in out]
# Status preamble is yielded first (no last_usage → no status); the
# pending-approval re-injection then matches the pre-lift body.
assert types[0] == "connected" assert types[0] == "connected"
assert "approve_request" in types assert "approve_request" in types
@@ -2246,9 +2235,7 @@ def test_coord_events_replay_skips_verdict_replay_without_pending_approval():
def test_coord_events_replay_yields_only_connected_when_no_pending(): def test_coord_events_replay_yields_only_connected_when_no_pending():
"""A workstream with a session but no pending approval / plan """Without controls or usage, replay contains only the preamble."""
review and no last_usage yields just the ``connected`` preamble.
The lifted body falls through to the live loop immediately after."""
from turnstone.console.server import _coord_events_replay from turnstone.console.server import _coord_events_replay
ws, ui, request = _make_coord_replay_mocks() ws, ui, request = _make_coord_replay_mocks()
+33
View File
@@ -118,6 +118,39 @@ class TestLiveProjectAccess:
assert access.attached_project_id == "p1" assert access.attached_project_id == "p1"
assert access.project_id == "" assert access.project_id == ""
def test_project_display_name_uses_explicit_principal(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
seen: list[tuple[str, str]] = []
def _resolve(principal_id: str, project_id: str) -> object:
seen.append((principal_id, project_id))
return self._access(True, True)
monkeypatch.setattr(auth, "resolve_project_access", _resolve)
s = _session(user_id="owner", project_id="p1")
s._acting_user_id = "stale-turn-actor"
seen.clear() # Ignore constructor-time system-context composition.
assert s.project_name_for_principal("reconnecting-viewer") == "P"
assert seen == [("reconnecting-viewer", "p1")]
def test_project_display_name_does_not_fallback_for_empty_principal(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
seen: list[tuple[str, str]] = []
def _resolve(principal_id: str, project_id: str) -> object:
seen.append((principal_id, project_id))
return self._access(True, True)
monkeypatch.setattr(auth, "resolve_project_access", _resolve)
s = _session(user_id="owner", project_id="p1")
seen.clear()
assert s.project_name_for_principal("") == ""
assert seen == []
class TestProjectRecall: class TestProjectRecall:
def test_interactive_visible_scopes_includes_project( def test_interactive_visible_scopes_includes_project(
+9 -11
View File
@@ -1435,28 +1435,26 @@ class TestInteractiveEventsLifted:
section. section.
""" """
def test_events_replay_yields_connected_first(self): def test_shared_preamble_yields_connected_first(self):
"""Pre-lift ``events_sse`` yielded a ``connected`` event """The shared handler's preamble preserves connected-event shape."""
first (model + skip_permissions). The lifted callback from turnstone.core.session_replay import session_replay_preamble
preserves the order so client SSE handlers that key on
the connected event for state setup keep working."""
from turnstone.server import _interactive_events_replay
ws, ui, request = _make_interactive_replay_mocks() ws, ui, request = _make_interactive_replay_mocks()
out = list(_interactive_events_replay(ws, ui, request)) out = list(session_replay_preamble(ws.session, ui, project_name="Visible Project"))
assert out[0]["type"] == "connected" assert out[0]["type"] == "connected"
assert out[0]["model"] == "gpt-5" assert out[0]["model"] == "gpt-5"
assert out[0]["model_alias"] == "default" assert out[0]["model_alias"] == "default"
assert out[0]["project_name"] == "Visible Project"
assert out[0]["skip_permissions"] is False assert out[0]["skip_permissions"] is False
def test_events_replay_includes_status_only_when_last_usage_present(self): def test_shared_preamble_includes_status_only_when_last_usage_present(self):
"""The ``status`` event populates the per-tab token-usage """The ``status`` event populates the per-tab token-usage
bar on resume. Skipped when ``session._last_usage`` is None bar on resume. Skipped when ``session._last_usage`` is None
(a freshly-created workstream that hasn't completed a turn).""" (a freshly-created workstream that hasn't completed a turn)."""
from turnstone.server import _interactive_events_replay from turnstone.core.session_replay import session_replay_preamble
ws, ui, request = _make_interactive_replay_mocks() ws, ui, _request = _make_interactive_replay_mocks()
out = list(_interactive_events_replay(ws, ui, request)) out = list(session_replay_preamble(ws.session, ui))
assert "status" not in {ev["type"] for ev in out} assert "status" not in {ev["type"] for ev in out}
def test_events_replay_yields_pending_approval_then_verdicts(self): def test_events_replay_yields_pending_approval_then_verdicts(self):
+93 -6
View File
@@ -53,6 +53,7 @@ def _fake_request(
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
query: dict[str, str] | None = None, query: dict[str, str] | None = None,
path_params: dict[str, str] | None = None, path_params: dict[str, str] | None = None,
user_id: str = "viewer-1",
) -> Request: ) -> Request:
"""Construct a Starlette ``Request`` for the events handler. """Construct a Starlette ``Request`` for the events handler.
@@ -80,7 +81,9 @@ def _fake_request(
async def _recv() -> dict[str, Any]: # noqa: RUF029 — async signature required async def _recv() -> dict[str, Any]: # noqa: RUF029 — async signature required
return {"type": "http.disconnect"} return {"type": "http.disconnect"}
return Request(scope, receive=_recv) request = Request(scope, receive=_recv)
request.state.auth_result = SimpleNS(user_id=user_id)
return request
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -673,6 +676,87 @@ def test_handler_emits_retry_on_first_yield() -> None:
assert 2500 <= retry <= 4500, f"retry {retry} outside jitter band [2500, 4500]" assert 2500 <= retry <= 4500, f"retry {retry} outside jitter band [2500, 4500]"
def test_handler_resolves_project_off_loop_and_preserves_legacy_replay_callback() -> None:
"""Project metadata uses the viewer off-loop; replay keeps its 3-arg API."""
from turnstone.core.session_replay import (
request_replay_project_name,
session_replay_preamble,
)
event_loop_thread = threading.get_ident()
resolver_threads: list[int] = []
principals: list[str] = []
callback_calls: list[tuple[Any, Any, Any]] = []
session = MagicMock()
session.model = "test"
session.model_alias = ""
session._last_usage = None
def _project_name(principal_id: str) -> str:
resolver_threads.append(threading.get_ident())
principals.append(principal_id)
return "Visible Project"
session.project_name_for_principal.side_effect = _project_name
def _replay(ws: Any, ui: Any, request: Any) -> Any:
callback_calls.append((ws, ui, request))
yield from session_replay_preamble(
ws.session,
ui,
project_name=request_replay_project_name(request),
)
yield {"type": "tail"}
ui = _make_ui()
_, blob = _drain_handler_yields(
ui,
session=session,
events_replay=_replay,
max_yields=4,
)
assert principals == ["viewer-1"]
assert resolver_threads and resolver_threads[0] != event_loop_thread
assert len(callback_calls) == 1
assert '"type": "connected"' in blob
assert '"project_name": "Visible Project"' in blob
assert '"type": "tail"' in blob
def test_handler_project_lookup_failure_keeps_connected_event() -> None:
"""Optional project metadata fails closed without losing bootstrap."""
from turnstone.core.session_replay import (
request_replay_project_name,
session_replay_preamble,
)
session = MagicMock()
session.model = "test"
session.model_alias = ""
session._last_usage = None
session.project_name_for_principal.side_effect = RuntimeError("storage unavailable")
def _replay(ws: Any, ui: Any, request: Any) -> Any:
yield from session_replay_preamble(
ws.session,
ui,
project_name=request_replay_project_name(request),
)
ui = _make_ui()
_, blob = _drain_handler_yields(
ui,
session=session,
events_replay=_replay,
max_yields=3,
)
assert '"type": "connected"' in blob
assert '"project_name": ""' in blob
def test_handler_replay_ok_skips_snapshot_emits_id(monkeypatch: Any) -> None: def test_handler_replay_ok_skips_snapshot_emits_id(monkeypatch: Any) -> None:
"""``Last-Event-ID`` + buffer covers gap → emit buffered events """``Last-Event-ID`` + buffer covers gap → emit buffered events
with SSE ``id:`` field, SKIP the in-progress snapshot (it would with SSE ``id:`` field, SKIP the in-progress snapshot (it would
@@ -982,6 +1066,11 @@ class _HandoffSession:
self.context_window = 1000 self.context_window = 1000
self.reasoning_effort = "low" self.reasoning_effort = "low"
self._last_usage = {"prompt_tokens": 12, "completion_tokens": 0} self._last_usage = {"prompt_tokens": 12, "completion_tokens": 0}
self.project_name_principals: list[str] = []
def project_name_for_principal(self, principal_id: str) -> str:
self.project_name_principals.append(principal_id)
return ""
def register_listener_for_history_handoff( def register_listener_for_history_handoff(
self, self,
@@ -1093,11 +1182,8 @@ def test_handoff_cursor_replay_keeps_preamble_without_pending_control_duplicate(
session = _HandoffSession(ui) session = _HandoffSession(ui)
def _full_replay(_ws: Any, _ui: Any, _request: Any) -> Any: def _full_replay(_ws: Any, _ui: Any, _request: Any) -> Any:
# The full replay's own preamble half is what the replay_ok path # The kind-specific tail must NOT run on replay_ok; the lifted body
# must NOT re-run; the lifted body calls the shared # calls the shared preamble directly and the ring owns controls.
# session_replay_preamble directly instead (no per-kind hook).
yield {"type": "connected", "model": "test"}
yield {"type": "status", "total_tokens": 12}
yield {"type": "approve_request", "items": [{"call_id": "duplicate"}]} yield {"type": "approve_request", "items": [{"call_id": "duplicate"}]}
_, blob = _drain_handler_yields( _, blob = _drain_handler_yields(
@@ -1110,6 +1196,7 @@ def test_handoff_cursor_replay_keeps_preamble_without_pending_control_duplicate(
) )
assert session.calls == [(session.token, 0)] assert session.calls == [(session.token, 0)]
assert session.project_name_principals == ["viewer-1"]
assert '"type": "connected"' in blob assert '"type": "connected"' in blob
assert '"type": "status"' in blob assert '"type": "status"' in blob
assert '"type": "state_change"' in blob assert '"type": "state_change"' in blob
+31 -2
View File
@@ -20,10 +20,39 @@ def _save(name, content, **kwargs):
class TestSaveStructuredMemory: class TestSaveStructuredMemory:
@pytest.mark.parametrize("save", [save_structured_memory, save_structured_memory_strict])
@pytest.mark.parametrize("description", [None, "", " "]) @pytest.mark.parametrize("description", [None, "", " "])
def test_description_is_required(self, tmp_db, description): def test_description_is_required(self, tmp_db, description, save):
with pytest.raises(ValueError, match="description is required"): with pytest.raises(ValueError, match="description is required"):
save_structured_memory_strict("test_key", "hello world", description=description) save("test_key", "hello world", description=description)
def test_best_effort_save_still_swallows_storage_failures(self, tmp_db, monkeypatch):
from turnstone.core import memory as memory_mod
class _BoomStorage:
def upsert_structured_memory(self, *_args, **_kwargs):
raise RuntimeError("simulated storage failure")
monkeypatch.setattr(memory_mod, "get_storage", lambda: _BoomStorage())
assert save_structured_memory(
"test_key",
"hello world",
description="Test memory",
) == (None, False)
def test_best_effort_save_does_not_misclassify_backend_value_error(self, tmp_db, monkeypatch):
from turnstone.core import memory as memory_mod
class _BoomStorage:
def upsert_structured_memory(self, *_args, **_kwargs):
raise ValueError("backend decode failure")
monkeypatch.setattr(memory_mod, "get_storage", lambda: _BoomStorage())
assert save_structured_memory(
"test_key",
"hello world",
description="Test memory",
) == (None, False)
def test_save_new(self, tmp_db): def test_save_new(self, tmp_db):
row, was_update = _save("test_key", "hello world") row, was_update = _save("test_key", "hello world")
+15 -13
View File
@@ -76,7 +76,10 @@ from turnstone.core.model_registry import (
from turnstone.core.model_registry import MODEL_AUTH_MODES as _MODEL_AUTH_MODES from turnstone.core.model_registry import MODEL_AUTH_MODES as _MODEL_AUTH_MODES
from turnstone.core.rendezvous import NoAvailableNodeError, NodeRef from turnstone.core.rendezvous import NoAvailableNodeError, NodeRef
from turnstone.core.rerank_calibrate import canonical_caps_value from turnstone.core.rerank_calibrate import canonical_caps_value
from turnstone.core.session_replay import session_replay_preamble from turnstone.core.session_replay import (
request_replay_project_name,
session_replay_preamble,
)
from turnstone.core.session_routes import ( from turnstone.core.session_routes import (
AttachmentUploadHelpers, AttachmentUploadHelpers,
CoordOnlyVerbHandlers, CoordOnlyVerbHandlers,
@@ -3687,20 +3690,15 @@ def _audit_retry_coordinator(
def _coord_events_replay( def _coord_events_replay(
ws: Workstream, ws: Workstream,
ui: Any, ui: Any,
request: Request, # noqa: ARG001 — coord replay doesn't need request context request: Request,
) -> Iterable[dict[str, Any]]: ) -> Iterable[dict[str, Any]]:
"""Initial SSE replay payload for coord ``events`` connections. """Initial SSE replay payload for coord ``events`` connections.
Yields, in order: Yields ``connected`` plus optional ``status``, then the pending approval
prompt (if any) and cached LLM verdicts that fired since it surfaced. The
1. ``connected`` + optional ``status`` via the shared shared handler resolves viewer-specific project metadata off-loop before
:func:`turnstone.core.session_replay.session_replay_preamble` invoking this callback. Without the control replay a refresh loses the
so the dashboard's status bar populates before any live tick. judge chip until the operator re-invokes the action.
Same payload shape interactive uses.
2. Pending approval prompt (if any) and the cached LLM verdicts
that fired since it surfaced. Without this replay a refresh
loses the judge chip on the pending approval until the
operator re-invokes the action.
Coord still skips conversation history the dashboard fetches it Coord still skips conversation history the dashboard fetches it
via a separate ``GET /history`` endpoint and doesn't want a via a separate ``GET /history`` endpoint and doesn't want a
@@ -3708,7 +3706,11 @@ def _coord_events_replay(
Pure read never mutates ``ui`` / ``ws`` / ``session``. Pure read never mutates ``ui`` / ``ws`` / ``session``.
""" """
yield from session_replay_preamble(ws.session, ui) yield from session_replay_preamble(
ws.session,
ui,
project_name=request_replay_project_name(request),
)
# EVERY live approval cycle replays (parallel task agents can have # EVERY live approval cycle replays (parallel task agents can have
# several outstanding), each card followed once by the cached LLM # several outstanding), each card followed once by the cached LLM
+18 -9
View File
@@ -855,6 +855,13 @@ def search_history_recent(limit: int = 20, *, user_id: str | None = None) -> lis
# -- Structured memories ------------------------------------------------------- # -- Structured memories -------------------------------------------------------
def _require_memory_description(description: str) -> str:
"""Return a normalized description or raise the public validation error."""
if not isinstance(description, str) or not (normalized := description.strip()):
raise ValueError("memory description is required and must be non-empty")
return normalized
def save_structured_memory( def save_structured_memory(
name: str, name: str,
content: str, content: str,
@@ -873,16 +880,20 @@ def save_structured_memory(
:meth:`StorageBackend.upsert_structured_memory`) -- no preceding read, no :meth:`StorageBackend.upsert_structured_memory`) -- no preceding read, no
IntegrityError round-trip, no TOCTOU window. ``(row, was_update)`` comes IntegrityError round-trip, no TOCTOU window. ``(row, was_update)`` comes
straight from that upsert (this passes a fresh ``memory_id``, so a differing straight from that upsert (this passes a fresh ``memory_id``, so a differing
returned id means an existing row was updated in place). A ``None`` returned id means an existing row was updated in place). ``description``
``description`` is required and must contain non-whitespace text for both is required and must contain non-whitespace text for both inserts and
inserts and updates. A ``None`` ``mem_type`` keeps the stored value on an updates. A ``None`` ``mem_type`` keeps the stored value on an update and
update and uses the column default on insert. uses the column default on insert.
""" """
# Validate outside the best-effort storage boundary. Backend/driver
# ``ValueError`` instances remain operational failures; only this explicit
# caller-input check propagates.
normalized_description = _require_memory_description(description)
try: try:
return save_structured_memory_strict( return save_structured_memory_strict(
name, name,
content, content,
description=description, description=normalized_description,
mem_type=mem_type, mem_type=mem_type,
scope=scope, scope=scope,
scope_id=scope_id, scope_id=scope_id,
@@ -907,14 +918,12 @@ def save_structured_memory_strict(
Unlike :func:`save_structured_memory`, storage failures propagate so an Unlike :func:`save_structured_memory`, storage failures propagate so an
API or tool cannot report a database outage as an ordinary failed/not-found API or tool cannot report a database outage as an ordinary failed/not-found
result. Prompt composition keeps using the best-effort facade. result. Best-effort internal callers keep using the facade.
""" """
import uuid import uuid
normalized = normalize_key(name) normalized = normalize_key(name)
normalized_description = (description or "").strip() normalized_description = _require_memory_description(description)
if not normalized_description:
raise ValueError("memory description is required and must be non-empty")
row, was_update = get_storage().upsert_structured_memory( row, was_update = get_storage().upsert_structured_memory(
str(uuid.uuid4()), str(uuid.uuid4()),
normalized, normalized,
+9
View File
@@ -18417,6 +18417,15 @@ class ChatSession:
project_writable=project_writable, project_writable=project_writable,
) )
def project_name_for_principal(self, principal_id: str) -> str:
"""Return the active attached-project name visible to ``principal_id``.
The principal is explicit so connection-level presentation never reads
the session's mutable acting-user binding. Project rename, archival,
and access revocation therefore take effect on the next lookup.
"""
return self._memory_access(principal_id).project_name
def _resolve_scope_id(self, scope: str, access: _MemoryAccess | None = None) -> str: def _resolve_scope_id(self, scope: str, access: _MemoryAccess | None = None) -> str:
"""Map a validated scope to the actor-specific storage key.""" """Map a validated scope to the actor-specific storage key."""
resolved = access or self._memory_access() resolved = access or self._memory_access()
+13 -4
View File
@@ -22,9 +22,17 @@ if TYPE_CHECKING:
from turnstone.core.session import ChatSession from turnstone.core.session import ChatSession
def request_replay_project_name(request: Any) -> str:
"""Return project metadata pre-resolved by the shared SSE route."""
value = getattr(getattr(request, "state", None), "_session_replay_project_name", "")
return value if isinstance(value, str) else ""
def session_replay_preamble( def session_replay_preamble(
session: ChatSession | None, session: ChatSession | None,
ui: Any, ui: Any,
*,
project_name: str = "",
) -> Iterable[dict[str, Any]]: ) -> Iterable[dict[str, Any]]:
"""Yield ``connected`` + optional ``status`` events for an SSE replay. """Yield ``connected`` + optional ``status`` events for an SSE replay.
@@ -33,7 +41,8 @@ def session_replay_preamble(
through to the kind-specific tail. through to the kind-specific tail.
- ``connected`` carries ``model`` / ``model_alias`` / ``skip_permissions`` - ``connected`` carries ``model`` / ``model_alias`` / ``skip_permissions``
so the per-tab status bar populates the model cell before any so the per-tab status bar populates the model cell before any
history arrives. history arrives. The caller supplies the project display name already
resolved for the authenticated connection principal.
- ``status`` only fires when ``session._last_usage`` exists (a - ``status`` only fires when ``session._last_usage`` exists (a
session that has completed at least one turn). The payload shape session that has completed at least one turn). The payload shape
matches :meth:`SessionUI.on_status` so live ticks and replays use matches :meth:`SessionUI.on_status` so live ticks and replays use
@@ -48,9 +57,9 @@ def session_replay_preamble(
"type": "connected", "type": "connected",
"model": session.model, "model": session.model,
"model_alias": session.model_alias or "", "model_alias": session.model_alias or "",
# The attached project's display name (""=none) so the composer can # The visible attached-project display name (""=none) so the composer
# paint its "has a project" badge on connect, beside the model chip. # can paint its project badge on connect, beside the model chip.
"project_name": getattr(session, "_project_name", "") or "", "project_name": project_name,
"skip_permissions": getattr(ui, "auto_approve", False), "skip_permissions": getattr(ui, "auto_approve", False),
} }
+52 -27
View File
@@ -173,15 +173,11 @@ class EventsReplay(Protocol):
live event loop starts. Each yielded dict gets JSON-serialised live event loop starts. Each yielded dict gets JSON-serialised
and sent as a single ``data:`` line to the client. and sent as a single ``data:`` line to the client.
Interactive yields four things on connect: ``connected`` (model + The shared handler pre-resolves viewer-specific project metadata onto the
skip_permissions), ``status`` (token usage + context %, only when request before calling this stable three-argument callback. Production
``session._last_usage`` exists), ``history`` (replayed conversation), callbacks emit ``connected`` plus optional ``status``, then pending controls
and ``pending_approval`` + cached intent verdicts. Coord yields and cached verdicts. Conversation history stays on the separate REST
just one: ``pending_approval`` (the rest aren't needed because endpoint. Kinds without replay wire ``None``.
coord's dashboard fetches history via a separate ``/history``
endpoint and doesn't render the per-tab status bar). Kinds that
don't need any pre-replay wire ``None`` and the live loop starts
immediately.
""" """
def __call__(self, ws: Workstream, ui: Any, request: Request) -> Iterable[dict[str, Any]]: def __call__(self, ws: Workstream, ui: Any, request: Request) -> Iterable[dict[str, Any]]:
@@ -444,13 +440,11 @@ class SessionEndpointConfig:
# wires ``None`` and lets the cluster collector handle the # wires ``None`` and lets the cluster collector handle the
# transition via ``CoordinatorAdapter.emit_rehydrated``. # transition via ``CoordinatorAdapter.emit_rehydrated``.
open_post_load: OpenPostLoad | None = None open_post_load: OpenPostLoad | None = None
# (ws, ui, request) -> Iterable[dict]. Kind-specific initial # (ws, ui, request) -> Iterable[dict]. Kind-specific initial SSE replay
# SSE replay payload the lifted ``events`` body yields after # payload the lifted ``events`` body yields before the live event loop.
# registering the per-UI listener queue but before the live # The shared handler pins viewer-specific project metadata on ``request``;
# event loop. Both production kinds yield connected + optional status, # production callbacks combine it with connected/status and pending
# followed by pending approval controls and cached verdicts. Conversation # controls. Conversation history stays on the separate REST bootstrap.
# history stays on the separate REST ``/history`` bootstrap. Kinds that
# don't need pre-replay wire ``None``.
events_replay: EventsReplay | None = None events_replay: EventsReplay | None = None
# (request) -> Executor for the SSE live-loop's blocking # (request) -> Executor for the SSE live-loop's blocking
# ``queue.get`` wait. Interactive returns the dedicated # ``queue.get`` wait. Interactive returns the dedicated
@@ -2298,15 +2292,15 @@ def make_open_handler(
def make_events_handler(cfg: SessionEndpointConfig) -> Handler: def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
"""Lifted body for ``GET {prefix}/{ws_id}/events`` — per-workstream SSE. """Lifted body for ``GET {prefix}/{ws_id}/events`` — per-workstream SSE.
Both kinds share the SSE plumbing: register the per-UI listener Both kinds share the SSE plumbing: resolve viewer-specific project
queue, run the kind-specific initial replay (``cfg.events_replay``, metadata off-loop, register the per-UI listener queue, run the configured
typically ``connected`` + ``status`` + ``history`` + pending initial replay (``cfg.events_replay``), then drain the queue forever until
approval / plan on interactive; just pending approval / plan on either the workstream closes (``ws_closed`` event) or the client
coord), then drain the queue forever until either the workstream disconnects. Cursor-only replay emits the shared connected/status preamble
closes (``ws_closed`` event) or the client disconnects. directly because it deliberately skips kind-specific pending controls.
The kind-specific divergence is captured entirely by The kind-specific replay divergence is captured by
``cfg.events_replay``. The live-loop body, the listener ``cfg.events_replay``. The cursor preamble, live-loop body, listener
registration, the ``ws_closed`` exit, the disconnect detection, registration, the ``ws_closed`` exit, the disconnect detection,
and the SSE-connect/disconnect metric recording are uniform. and the SSE-connect/disconnect metric recording are uniform.
@@ -2432,6 +2426,33 @@ def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
# ``ui`` is a ``SessionUIBase`` subclass, so the cast is # ``ui`` is a ``SessionUIBase`` subclass, so the cast is
# tightening the type, not weakening it. # tightening the type, not weakening it.
ui_base = cast("SessionUIBase", ui) ui_base = cast("SessionUIBase", ui)
from turnstone.core.web_helpers import auth_user_id
# Pin the authenticated viewer once per connection. Resolve optional
# project presentation metadata off the event loop and before listener
# registration, so database latency cannot stall unrelated async work
# or let this listener's queue accumulate while it waits.
replay_principal_id = auth_user_id(request).strip()
replay_project_name = ""
session = getattr(ws, "session", None)
project_name_for_principal = getattr(session, "project_name_for_principal", None)
if callable(project_name_for_principal):
try:
resolved_project_name = await asyncio.to_thread(
project_name_for_principal,
replay_principal_id,
)
if isinstance(resolved_project_name, str):
replay_project_name = resolved_project_name
except Exception:
# Project metadata is optional presentation context. Fail
# closed to no badge without dropping the SSE bootstrap.
log.debug(
"ws.events.project_name_failed ws=%s",
ws_id[:8],
exc_info=True,
)
request.state._session_replay_project_name = replay_project_name
replay_status: str replay_status: str
replay_events: list[dict[str, Any]] = [] replay_events: list[dict[str, Any]] = []
lost_count = 0 lost_count = 0
@@ -2675,7 +2696,11 @@ def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
# contract lives in session_replay_preamble alone (it # contract lives in session_replay_preamble alone (it
# no-ops on a detached session internally). # no-ops on a detached session internally).
try: try:
for ev in session_replay_preamble(ws.session, ui): for ev in session_replay_preamble(
ws.session,
ui,
project_name=replay_project_name,
):
yield {"data": json.dumps(ev)} yield {"data": json.dumps(ev)}
except Exception: except Exception:
log.debug( log.debug(
@@ -2745,8 +2770,8 @@ def make_events_handler(cfg: SessionEndpointConfig) -> Handler:
) )
} }
# Replay phase — stream the kind-specific initial # Replay phase — stream the kind-specific initial payload
# payload one event at a time so the client sees # one event at a time so the client sees
# the first byte immediately. Pre-building into # the first byte immediately. Pre-building into
# a list would block time-to-first-byte until the # a list would block time-to-first-byte until the
# entire replay materialized AND let the listener # entire replay materialized AND let the listener
+12 -11
View File
@@ -78,7 +78,10 @@ from turnstone.core.session_manager import (
STALE_CREATE_SWEEP_INTERVAL_SECONDS, STALE_CREATE_SWEEP_INTERVAL_SECONDS,
SessionManager, SessionManager,
) )
from turnstone.core.session_replay import session_replay_preamble from turnstone.core.session_replay import (
request_replay_project_name,
session_replay_preamble,
)
from turnstone.core.session_routes import ( from turnstone.core.session_routes import (
AttachmentUploadHelpers, AttachmentUploadHelpers,
CreatePreCommitError, CreatePreCommitError,
@@ -801,12 +804,9 @@ def _interactive_events_replay(
) -> Iterable[dict[str, Any]]: ) -> Iterable[dict[str, Any]]:
"""Initial SSE replay payload for interactive ``events`` connections. """Initial SSE replay payload for interactive ``events`` connections.
Yields a ``connected`` event (model + skip_permissions), a Yields the shared ``connected`` / ``status`` preamble, followed by pending
``status`` event with the workstream's last token usage + context % approval prompts and cached intent verdicts. The lifted handler resolves
(when a turn has completed), and the pending approval prompt + cached viewer-specific project metadata off-loop before invoking this callback.
intent verdicts (if a prompt is pending). The lifted
``make_events_handler`` body delegates that yield sequence to this
callback so the kind-specific shape stays in this module.
Conversation history is NOT replayed over SSE: the frontend fetches Conversation history is NOT replayed over SSE: the frontend fetches
it via ``GET /history`` on page load and re-fetches on the it via ``GET /history`` on page load and re-fetches on the
@@ -821,10 +821,11 @@ def _interactive_events_replay(
# session can still be detached on the close-then-reopen path. # session can still be detached on the close-then-reopen path.
return return
# Connected + status preamble — same shape coord replays and the yield from session_replay_preamble(
# lifted reconnect path use; one shared function, no per-kind ws.session,
# wrapper, so a future field add cannot land on one surface only. ui,
yield from session_replay_preamble(ws.session, ui) project_name=request_replay_project_name(request),
)
# Pending approval re-injection (so a reconnecting tab sees the # Pending approval re-injection (so a reconnecting tab sees the
# prompt) + cached LLM verdicts received since the prompt fired. # prompt) + cached LLM verdicts received since the prompt fired.