fix: thread_closed uses chat-clock time, not wall clock (T80.4)
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.
This commit is contained in:
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user