diff --git a/tests/test_session.py b/tests/test_session.py index 34c1cc9f..07c2df19 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -3191,11 +3191,10 @@ class TestDeliverWakeNudge: assert session._wake_source_tag == "" def test_wake_row_persists_with_source_column(self, tmp_db): - """The wake's synthesised empty user turn now persists with - ``_source = "system_nudge"`` (post-#484 the skip at - ``session.py:2685-2686`` is dropped). Without persistence, - a second tab connecting via /history would see the assistant - turn with no preceding wake context. + """The wake's synthesised empty user turn persists with + ``_source = "system_nudge"``. Without persistence, a second + tab connecting via /history would see the assistant turn with + no preceding wake context. """ from turnstone.core.storage import get_storage diff --git a/tests/test_watch.py b/tests/test_watch.py index 0c0097af..2ef6297e 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -507,8 +507,8 @@ class TestWatchRunner: runner.set_dispatch_fn("ws-1", fn1) runner.set_dispatch_fn("ws-2", fn2) - # Post-Step-7 dispatch surface: ``_dispatch_result`` takes a - # structured reminder dict, not a bare string. + # ``_dispatch_result`` takes a structured reminder dict, not a + # bare string. reminder1 = {"type": "watch_triggered", "text": "msg1"} runner._dispatch_result("ws-1", reminder1, "watch-a") fn1.assert_called_once_with(reminder1, "watch-a") diff --git a/tests/test_watch_dispatch.py b/tests/test_watch_dispatch.py index de6d20ae..4b850965 100644 --- a/tests/test_watch_dispatch.py +++ b/tests/test_watch_dispatch.py @@ -1,8 +1,8 @@ """Tests for the watch dispatch closure built inside ``set_watch_runner``. The closure routes watch results onto the per-session :class:`NudgeQueue` -under the unified pull-model surface (post-#482). Each test focuses on -one assertion: enqueue shape, sanitisation, soft-cap drop-oldest, +under the unified pull-model surface. Each test focuses on one +assertion: enqueue shape, sanitisation, soft-cap drop-oldest, ``valid_until`` predicate, and concurrent-enqueue safety. Tests in this file replace the pre-switchover suite that pinned the @@ -357,11 +357,11 @@ def test_dispatch_no_op_for_empty_payloads(tmp_db, payload: str): class TestMetadataPropagation: - """Step 7 of the watch-card UX plan: the dispatch closure pulls - optional fields out of the structured ``reminder`` dict and - attaches them to the queue entry's ``metadata``. Drain seams later - merge ``metadata`` into the rendered reminder dict so the frontend - can display a structured ``.msg.watch-result`` card. + """The dispatch closure pulls optional fields out of the structured + ``reminder`` dict and attaches them to the queue entry's + ``metadata``. Drain seams later merge ``metadata`` into the + rendered reminder dict so the frontend can display a structured + ``.msg.watch-result`` card. """ def test_dispatch_attaches_watch_metadata_on_enqueue(self, tmp_db): diff --git a/tests/test_watch_integration.py b/tests/test_watch_integration.py index daedc1b4..ecf86f00 100644 --- a/tests/test_watch_integration.py +++ b/tests/test_watch_integration.py @@ -248,8 +248,8 @@ def test_watch_dispatch_through_restore_fn_lands_on_rehydrated_session(tmp_db, m assert runner.get_dispatch_fn(original_ws_id) is None # Stage 3 — fire a watch result. ``_dispatch_result`` should fall - # through to the restore branch. Post-Step-7 dispatch surface uses - # a structured reminder dict. + # through to the restore branch. The dispatch surface takes a + # structured reminder dict. runner._dispatch_result( original_ws_id, {"type": "watch_triggered", "text": "post-restore body"}, diff --git a/turnstone/cli.py b/turnstone/cli.py index 7b0cccfc..92177ff5 100644 --- a/turnstone/cli.py +++ b/turnstone/cli.py @@ -329,7 +329,6 @@ class TerminalUI(SessionUI): def on_user_reminder(self, reminders: list[dict[str, Any]], source: str | None = None) -> None: # ``source`` ignored — the CLI doesn't render a wake marker # (terminal output is anchored by sequence, not anchor element). - del source self._print_reminder(reminders) def on_tool_reminder(self, reminders: list[dict[str, Any]], tool_call_id: str) -> None: diff --git a/turnstone/core/session.py b/turnstone/core/session.py index fc0691d7..3cd04a3d 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -1398,7 +1398,7 @@ class ChatSession: """Inject the server-level WatchRunner and register a dispatch fn that routes watch results onto this session's NudgeQueue. - The dispatch closure is the post-#482 unified path: each watch + The dispatch closure is the unified pull-model path: each watch fire enqueues a ``"watch_triggered"`` entry on ``"any"`` channel, and the existing drain seams (USER_DRAIN, TOOL_DRAIN, ``IdleNudgeWatcher`` IDLE wake) splice it into a @@ -1457,10 +1457,15 @@ class ChatSession: except Exception: return False + from turnstone.core.watch import _WATCH_REMINDER_OPTIONAL_KEYS + + def _maybe_sanitize(v: Any) -> Any: + return sanitize_payload(v) if isinstance(v, str) else v + metadata = { - k: reminder[k] - for k in ("watch_name", "command", "poll_count", "max_polls", "is_final") - if isinstance(reminder, dict) and k in reminder + k: _maybe_sanitize(reminder[k]) + for k in _WATCH_REMINDER_OPTIONAL_KEYS + if k in reminder } nudge_queue.enqueue( "watch_triggered", diff --git a/turnstone/core/storage/migrations/versions/050_conversations_source_and_reminders.py b/turnstone/core/storage/migrations/versions/050_conversations_source_and_reminders.py index 636ad697..969108a8 100644 --- a/turnstone/core/storage/migrations/versions/050_conversations_source_and_reminders.py +++ b/turnstone/core/storage/migrations/versions/050_conversations_source_and_reminders.py @@ -14,6 +14,13 @@ at save time) and any preceding tab's reminder bubbles missing as well. ``{type, text, ...optional}``. Empty / missing column means no reminders for that row. +**SQLite upgrade cost.** Alembic env.py runs migrations with +``render_as_batch=True``, which on SQLite implements ``add_column`` by +recreating the table. Two ``add_column`` calls = two full-table +copies on first deployment after upgrade. For installs with months +of chat history (millions of rows) the migration takes seconds to +minutes. PostgreSQL is unaffected (metadata-only ALTER). + Revision ID: 050 Revises: 049 Create Date: 2026-05-06 diff --git a/turnstone/core/watch.py b/turnstone/core/watch.py index 98c51e9d..56a1a6ee 100644 --- a/turnstone/core/watch.py +++ b/turnstone/core/watch.py @@ -195,6 +195,15 @@ def format_watch_message( return "\n".join(lines) +_WATCH_REMINDER_OPTIONAL_KEYS = ( + "watch_name", + "command", + "poll_count", + "max_polls", + "is_final", +) + + def build_watch_reminder( name: str, command: str, diff --git a/turnstone/server.py b/turnstone/server.py index 41db3e82..0d760964 100644 --- a/turnstone/server.py +++ b/turnstone/server.py @@ -92,6 +92,7 @@ from turnstone.core.session_ui_base import ( fire_judge_verdict_metric, ) from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection +from turnstone.core.watch import _WATCH_REMINDER_OPTIONAL_KEYS from turnstone.core.web_helpers import version_html as _version_html from turnstone.core.workstream import ( Workstream, @@ -549,14 +550,7 @@ def _build_history( if not rtype and not rtext: continue clean: dict[str, Any] = {"type": rtype, "text": rtext} - # Preserve watch-card optional fields verbatim. - for opt_key in ( - "watch_name", - "command", - "poll_count", - "max_polls", - "is_final", - ): + for opt_key in _WATCH_REMINDER_OPTIONAL_KEYS: if opt_key in r: clean[opt_key] = r[opt_key] clean_reminders.append(clean)