Files
chat/tests/test_per_pov_summary.py
T
Joseph Doherty 0d3bbf4272 test: T58 coverage gaps (truncation, update/close paths) (T80.5)
Three gaps left by T58's initial test coverage:

* test_key_quote_truncation_at_200_chars — exercises the 200-char hard
  slice in _build_key_quotes_suffix so any future change to the
  truncation strategy (ellipsis, word boundary, etc) trips the test.
* test_thread_detection_update_candidate_emits_thread_updated —
  pins the ``update`` action emission shape (thread_id, summary,
  last_referenced_scene_id).
* test_thread_detection_close_candidate_emits_thread_closed — pins
  the ``close`` action emission shape (thread_id, closed_at).

No production change; pure coverage add.
2026-04-26 21:50:55 -04:00

1946 lines
62 KiB
Python

"""Per-POV summary and edge summary update on scene close (T27).
When a scene closes (via the auto-close path in the turn flow or the
manual button in the drawer), we run a classifier that produces a
per-POV summary for each present witness — Phase 1 single-bot only the
host bot, since "you" doesn't have a memory store in v1. The output
drives three projected updates:
1. Each ``memories`` row for the closed scene owned by the host bot has
its ``pov_summary`` rewritten via ``manual_edit`` events
(``target_kind="memory_pov_summary"``) so the field carries a proper
scene-level summary instead of the per-turn raw narrative seeded by
T21.
2. The directed bot->you ``edges.summary`` is updated via a new
``manual_edit`` target_kind ``edge_summary``. v1 strategy combines
the prior summary with the classifier's ``relationship_summary``
field; the LLM is the one phrasing the merge.
3. Newly-learned facts from the classifier's ``knowledge_facts`` field
are appended via the existing ``edge_update`` event handler.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.mock import MockLLMClient
from chat.services.scene_summarize import (
ScenePOVSummary,
apply_scene_close_summary,
summarize_scene,
)
# Importing for handler-registration side effects so the freshly-migrated
# DB created in each test below has the projector ready.
import chat.state.edges # noqa: F401
import chat.state.entities # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
# ---------------------------------------------------------------------------
# Service-level tests — no FastAPI involvement.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_summarize_scene_parses_classifier_output():
canned = json.dumps(
{
"summary": "BotA shared a quiet moment with you in the office.",
"knowledge_facts": ["You like coffee black."],
"relationship_summary": "BotA feels closer to you after this conversation.",
}
)
mock = MockLLMClient(canned=[canned])
result = await summarize_scene(
mock,
model="x",
bot_name="BotA",
bot_persona="thoughtful",
you_name="Me",
prior_edge_summary="",
dialogue=[
{"speaker": "Me", "text": "hi"},
{"speaker": "BotA", "text": "Hello!"},
],
)
assert isinstance(result, ScenePOVSummary)
assert result.summary.startswith("BotA shared")
assert result.knowledge_facts == ["You like coffee black."]
assert "closer" in result.relationship_summary
@pytest.mark.asyncio
async def test_summarize_scene_default_on_failure():
"""Two consecutive non-JSON returns trip the classifier's retry-then-default
path; we should get the empty fallback rather than crashing the close
flow."""
mock = MockLLMClient(canned=["bad", "still bad", "bad3"])
result = await summarize_scene(
mock,
model="x",
bot_name="BotA",
bot_persona="",
you_name="Me",
prior_edge_summary="",
dialogue=[],
)
assert result.summary == ""
assert result.knowledge_facts == []
assert result.relationship_summary == ""
@pytest.mark.asyncio
async def test_apply_scene_close_summary_updates_memories_and_edge(tmp_path):
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA reassured you about the project deadline.",
"knowledge_facts": ["You are nervous about the deadline."],
"relationship_summary": "BotA showed quiet support.",
}
)
with open_db(db) as conn:
# Seed bot, you, chat, container, scene, edge, memory, dialogue.
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="you_authored",
payload={
"name": "Me",
"pronouns": "they/them",
"persona": "engineer",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "I'm nervous about the deadline",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "It's going to be okay.",
"truncated": False,
"user_turn_id": 1,
},
)
project(conn)
client = MockLLMClient(canned=[canned])
result = await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Returned summary plumbs through.
assert "reassured" in result.summary
assert result.knowledge_facts == ["You are nervous about the deadline."]
# Memory pov_summary updated.
new_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
assert "reassured" in new_pov
# And the manual_edit event was logged with prior_value capture.
edits = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert any(
json.loads(p[0]).get("target_kind") == "memory_pov_summary"
for p in edits
)
mem_edit = next(
json.loads(p[0])
for p in edits
if json.loads(p[0]).get("target_kind") == "memory_pov_summary"
)
assert mem_edit["prior_value"] == "Original raw narrative"
# Edge summary updated via manual_edit (target_kind="edge_summary").
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "support" in edge["summary"]
assert any(
json.loads(p[0]).get("target_kind") == "edge_summary"
for p in edits
)
# Knowledge fact appended via edge_update.
assert any("deadline" in fact for fact in edge["knowledge"])
# ---------------------------------------------------------------------------
# T45: per-POV summaries on close for each present witness.
# ---------------------------------------------------------------------------
def _bot_payload(bot_id: str, name: str, persona: str = "thoughtful") -> dict:
return {
"id": bot_id,
"name": name,
"persona": persona,
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
}
def _seed_single_bot_scene(conn) -> None:
"""Seed the canonical Phase 1 single-bot scene used by the regression test."""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (host)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Quick chat about the deadline",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "It's going to be okay.",
"truncated": False,
"user_turn_id": 1,
},
)
def _seed_two_bot_scene(conn, *, with_group_node: bool = False) -> None:
"""Seed a host+guest scene with bot_a (host) and bot_b (guest), plus a
memory row per bot owner so each per-POV update has something to rewrite,
and seeded directed edges from each bot to ``you`` so each edge_summary
update has a row to operate on. Optionally seeds the group_node row too.
"""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a", "bot_b"],
},
)
# Seed edges in both bot -> you directions so the edge_summary updates
# have rows to target.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_b",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
# One memory per witness, scene 1.
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (host)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_b",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": "Original raw narrative (guest)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Three of us in the office.",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "Glad you're both here.",
"truncated": False,
"user_turn_id": 1,
},
)
if with_group_node:
append_event(
conn,
kind="group_node_initialized",
payload={
"chat_id": "chat_bot_a",
"members": ["you", "bot_a", "bot_b"],
"summary": "",
"dynamic": "",
"threads": [],
},
)
@pytest.mark.asyncio
async def test_close_with_no_guest_matches_phase1(tmp_path):
"""Regression: when guest_bot_id is None, the close summary path runs
summarize_scene exactly once and rewrites the host's memory + host->you
edge in place — same as Phase 1 behavior."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA helped you talk through the deadline anxiety.",
"knowledge_facts": ["Deadline next Friday."],
"relationship_summary": "BotA leaned in supportively.",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
# 1 host-POV entry + 1 thread-detection entry (T58.2) + 1 spare
# to detect any over-call. Assertion below confirms exactly two
# were consumed.
client = MockLLMClient(canned=[canned, no_threads, canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Host POV + thread detection -> exactly two canned entries
# consumed, leaving the spare untouched.
assert len(client._canned) == 1
# Host memory rewritten with the per-POV summary content.
new_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
assert "BotA helped" in new_pov
# host->you edge summary rewritten with the relationship_summary.
from chat.state.edges import get_edge
edge = get_edge(conn, "bot_a", "you")
assert "supportively" in edge["summary"]
@pytest.mark.asyncio
async def test_close_with_guest_calls_summarize_twice(tmp_path):
"""When a guest is present, summarize_scene runs once per witness
(host + guest) and each bot's memory rewrite uses its own POV summary."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA noticed BotB warming up to you.",
"knowledge_facts": ["You sketched on the whiteboard."],
"relationship_summary": "BotA felt steady around you.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the office quieter than expected.",
"knowledge_facts": ["You prefer black coffee."],
"relationship_summary": "BotB warmed up to you a little.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Both canned entries consumed -> classifier ran twice.
assert client._canned == []
# Host memory carries the host's per-POV summary; guest memory
# carries the guest's.
host_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_a' AND scene_id = 1"
).fetchone()[0]
guest_pov = conn.execute(
"SELECT pov_summary FROM memories "
"WHERE owner_id = 'bot_b' AND scene_id = 1"
).fetchone()[0]
assert "BotA noticed" in host_pov
assert "BotB found" in guest_pov
assert host_pov != guest_pov
@pytest.mark.asyncio
async def test_close_with_guest_updates_both_edges(tmp_path):
"""Both bot->you edges receive their own relationship_summary on close."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA noticed BotB warming up.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady around you.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB warmed to the office.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up to you a little.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn)
project(conn)
client = MockLLMClient(canned=[host_canned, guest_canned])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.edges import get_edge
edge_h2y = get_edge(conn, "bot_a", "you")
edge_g2y = get_edge(conn, "bot_b", "you")
assert "steady" in edge_h2y["summary"]
assert "warmed up" in edge_g2y["summary"]
# Per-POV; the two edges did not collapse onto the same text.
assert edge_h2y["summary"] != edge_g2y["summary"]
@pytest.mark.asyncio
async def test_close_with_group_node_updates_group_summary(tmp_path):
"""When a group_node row exists, scene close emits group_node_updated
with a non-empty summary that mentions both bots' names. T70 swapped
the Phase 2 naive concat for an LLM-merged summary; this regression
test feeds bad-JSON merge responses so the helper falls back to the
original naive-concat shape, preserving the original assertions."""
db = tmp_path / "t.db"
apply_migrations(db)
import chat.state.group_node # noqa: F401 -- register handlers
host_canned = json.dumps(
{
"summary": "BotA appreciated the calm.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the room friendly.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn, with_group_node=True)
project(conn)
# 2 valid (host POV, guest POV) + 3 bad-JSON merge attempts ->
# merge_group_summary falls back to the naive concat default.
client = MockLLMClient(
canned=[host_canned, guest_canned, "bad1", "bad2", "bad3"]
)
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.group_node import get_group_node
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
assert gn["summary"] # non-empty
# Naive-concat fallback surfaces both bot names in the group summary.
assert "BotA" in gn["summary"]
assert "BotB" in gn["summary"]
# Naive-concat fallback keeps dynamic empty.
assert gn["dynamic"] == ""
# ---------------------------------------------------------------------------
# T70: LLM-merged group meta-summary on scene close.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_group_summary_merges_per_pov_via_classifier_when_guest_present(
tmp_path,
):
"""With a guest present and a group_node row, scene close runs the
merge classifier as a third call after the two per-POV summarize_scene
calls; its output drives the group_node summary + dynamic fields."""
db = tmp_path / "t.db"
apply_migrations(db)
import chat.state.group_node # noqa: F401 -- register handlers
host_canned = json.dumps(
{
"summary": "BotA appreciated the calm.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the room friendly.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up.",
}
)
merge_canned = json.dumps(
{"summary": "merged group view", "dynamic": "warm rapport"}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn, with_group_node=True)
project(conn)
# Canned-queue layout matches the production call order in
# apply_scene_close_summary: host POV summarize_scene runs first,
# then guest POV summarize_scene, then merge_group_summary.
client = MockLLMClient(
canned=[host_canned, guest_canned, merge_canned]
)
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# All three canned entries consumed -> classifier ran exactly 3x.
assert client._canned == []
from chat.state.group_node import get_group_node
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
assert gn["summary"] == "merged group view"
assert gn["dynamic"] == "warm rapport"
@pytest.mark.asyncio
async def test_group_summary_falls_back_to_naive_concat_on_classifier_failure(
tmp_path,
):
"""If the merge classifier flaps (bad JSON across all 3 retries), the
helper falls back to the original Phase 2 naive concat shape and
leaves dynamic empty."""
db = tmp_path / "t.db"
apply_migrations(db)
import chat.state.group_node # noqa: F401 -- register handlers
host_canned = json.dumps(
{
"summary": "BotA appreciated the calm.",
"knowledge_facts": [],
"relationship_summary": "BotA felt steady.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB found the room friendly.",
"knowledge_facts": [],
"relationship_summary": "BotB warmed up.",
}
)
with open_db(db) as conn:
_seed_two_bot_scene(conn, with_group_node=True)
project(conn)
# 2 valid POV summaries + 3 bad-JSON merge attempts trip the
# classifier's retry-then-default path; the default is the naive
# concat fallback.
client = MockLLMClient(
canned=[host_canned, guest_canned, "bad1", "bad2", "bad3"]
)
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
from chat.state.group_node import get_group_node
gn = get_group_node(conn, "chat_bot_a")
assert gn is not None
expected = (
"BotA: BotA appreciated the calm.\n\n"
"BotB: BotB found the room friendly."
)
assert gn["summary"] == expected
assert gn["dynamic"] == ""
@pytest.mark.asyncio
async def test_group_summary_skipped_when_no_guest(tmp_path):
"""No-guest path: scene close does NOT invoke merge_group_summary
and emits no group_node_updated event. Confirms the existing
`if guest_bot_id is not None` gating at the call site."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA helped you talk through the deadline anxiety.",
"knowledge_facts": ["Deadline next Friday."],
"relationship_summary": "BotA leaned in supportively.",
}
)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
# Only 1 canned entry; if merge_group_summary were called the
# MockLLMClient would IndexError on the empty queue.
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",
)
# Exactly the host POV call consumed, nothing else.
assert client._canned == []
# No group_node_updated event was emitted.
rows = conn.execute(
"SELECT 1 FROM event_log WHERE kind = 'group_node_updated'"
).fetchall()
assert rows == []
# ---------------------------------------------------------------------------
# T58: significance-driven quote retention + thread detection on close.
# ---------------------------------------------------------------------------
def _seed_single_bot_scene_no_memory(conn) -> None:
"""Like ``_seed_single_bot_scene`` but skips the memory_written event so
callers can seed memories with custom significance / text themselves."""
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "Quick chat about the deadline",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "It's going to be okay.",
"truncated": False,
"user_turn_id": 1,
},
)
def _seed_memory(conn, *, pov_summary: str, significance: int) -> None:
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 1,
"pov_summary": pov_summary,
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": significance,
},
)
@pytest.mark.asyncio
async def test_low_significance_scene_omits_quotes(tmp_path):
"""When the scene's max-turn-significance is < 2, the per-POV summary
rewrite collapses fully — no "Key quotes:" suffix is appended."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA had a low-key chat with you.",
"knowledge_facts": [],
"relationship_summary": "Nothing major shifted.",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_single_bot_scene_no_memory(conn)
_seed_memory(conn, pov_summary="Maya rambled about coffee", significance=1)
_seed_memory(conn, pov_summary="Maya glanced at the clock", significance=0)
project(conn)
client = MockLLMClient(canned=[canned, no_threads])
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 pov_summary FROM memories WHERE scene_id = 1"
).fetchall()
assert rows
for (pov,) in rows:
assert "Key quotes:" not in pov
assert "BotA had a low-key chat" in pov
@pytest.mark.asyncio
async def test_high_significance_scene_includes_top_3_quotes(tmp_path):
"""When max-turn-significance is >= 2, each per-POV summary text gains
a "Key quotes:" suffix listing the top-3 highest-significance memory
rows verbatim, ordered by (significance DESC, id ASC)."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA had a heavy talk with you.",
"knowledge_facts": [],
"relationship_summary": "Things shifted.",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_single_bot_scene_no_memory(conn)
# Insertion order matches id ASC. Top-3 by (sig DESC, id ASC):
# quote 1 (sig 3) -> quote 2 (sig 2, lower id) -> quote 4 (sig 2,
# higher id). quote 3 (sig 1) is dropped.
_seed_memory(conn, pov_summary="Maya quote one", significance=3)
_seed_memory(conn, pov_summary="Maya quote two", significance=2)
_seed_memory(conn, pov_summary="Maya quote three", significance=1)
_seed_memory(conn, pov_summary="Maya quote four", significance=2)
project(conn)
client = MockLLMClient(canned=[canned, no_threads])
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 pov_summary FROM memories WHERE scene_id = 1"
).fetchall()
assert rows
for (pov,) in rows:
assert "Key quotes:" in pov
assert '"Maya quote one"' in pov
assert '"Maya quote two"' in pov
assert '"Maya quote four"' in pov
# The sig-1 quote falls outside the top-3 cap.
assert '"Maya quote three"' not in pov
# Ordering: sig 3 first, then the two sig-2s by id ASC.
i_one = pov.index('"Maya quote one"')
i_two = pov.index('"Maya quote two"')
i_four = pov.index('"Maya quote four"')
assert i_one < i_two < i_four
@pytest.mark.asyncio
async def test_thread_detection_emits_events(tmp_path, monkeypatch):
"""On scene close, ``detect_threads`` is invoked and each "open"
candidate yields a ``thread_opened`` event with a fresh thread_id."""
from chat.services import thread_detection as td_mod
canned = json.dumps(
{
"summary": "BotA noticed something unresolved.",
"knowledge_facts": [],
"relationship_summary": "Tension lingered.",
}
)
async def fake_detect_threads(client, **kwargs):
return td_mod.ThreadDetectionResult(
candidates=[
td_mod.ThreadCandidate(
action="open",
title="Test thread",
summary="A test",
existing_thread_id=None,
),
]
)
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)
project(conn)
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_opened'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["title"] == "Test thread"
assert payload["summary"] == "A test"
assert payload["chat_id"] == "chat_bot_a"
assert payload["thread_id"].startswith("thr_")
# The threads-table projection ran via append_and_apply.
from chat.state.threads import list_open_threads
open_threads = list_open_threads(conn, "chat_bot_a")
assert len(open_threads) == 1
assert open_threads[0]["title"] == "Test thread"
# ---------------------------------------------------------------------------
# T65: meanwhile summary digest emitted on meanwhile-scene close, surfaced in
# the next you-scene's prompt as a SHOULD-tier "Meanwhile while you were away:"
# block, then consumed so it never re-renders.
# ---------------------------------------------------------------------------
def _seed_meanwhile_scene(conn) -> None:
"""Seed a parent you-scene + a meanwhile child scene with one assistant
turn so apply_scene_close_summary has dialogue to summarize.
The meanwhile scene id is 2 (parent is scene 1). The meanwhile dialogue
is appended via assistant_turn events under chat_bot_a; the
_read_recent_dialogue helper picks them up by chat_id.
"""
import chat.state.meanwhile # noqa: F401 -- register handlers
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA"))
append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB"))
append_event(
conn,
kind="you_authored",
payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
append_event(
conn,
kind="container_created",
payload={
"chat_id": "chat_bot_a",
"name": "office",
"type": "workplace",
"properties": {},
},
)
# Parent you-scene (scene_id=1).
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T20:00:00+00:00",
"participants": ["you", "bot_a", "bot_b"],
},
)
# Meanwhile child scene (scene_id=2) — bot_a + bot_b only.
append_event(
conn,
kind="meanwhile_scene_started",
payload={
"scene_id": 2,
"chat_id": "chat_bot_a",
"parent_scene_id": 1,
"host_bot_id": "bot_a",
"guest_bot_id": "bot_b",
"started_at": "2026-04-26T20:05:00+00:00",
},
)
# Edges so per-POV apply has rows to update.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_b",
"target_id": "you",
"chat_id": "chat_bot_a",
},
)
# One memory per witness in the meanwhile scene.
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 2,
"pov_summary": "Original raw narrative (host, meanwhile)",
"witness_you": 0,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_b",
"chat_id": "chat_bot_a",
"scene_id": 2,
"pov_summary": "Original raw narrative (guest, meanwhile)",
"witness_you": 0,
"witness_host": 1,
"witness_guest": 1,
"significance": 1,
},
)
# A bot-bot turn happens during the meanwhile scene.
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "Did you hear what happened with the missing file?",
"truncated": False,
"user_turn_id": None,
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_b",
"text": "I have a theory but no proof yet.",
"truncated": False,
"user_turn_id": None,
},
)
@pytest.mark.asyncio
async def test_meanwhile_close_creates_digest(tmp_path):
"""When apply_scene_close_summary runs on a meanwhile scene
(present_set_kind == 'host_guest'), it emits a meanwhile_digest_created
event after the per-POV summaries land; the meanwhile_digest_pending
table then holds a row with non-empty summary text."""
db = tmp_path / "t.db"
apply_migrations(db)
host_canned = json.dumps(
{
"summary": "BotA confided in BotB about the missing file.",
"knowledge_facts": [],
"relationship_summary": "BotA leaned on BotB.",
}
)
guest_canned = json.dumps(
{
"summary": "BotB listened and offered to help investigate.",
"knowledge_facts": [],
"relationship_summary": "BotB grew protective.",
}
)
digest_canned = json.dumps(
{
"summary": (
"While you were away, BotA confided in BotB about a "
"missing file; BotB offered to help quietly investigate."
),
"knowledge_facts": [],
"relationship_summary": "",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_meanwhile_scene(conn)
project(conn)
# Order: host POV summary, guest POV summary, digest summary,
# thread detection.
client = MockLLMClient(
canned=[host_canned, guest_canned, digest_canned, no_threads]
)
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=2,
host_bot_id="bot_a",
)
# The meanwhile_digest_pending row was written.
from chat.state.meanwhile import list_pending_meanwhile_digests
pending = list_pending_meanwhile_digests(conn, "chat_bot_a")
assert len(pending) == 1
assert pending[0]["scene_id"] == 2
assert pending[0]["summary"]
assert "missing file" in pending[0]["summary"]
# And the meanwhile_digest_created event was logged.
rows = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE kind = 'meanwhile_digest_created'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["chat_id"] == "chat_bot_a"
assert payload["scene_id"] == 2
assert "missing file" in payload["summary"]
def test_pending_digest_renders_in_you_scene_prompt(tmp_path):
"""A pending meanwhile digest (created via direct event append) renders
as a 'Meanwhile while you were away:' SHOULD-tier block in the
assembled you-scene narrative prompt."""
from chat.eventlog.log import append_and_apply
from chat.services.prompt import assemble_narrative_prompt
import chat.state.meanwhile # noqa: F401 -- register handlers
import chat.state.threads # noqa: F401
import chat.state.events # noqa: F401
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
digest_text = (
"While you were away, BotA confided in BotB about a missing file."
)
append_and_apply(
conn,
kind="meanwhile_digest_created",
payload={
"chat_id": "chat_bot_a",
"scene_id": 2,
"summary": digest_text,
},
)
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
body = msgs[0].content
assert "Meanwhile while you were away:" in body
assert digest_text in body
def test_consumed_digest_does_not_render_again(tmp_path):
"""After meanwhile_digest_consumed lands for a digest, reassembling the
you-scene prompt must NOT include that digest's text — the pending
list is filtered by ``consumed_at IS NULL``."""
from chat.eventlog.log import append_and_apply
from chat.services.prompt import assemble_narrative_prompt
import chat.state.meanwhile # noqa: F401 -- register handlers
import chat.state.threads # noqa: F401
import chat.state.events # noqa: F401
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
digest_text = (
"While you were away, BotA confided in BotB about a missing file."
)
append_and_apply(
conn,
kind="meanwhile_digest_created",
payload={
"chat_id": "chat_bot_a",
"scene_id": 2,
"summary": digest_text,
},
)
# Sanity: it renders before consumption.
msgs = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
assert digest_text in msgs[0].content
# Look up the pending digest id, then consume it.
from chat.state.meanwhile import list_pending_meanwhile_digests
pending = list_pending_meanwhile_digests(conn, "chat_bot_a")
assert len(pending) == 1
digest_id = pending[0]["id"]
append_and_apply(
conn,
kind="meanwhile_digest_consumed",
payload={
"digest_id": digest_id,
"consumed_at": "2026-04-26T20:30:00+00:00",
},
)
msgs2 = assemble_narrative_prompt(
conn,
chat_id="chat_bot_a",
speaker_bot_id="bot_a",
recent_dialogue=[],
retrieved_memory_summaries=[],
)
body2 = msgs2[0].content
assert "Meanwhile while you were away:" not in body2
assert digest_text not in body2
# ---------------------------------------------------------------------------
# T80: scene_summarize polish bundle.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_scene_close_re_run_does_not_double_suffix(tmp_path):
"""T80.1: re-running ``apply_scene_close_summary`` on the same scene
must NOT stack a second "Key quotes:" suffix on each pov_summary. The
builder strips any existing suffix from candidate text before
composing the new one, and the per-POV write replaces (not appends
to) the existing suffix.
"""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA had a heavy talk with you.",
"knowledge_facts": [],
"relationship_summary": "Things shifted.",
}
)
no_threads = json.dumps({"candidates": []})
with open_db(db) as conn:
_seed_single_bot_scene_no_memory(conn)
# Significance >= 2 triggers the Key quotes suffix path.
_seed_memory(conn, pov_summary="Maya quote one", significance=3)
_seed_memory(conn, pov_summary="Maya quote two", significance=2)
project(conn)
# First close.
client = MockLLMClient(canned=[canned, no_threads])
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 pov_summary FROM memories WHERE scene_id = 1"
).fetchall()
assert rows
for (pov,) in rows:
assert pov.count("Key quotes:") == 1
# Second close on the same scene with fresh canned responses.
client2 = MockLLMClient(canned=[canned, no_threads])
await apply_scene_close_summary(
conn,
client2,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
rows2 = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = 1"
).fetchall()
assert rows2
for (pov,) in rows2:
# Still exactly ONE "Key quotes:" suffix — no recursive bloat.
assert pov.count("Key quotes:") == 1
# And no nested-quote artifacts (the suffix wasn't sourced
# from a row whose text already contained the suffix).
inner_count = pov.count("Key quotes:")
assert inner_count == 1
@pytest.mark.asyncio
async def test_thread_detection_uses_scene_scoped_transcript(
tmp_path, monkeypatch
):
"""T80.2: when a chat has multiple closed scenes, the second scene's
close must hand ``detect_threads`` ONLY the second scene's turns —
not the chat-wide last-50, which would bleed in the first scene's
transcript and risk mis-closing threads."""
from chat.services import thread_detection as td_mod
canned = json.dumps(
{
"summary": "BotA had a quick chat.",
"knowledge_facts": [],
"relationship_summary": "Steady.",
}
)
captured_transcripts: list[list[dict]] = []
async def capturing_detect_threads(client, **kwargs):
captured_transcripts.append(list(kwargs["scene_transcript"]))
return td_mod.ThreadDetectionResult()
monkeypatch.setattr(td_mod, "detect_threads", capturing_detect_threads)
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
# Seed scene 1 + 3 turns + close.
_seed_single_bot_scene(conn)
# Add two extra distinct turns inside scene 1 so the transcript
# has clearly-scene-1 markers we can assert on.
append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "SCENE_ONE_USER_TURN",
"segments": [],
},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "SCENE_ONE_BOT_TURN",
"truncated": False,
"user_turn_id": 2,
},
)
project(conn)
# Close scene 1.
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",
)
# Open scene 2 with distinct dialogue. Use append_and_apply so
# the new events project incrementally without re-running the
# already-applied seed events.
from chat.eventlog.log import append_and_apply
append_and_apply(
conn,
kind="scene_opened",
payload={
"chat_id": "chat_bot_a",
"container_id": 1,
"started_at": "2026-04-26T21:00:00+00:00",
"participants": ["you", "bot_a"],
},
)
append_and_apply(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"scene_id": 2,
"pov_summary": "Original (scene 2)",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 1,
},
)
append_and_apply(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "SCENE_TWO_USER_TURN",
"segments": [],
},
)
append_and_apply(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "SCENE_TWO_BOT_TURN",
"truncated": False,
"user_turn_id": 3,
},
)
# Close scene 2.
client2 = MockLLMClient(canned=[canned])
await apply_scene_close_summary(
conn,
client2,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=2,
host_bot_id="bot_a",
)
# The second close's transcript holds only scene-2 markers.
assert len(captured_transcripts) == 2
scene_two_transcript = captured_transcripts[1]
joined = " ".join(t.get("text", "") for t in scene_two_transcript)
assert "SCENE_TWO" in joined
assert "SCENE_ONE" not in joined
@pytest.mark.asyncio
async def test_detect_threads_failure_is_logged(tmp_path, monkeypatch, caplog):
"""T80.3: when ``detect_threads`` raises, the broad except must log
the failure at DEBUG so a programmer-error flap surfaces in local
logs even though the close pipeline keeps moving."""
import logging
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 boom(client, **kwargs):
raise RuntimeError("test-detect-threads-boom")
monkeypatch.setattr(td_mod, "detect_threads", boom)
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_single_bot_scene(conn)
project(conn)
caplog.set_level(logging.DEBUG, logger="chat.services.scene_summarize")
client = MockLLMClient(canned=[canned])
# Close should NOT raise even though detect_threads did.
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
# Log carries the error message.
assert any(
"detect_threads failed" in rec.message
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
# ---------------------------------------------------------------------------
# T80.5: T58 coverage gaps (truncation, thread update/close emissions).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_key_quote_truncation_at_200_chars(tmp_path):
"""T80.5: when a memory's pov_summary exceeds 200 chars, the
Key-quote bullet truncates the source text to exactly 200 chars
(no ellipsis — a hard slice, per the existing T58 implementation)."""
db = tmp_path / "t.db"
apply_migrations(db)
canned = json.dumps(
{
"summary": "BotA had a heavy talk.",
"knowledge_facts": [],
"relationship_summary": "Things shifted.",
}
)
no_threads = json.dumps({"candidates": []})
long_text = "X" * 500 # 500 X's; expected slice is 200 X's.
with open_db(db) as conn:
_seed_single_bot_scene_no_memory(conn)
_seed_memory(conn, pov_summary=long_text, significance=2)
project(conn)
client = MockLLMClient(canned=[canned, no_threads])
await apply_scene_close_summary(
conn,
client,
classifier_model="x",
chat_id="chat_bot_a",
scene_id=1,
host_bot_id="bot_a",
)
new_pov = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = 1"
).fetchone()[0]
assert "Key quotes:" in new_pov
# The bullet should contain exactly 200 X's, not 500.
# Format from _build_key_quotes_suffix: ``- "<text>"``.
bullet_marker = '- "'
idx = new_pov.index(bullet_marker)
# Count consecutive X's after the bullet marker.
x_run = 0
for ch in new_pov[idx + len(bullet_marker):]:
if ch == "X":
x_run += 1
else:
break
assert x_run == 200, (
f"expected 200-char truncation, got {x_run}"
)
@pytest.mark.asyncio
async def test_thread_detection_update_candidate_emits_thread_updated(
tmp_path, monkeypatch
):
"""T80.5: a detect_threads ``update`` candidate produces a
``thread_updated`` event with the candidate's summary and a
last_referenced_scene_id pointing at the closed scene."""
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="update",
existing_thread_id="thr_x",
summary="updated summary",
),
]
)
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)
from chat.eventlog.log import append_and_apply
import chat.state.threads # noqa: F401
# Pre-seed the open thread so the update has a row to target.
append_and_apply(
conn,
kind="thread_opened",
payload={
"thread_id": "thr_x",
"chat_id": "chat_bot_a",
"title": "Lingering question",
"summary": "old summary",
},
)
project(conn)
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_updated'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["thread_id"] == "thr_x"
assert payload["summary"] == "updated summary"
assert payload["last_referenced_scene_id"] == 1
@pytest.mark.asyncio
async def test_thread_detection_close_candidate_emits_thread_closed(
tmp_path, monkeypatch
):
"""T80.5: a detect_threads ``close`` candidate produces a
``thread_closed`` event for the existing thread."""
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)
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": "open",
},
)
project(conn)
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"
# closed_at field is present (T80.4 verifies its value).
assert "closed_at" in payload