diff --git a/solstone/think/push/runtime.py b/solstone/think/push/runtime.py index 13c6222cd..969b8563b 100644 --- a/solstone/think/push/runtime.py +++ b/solstone/think/push/runtime.py @@ -40,6 +40,7 @@ def _on_callosum_message(message: dict[str, Any]) -> None: try: triggers.handle_sol_chat_request(message) triggers.handle_chat_lifecycle(message) + triggers.handle_chat_fold(message) except Exception: logger.exception("push callosum handler failed") diff --git a/solstone/think/push/triggers.py b/solstone/think/push/triggers.py index 6011e6088..0cc24580c 100644 --- a/solstone/think/push/triggers.py +++ b/solstone/think/push/triggers.py @@ -11,6 +11,7 @@ import time from pathlib import Path from typing import Any +from solstone.convey.chat_stream import day_for_ts, read_chat_events from solstone.convey.sol_initiated.copy import ( KIND_OWNER_CHAT_DISMISSED, KIND_OWNER_CHAT_OPEN, @@ -26,6 +27,9 @@ from solstone.think.utils import get_journal logger = logging.getLogger("solstone.push.triggers") +FOLD_PUSH_ACTION = "chat_answer_ready" +_VIEWING_STALENESS_MS = 15 * 60 * 1000 + def _nudge_log_path() -> Path: return Path(get_journal()) / "push" / "nudge_log.jsonl" @@ -38,6 +42,25 @@ def _append_nudge_log(line: dict[str, Any]) -> None: handle.write(json.dumps(line, ensure_ascii=False) + "\n") +def _owner_viewing_chat(fold_ts_ms: int) -> bool: + open_ts: dict[str, int] = {} + for event in read_chat_events(day_for_ts(fold_ts_ms)): + kind = event.get("kind") + request_id = str(event.get("request_id") or "").strip() + if not request_id: + continue + if kind == KIND_OWNER_CHAT_OPEN: + open_ts[request_id] = int(event.get("ts", 0) or 0) + continue + if kind == KIND_OWNER_CHAT_DISMISSED: + open_ts.pop(request_id, None) + + return any( + fold_ts_ms - opened_ts <= _VIEWING_STALENESS_MS + for opened_ts in open_ts.values() + ) + + def handle_sol_chat_request(message: dict[str, Any]) -> None: if message.get("tract") != "chat" or message.get("event") != KIND_SOL_CHAT_REQUEST: return @@ -108,6 +131,90 @@ def handle_sol_chat_request(message: dict[str, Any]) -> None: ) +def handle_chat_fold(message: dict[str, Any]) -> None: + if message.get("tract") != "chat" or message.get("event") != "sol_message": + return + origin = message.get("origin") + if not isinstance(origin, dict) or not origin or message.get("requested_target"): + return + route_id = str(origin.get("logical_use_id") or "").strip() + if not route_id: + return + fold_ts_ms = int(message["ts"]) + kind = "chat_fold_push" + + if _owner_viewing_chat(fold_ts_ms): + _append_nudge_log( + { + "ts": int(time.time()), + "kind": kind, + "dedupe_key": route_id, + "category": FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "owner_viewing_chat", + } + ) + return + + if not push_relay_token(): + _append_nudge_log( + { + "ts": int(time.time()), + "kind": kind, + "dedupe_key": route_id, + "category": FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "no_relay_token", + } + ) + return + + if not load_devices(): + _append_nudge_log( + { + "ts": int(time.time()), + "kind": kind, + "dedupe_key": route_id, + "category": FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "no_devices", + } + ) + return + + portal_result = dispatch_dedup_via_portal( + request_id=route_id, + action=FOLD_PUSH_ACTION, + ) + if portal_result is not None: + _append_nudge_log( + { + "ts": int(time.time()), + "kind": kind, + "dedupe_key": route_id, + "category": FOLD_PUSH_ACTION, + "outcome": "dispatched", + "via": "portal", + } + ) + return + + logger.warning( + "chat fold push skipped: relay dispatch failed request_id=%s", + route_id, + ) + _append_nudge_log( + { + "ts": int(time.time()), + "kind": kind, + "dedupe_key": route_id, + "category": FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "portal_unavailable", + } + ) + + def handle_chat_lifecycle(message: dict[str, Any]) -> None: if message.get("tract") != "chat": return @@ -178,6 +285,7 @@ def handle_chat_lifecycle(message: dict[str, Any]) -> None: __all__ = [ + "handle_chat_fold", "handle_chat_lifecycle", "handle_sol_chat_" + "request", ] diff --git a/tests/test_push_runtime.py b/tests/test_push_runtime.py index 9c7a9a91f..81b27235b 100644 --- a/tests/test_push_runtime.py +++ b/tests/test_push_runtime.py @@ -106,7 +106,7 @@ def test_stop_all_push_runtime_clears_runtime(monkeypatch): assert get_runtime_state() is None -def test_on_callosum_message_calls_both_handlers(monkeypatch): +def test_on_callosum_message_calls_all_handlers(monkeypatch): calls: list[tuple[str, dict[str, str]]] = [] request_handler = "handle_sol_chat_" + "request" monkeypatch.setattr( @@ -119,8 +119,17 @@ def test_on_callosum_message_calls_both_handlers(monkeypatch): "handle_chat_lifecycle", lambda message: calls.append(("chat_lifecycle", message)), ) + monkeypatch.setattr( + runtime.triggers, + "handle_chat_fold", + lambda message: calls.append(("chat_fold", message)), + ) message = {"tract": "chat", "event": KIND_SOL_CHAT_REQUEST, "request_id": "req-1"} runtime._on_callosum_message(message) - assert calls == [("request_handler", message), ("chat_lifecycle", message)] + assert calls == [ + ("request_handler", message), + ("chat_lifecycle", message), + ("chat_fold", message), + ] diff --git a/tests/test_push_triggers.py b/tests/test_push_triggers.py index 0ba4e19d2..afaa4eeec 100644 --- a/tests/test_push_triggers.py +++ b/tests/test_push_triggers.py @@ -4,10 +4,12 @@ from __future__ import annotations import json +from datetime import datetime from pathlib import Path import pytest +from solstone.convey.chat_stream import append_chat_event from solstone.convey.sol_initiated.copy import ( KIND_OWNER_CHAT_DISMISSED, KIND_OWNER_CHAT_OPEN, @@ -15,6 +17,8 @@ from solstone.convey.sol_initiated.copy import ( ) from solstone.think.push import triggers +_FOLD_TS_MS = int(datetime(2026, 3, 31, 12, 0, 0).timestamp() * 1000) + def _log_path(tmp_path: Path) -> Path: return tmp_path / "push" / "nudge_log.jsonl" @@ -38,6 +42,49 @@ def _device_row() -> dict[str, object]: } +def _fold_message( + *, + use_id: str = "fold-synth-1", + route_id: str = "dispatch-1", + ask: str = "what happened?", + ts: int = _FOLD_TS_MS, +) -> dict[str, object]: + return { + "tract": "chat", + "event": "sol_message", + "use_id": use_id, + "origin": {"logical_use_id": route_id, "ask": ask}, + "requested_target": None, + "ts": ts, + } + + +def _fail_dispatch_via_portal(**kwargs): + raise AssertionError("dispatch_via_portal should not be called") + + +def _fail_dedup_dispatch(**kwargs): + raise AssertionError("dispatch_dedup_via_portal should not be called") + + +def _install_fold_success(monkeypatch, calls: list[dict[str, str]]) -> None: + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr( + triggers, + "dispatch_dedup_via_portal", + lambda **kwargs: calls.append(kwargs) or {"ok": True}, + ) + + +def _install_chat_seed(monkeypatch) -> None: + monkeypatch.setattr( + "solstone.think.indexer.journal.index_file", + lambda *_args: True, + ) + + def test_handle_sol_chat_request_routes_via_portal(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") @@ -241,6 +288,264 @@ def test_handle_chat_lifecycle_portal_unavailable_logs_skip(monkeypatch, tmp_pat ] +def test_handle_chat_fold_routes_content_free_via_dedup(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + calls: list[dict[str, str]] = [] + _install_fold_success(monkeypatch, calls) + + triggers.handle_chat_fold( + _fold_message(use_id="fold-synth-1", route_id="dispatch-1") + ) + + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] + assert _read_log(tmp_path) == [ + { + "ts": _read_log(tmp_path)[0]["ts"], + "kind": "chat_fold_push", + "dedupe_key": "dispatch-1", + "category": triggers.FOLD_PUSH_ACTION, + "outcome": "dispatched", + "via": "portal", + } + ] + + +def test_handle_chat_fold_recovery_shape_stays_content_free(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + calls: list[dict[str, str]] = [] + _install_fold_success(monkeypatch, calls) + message = _fold_message( + use_id="recovered-fold-synth", + route_id="dispatch-1", + ask="private owner question", + ) + message.update( + { + "text": "private answer", + "sources": [{"kind": "journal", "title": "private source"}], + "notes": "private notes", + } + ) + + triggers.handle_chat_fold(message) + + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] + row = _read_log(tmp_path)[0] + assert set(row) == {"ts", "kind", "dedupe_key", "category", "outcome", "via"} + assert row == { + "ts": row["ts"], + "kind": "chat_fold_push", + "dedupe_key": "dispatch-1", + "category": triggers.FOLD_PUSH_ACTION, + "outcome": "dispatched", + "via": "portal", + } + + +@pytest.mark.parametrize( + "message", + [ + { + "tract": "chat", + "event": "sol_message", + "use_id": "ack-1", + "requested_target": "exec", + "ts": _FOLD_TS_MS, + }, + { + "tract": "chat", + "event": "sol_message", + "use_id": "direct-1", + "requested_target": None, + "ts": _FOLD_TS_MS, + }, + { + "tract": "chat", + "event": "other", + "use_id": "fold-synth-1", + "origin": {"logical_use_id": "dispatch-1", "ask": "what happened?"}, + "requested_target": None, + "ts": _FOLD_TS_MS, + }, + { + "tract": "chat", + "event": "sol_message", + "use_id": "fold-synth-1", + "origin": {}, + "requested_target": None, + "ts": _FOLD_TS_MS, + }, + { + "tract": "chat", + "event": "sol_message", + "use_id": "fold-synth-1", + "origin": {"logical_use_id": " ", "ask": "what happened?"}, + "requested_target": None, + "ts": _FOLD_TS_MS, + }, + ], +) +def test_handle_chat_fold_noop_shapes_do_not_dispatch(monkeypatch, tmp_path, message): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) + + triggers.handle_chat_fold(message) + + assert _read_log(tmp_path) == [] + + +def test_handle_chat_fold_suppresses_when_owner_viewing(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + _install_chat_seed(monkeypatch) + append_chat_event( + KIND_OWNER_CHAT_OPEN, + request_id="visible-request", + surface="convey", + ts=_FOLD_TS_MS - 5 * 60 * 1000, + ) + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) + + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) + + assert _read_log(tmp_path) == [ + { + "ts": _read_log(tmp_path)[0]["ts"], + "kind": "chat_fold_push", + "dedupe_key": "dispatch-1", + "category": triggers.FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "owner_viewing_chat", + } + ] + + +def test_handle_chat_fold_dispatches_after_stale_open(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + _install_chat_seed(monkeypatch) + append_chat_event( + KIND_OWNER_CHAT_OPEN, + request_id="stale-request", + surface="convey", + ts=_FOLD_TS_MS - 30 * 60 * 1000, + ) + calls: list[dict[str, str]] = [] + _install_fold_success(monkeypatch, calls) + + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) + + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] + + +def test_handle_chat_fold_dispatches_after_dismissed_open(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + _install_chat_seed(monkeypatch) + append_chat_event( + KIND_OWNER_CHAT_OPEN, + request_id="dismissed-request", + surface="convey", + ts=_FOLD_TS_MS - 5 * 60 * 1000, + ) + append_chat_event( + KIND_OWNER_CHAT_DISMISSED, + request_id="dismissed-request", + surface="convey", + reason=None, + ts=_FOLD_TS_MS - 60 * 1000, + ) + calls: list[dict[str, str]] = [] + _install_fold_success(monkeypatch, calls) + + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) + + assert calls == [{"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}] + + +def test_handle_chat_fold_uses_origin_logical_id_for_dedup(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + calls: list[dict[str, str]] = [] + _install_fold_success(monkeypatch, calls) + + triggers.handle_chat_fold( + _fold_message(use_id="fold-synth-1", route_id="dispatch-1") + ) + triggers.handle_chat_fold( + _fold_message(use_id="fold-synth-2", route_id="dispatch-1") + ) + + assert calls == [ + {"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}, + {"request_id": "dispatch-1", "action": triggers.FOLD_PUSH_ACTION}, + ] + + +def test_handle_chat_fold_no_token_skips_without_dispatch(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr(triggers, "push_relay_token", lambda: "") + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) + + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) + + assert _read_log(tmp_path) == [ + { + "ts": _read_log(tmp_path)[0]["ts"], + "kind": "chat_fold_push", + "dedupe_key": "dispatch-1", + "category": triggers.FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "no_relay_token", + } + ] + + +def test_handle_chat_fold_no_devices_skips_without_dispatch(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") + monkeypatch.setattr(triggers, "load_devices", lambda: []) + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) + + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) + + assert _read_log(tmp_path) == [ + { + "ts": _read_log(tmp_path)[0]["ts"], + "kind": "chat_fold_push", + "dedupe_key": "dispatch-1", + "category": triggers.FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "no_devices", + } + ] + + +def test_handle_chat_fold_portal_unavailable_logs_skip(monkeypatch, tmp_path): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") + monkeypatch.setattr(triggers, "load_devices", lambda: [_device_row()]) + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", lambda **kwargs: None) + + triggers.handle_chat_fold(_fold_message(route_id="dispatch-1")) + + assert _read_log(tmp_path) == [ + { + "ts": _read_log(tmp_path)[0]["ts"], + "kind": "chat_fold_push", + "dedupe_key": "dispatch-1", + "category": triggers.FOLD_PUSH_ACTION, + "outcome": "skipped", + "reason": "portal_unavailable", + } + ] + + @pytest.mark.parametrize( "message", [ @@ -252,9 +557,12 @@ def test_handle_chat_lifecycle_portal_unavailable_logs_skip(monkeypatch, tmp_pat def test_non_chat_or_wrong_event_messages_are_noops(monkeypatch, tmp_path, message): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) monkeypatch.setattr(triggers, "push_relay_token", lambda: "tok") + monkeypatch.setattr(triggers, "dispatch_via_portal", _fail_dispatch_via_portal) + monkeypatch.setattr(triggers, "dispatch_dedup_via_portal", _fail_dedup_dispatch) triggers.handle_sol_chat_request(message) triggers.handle_chat_lifecycle(message) + triggers.handle_chat_fold(message) assert _read_log(tmp_path) == []