mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(memory): preserve replay identity and validation
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user