From be92691f9aa44be692247f602c288c59aeab6103 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 22:02:25 -0400 Subject: [PATCH 1/2] fix: post_turn consumes pending meanwhile digests (T82.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire chat.services.prompt.consume_pending_meanwhile_digests into chat.web.turns.post_turn at the END of the handler, after scene-close detection and before the response broadcast. Without this call digests created by a meanwhile close stay pending forever — they surface in the next you-turn's prompt (via T65) but are never marked consumed, so they re-render on every subsequent turn. Idempotent: re-calling the helper produces zero events when nothing's pending. The T66 cross-feature note is updated to reflect the new wiring; the existing direct-helper test in test_phase3_integration.py is preserved as defensive coverage of the helper contract in isolation. --- chat/web/turns.py | 14 +++++- tests/test_phase3_integration.py | 19 ++++---- tests/test_turn_flow.py | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/chat/web/turns.py b/chat/web/turns.py index 0dd17b5..ff75b2b 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -64,7 +64,10 @@ from chat.services.event_promotion import promote_completed_event from chat.services.interjection import detect_interjection from chat.services.memory_write import record_turn_memory_for_present from chat.services.multi_state_update import compute_state_updates_for_present -from chat.services.prompt import assemble_narrative_prompt +from chat.services.prompt import ( + assemble_narrative_prompt, + consume_pending_meanwhile_digests, +) from chat.services.rewind import compute_rewind_preview, execute_rewind from chat.services.scene_close import detect_scene_close from chat.services.scene_summarize import apply_scene_close_summary @@ -886,6 +889,15 @@ async def post_turn( timeout_s=settings.classifier_timeout_s, ) + # 9a. Consume any pending meanwhile digests now that the assistant_turn + # (which surfaced them in its prompt via T65's helper) has landed. The + # spec's "first you-turn AFTER meanwhile close consumes the digest" + # semantics are preserved by running this AFTER scene-close detection + # — anything pending right now belongs to the prompt we just answered, + # so it's safe to mark consumed and the NEXT turn starts clean. + # Idempotent: re-calling produces zero events when nothing's pending. + consume_pending_meanwhile_digests(conn, chat_id) + # 10. Broadcast a JSON completion event (for JS consumers) and an HTML # fragment event (for HTMX SSE swap-into-timeline). One pair per # written assistant_turn so the timeline ends up with both the diff --git a/tests/test_phase3_integration.py b/tests/test_phase3_integration.py index 11a9bb0..f01eb16 100644 --- a/tests/test_phase3_integration.py +++ b/tests/test_phase3_integration.py @@ -39,11 +39,11 @@ Cross-feature notes discovered while writing these tests: swallowed. Tests that don't care about thread coverage can omit the slot; test 2 includes a valid thread response to exercise the path. - ``consume_pending_meanwhile_digests`` is defined in chat.services.prompt - but is NOT currently wired into the post_turn flow. The digest stays - pending across turns until the helper is called explicitly. Test 4 - reflects this: it asserts the digest renders pre-consumption AND - post-consumption (driven via the helper directly), and that the - meanwhile_digest_consumed event lands in the event_log. + and is wired into the END of post_turn (after scene-close detection) + by T82.1. Test 4 still drives the helper directly because it asserts + the helper's contract in isolation (no post_turn round-trip in scope); + the explicit call doubles as defensive coverage and is idempotent — a + second call on already-consumed digests is a no-op. - The host-only ``apply_scene_close_summary`` canned queue layout is ``[host_pov, thread_detection]`` (2 slots) when a single bot is present and there are dialogue rows, with thread_detection being optional / @@ -769,10 +769,11 @@ def test_meanwhile_close_digest_surfaces_then_consumed( — the digest is gone, and a meanwhile_digest_consumed event landed. Cross-feature finding: ``consume_pending_meanwhile_digests`` is - defined in chat.services.prompt but is NOT wired into the post_turn - flow. The digest stays pending across turns until callers invoke - the helper. Test exercises the helper directly so the consumption - contract is pinned independent of any future post_turn integration. + defined in chat.services.prompt and wired into post_turn by T82.1 + (after scene-close detection). This test exercises the helper + directly so the consumption contract is pinned in isolation from + the post_turn round-trip; T82.1's wiring is covered by a dedicated + test in tests/test_turn_flow.py. Canned queue for the meanwhile turn: 1. parse_turn diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index 80364ec..1f8164d 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -1317,3 +1317,80 @@ def test_skip_command_does_not_run_narrative_classifier( "assemble_narrative_prompt was called on the skip path; the " "natural-language skip dispatch must bypass narrative assembly." ) + + +# --------------------------------------------------------------------------- +# Phase 3.5 (T82.1) — post_turn consumes pending meanwhile digests. +# +# The helper ``consume_pending_meanwhile_digests`` lives in +# chat.services.prompt and is now wired into the END of post_turn (after +# scene-close detection, before the response broadcast). This pins the +# wiring so future refactors don't accidentally drop the call and leave +# digests pending forever. +# --------------------------------------------------------------------------- + + +def test_post_turn_consumes_pending_meanwhile_digests( + app_state_setup, tmp_path +): + """Seed a pending meanwhile digest via ``meanwhile_digest_created``, + POST a regular you-turn through post_turn, and assert: + + 1. A ``meanwhile_digest_consumed`` event lands in the event_log. + 2. ``list_pending_meanwhile_digests`` returns empty after the turn. + + The post_turn flow surfaces the digest in the prompt (T65) and then + consumes it (T82.1) so the next turn starts clean. + """ + _seed(tmp_path / "test.db") + + db_path = tmp_path / "test.db" + # Seed a pending digest directly via the projection event. The scene_id + # field doesn't need to reference an existing meanwhile scene for the + # digest table — the FK is on the digest payload only. + with open_db(db_path) as conn: + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "scene_id": 99, + "chat_id": "chat_bot_a", + "summary": "While you were away, the bots talked.", + }, + ) + # Confirm the digest is pending before the turn lands. + from chat.state.meanwhile import list_pending_meanwhile_digests + + assert len(list_pending_meanwhile_digests(conn, "chat_bot_a")) == 1 + + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "hello"}]} + ) + # Standard 4-slot queue: parse + narrative + 2 state-updates. No + # active scene so scene-close detection short-circuits without an LLM + # call (consistent with the no-guest regression test). + mock = _override_llm( + [canned_parse, "Hi there.", _zero_state(), _zero_state()] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "hello"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + # All canned slots drained — no extra classifier calls fired. + assert mock._canned == [] + + with open_db(db_path) as conn: + # A meanwhile_digest_consumed event landed for the seeded digest. + consumed_rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'meanwhile_digest_consumed' ORDER BY id" + ).fetchall() + assert len(consumed_rows) == 1 + + # The pending list is empty after consumption. + from chat.state.meanwhile import list_pending_meanwhile_digests + + assert list_pending_meanwhile_digests(conn, "chat_bot_a") == [] From 71245fb85a57bf1a3261174104a668605ea1a921 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 22:06:24 -0400 Subject: [PATCH 2/2] fix: natural-language skip runs scene close detection (T82.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat/web/turns.py | 43 +++++++++++ tests/test_turn_flow.py | 167 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) 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" + )