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:
|
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(
|
append_and_apply(
|
||||||
conn,
|
conn,
|
||||||
kind="thread_closed",
|
kind="thread_closed",
|
||||||
payload={
|
payload={
|
||||||
"thread_id": cand.existing_thread_id,
|
"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
|
and "test-detect-threads-boom" in rec.message
|
||||||
for rec in caplog.records
|
for rec in caplog.records
|
||||||
), [r.message for r 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