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:
+13
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user