From be92691f9aa44be692247f602c288c59aeab6103 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 22:02:25 -0400 Subject: [PATCH] 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") == []