# SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc import base64 import json from datetime import datetime from pathlib import Path 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, read_chat_events, read_chat_tail, reduce_chat_state, ) def _setup_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: journal = tmp_path / "journal" journal.mkdir() monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) return journal def _ms(year: int, month: int, day: int, hour: int, minute: int, second: int) -> int: return int(datetime(year, month, day, hour, minute, second).timestamp() * 1000) def test_append_owner_message_creates_segment_and_jsonl(tmp_path, monkeypatch): journal = _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) event = append_chat_event( "owner_message", ts=ts, text="hello", app="sol", path="/chat", facet="work", ) segment_dir = journal / "chronicle" / "20260420" / "chat" / "120000_300" chat_path = segment_dir / "chat.jsonl" assert event["kind"] == "owner_message" assert event["ts"] == ts assert segment_dir.is_dir() assert (segment_dir / "stream.json").is_file() assert chat_path.is_file() assert (journal / "streams" / "chat.json").is_file() entries = [ json.loads(line) for line in chat_path.read_text(encoding="utf-8").splitlines() ] assert entries == [event] def test_append_is_atomic(tmp_path, monkeypatch): journal = _setup_journal(tmp_path, monkeypatch) append_chat_event( "owner_message", ts=_ms(2026, 4, 20, 12, 0, 0), text="hello", app="sol", path="/chat", facet="work", ) chat_path = ( journal / "chronicle" / "20260420" / "chat" / "120000_300" / "chat.jsonl" ) lines = chat_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 1 assert json.loads(lines[0])["text"] == "hello" def test_append_broadcasts_on_chat_tract_with_stored_event_payload( tmp_path, monkeypatch ): _setup_journal(tmp_path, monkeypatch) import solstone.convey.chat as chat calls: list[tuple[str, str, dict]] = [] class FakeCallosum: def emit(self, tract, event, **fields): calls.append((tract, event, fields)) return True monkeypatch.setattr(chat, "_runtime", SimpleNamespace(callosum=FakeCallosum())) event = append_chat_event( "sol_message", ts=_ms(2026, 4, 20, 12, 0, 0), use_id="1713626000000", text="hello", notes="ready", requested_target=None, requested_task=None, ) assert calls == [("chat", "sol_message", event)] def test_append_broadcast_failure_is_swallowed(tmp_path, monkeypatch): journal = _setup_journal(tmp_path, monkeypatch) import solstone.convey.chat as chat class FakeCallosum: def emit(self, tract, event, **fields): raise RuntimeError("boom") monkeypatch.setattr(chat, "_runtime", SimpleNamespace(callosum=FakeCallosum())) event = append_chat_event( "owner_message", ts=_ms(2026, 4, 20, 12, 0, 0), text="hello", app="sol", path="/chat", facet="work", ) chat_path = ( journal / "chronicle" / "20260420" / "chat" / "120000_300" / "chat.jsonl" ) assert event["kind"] == "owner_message" lines = chat_path.read_text(encoding="utf-8").splitlines() assert len(lines) == 1 assert json.loads(lines[0]) == event def test_append_rejects_unknown_kind(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) with pytest.raises(ValueError, match="Unknown chat event kind"): append_chat_event("unknown", ts=1) def test_append_rejects_missing_required_fields(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) with pytest.raises(ValueError, match="owner_message requires fields: path, facet"): append_chat_event( "owner_message", ts=1, text="hello", app="sol", ) def test_chat_queue_depth_event_validates_depth(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) event = append_chat_event("chat_queue_depth", ts=ts, depth=3) assert event["kind"] == "chat_queue_depth" assert read_chat_events("20260420") == [event] with pytest.raises(ValueError, match="chat_queue_depth requires fields: depth"): append_chat_event("chat_queue_depth", ts=ts + 1) with pytest.raises(ValueError, match="chat_queue_depth depth must be an int"): append_chat_event("chat_queue_depth", ts=ts + 2, depth="3") def test_support_draft_event_round_trips(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) event = append_chat_event( "support_draft", ts=ts, draft_id="draft-1", captured_day="20260420", verb="create", payload={"subject": "help"}, diagnostics_snapshot=None, ) events = read_chat_events("20260420") assert events == [event] assert events[0]["kind"] == "support_draft" assert events[0]["draft_id"] == "draft-1" attach_payload = { "ticket_id": 42, "filename": "evidence.txt", "content_type": "text/plain", "byte_size": 5, "content_b64": base64.b64encode(b"bytes").decode("ascii"), } attach_event = append_chat_event( "support_draft", ts=ts + 1, draft_id="draft-attach", captured_day="20260420", verb="attach", payload=attach_payload, diagnostics_snapshot=None, ) events = read_chat_events("20260420") assert events == [event, attach_event] assert events[1]["payload"] == attach_payload def test_chat_error_preserves_optional_provider(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) event = append_chat_event( "chat_error", ts=ts, reason="provider_key_invalid", use_id="1713626000000", provider="google", ) events = read_chat_events("20260420") assert events == [event] assert events[0]["provider"] == "google" def test_chat_error_preserves_optional_detail_verbatim(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) detail = "provider raw detail: " + ("x" * 400) event = append_chat_event( "chat_error", ts=ts, reason="unknown", use_id="1713626000000", provider="google", detail=detail, ) events = read_chat_events("20260420") assert events == [event] assert events[0]["detail"] == detail def test_thinking_payload_round_trips_for_sol_message_and_talent_finished( tmp_path, monkeypatch ): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) thinking = { "content": "reasoning text", "provider": "openai", "model": "gpt-reasoning", "tokens": 100, } sol_event = append_chat_event( "sol_message", ts=ts, use_id="1713626000000", text="hello", notes="ready", requested_target=None, requested_task=None, thinking=thinking, ) talent_event = append_chat_event( "talent_finished", ts=ts + 1_000, use_id="1713626000001", name="exec", summary="done", thinking=thinking, ) events = read_chat_events("20260420") assert events == [sol_event, talent_event] assert events[0]["thinking"] == thinking assert events[1]["thinking"] == thinking def test_historical_events_without_thinking_replay_unchanged(tmp_path, monkeypatch): journal = _setup_journal(tmp_path, monkeypatch) fixture = ( '{"kind": "sol_message", "ts": 1776708000000, ' '"use_id": "1713626000000", "text": "hello", "notes": "ready", ' '"requested_target": null, "requested_task": null}\n' ) chat_dir = journal / "chronicle" / "20260420" / "chat" / "120000_300" chat_dir.mkdir(parents=True) (chat_dir / "chat.jsonl").write_text(fixture, encoding="utf-8") events = read_chat_events("20260420") assert events == [json.loads(fixture)] assert "thinking" not in events[0] replayed = "".join(json.dumps(event, ensure_ascii=False) + "\n" for event in events) assert replayed == fixture state = reduce_chat_state("20260420") assert state["latest_sol_message"] is not None assert "thinking" not in state["latest_sol_message"] assert state["latest_sol_message"]["offer"] is None import solstone.convey.chat_stream as chat_stream assert "thinking" not in chat_stream._VALID_KINDS["sol_message"] assert "offer" not in chat_stream._VALID_KINDS["sol_message"] assert "thinking" not in chat_stream._VALID_KINDS["talent_finished"] def test_owner_message_preserves_optional_source(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) source = {"kind": "needs_you", "item_text": "Review the launch checklist"} event = append_chat_event( "owner_message", ts=_ms(2026, 4, 20, 12, 0, 0), text="let's dig into Review the launch checklist", app="home", path="/app/home", facet=None, source=source, ) events = read_chat_events("20260420") assert events == [event] assert events[0]["source"] == source def test_append_rolls_at_300_seconds(tmp_path, monkeypatch): journal = _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="first", app="sol", path="/chat", facet="work", ) append_chat_event( "owner_message", ts=start + 299_999, text="second", app="sol", path="/chat", facet="work", ) append_chat_event( "owner_message", ts=start + 300_000, text="third", app="sol", path="/chat", facet="work", ) chat_root = journal / "chronicle" / "20260420" / "chat" assert sorted(path.name for path in chat_root.iterdir()) == [ "120000_300", "120500_300", ] def test_append_rolls_at_day_cross(tmp_path, monkeypatch): journal = _setup_journal(tmp_path, monkeypatch) first = _ms(2026, 4, 20, 23, 59, 59) second = _ms(2026, 4, 21, 0, 0, 0) append_chat_event( "owner_message", ts=first, text="late", app="sol", path="/chat", facet="work", ) append_chat_event( "owner_message", ts=second, text="next day", app="sol", path="/chat", facet="work", ) assert ( journal / "chronicle" / "20260420" / "chat" / "235959_300" / "chat.jsonl" ).is_file() assert ( journal / "chronicle" / "20260421" / "chat" / "000000_300" / "chat.jsonl" ).is_file() def test_read_chat_events_returns_ordered(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="first", app="sol", path="/chat", facet="work", ) append_chat_event( "owner_message", ts=start + 300_000, text="second", app="sol", path="/chat", facet="work", ) append_chat_event( "owner_message", ts=start + 600_000, text="third", app="sol", path="/chat", facet="work", ) events = read_chat_events("20260420") assert [event["text"] for event in events] == ["first", "second", "third"] assert [event["ts"] for event in events] == [ start, start + 300_000, start + 600_000, ] def test_read_chat_tail_last_n(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) for index in range(4): append_chat_event( "owner_message", ts=start + (index * 60_000), text=f"msg-{index}", app="sol", path="/chat", facet="work", ) tail = read_chat_tail("20260420", limit=2) assert [event["text"] for event in tail] == ["msg-2", "msg-3"] def test_reduce_chat_state_extracts_latest_sol_and_active_talents( tmp_path, monkeypatch ): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="hello", app="sol", path="/chat", facet="work", ) append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-1", text="dispatching", notes="planning", requested_target="exec", requested_task="compare drafts", ) append_chat_event( "talent_spawned", ts=start + 2_000, use_id="exec-1", name="exec", task="compare drafts", started_at=start + 2_000, ) append_chat_event( "talent_finished", ts=start + 3_000, use_id="exec-1", name="exec", summary="done", ) append_chat_event( "talent_spawned", ts=start + 4_000, use_id="exec-2", name="exec", task="write summary", started_at=start + 4_000, ) append_chat_event( "talent_errored", ts=start + 5_000, use_id="exec-3", name="exec", reason="bad input", ) append_chat_event( "chat_error", ts=start + 6_000, reason="unknown", use_id=None, ) append_chat_event( "reflection_ready", ts=start + 7_000, day="20260308", url="/app/reflections/20260308", ) reduced = reduce_chat_state("20260420") assert reduced["latest_sol_message"] == { "ts": start + 1_000, "use_id": "chat-1", "text": "dispatching", "notes": "planning", "requested_target": "exec", "requested_task": "compare drafts", "offer": None, "draft": None, "origin": None, "sources": [], "answer_state": "answered", } assert reduced["active_talents"] == [ { "use_id": "exec-2", "name": "exec", "task": "write summary", "started_at": start + 4_000, "label": talent_label_for("exec", "running"), } ] assert reduced["completed_talents"] == [ { "use_id": "exec-1", "name": "exec", "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 assert reduced["queued_talents"] == [] 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 ): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "sol_message", ts=start, use_id="chat-offer", text="I can bring in solstone support.", notes="offer support", requested_target=None, requested_task=None, offer={"kind": "support"}, ) reduced = reduce_chat_state("20260420") assert reduced["latest_sol_message"]["offer"] == {"kind": "support"} append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-answer", text="done", notes="answered", requested_target=None, requested_task=None, ) reduced = reduce_chat_state("20260420") assert reduced["latest_sol_message"]["offer"] is None def test_reduce_chat_state_includes_draft_and_clears_on_later_sol_message( tmp_path, monkeypatch ): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) draft = { "draft_id": "draft-attach", "verb": "attach", "payload": { "ticket_id": 42, "filename": "evidence.txt", "content_type": "text/plain", "byte_size": 5, }, "diagnostics_snapshot": None, } append_chat_event( "sol_message", ts=start, use_id="chat-draft", text="draft ready", notes="support draft", requested_target=None, requested_task=None, draft=draft, ) reduced = reduce_chat_state("20260420") assert reduced["latest_sol_message"]["draft"] == draft assert "content_b64" not in reduced["latest_sol_message"]["draft"]["payload"] append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-answer", text="done", notes="answered", requested_target=None, requested_task=None, ) reduced = reduce_chat_state("20260420") assert reduced["latest_sol_message"]["draft"] is None def test_reduce_chat_state_returns_last_queue_depth(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) assert reduce_chat_state("20260420")["queue_depth"] == 0 append_chat_event("chat_queue_depth", ts=start, depth=2) append_chat_event("chat_queue_depth", ts=start + 1_000, depth=5) append_chat_event("chat_queue_depth", ts=start + 2_000, depth=1) assert reduce_chat_state("20260420")["queue_depth"] == 1 def test_reduce_chat_state_surfaces_sol_message_origin(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) origin = {"logical_use_id": "chat-dispatch", "ask": "look this up"} append_chat_event( "sol_message", ts=start, use_id="chat-fold", text="folded answer", notes="", requested_target=None, requested_task=None, origin=origin, ) reduced = reduce_chat_state("20260420") assert reduced["latest_sol_message"]["origin"] == origin def test_reduce_chat_state_tracks_queued_talents_until_spawn(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "talent_queued", ts=start, use_id="talent-queued", name="exec", task="research", queued_at=start, chat_use_id="chat-dispatch", ask="research this", context={"scope": "today"}, location={"app": "sol", "path": "/app/sol", "facet": "work"}, ) assert reduce_chat_state("20260420")["queued_talents"] == [ { "use_id": "talent-queued", "name": "exec", "task": "research", "queued_at": start, } ] append_chat_event( "talent_spawned", ts=start + 1_000, use_id="talent-queued", name="exec", task="research", started_at=start + 1_000, ) reduced = reduce_chat_state("20260420") assert reduced["queued_talents"] == [] assert reduced["active_talents"][0]["use_id"] == "talent-queued" def test_append_reflection_ready_event(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) event = append_chat_event( "reflection_ready", ts=ts, day="20260308", url="/app/reflections/20260308", ) assert event["kind"] == "reflection_ready" assert event["day"] == "20260308" assert event["url"] == "/app/reflections/20260308" def test_find_unresponded_trigger_owner_message(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) ts = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=ts, text="hello", app="sol", path="/chat", facet="work", ) trigger = find_unresponded_trigger("20260420") assert trigger is not None assert trigger["kind"] == "owner_message" assert trigger["text"] == "hello" def test_find_unresponded_trigger_talent_finished(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="hello", app="sol", path="/chat", facet="work", ) append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-1", text="working", notes="", requested_target=None, requested_task=None, ) append_chat_event( "talent_finished", ts=start + 2_000, use_id="exec-1", name="exec", summary="done", ) trigger = find_unresponded_trigger("20260420") assert trigger is not None assert trigger["kind"] == "talent_finished" assert trigger["summary"] == "done" def test_find_unresponded_trigger_after_dispatch_ack_and_spawn(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="look this up", app="sol", path="/chat", facet="work", ) append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-dispatch", text="working", notes="", requested_target="exec", requested_task="research", ) append_chat_event( "talent_spawned", ts=start + 2_000, use_id="talent-1", name="exec", task="research", started_at=start + 2_000, ) append_chat_event( "talent_finished", ts=start + 3_000, use_id="talent-1", name="exec", summary="done", ) trigger = find_unresponded_trigger("20260420") assert trigger is not None assert trigger["kind"] == "talent_finished" assert trigger["use_id"] == "talent-1" def test_talent_queued_is_not_an_unresponded_trigger(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="look this up", app="sol", path="/chat", facet="work", ) append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-dispatch", text="working", notes="", requested_target="exec", requested_task="research", ) append_chat_event( "talent_queued", ts=start + 2_000, use_id="talent-queued", name="exec", task="research", queued_at=start + 2_000, chat_use_id="chat-dispatch", ask="look this up", context={}, location={"app": "sol", "path": "/chat", "facet": "work"}, ) assert find_unresponded_trigger("20260420") is None def test_find_unresponded_trigger_chat_error_terminal_preserves_owner_recovery( tmp_path, monkeypatch ): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "owner_message", ts=start, text="hello", app="sol", path="/chat", facet="work", ) append_chat_event( "chat_error", ts=start + 1_000, reason="chat_timeout", use_id="chat-1", ) assert find_unresponded_trigger("20260420") is None recovery_start = _ms(2026, 4, 21, 12, 0, 0) append_chat_event( "owner_message", ts=recovery_start, text="restart me", app="sol", path="/chat", facet="work", ) trigger = find_unresponded_trigger("20260421") assert trigger is not None assert trigger["kind"] == "owner_message" assert trigger["text"] == "restart me" def test_find_unresponded_trigger_resolved(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) append_chat_event( "talent_finished", ts=start, use_id="exec-1", name="exec", summary="done", ) append_chat_event( "sol_message", ts=start + 1_000, use_id="chat-1", text="thanks", notes="", requested_target=None, requested_task=None, ) assert find_unresponded_trigger("20260420") is None