diff --git a/convey/push.py b/convey/push.py index ec784b18b..ce26e55d2 100644 --- a/convey/push.py +++ b/convey/push.py @@ -120,6 +120,7 @@ def send_push_test(): title=title, body=message, context_id=f"push-test-{uuid.uuid4().hex[:12]}", + route="/app/home", ) return jsonify({"sent": sent, "failed": failed}) diff --git a/tests/test_push_dispatch.py b/tests/test_push_dispatch.py index 00751e5bf..c1bc0c32a 100644 --- a/tests/test_push_dispatch.py +++ b/tests/test_push_dispatch.py @@ -122,6 +122,21 @@ def test_agent_alert_payload_shape(): assert "interruption-level" not in payload["aps"] +def test_agent_alert_payload_includes_route_when_present(): + payload = dispatch.build_agent_alert_payload( + title="Agent Alert", + body="Needs review", + context_id="ctx-1", + route="/app/reflections/20260308", + ) + + assert payload["data"] == { + "action": "open_alert", + "context_id": "ctx-1", + "route": "/app/reflections/20260308", + } + + def test_commitment_payload_shape(): payload = dispatch.build_commitment_payload(ledger_id="lg_123") diff --git a/tests/test_push_routes.py b/tests/test_push_routes.py index 5138d9c33..f906ae2b1 100644 --- a/tests/test_push_routes.py +++ b/tests/test_push_routes.py @@ -128,7 +128,7 @@ def test_push_test_happy_path(push_client, monkeypatch): monkeypatch.setattr("convey.push.is_configured", lambda: True) monkeypatch.setattr( "convey.push.triggers.send_agent_alert", - lambda *, title, body, context_id: (1, 0), + lambda *, title, body, context_id, route: (1, 0), ) response = push_client.post( diff --git a/tests/test_push_runtime.py b/tests/test_push_runtime.py index 1652bd3dd..c35838815 100644 --- a/tests/test_push_runtime.py +++ b/tests/test_push_runtime.py @@ -6,6 +6,7 @@ from __future__ import annotations import pytest from flask import Flask +from think.push import runtime from think.push.runtime import ( get_runtime_state, start_push_runtime, @@ -35,11 +36,11 @@ def test_start_push_runtime_attaches_state(monkeypatch): start_push_runtime(app) try: - runtime = get_runtime_state() + runtime_state = get_runtime_state() assert app.push_runtime_started is True - assert runtime is not None - assert runtime.loop is not None - assert runtime.thread is not None + assert runtime_state is not None + assert runtime_state.loop is not None + assert runtime_state.thread is not None assert calls == ["start"] finally: stop_push_runtime(app) @@ -53,16 +54,16 @@ def test_start_push_runtime_is_idempotent(monkeypatch): app = Flask(__name__) start_push_runtime(app) - runtime = get_runtime_state() - first_loop = runtime.loop if runtime else None - first_thread = runtime.thread if runtime else None + runtime_state = get_runtime_state() + first_loop = runtime_state.loop if runtime_state else None + first_thread = runtime_state.thread if runtime_state else None try: start_push_runtime(app) - runtime = get_runtime_state() - assert runtime is not None - assert runtime.loop is first_loop - assert runtime.thread is first_thread - assert runtime.apps.count(app) == 1 + runtime_state = get_runtime_state() + assert runtime_state is not None + assert runtime_state.loop is first_loop + assert runtime_state.thread is first_thread + assert runtime_state.apps.count(app) == 1 finally: stop_push_runtime(app) @@ -93,3 +94,22 @@ def test_stop_all_push_runtime_clears_runtime(monkeypatch): assert app.push_runtime_started is False assert get_runtime_state() is None + + +def test_on_callosum_message_calls_both_handlers(monkeypatch): + calls: list[tuple[str, dict[str, str]]] = [] + monkeypatch.setattr( + runtime.triggers, + "handle_briefing_finish", + lambda message: calls.append(("briefing", message)), + ) + monkeypatch.setattr( + runtime.triggers, + "handle_weekly_reflection_finish", + lambda message: calls.append(("weekly_reflection", message)), + ) + message = {"tract": "cortex", "event": "finish", "name": "weekly_reflection"} + + runtime._on_callosum_message(message) + + assert calls == [("briefing", message), ("weekly_reflection", message)] diff --git a/tests/test_push_triggers.py b/tests/test_push_triggers.py index aecc3c542..163b9c037 100644 --- a/tests/test_push_triggers.py +++ b/tests/test_push_triggers.py @@ -186,12 +186,13 @@ def test_check_pre_meeting_prep_zero_devices_skips_log(monkeypatch, tmp_path): def test_send_agent_alert_same_context_id_fires_once(monkeypatch, tmp_path): monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) - sent_calls: list[str] = [] + sent_calls: list[dict[str, object]] = [] monkeypatch.setattr( triggers, "send_many", lambda devices, payload, *, collapse_id: ( - sent_calls.append(collapse_id) or (1, 0) + sent_calls.append({"collapse_id": collapse_id, "payload": payload}) + or (1, 0) ), ) @@ -204,9 +205,190 @@ def test_send_agent_alert_same_context_id_fires_once(monkeypatch, tmp_path): assert first == (1, 0) assert second == (0, 0) - assert sent_calls == ["alert.ctx-1"] + assert sent_calls == [ + { + "collapse_id": "alert.ctx-1", + "payload": { + "aps": { + "alert": {"title": "Agent Alert", "body": "Needs review"}, + "category": "SOLSTONE_AGENT_ALERT", + "sound": "default", + "mutable-content": 1, + "content-available": 1, + }, + "data": {"action": "open_alert", "context_id": "ctx-1"}, + }, + } + ] lines = [ json.loads(line) for line in _log_path(tmp_path).read_text(encoding="utf-8").splitlines() ] assert len(lines) == 1 + + +def test_send_agent_alert_forwards_route(monkeypatch, tmp_path): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) + payloads: list[dict[str, object]] = [] + monkeypatch.setattr( + triggers, + "send_many", + lambda devices, payload, *, collapse_id: payloads.append(payload) or (1, 0), + ) + + sent, failed = triggers.send_agent_alert( + title="Agent Alert", + body="Needs review", + context_id="ctx-2", + route="/app/reflections/20260308", + ) + + assert (sent, failed) == (1, 0) + assert payloads[0]["data"]["route"] == "/app/reflections/20260308" + + +def test_handle_weekly_reflection_finish_sends_once_and_appends_chat_event( + monkeypatch, tmp_path +): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + reflection_path = tmp_path / "reflections" / "weekly" / "20260308.md" + reflection_path.parent.mkdir(parents=True, exist_ok=True) + reflection_path.write_text("# reflection\n", encoding="utf-8") + monkeypatch.setattr(triggers.time, "sleep", lambda seconds: None) + monkeypatch.setattr(triggers, "_eligible_devices", lambda: [{"token": "a" * 64}]) + sent_calls: list[dict[str, object]] = [] + chat_events: list[dict[str, str]] = [] + monkeypatch.setattr( + triggers, + "send_many", + lambda devices, payload, *, collapse_id: ( + sent_calls.append({"payload": payload, "collapse_id": collapse_id}) + or (1, 0) + ), + ) + monkeypatch.setattr( + triggers, + "append_chat_event", + lambda kind, **fields: chat_events.append({"kind": kind, **fields}), + ) + + message = { + "tract": "cortex", + "event": "finish", + "name": "weekly_reflection", + "day": "20260308", + } + triggers.handle_weekly_reflection_finish(message) + triggers.handle_weekly_reflection_finish(message) + + assert sent_calls == [ + { + "payload": { + "aps": { + "alert": {"title": "your week is ready", "body": ""}, + "category": "SOLSTONE_AGENT_ALERT", + "sound": "default", + "mutable-content": 1, + "content-available": 1, + }, + "data": { + "action": "open_alert", + "context_id": "weekly_reflection:20260308", + "route": "/app/reflections/20260308", + }, + }, + "collapse_id": "alert.weekly_reflection:20260308", + } + ] + assert chat_events == [ + { + "kind": "reflection_ready", + "day": "20260308", + "url": "/app/reflections/20260308", + } + ] + lines = [ + json.loads(line) + for line in _log_path(tmp_path).read_text(encoding="utf-8").splitlines() + ] + assert len(lines) == 1 + assert lines[0]["category"] == "SOLSTONE_AGENT_ALERT" + assert lines[0]["context_id"] == "weekly_reflection:20260308" + + +def test_handle_weekly_reflection_finish_ignores_unrelated_events( + monkeypatch, tmp_path +): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + send_calls: list[tuple] = [] + monkeypatch.setattr( + triggers, + "send_agent_alert", + lambda **kwargs: send_calls.append(tuple(sorted(kwargs.items()))) or (1, 0), + ) + monkeypatch.setattr( + triggers, + "append_chat_event", + lambda kind, **fields: send_calls.append(("chat", kind, fields)), + ) + + triggers.handle_weekly_reflection_finish( + { + "tract": "chat", + "event": "finish", + "name": "weekly_reflection", + "day": "20260308", + } + ) + triggers.handle_weekly_reflection_finish( + { + "tract": "cortex", + "event": "start", + "name": "weekly_reflection", + "day": "20260308", + } + ) + triggers.handle_weekly_reflection_finish( + { + "tract": "cortex", + "event": "finish", + "name": "morning_briefing", + "day": "20260308", + } + ) + + assert send_calls == [] + + +def test_handle_weekly_reflection_finish_skips_when_file_never_appears( + monkeypatch, tmp_path +): + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) + sleeps: list[int] = [] + send_calls: list[dict[str, object]] = [] + chat_events: list[dict[str, object]] = [] + monkeypatch.setattr(triggers.time, "sleep", lambda seconds: sleeps.append(seconds)) + monkeypatch.setattr( + triggers, + "send_agent_alert", + lambda **kwargs: send_calls.append(kwargs) or (1, 0), + ) + monkeypatch.setattr( + triggers, + "append_chat_event", + lambda kind, **fields: chat_events.append({"kind": kind, **fields}), + ) + + triggers.handle_weekly_reflection_finish( + { + "tract": "cortex", + "event": "finish", + "name": "weekly_reflection", + "day": "20260308", + } + ) + + assert sleeps == [1] * 10 + assert send_calls == [] + assert chat_events == [] diff --git a/think/push/dispatch.py b/think/push/dispatch.py index ad1290a9f..9ef36dbe7 100644 --- a/think/push/dispatch.py +++ b/think/push/dispatch.py @@ -171,8 +171,11 @@ def build_pre_meeting_payload( def build_agent_alert_payload( - *, title: str, body: str, context_id: str + *, title: str, body: str, context_id: str, route: str | None = None ) -> dict[str, Any]: + data = {"action": "open_alert", "context_id": context_id} + if route is not None: + data["route"] = route return { "aps": { "alert": {"title": title, "body": body}, @@ -181,7 +184,7 @@ def build_agent_alert_payload( "mutable-content": 1, "content-available": 1, }, - "data": {"action": "open_alert", "context_id": context_id}, + "data": data, } diff --git a/think/push/runtime.py b/think/push/runtime.py index 3b4fba466..2a0910174 100644 --- a/think/push/runtime.py +++ b/think/push/runtime.py @@ -41,6 +41,7 @@ def get_runtime_state() -> RuntimeState | None: def _on_callosum_message(message: dict[str, Any]) -> None: try: triggers.handle_briefing_finish(message) + triggers.handle_weekly_reflection_finish(message) except Exception: logger.exception("push callosum handler failed") diff --git a/think/push/triggers.py b/think/push/triggers.py index 917a454c9..d22b99c0a 100644 --- a/think/push/triggers.py +++ b/think/push/triggers.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any from apps.home.routes import _load_briefing_md +from convey.chat_stream import append_chat_event from think.activities import load_activity_records from think.facets import get_enabled_facets from think.push.config import get_bundle_id, get_environment, is_configured @@ -214,7 +215,9 @@ def check_pre_meeting_prep(now: datetime) -> None: ) -def send_agent_alert(*, title: str, body: str, context_id: str) -> tuple[int, int]: +def send_agent_alert( + *, title: str, body: str, context_id: str, route: str | None = None +) -> tuple[int, int]: dedupe_key = (CATEGORY_AGENT_ALERT, context_id) if _has_nudged(dedupe_key): return 0, 0 @@ -223,7 +226,12 @@ def send_agent_alert(*, title: str, body: str, context_id: str) -> tuple[int, in return 0, 0 sent, failed = send_many( eligible_devices, - build_agent_alert_payload(title=title, body=body, context_id=context_id), + build_agent_alert_payload( + title=title, + body=body, + context_id=context_id, + route=route, + ), collapse_id=build_agent_alert_collapse_id(context_id), ) if sent > 0: @@ -237,4 +245,45 @@ def send_agent_alert(*, title: str, body: str, context_id: str) -> tuple[int, in return sent, failed -__all__ = ["check_pre_meeting_prep", "handle_briefing_finish", "send_agent_alert"] +def handle_weekly_reflection_finish(message: dict[str, Any]) -> None: + if message.get("tract") != "cortex": + return + if message.get("event") != "finish": + return + if message.get("name") != "weekly_reflection": + return + + day = str(message.get("day") or "").strip() + if not day: + return + + context_id = f"weekly_reflection:{day}" + dedupe_key = (CATEGORY_AGENT_ALERT, context_id) + if _has_nudged(dedupe_key): + return + + reflection_path = Path(get_journal()) / "reflections" / "weekly" / f"{day}.md" + for _ in range(10): + if reflection_path.is_file(): + break + time.sleep(1) + else: + logger.warning("push weekly reflection unavailable after finish day=%s", day) + return + + route = f"/app/reflections/{day}" + send_agent_alert( + title="your week is ready", + body="", + context_id=context_id, + route=route, + ) + append_chat_event("reflection_ready", day=day, url=route) + + +__all__ = [ + "check_pre_meeting_prep", + "handle_briefing_finish", + "handle_weekly_reflection_finish", + "send_agent_alert", +]