From d7fb3e812700e4b314bf65e6d308c88ad5914fed Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 18 Jun 2026 10:35:35 -0600 Subject: [PATCH] feat(convey): enrich chat read model for native ask clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three additive read-model fields so a native client can render an honest "ask sol" view without mirroring server copy or parsing journal layout client-side. All strictly additive — every existing field keeps its shape and meaning. - reduce_chat_state: owner-language `label` (verbatim talent_label_for, raw-name fallback) on active/completed talents, a new `errored_talents[]`, pass-through of `sources`/`answer_state` on latest_sol_message, and a top-level `chat_error` surfaced via newest-unsuperseded-by-sol_message. - New pure chat_sources.py: parse `sol://` refs (markdown + bare, de-duped first-seen) and resolve to relative loopback paths (timeline/news/ reflections; events + unknown -> url null). Stdlib only, no Flask. - chat.py: compute `sources`/`answer_state` at sol_message compose time in _on_cortex_finish and on the decline/confirm/cancel paths. answer_state maps off the existing exit_mode (answered / partial / failed). - Update the /api/chat/session baseline to the 6-key shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/convey/chat.py | 14 ++ solstone/convey/chat_sources.py | 70 +++++++ solstone/convey/chat_stream.py | 41 +++- tests/baselines/api/chat/session.json | 2 + tests/test_chat_sources.py | 87 +++++++++ tests/test_chat_stream.py | 258 ++++++++++++++++++++++++++ tests/test_convey_chat.py | 20 ++ 7 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 solstone/convey/chat_sources.py create mode 100644 tests/test_chat_sources.py diff --git a/solstone/convey/chat.py b/solstone/convey/chat.py index d03038aeb..032d82590 100644 --- a/solstone/convey/chat.py +++ b/solstone/convey/chat.py @@ -39,6 +39,7 @@ from solstone.apps.chat.copy import ( CHAT_SUPPORT_SUBMIT_FILED_FORMAT, ) from solstone.apps.support.tools import support_attach, support_create, support_reply +from solstone.convey.chat_sources import parse_sol_sources from solstone.convey.chat_stream import ( append_chat_event, find_unresponded_trigger, @@ -271,6 +272,8 @@ def decline_offer() -> Any: notes="owner declined the support offer", requested_target=None, requested_task=None, + sources=[], + answer_state="answered", ) return jsonify(ok=True) @@ -334,6 +337,8 @@ def confirm_support_draft() -> Any: notes=f"support draft {submit_result.outcome}", requested_target=None, requested_task=None, + sources=[], + answer_state="answered", ) response: dict[str, Any] = { @@ -391,6 +396,8 @@ def cancel_support_draft() -> Any: "notes": "support draft cancelled", "requested_target": None, "requested_task": None, + "sources": [], + "answer_state": "answered", }, ), ], @@ -600,6 +607,7 @@ def _on_cortex_finish(message: dict[str, Any]) -> None: else None ) offer: dict[str, Any] | None = None + answer_state = "answered" trigger = _current_chat_state.get("trigger") or {} trigger_type = trigger.get("type") if trigger_type in {"talent_finished", "talent_errored"}: @@ -616,6 +624,9 @@ def _on_cortex_finish(message: dict[str, Any]) -> None: talent_errored_reason_code=trigger.get("reason_code"), talent_finished_summary=trigger.get("summary"), ) + answer_state = ( + "failed" if exit_mode == "talent_errored" else "partial" + ) requested_target = None requested_task = None draft: dict[str, Any] | None = None @@ -646,6 +657,7 @@ def _on_cortex_finish(message: dict[str, Any]) -> None: "diagnostics_snapshot" ), } + answer_state = "answered" if requested_target in OUTBOUND_TALENTS: consent = _support_consent_state(_today_day()) if consent == "none": @@ -664,6 +676,8 @@ def _on_cortex_finish(message: dict[str, Any]) -> None: "notes": parsed["notes"], "requested_target": requested_target, "requested_task": requested_task, + "sources": parse_sol_sources(message_text), + "answer_state": answer_state, } if thinking is not None: sol_message_fields["thinking"] = thinking diff --git a/solstone/convey/chat_sources.py b/solstone/convey/chat_sources.py new file mode 100644 index 000000000..57349d2d5 --- /dev/null +++ b/solstone/convey/chat_sources.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import calendar +import re +from typing import Any + +_SOL_REF_RE = re.compile(r"\[([^\]]+)\]\((sol://[^\s)]+)\)|(sol://[^\s)\]]+)") +_DAY_RE = re.compile(r"^\d{8}$") + + +def parse_sol_sources(text: str) -> list[dict[str, Any]]: + sources: list[dict[str, Any]] = [] + seen: set[str] = set() + + for match in _SOL_REF_RE.finditer(text): + if match.group(2): + ref = match.group(2) + label = match.group(1) + else: + ref = match.group(3).rstrip(".,;:!?") + label = _derive_label(ref) + + if ref in seen: + continue + seen.add(ref) + sources.append({"ref": ref, "label": label, "url": resolve_sol_ref(ref)}) + + return sources + + +def resolve_sol_ref(ref: str) -> str | None: + if not ref.startswith("sol://"): + return None + + parts = ref.removeprefix("sol://").split("/") + if len(parts) == 4 and parts[0] == "facets" and _DAY_RE.match(parts[3]): + _prefix, facet, kind, day = parts + if kind == "news": + return f"/app/news/{facet}/{day}" + if kind == "reflections": + # Reflections week_view(day) is not facet-scoped; facet cannot be honored. + return f"/app/reflections/{day}" + return None + + if parts and _DAY_RE.match(parts[0]): + return f"/app/timeline/{parts[0]}" + + return None + + +def _derive_label(ref: str) -> str: + if not ref.startswith("sol://"): + return ref + + parts = ref.removeprefix("sol://").split("/") + if parts and _DAY_RE.match(parts[0]): + day = parts[0] + month = int(day[4:6]) + if 1 <= month <= 12: + return f"{calendar.month_abbr[month]} {int(day[6:8])}" + return ref + + if len(parts) == 4 and parts[0] == "facets" and _DAY_RE.match(parts[3]): + _prefix, facet, kind, _day = parts + return f"{facet} · {kind}" + + return ref diff --git a/solstone/convey/chat_stream.py b/solstone/convey/chat_stream.py index 13287a436..674a0b272 100644 --- a/solstone/convey/chat_stream.py +++ b/solstone/convey/chat_stream.py @@ -12,6 +12,7 @@ from datetime import date, datetime from pathlib import Path from typing import Any +from solstone.apps.chat.copy import talent_label_for from solstone.think.utils import ( day_path, get_journal, @@ -27,10 +28,10 @@ _CHAT_STREAM = "chat" _SEGMENT_WINDOW_MS = 300_000 _APPENDED_CHAT_PATHS: dict[int, Path] = {} # owner_message may carry optional `source`; extras flow through unchanged. -# sol_message may carry optional `thinking`, `offer`, and `draft`; result may -# carry optional `ticket_id`, `error`, `ambiguous`, and `cancelled`; talent_finished -# may carry optional `thinking`. Extras flow through unchanged and are not part of -# the required-field tuples below. +# sol_message may carry optional `thinking`, `offer`, `draft`, `sources`, and +# `answer_state`; result may carry optional `ticket_id`, `error`, `ambiguous`, and +# `cancelled`; talent_finished may carry optional `thinking`. Extras flow through +# unchanged and are not part of the required-field tuples below. _VALID_KINDS = { "owner_message": ("text", "app", "path", "facet"), "sol_message": ( @@ -79,6 +80,13 @@ _TRIGGER_KINDS = { } +def _talent_label(name: Any, status: str) -> str: + try: + return talent_label_for(str(name), status) + except ValueError: + return str(name) + + def append_chat_event(kind: str, **fields: Any) -> dict[str, Any]: """Append a chat event to the current 5-minute segment.""" return append_chat_events_locked([(kind, fields)])[0] @@ -224,6 +232,8 @@ def reduce_chat_state(day: str) -> dict[str, Any]: latest_sol_message: dict[str, Any] | None = None active_talents: dict[str, dict[str, Any]] = {} completed_talents: list[dict[str, Any]] = [] + errored_talents: list[dict[str, Any]] = [] + chat_error: dict[str, Any] | None = None queue_depth = 0 for event in read_chat_events(day): @@ -242,7 +252,10 @@ def reduce_chat_state(day: str) -> dict[str, Any]: "requested_task": event["requested_task"], "offer": event.get("offer"), "draft": event.get("draft"), + "sources": event.get("sources", []), + "answer_state": event.get("answer_state", "answered"), } + chat_error = None continue if kind == "talent_spawned": @@ -251,6 +264,7 @@ def reduce_chat_state(day: str) -> dict[str, Any]: "name": event["name"], "task": event["task"], "started_at": event["started_at"], + "label": _talent_label(event["name"], "running"), } continue @@ -263,12 +277,29 @@ def reduce_chat_state(day: str) -> dict[str, Any]: "task": started["task"] if started else None, "summary": event["summary"], "finished_at": event["ts"], + "label": _talent_label(event["name"], "finished"), } ) continue if kind == "talent_errored": active_talents.pop(str(event["use_id"]), None) + errored_talents.append( + { + "use_id": event["use_id"], + "name": event["name"], + "finished_at": event["ts"], + "label": _talent_label(event["name"], "errored"), + } + ) + continue + + if kind == "chat_error": + chat_error = { + "reason": event["reason"], + "provider": event.get("provider", ""), + "detail": event.get("detail", ""), + } continue if kind == "reflection_ready": @@ -284,6 +315,8 @@ def reduce_chat_state(day: str) -> dict[str, Any]: ), ), "completed_talents": completed_talents, + "errored_talents": errored_talents, + "chat_error": chat_error, "queue_depth": queue_depth, } diff --git a/tests/baselines/api/chat/session.json b/tests/baselines/api/chat/session.json index fdb755380..8bb80ed29 100644 --- a/tests/baselines/api/chat/session.json +++ b/tests/baselines/api/chat/session.json @@ -1,6 +1,8 @@ { "active_talents": [], + "chat_error": null, "completed_talents": [], + "errored_talents": [], "latest_sol_message": null, "queue_depth": 0 } diff --git a/tests/test_chat_sources.py b/tests/test_chat_sources.py new file mode 100644 index 000000000..ac9f36d2f --- /dev/null +++ b/tests/test_chat_sources.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from solstone.convey.chat_sources import parse_sol_sources + + +def test_markdown_link_source_uses_link_text_and_timeline_url(): + assert parse_sol_sources("[March standup](sol://20260313/archon/091500_300)") == [ + { + "ref": "sol://20260313/archon/091500_300", + "label": "March standup", + "url": "/app/timeline/20260313", + } + ] + + +def test_bare_facet_news_source_derives_label_and_url(): + assert parse_sol_sources("See sol://facets/work/news/20260326") == [ + { + "ref": "sol://facets/work/news/20260326", + "label": "work · news", + "url": "/app/news/work/20260326", + } + ] + + +def test_bare_day_source_derives_month_day_label(): + assert parse_sol_sources("See sol://20260313/archon/091500_300") == [ + { + "ref": "sol://20260313/archon/091500_300", + "label": "Mar 13", + "url": "/app/timeline/20260313", + } + ] + + +def test_events_facet_shape_has_no_url(): + assert parse_sol_sources("See sol://facets/verona/events/20260308") == [ + { + "ref": "sol://facets/verona/events/20260308", + "label": "verona · events", + "url": None, + } + ] + + +def test_reflections_drop_facet_in_url(): + assert parse_sol_sources("See sol://facets/x/reflections/20260310") == [ + { + "ref": "sol://facets/x/reflections/20260310", + "label": "x · reflections", + "url": "/app/reflections/20260310", + } + ] + + +def test_dedupes_by_ref_preserving_first_seen_label(): + assert parse_sol_sources( + "[First](sol://20260313/archon/091500_300) " + "[Second](sol://20260313/archon/091500_300) " + "sol://facets/work/news/20260326" + ) == [ + { + "ref": "sol://20260313/archon/091500_300", + "label": "First", + "url": "/app/timeline/20260313", + }, + { + "ref": "sol://facets/work/news/20260326", + "label": "work · news", + "url": "/app/news/work/20260326", + }, + ] + + +def test_unknown_shape_degrades_to_ref_label_and_no_url(): + assert parse_sol_sources("See sol://unknown/value") == [ + { + "ref": "sol://unknown/value", + "label": "sol://unknown/value", + "url": None, + } + ] + + +def test_empty_string_returns_no_sources(): + assert parse_sol_sources("") == [] diff --git a/tests/test_chat_stream.py b/tests/test_chat_stream.py index b5fc6ce82..b59370133 100644 --- a/tests/test_chat_stream.py +++ b/tests/test_chat_stream.py @@ -9,6 +9,7 @@ from types import SimpleNamespace import pytest +from solstone.apps.chat.copy import talent_label_for from solstone.convey.chat_stream import ( append_chat_event, find_unresponded_trigger, @@ -529,6 +530,8 @@ def test_reduce_chat_state_extracts_latest_sol_and_active_talents( "requested_task": "compare drafts", "offer": None, "draft": None, + "sources": [], + "answer_state": "answered", } assert reduced["active_talents"] == [ { @@ -536,6 +539,7 @@ def test_reduce_chat_state_extracts_latest_sol_and_active_talents( "name": "exec", "task": "write summary", "started_at": start + 4_000, + "label": talent_label_for("exec", "running"), } ] assert reduced["completed_talents"] == [ @@ -545,11 +549,265 @@ def test_reduce_chat_state_extracts_latest_sol_and_active_talents( "task": "compare drafts", "summary": "done", "finished_at": start + 3_000, + "label": talent_label_for("exec", "finished"), } ] + assert reduced["errored_talents"] == [ + { + "use_id": "exec-3", + "name": "exec", + "finished_at": start + 5_000, + "label": talent_label_for("exec", "errored"), + } + ] + assert reduced["chat_error"] == { + "reason": "unknown", + "provider": "", + "detail": "", + } assert reduced["queue_depth"] == 0 +def test_reduce_chat_state_enriches_talent_labels(tmp_path, monkeypatch): + _setup_journal(tmp_path, monkeypatch) + start = _ms(2026, 4, 20, 12, 0, 0) + + append_chat_event( + "talent_spawned", + ts=start, + use_id="read-running", + name="read", + task="scan today", + started_at=start, + ) + append_chat_event( + "talent_spawned", + ts=start + 1_000, + use_id="support-running", + name="support", + task="contact support", + started_at=start + 1_000, + ) + append_chat_event( + "talent_spawned", + ts=start + 2_000, + use_id="read-finished", + name="read", + task="read memory", + started_at=start + 2_000, + ) + append_chat_event( + "talent_finished", + ts=start + 3_000, + use_id="read-finished", + name="read", + summary="done", + ) + append_chat_event( + "talent_spawned", + ts=start + 4_000, + use_id="support-finished", + name="support", + task="send draft", + started_at=start + 4_000, + ) + append_chat_event( + "talent_finished", + ts=start + 5_000, + use_id="support-finished", + name="support", + summary="submitted", + ) + append_chat_event( + "talent_spawned", + ts=start + 6_000, + use_id="read-errored", + name="read", + task="find note", + started_at=start + 6_000, + ) + append_chat_event( + "talent_errored", + ts=start + 7_000, + use_id="read-errored", + name="read", + reason="timeout", + ) + append_chat_event( + "talent_spawned", + ts=start + 8_000, + use_id="support-errored", + name="support", + task="send ticket", + started_at=start + 8_000, + ) + append_chat_event( + "talent_errored", + ts=start + 9_000, + use_id="support-errored", + name="support", + reason="network", + ) + + reduced = reduce_chat_state("20260420") + + assert reduced["active_talents"] == [ + { + "use_id": "read-running", + "name": "read", + "task": "scan today", + "started_at": start, + "label": talent_label_for("read", "running"), + }, + { + "use_id": "support-running", + "name": "support", + "task": "contact support", + "started_at": start + 1_000, + "label": talent_label_for("support", "running"), + }, + ] + assert reduced["completed_talents"] == [ + { + "use_id": "read-finished", + "name": "read", + "task": "read memory", + "summary": "done", + "finished_at": start + 3_000, + "label": talent_label_for("read", "finished"), + }, + { + "use_id": "support-finished", + "name": "support", + "task": "send draft", + "summary": "submitted", + "finished_at": start + 5_000, + "label": talent_label_for("support", "finished"), + }, + ] + assert reduced["errored_talents"] == [ + { + "use_id": "read-errored", + "name": "read", + "finished_at": start + 7_000, + "label": talent_label_for("read", "errored"), + }, + { + "use_id": "support-errored", + "name": "support", + "finished_at": start + 9_000, + "label": talent_label_for("support", "errored"), + }, + ] + + +def test_reduce_chat_state_unknown_talent_label_falls_back_to_name( + tmp_path, monkeypatch +): + _setup_journal(tmp_path, monkeypatch) + start = _ms(2026, 4, 20, 12, 0, 0) + + append_chat_event( + "talent_spawned", + ts=start, + use_id="mystery-1", + name="mystery", + task="unknown task", + started_at=start, + ) + + reduced = reduce_chat_state("20260420") + + assert reduced["active_talents"] == [ + { + "use_id": "mystery-1", + "name": "mystery", + "task": "unknown task", + "started_at": start, + "label": "mystery", + } + ] + + +def test_reduce_chat_state_sol_message_sources_and_answer_state_defaults( + tmp_path, monkeypatch +): + _setup_journal(tmp_path, monkeypatch) + start = _ms(2026, 4, 20, 12, 0, 0) + sources = [ + { + "ref": "sol://20260313/archon/091500_300", + "label": "Mar 13", + "url": "/app/timeline/20260313", + } + ] + + append_chat_event( + "sol_message", + ts=start, + use_id="chat-1", + text="partial", + notes="first", + requested_target=None, + requested_task=None, + sources=sources, + answer_state="partial", + ) + + reduced = reduce_chat_state("20260420") + assert reduced["latest_sol_message"]["sources"] == sources + assert reduced["latest_sol_message"]["answer_state"] == "partial" + + append_chat_event( + "sol_message", + ts=start + 1_000, + use_id="chat-2", + text="answered", + notes="second", + requested_target=None, + requested_task=None, + ) + + reduced = reduce_chat_state("20260420") + assert reduced["latest_sol_message"]["sources"] == [] + assert reduced["latest_sol_message"]["answer_state"] == "answered" + + +def test_reduce_chat_state_chat_error_cleared_by_newer_sol_message( + tmp_path, monkeypatch +): + _setup_journal(tmp_path, monkeypatch) + start = _ms(2026, 4, 20, 12, 0, 0) + + append_chat_event( + "chat_error", + ts=start, + reason="provider_response_invalid", + use_id="chat-1", + provider="openai", + detail="bad json", + ) + + reduced = reduce_chat_state("20260420") + assert reduced["chat_error"] == { + "reason": "provider_response_invalid", + "provider": "openai", + "detail": "bad json", + } + + append_chat_event( + "sol_message", + ts=start + 1_000, + use_id="chat-1", + text="recovered", + notes="ok", + requested_target=None, + requested_task=None, + ) + + assert reduce_chat_state("20260420")["chat_error"] is None + + def test_reduce_chat_state_includes_offer_and_clears_on_later_sol_message( tmp_path, monkeypatch ): diff --git a/tests/test_convey_chat.py b/tests/test_convey_chat.py index 0d3e9992e..c674e39a9 100644 --- a/tests/test_convey_chat.py +++ b/tests/test_convey_chat.py @@ -25,6 +25,7 @@ from solstone.apps.chat.copy import ( CHAT_SUPPORT_SUBMIT_AMBIGUOUS, CHAT_SUPPORT_SUBMIT_FAILED, CHAT_SUPPORT_SUBMIT_FILED_FORMAT, + talent_label_for, ) from solstone.convey.chat import ChatSpawnResult, chat_bp from solstone.convey.chat_stream import ( @@ -529,6 +530,8 @@ def test_clean_support_finish_with_attach_draft_emits_slim_marker( } assert sol_message["draft"] == expected_draft assert "content_b64" not in sol_message["draft"]["payload"] + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "answered" assert reduce_chat_state(day)["latest_sol_message"]["draft"] == expected_draft session = chat_client.get("/api/chat/session") assert session.status_code == 200 @@ -581,6 +584,8 @@ def test_errored_support_finish_with_pending_draft_keeps_send_failed_closer( ) assert sol_message["text"] == CHAT_CLOSER_SUPPORT_SEND_FAILED assert "draft" not in sol_message + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "failed" def test_clean_support_finish_without_pending_draft_does_not_emit_marker( @@ -616,6 +621,8 @@ def test_clean_support_finish_without_pending_draft_does_not_emit_marker( ) assert "draft" not in sol_message assert sol_message["text"] != CHAT_SUPPORT_DRAFT_READY + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "partial" def test_non_support_finish_with_pending_draft_does_not_emit_marker( @@ -724,6 +731,8 @@ def test_support_finish_draft_marker_does_not_emit_offer(chat_client, monkeypatc ) assert "draft" in sol_message assert "offer" not in sol_message + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "answered" def test_decline_offer_endpoint_appends_local_sol_message(chat_client, monkeypatch): @@ -739,6 +748,8 @@ def test_decline_offer_endpoint_appends_local_sol_message(chat_client, monkeypat sol_message = next(event for event in events if event["kind"] == "sol_message") assert sol_message["text"] == CHAT_OFFER_SUPPORT_DECLINE assert "offer" not in sol_message + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "answered" assert [event for event in events if event["kind"] == "talent_spawned"] == [] @@ -1356,6 +1367,8 @@ def test_support_draft_confirm_sol_message_supersedes_draft(chat_client, monkeyp sol_message = _events_of_kind(day, "sol_message")[-1] assert sol_message["text"] == CHAT_SUPPORT_SUBMIT_FILED_FORMAT.format(ticket_id=123) assert "draft" not in sol_message + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "answered" assert reduce_chat_state(day)["latest_sol_message"]["draft"] is None session = chat_client.get("/api/chat/session") assert session.status_code == 200 @@ -1451,6 +1464,8 @@ def test_support_draft_cancel_records_terminal_result_without_submit( sol_message = _events_of_kind(day, "sol_message")[0] assert sol_message["text"] == CHAT_SUPPORT_DRAFT_CANCELLED assert "draft" not in sol_message + assert sol_message["sources"] == [] + assert sol_message["answer_state"] == "answered" assert _events_of_kind(day, "talent_spawned") == [] assert chat._support_draft_state(day) == "submitted" @@ -2202,16 +2217,21 @@ def test_session_endpoint_reduces_from_chat_stream(chat_client, monkeypatch): assert response.status_code == 200 payload = response.get_json() assert payload["latest_sol_message"]["text"] == "hello" + assert payload["latest_sol_message"]["sources"] == [] + assert payload["latest_sol_message"]["answer_state"] == "answered" assert payload["active_talents"] == [] assert payload["completed_talents"] == [ { "finished_at": finished_at, + "label": talent_label_for("exec", "finished"), "name": "exec", "summary": "done", "task": "research", "use_id": "1713626000001", } ] + assert payload["errored_talents"] == [] + assert payload["chat_error"] is None def test_chat_session_retries_unresolved_trigger_when_idle(chat_client, monkeypatch): -- 2.51.2