fix: natural-language skip runs scene close detection (T82.2)
The natural-language skip dispatch in chat.web.turns.post_turn (intent="skip_elision") previously bypassed scene close detection entirely. User prose like "fade out, skip an hour" carries both a close signal and a skip directive — the close summary must capture the closing scene's final beat (and promote per-POV memories) before the time advances. Insert detect_scene_close + apply_scene_close_summary BEFORE the skip controller invocation in the skip_elision branch. Order: scene close -> skip narration -> time advance. When there's no active scene or the prose carries no close signal, detect_scene_close returns the safe should_close=False default and the flow drops straight to the skip controller — same behavior as today.
This commit is contained in:
@@ -317,6 +317,49 @@ async def post_turn(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if intent == "skip_elision":
|
if intent == "skip_elision":
|
||||||
|
# T82.2: run scene-close detection on the user's prose BEFORE
|
||||||
|
# the skip controller fires. Prose like "fade out, skip an hour"
|
||||||
|
# carries both a close signal and a skip directive; we want the
|
||||||
|
# close summary to capture the closing scene's final beat (and
|
||||||
|
# promote per-POV memories) before the time advances. Order
|
||||||
|
# matters: scene close -> skip narration -> time advance.
|
||||||
|
#
|
||||||
|
# When there's no active scene (or the prose carries no close
|
||||||
|
# signal) ``detect_scene_close`` returns the safe
|
||||||
|
# ``should_close=False`` default and we drop straight to the
|
||||||
|
# skip controller — same behavior as today, no extra cost.
|
||||||
|
skip_scene = active_scene(conn, chat_id)
|
||||||
|
if skip_scene is not None:
|
||||||
|
container = None
|
||||||
|
if skip_scene.get("container_id") is not None:
|
||||||
|
container = get_container(conn, skip_scene["container_id"])
|
||||||
|
container_name = container["name"] if container else "unknown"
|
||||||
|
close_decision = await detect_scene_close(
|
||||||
|
client,
|
||||||
|
model=settings.classifier_model,
|
||||||
|
prose=prose,
|
||||||
|
current_container_name=container_name,
|
||||||
|
)
|
||||||
|
if close_decision.should_close:
|
||||||
|
append_and_apply(
|
||||||
|
conn,
|
||||||
|
kind="scene_closed",
|
||||||
|
payload={
|
||||||
|
"scene_id": skip_scene["id"],
|
||||||
|
"ended_at": chat.get("time"),
|
||||||
|
"significance": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await apply_scene_close_summary(
|
||||||
|
conn,
|
||||||
|
client,
|
||||||
|
classifier_model=settings.classifier_model,
|
||||||
|
chat_id=chat_id,
|
||||||
|
scene_id=skip_scene["id"],
|
||||||
|
host_bot_id=host_bot["id"],
|
||||||
|
timeout_s=settings.classifier_timeout_s,
|
||||||
|
)
|
||||||
|
|
||||||
# Derive ``new_time`` from the chat clock. Phase 3 stub: bump by
|
# Derive ``new_time`` from the chat clock. Phase 3 stub: bump by
|
||||||
# 1 hour. The drawer's elision form is the structured path when
|
# 1 hour. The drawer's elision form is the structured path when
|
||||||
# the author wants a specific landing time; here the goal is
|
# the author wants a specific landing time; here the goal is
|
||||||
|
|||||||
@@ -1394,3 +1394,170 @@ def test_post_turn_consumes_pending_meanwhile_digests(
|
|||||||
from chat.state.meanwhile import list_pending_meanwhile_digests
|
from chat.state.meanwhile import list_pending_meanwhile_digests
|
||||||
|
|
||||||
assert list_pending_meanwhile_digests(conn, "chat_bot_a") == []
|
assert list_pending_meanwhile_digests(conn, "chat_bot_a") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3.5 (T82.2) — natural-language skip runs scene close detection.
|
||||||
|
#
|
||||||
|
# A user typing "fade out, skip an hour" should close the scene FIRST
|
||||||
|
# (so the close summary captures the closing scene's final beat) and
|
||||||
|
# THEN run the elision skip. Without this wiring, the skip dispatch
|
||||||
|
# branch bypasses scene close entirely.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_natural_language_skip_with_close_signal_closes_scene(
|
||||||
|
app_state_setup, tmp_path
|
||||||
|
):
|
||||||
|
"""Prose that hard-signals a close ("fade out, skip to morning") and
|
||||||
|
parses as ``intent=skip_elision`` must:
|
||||||
|
|
||||||
|
1. Land a ``scene_closed`` event before any skip event.
|
||||||
|
2. Run ``apply_scene_close_summary`` for the closing scene.
|
||||||
|
3. Land a ``time_skip_elision`` event AFTER the scene_closed.
|
||||||
|
|
||||||
|
Order matters — the scene_closed id must be lower than the
|
||||||
|
time_skip_elision id in the event_log.
|
||||||
|
|
||||||
|
Canned queue (single-bot, scene seeded, NO prior dialogue rows):
|
||||||
|
1. parse_turn -> intent=skip_elision
|
||||||
|
2. detect_scene_close -> should_close=True
|
||||||
|
3. apply_scene_close_summary host POV
|
||||||
|
4. narrate_skip narration
|
||||||
|
|
||||||
|
detect_threads (T58.2 fires on every close) short-circuits when the
|
||||||
|
scene-scoped transcript is empty — in this test no user/assistant
|
||||||
|
turns landed in scene 1 before the close, so no thread-detection
|
||||||
|
slot is needed.
|
||||||
|
"""
|
||||||
|
# Seed an open scene so detect_scene_close has something to act on.
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
with open_db(db_path) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "thoughtful, observant",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": [],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "",
|
||||||
|
"kickoff_prose": "...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
"knowledge_facts": ["coworker"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for entity_id, verb in [("you", "talking"), ("bot_a", "listening")]:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="activity_change",
|
||||||
|
payload={
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"posture": "sitting",
|
||||||
|
"action": {
|
||||||
|
"verb": verb,
|
||||||
|
"interruptible": True,
|
||||||
|
"required_attention": "low",
|
||||||
|
"expected_duration": "ongoing",
|
||||||
|
},
|
||||||
|
"attention": "",
|
||||||
|
"holding": [],
|
||||||
|
"status": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
canned_parse = json.dumps(
|
||||||
|
{
|
||||||
|
"segments": [
|
||||||
|
{"kind": "narration", "text": "fade out, skip to morning"}
|
||||||
|
],
|
||||||
|
"intent": "skip_elision",
|
||||||
|
"landing_state_hint": "morning at home",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
canned_close = json.dumps(
|
||||||
|
{"should_close": True, "reason": "fade out signaled"}
|
||||||
|
)
|
||||||
|
canned_pov = json.dumps(
|
||||||
|
{
|
||||||
|
"summary": "BotA noticed the day winding down.",
|
||||||
|
"knowledge_facts": [],
|
||||||
|
"relationship_summary": "warmer",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
canned_narration = "The night fades and morning arrives."
|
||||||
|
mock = _override_llm(
|
||||||
|
[
|
||||||
|
canned_parse,
|
||||||
|
canned_close,
|
||||||
|
canned_pov,
|
||||||
|
canned_narration,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = app_state_setup.post(
|
||||||
|
"/chats/chat_bot_a/turns",
|
||||||
|
data={"prose": "fade out, skip to morning"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
# All 4 canned slots drained — close + skip both ran end-to-end.
|
||||||
|
assert mock._canned == []
|
||||||
|
|
||||||
|
with open_db(db_path) as conn:
|
||||||
|
# scene_closed and time_skip_elision both landed.
|
||||||
|
scene_close_rows = conn.execute(
|
||||||
|
"SELECT id FROM event_log WHERE kind = 'scene_closed'"
|
||||||
|
).fetchall()
|
||||||
|
skip_rows = conn.execute(
|
||||||
|
"SELECT id FROM event_log WHERE kind = 'time_skip_elision'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(scene_close_rows) == 1, "scene_closed must land"
|
||||||
|
assert len(skip_rows) == 1, "time_skip_elision must land"
|
||||||
|
# Order: scene close first, then skip.
|
||||||
|
assert scene_close_rows[0][0] < skip_rows[0][0], (
|
||||||
|
"scene_closed must precede time_skip_elision in the event_log"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user