diff --git a/chat/web/turns.py b/chat/web/turns.py index ff75b2b..0368d8b 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -317,6 +317,49 @@ async def post_turn( ) 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 # 1 hour. The drawer's elision form is the structured path when # the author wants a specific landing time; here the goal is diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index 1f8164d..043fa78 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -1394,3 +1394,170 @@ def test_post_turn_consumes_pending_meanwhile_digests( from chat.state.meanwhile import list_pending_meanwhile_digests 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" + )