fix: post_turn consumes pending meanwhile digests (T82.1)

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.
This commit is contained in:
Joseph Doherty
2026-04-26 22:02:25 -04:00
parent 6f50ce5b7a
commit be92691f9a
3 changed files with 100 additions and 10 deletions
+13 -1
View File
@@ -64,7 +64,10 @@ from chat.services.event_promotion import promote_completed_event
from chat.services.interjection import detect_interjection from chat.services.interjection import detect_interjection
from chat.services.memory_write import record_turn_memory_for_present 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.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.rewind import compute_rewind_preview, execute_rewind
from chat.services.scene_close import detect_scene_close from chat.services.scene_close import detect_scene_close
from chat.services.scene_summarize import apply_scene_close_summary from chat.services.scene_summarize import apply_scene_close_summary
@@ -886,6 +889,15 @@ async def post_turn(
timeout_s=settings.classifier_timeout_s, 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 # 10. Broadcast a JSON completion event (for JS consumers) and an HTML
# fragment event (for HTMX SSE swap-into-timeline). One pair per # fragment event (for HTMX SSE swap-into-timeline). One pair per
# written assistant_turn so the timeline ends up with both the # written assistant_turn so the timeline ends up with both the
+10 -9
View File
@@ -39,11 +39,11 @@ Cross-feature notes discovered while writing these tests:
swallowed. Tests that don't care about thread coverage can omit the swallowed. Tests that don't care about thread coverage can omit the
slot; test 2 includes a valid thread response to exercise the path. slot; test 2 includes a valid thread response to exercise the path.
- ``consume_pending_meanwhile_digests`` is defined in chat.services.prompt - ``consume_pending_meanwhile_digests`` is defined in chat.services.prompt
but is NOT currently wired into the post_turn flow. The digest stays and is wired into the END of post_turn (after scene-close detection)
pending across turns until the helper is called explicitly. Test 4 by T82.1. Test 4 still drives the helper directly because it asserts
reflects this: it asserts the digest renders pre-consumption AND the helper's contract in isolation (no post_turn round-trip in scope);
post-consumption (driven via the helper directly), and that the the explicit call doubles as defensive coverage and is idempotent — a
meanwhile_digest_consumed event lands in the event_log. second call on already-consumed digests is a no-op.
- The host-only ``apply_scene_close_summary`` canned queue layout is - The host-only ``apply_scene_close_summary`` canned queue layout is
``[host_pov, thread_detection]`` (2 slots) when a single bot is present ``[host_pov, thread_detection]`` (2 slots) when a single bot is present
and there are dialogue rows, with thread_detection being optional / 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. — the digest is gone, and a meanwhile_digest_consumed event landed.
Cross-feature finding: ``consume_pending_meanwhile_digests`` is Cross-feature finding: ``consume_pending_meanwhile_digests`` is
defined in chat.services.prompt but is NOT wired into the post_turn defined in chat.services.prompt and wired into post_turn by T82.1
flow. The digest stays pending across turns until callers invoke (after scene-close detection). This test exercises the helper
the helper. Test exercises the helper directly so the consumption directly so the consumption contract is pinned in isolation from
contract is pinned independent of any future post_turn integration. 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: Canned queue for the meanwhile turn:
1. parse_turn 1. parse_turn
+77
View File
@@ -1317,3 +1317,80 @@ def test_skip_command_does_not_run_narrative_classifier(
"assemble_narrative_prompt was called on the skip path; the " "assemble_narrative_prompt was called on the skip path; the "
"natural-language skip dispatch must bypass narrative assembly." "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") == []