diff --git a/solstone/convey/sol_initiated/copy.py b/solstone/convey/sol_initiated/copy.py index 17eb1f8f6..d79421250 100644 --- a/solstone/convey/sol_initiated/copy.py +++ b/solstone/convey/sol_initiated/copy.py @@ -7,6 +7,8 @@ KIND_SOL_CHAT_REQUEST = "sol_chat_request" KIND_SOL_CHAT_REQUEST_SUPERSEDED = "sol_chat_request_superseded" KIND_OWNER_CHAT_OPEN = "owner_chat_open" KIND_OWNER_CHAT_DISMISSED = "owner_chat_dismissed" +SURFACE_CONVEY = "convey" +SOL_PINGED_OFFLINE_TOOLTIP = "sol-pinged but offline — refresh" TRIGGER_LABEL_SOL_INITIATED = "sol_initiated" SYNTHETIC_TRIGGER_LABEL = "synthetic" diff --git a/solstone/convey/sol_initiated/state.py b/solstone/convey/sol_initiated/state.py new file mode 100644 index 000000000..0cbf392e5 --- /dev/null +++ b/solstone/convey/sol_initiated/state.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Read helpers for sol-initiated chat stream state.""" + +from __future__ import annotations + +from solstone.convey.sol_initiated.copy import ( + KIND_OWNER_CHAT_DISMISSED, + KIND_OWNER_CHAT_OPEN, + KIND_SOL_CHAT_REQUEST, + KIND_SOL_CHAT_REQUEST_SUPERSEDED, +) + + +def latest_unresolved_sol_chat_request(events: list[dict]) -> dict | None: + """Return the latest unresolved sol-initiated request from chronological events. + + Resolved means the request_id appears in a later owner-open, owner-dismiss, + or supersede event. + + Returns dict with keys: request_id, summary, ts, event_index. None if no + unresolved request exists. + """ + resolved_request_ids: set[str] = set() + requests: list[dict] = [] + + for index, event in enumerate(events): + kind = event.get("kind") + if kind in { + KIND_OWNER_CHAT_OPEN, + KIND_OWNER_CHAT_DISMISSED, + KIND_SOL_CHAT_REQUEST_SUPERSEDED, + }: + request_id = str(event.get("request_id") or "").strip() + if request_id: + resolved_request_ids.add(request_id) + continue + + if kind != KIND_SOL_CHAT_REQUEST: + continue + + request_id = str(event.get("request_id") or "").strip() + if not request_id: + continue + requests.append( + { + "request_id": request_id, + "summary": str(event.get("summary") or ""), + "ts": event.get("ts"), + "event_index": index, + } + ) + + for request in reversed(requests): + if request["request_id"] not in resolved_request_ids: + return request + return None diff --git a/solstone/convey/static/sol_initiated_constants.js b/solstone/convey/static/sol_initiated_constants.js new file mode 100644 index 000000000..f66cb97a5 --- /dev/null +++ b/solstone/convey/static/sol_initiated_constants.js @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc +(function () { + window.SOL_INITIATED = Object.freeze({ + KIND_SOL_CHAT_REQUEST: "sol_chat_request", + KIND_SOL_CHAT_REQUEST_SUPERSEDED: "sol_chat_request_superseded", + KIND_OWNER_CHAT_OPEN: "owner_chat_open", + KIND_OWNER_CHAT_DISMISSED: "owner_chat_dismissed", + SURFACE_CONVEY: "convey", + SOL_PINGED_OFFLINE_TOOLTIP: "sol-pinged but offline \u2014 refresh", + }); +})(); diff --git a/tests/test_sol_initiated_constants_locked.py b/tests/test_sol_initiated_constants_locked.py index 2669cdd4b..ddb09905b 100644 --- a/tests/test_sol_initiated_constants_locked.py +++ b/tests/test_sol_initiated_constants_locked.py @@ -5,9 +5,12 @@ from __future__ import annotations import ast import io +import re import tokenize from pathlib import Path +from solstone.convey.sol_initiated import copy + LOCKED_LITERALS = ( "sol_chat_request", "sol_chat_request_superseded", @@ -20,8 +23,10 @@ LOCKED_LITERALS = ( ALLOWED_PATHS = { Path("solstone/convey/sol_initiated/copy.py"), Path("solstone/convey/chat_stream.py"), + Path("solstone/convey/static/sol_initiated_constants.js"), Path("tests/test_sol_initiated_constants_locked.py"), Path("docs/design/sol_initiated_chat_lode1.md"), + Path("docs/design/sol_initiated_chat_lode2.md"), } SEARCH_ROOTS = ( @@ -43,6 +48,29 @@ def test_locked_literals_stay_in_the_contract_files() -> None: assert hits == [] +def test_browser_constants_match_python_contract() -> None: + text = Path("solstone/convey/static/sol_initiated_constants.js").read_text( + encoding="utf-8" + ) + + assert _js_constant(text, "KIND_SOL_CHAT_REQUEST") == copy.KIND_SOL_CHAT_REQUEST + assert ( + _js_constant(text, "KIND_SOL_CHAT_REQUEST_SUPERSEDED") + == copy.KIND_SOL_CHAT_REQUEST_SUPERSEDED + ) + assert _js_constant(text, "KIND_OWNER_CHAT_OPEN") == copy.KIND_OWNER_CHAT_OPEN + assert ( + _js_constant(text, "KIND_OWNER_CHAT_DISMISSED") + == copy.KIND_OWNER_CHAT_DISMISSED + ) + assert 'SURFACE_CONVEY: "convey"' in text + assert _js_constant(text, "SURFACE_CONVEY") == copy.SURFACE_CONVEY + assert ( + _js_constant(text, "SOL_PINGED_OFFLINE_TOOLTIP") + == copy.SOL_PINGED_OFFLINE_TOOLTIP + ) + + def _iter_files() -> list[Path]: files: list[Path] = [] for root in SEARCH_ROOTS: @@ -53,11 +81,17 @@ def _iter_files() -> list[Path]: for path in root.rglob("*") if path.is_file() and "__pycache__" not in path.parts - and path.suffix in {".py", ".md"} + and path.suffix in {".py", ".md", ".js"} ) return sorted(files) +def _js_constant(text: str, name: str) -> str: + match = re.search(rf'{name}: "([^"]*)"', text) + assert match is not None, name + return match.group(1).encode("utf-8").decode("unicode_escape") + + def _locked_literal_hits(path: Path) -> list[tuple[int, str]]: if path.suffix == ".py": return _python_string_literal_hits(path) diff --git a/tests/test_sol_initiated_state.py b/tests/test_sol_initiated_state.py new file mode 100644 index 000000000..06eb1b1ba --- /dev/null +++ b/tests/test_sol_initiated_state.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from solstone.convey.sol_initiated.copy import ( + KIND_OWNER_CHAT_DISMISSED, + KIND_OWNER_CHAT_OPEN, + KIND_SOL_CHAT_REQUEST, + KIND_SOL_CHAT_REQUEST_SUPERSEDED, +) +from solstone.convey.sol_initiated.state import latest_unresolved_sol_chat_request + + +def _request(request_id: str, summary: str = "Notice this", ts: int = 1000) -> dict: + return { + "kind": KIND_SOL_CHAT_REQUEST, + "request_id": request_id, + "summary": summary, + "ts": ts, + } + + +def test_empty_list_returns_none() -> None: + assert latest_unresolved_sol_chat_request([]) is None + + +def test_single_request_returns_it_with_event_index() -> None: + assert latest_unresolved_sol_chat_request([_request("req")]) == { + "request_id": "req", + "summary": "Notice this", + "ts": 1000, + "event_index": 0, + } + + +def test_request_followed_by_open_is_resolved() -> None: + assert ( + latest_unresolved_sol_chat_request( + [ + _request("req"), + {"kind": KIND_OWNER_CHAT_OPEN, "request_id": "req", "surface": "test"}, + ] + ) + is None + ) + + +def test_request_followed_by_dismissed_is_resolved() -> None: + assert ( + latest_unresolved_sol_chat_request( + [ + _request("req"), + { + "kind": KIND_OWNER_CHAT_DISMISSED, + "request_id": "req", + "surface": "test", + "reason": None, + }, + ] + ) + is None + ) + + +def test_request_followed_by_superseded_is_resolved() -> None: + assert ( + latest_unresolved_sol_chat_request( + [ + _request("req"), + { + "kind": KIND_SOL_CHAT_REQUEST_SUPERSEDED, + "request_id": "req", + "replaced_by": "next", + }, + ] + ) + is None + ) + + +def test_two_unresolved_requests_returns_latter() -> None: + assert latest_unresolved_sol_chat_request( + [_request("first", "First", 1000), _request("second", "Second", 2000)] + ) == { + "request_id": "second", + "summary": "Second", + "ts": 2000, + "event_index": 1, + } + + +def test_first_dismissed_second_unresolved_returns_second() -> None: + assert latest_unresolved_sol_chat_request( + [ + _request("first", "First", 1000), + { + "kind": KIND_OWNER_CHAT_DISMISSED, + "request_id": "first", + "surface": "test", + "reason": None, + }, + _request("second", "Second", 2000), + ] + ) == { + "request_id": "second", + "summary": "Second", + "ts": 2000, + "event_index": 2, + } + + +def test_unrelated_chat_events_are_ignored() -> None: + assert latest_unresolved_sol_chat_request( + [ + {"kind": "owner_message", "text": "hello"}, + _request("req", "Summary", 1000), + {"kind": "sol_message", "text": "reply"}, + ] + ) == { + "request_id": "req", + "summary": "Summary", + "ts": 1000, + "event_index": 1, + } + + +def test_missing_summary_falls_back_to_empty_string() -> None: + assert latest_unresolved_sol_chat_request( + [{"kind": KIND_SOL_CHAT_REQUEST, "request_id": "req", "ts": 1000}] + ) == { + "request_id": "req", + "summary": "", + "ts": 1000, + "event_index": 0, + }