From b91a5e92938612aeecf3bdfa4aac2a9c2b882c25 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:50:04 -0400 Subject: [PATCH] fix: thread_closed uses chat-clock time, not wall clock (T80.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T58 stamped emitted ``thread_closed`` events with ``datetime.now(timezone.utc).isoformat()``. The rest of the close pipeline (memories.chat_clock_at, scene_closed.ended_at, edge writes) uses the chat's in-world clock. Threads must agree so timeline reconstruction stays consistent under time skips and replay. Read ``chat["time"]`` (already loaded for the per-POV path) and pass it through as ``closed_at``. Falls back to UTC now only when chat_state has no clock yet — defensive; chat_created always seeds it. Adds test_thread_closed_uses_chat_clock_time. --- chat/services/scene_summarize.py | 10 ++++- tests/test_per_pov_summary.py | 76 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index d72ae9b..7551f8b 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -624,12 +624,20 @@ async def apply_scene_close_summary( }, ) elif cand.action == "close" and cand.existing_thread_id: + # T80.4: chat-clock time, not wall clock — the rest of the + # close pipeline (memories, edges, scene_closed payloads) + # uses chat["time"] so threads must agree. Falls back to + # UTC now only when the chat row has no clock yet (defensive + # — chat_state always seeds "time" via chat_created). + chat_clock_at = chat.get("time") or datetime.now( + timezone.utc + ).isoformat() append_and_apply( conn, kind="thread_closed", payload={ "thread_id": cand.existing_thread_id, - "closed_at": datetime.now(timezone.utc).isoformat(), + "closed_at": chat_clock_at, }, ) diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index ae4d35d..a6e7a69 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -1673,3 +1673,79 @@ async def test_detect_threads_failure_is_logged(tmp_path, monkeypatch, caplog): and "test-detect-threads-boom" in rec.message for rec in caplog.records ), [r.message for r in caplog.records] + + +@pytest.mark.asyncio +async def test_thread_closed_uses_chat_clock_time(tmp_path, monkeypatch): + """T80.4: emitted ``thread_closed`` events stamp ``closed_at`` with + the chat-clock time (chat["time"]), not the host's wall clock. The + rest of the close pipeline already does this; threads must agree + so timeline reconstruction stays consistent.""" + from chat.services import thread_detection as td_mod + + canned = json.dumps( + { + "summary": "BotA had a quick chat.", + "knowledge_facts": [], + "relationship_summary": "Steady.", + } + ) + + async def fake_detect_threads(client, **kwargs): + return td_mod.ThreadDetectionResult( + candidates=[ + td_mod.ThreadCandidate( + action="close", + existing_thread_id="thr_x", + summary="resolved", + ), + ] + ) + + monkeypatch.setattr(td_mod, "detect_threads", fake_detect_threads) + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_single_bot_scene(conn) + # Pre-seed an open thread so the "close" candidate has something + # real to close, and pin the chat clock to a known value. + from chat.eventlog.log import append_and_apply + import chat.state.threads # noqa: F401 + + append_and_apply( + conn, + kind="thread_opened", + payload={ + "thread_id": "thr_x", + "chat_id": "chat_bot_a", + "title": "Lingering question", + "summary": "What did Maya hide?", + }, + ) + project(conn) + # UPDATE chat_state AFTER project so the re-projection doesn't + # overwrite the pinned clock value. + chat_clock = "2026-04-26T10:00:00+00:00" + conn.execute( + "UPDATE chat_state SET time = ? WHERE chat_id = ?", + (chat_clock, "chat_bot_a"), + ) + + client = MockLLMClient(canned=[canned]) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=1, + host_bot_id="bot_a", + ) + + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'thread_closed'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["thread_id"] == "thr_x" + assert payload["closed_at"] == chat_clock