From 88fae3315229e5add1e93d28b38fbe71b74a5886 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:38:30 -0400 Subject: [PATCH] fix: enqueue significance for interjection memories (T74.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T44's interjection branch wrote interjection memories via record_turn_memory_for_present but never enqueued a SignificanceJob, so the interjection beat could land in memory but never be scored — which meant it could never auto-pin even when it carried a pivotal moment. - Capture the host-POV memory id from the interjection's memory write result and enqueue a SignificanceJob mirroring the primary turn's pattern. One enqueue per beat (host id; guest POV piggybacks on the same score since the prose is identical for v2 — per-POV rewrite happens at scene close in T45). - New test test_interjection_enqueues_significance_job pins the contract by intercepting worker.enqueue and asserting two distinct jobs land per 3-entity turn that fires an interjection. --- chat/web/turns.py | 29 ++++++++++++++++++- tests/test_turn_flow.py | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/chat/web/turns.py b/chat/web/turns.py index 4309b8e..ab1308c 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -616,7 +616,7 @@ async def post_turn( # Memory write for the interjection beat — a second pair # of memory_written events (host + guest POVs). - record_turn_memory_for_present( + interject_memory_results = record_turn_memory_for_present( conn, chat_id=chat_id, host_bot_id=host_bot["id"], @@ -626,6 +626,33 @@ async def post_turn( chat_clock_at=chat.get("time"), ) + # T74.2: enqueue a significance pass for the interjection + # memory. Mirrors the primary-turn enqueue pattern above — + # we score on the host's memory id since the prose is + # identical across both POVs (per-POV rewrite happens at + # scene close in T45). Without this enqueue the + # interjection beat lands in memory but never gets scored, + # so it can never auto-pin even when it carries a pivotal + # moment. + interject_host_event = interject_memory_results.get( + host_bot["id"] + ) + interject_host_memory_id = ( + interject_host_event[1] if interject_host_event else None + ) + if ( + worker is not None + and interject_host_memory_id is not None + ): + worker.enqueue( + SignificanceJob( + memory_id=interject_host_memory_id, + narrative_text=interjection_text, + prior_dialogue=recent_post_interject, + host_bot_id=host_bot["id"], + ) + ) + # 9. Scene-close detection (Plan §7.2, T26). Runs AFTER assistant_turn # and the optional interjection so the bots' responses are part of # the closing scene's final beat — closing before narrative would diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index 665dc0c..281d123 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -713,3 +713,65 @@ def test_addressee_detection_routes_to_named_bot(app_state_setup, tmp_path): # Interjection follow-on goes to the silent witness — the host. assert interjection_payload["speaker_id"] == "bot_a" assert interjection_payload["interjection_of"] == "bot_b" + + +def test_interjection_enqueues_significance_job(app_state_setup, tmp_path): + """T74.2: when an interjection fires, the interjection memory is + enqueued for significance scoring just like the primary memory. + + Capture enqueued ``SignificanceJob``s by replacing the background + worker's ``enqueue`` method with a list-append. Without T74.2, the + interjection memory would never be scored — only the primary's + enqueue would land. We therefore expect TWO jobs after a turn that + has both a primary and an interjection beat: one for the primary + memory, one for the interjection memory. + """ + _seed_chat_with_guest(tmp_path / "test.db") + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "tell me"}]} + ) + canned = [ + canned_parse, + json.dumps( + {"addressee_id": "bot_a", "confidence": "medium", "reason": "host"} + ), + "Primary beat.", + _zero_state(), _zero_state(), _zero_state(), + _zero_state(), _zero_state(), _zero_state(), + json.dumps({"should_interject": True, "reason": "jealous"}), + "Interjection beat!", + _zero_state(), _zero_state(), _zero_state(), + _zero_state(), _zero_state(), _zero_state(), + json.dumps({"should_close": False, "reason": "no signal"}), + ] + _override_llm(canned) + + captured_jobs: list = [] + worker = app.state.background_worker + # Re-enable enqueue capture even though the worker's loop is disabled + # — we want to count enqueues without the loop running classifier work. + worker.enabled = True + original_enqueue = worker.enqueue + worker.enqueue = captured_jobs.append # type: ignore[assignment] + + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "tell me"} + ) + assert response.status_code == 204 + finally: + worker.enqueue = original_enqueue # type: ignore[assignment] + worker.enabled = False + app.dependency_overrides.clear() + + # Expect 2 enqueues: 1 for the primary memory + 1 for the + # interjection memory. + assert len(captured_jobs) == 2 + + # Both jobs should reference distinct memory ids — the primary's + # host-POV memory and the interjection's host-POV memory. + memory_ids = [job.memory_id for job in captured_jobs] + assert len(set(memory_ids)) == 2 + # The two narrative texts should be the two streamed beats. + narrative_texts = sorted(job.narrative_text for job in captured_jobs) + assert narrative_texts == ["Interjection beat!", "Primary beat."]