From 428438b2233d55ddfb25c46c9d95af18cf48f239 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:11:20 -0400 Subject: [PATCH 1/3] fix: witness role parametric in prompt assembly (T71.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 T46 pinned the witness mask contract on search_memories with a witness_role parameter (host/guest/you). The prompt-assembly call site in assemble_narrative_prompt was hardcoded to "host", which silently returned the wrong rows when the speaker was the guest bot. Derive the witness role from chat membership via a new private helper _witness_role_for(speaker_bot_id, host_bot_id), and apply it at the search_memories call. Behaviour is identical when the speaker is the host (or when no guest is present); the fix is load-bearing only when the guest bot is the speaker — exactly the scenario Phase 2 T43 added support for. Tests: pin both directions (host-as-speaker and guest-as-speaker) by patching the imported search_memories reference and asserting the witness_role argument the call site emits. --- chat/services/prompt.py | 19 ++++++++++++- tests/test_prompt.py | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 48a0a7f..5ed4905 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -273,6 +273,18 @@ def _resolve_previous_scene_summary( return mem[0] +def _witness_role_for(speaker_bot_id: str, host_bot_id: str | None) -> str: + """Return the witness POV role for the speaker's memory query. + + The host bot of a chat queries memories with ``witness_role="host"``; + the guest bot queries with ``witness_role="guest"``. Phase 2 T46 + pinned the contract on ``search_memories``; this helper applies it + at the call site so a guest-as-speaker doesn't silently retrieve + memories under the wrong POV mask. + """ + return "host" if speaker_bot_id == host_bot_id else "guest" + + def _resolve_addressee( conn: Connection, addressee: str, you: dict | None ) -> tuple[str, str]: @@ -433,7 +445,12 @@ def assemble_narrative_prompt( memory_summaries = [] if query: try: - hits = search_memories(conn, speaker_bot_id, "host", query, k=4) + witness_role = _witness_role_for( + speaker_bot_id, chat.get("host_bot_id") + ) + hits = search_memories( + conn, speaker_bot_id, witness_role, query, k=4 + ) memory_summaries = [h["pov_summary"] for h in hits] except Exception: memory_summaries = [] diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 2ef4641..d4fc6a5 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -452,6 +452,68 @@ def test_assemble_when_speaker_is_guest_orients_edges_correctly(tmp_path): assert "68/100" in body +def test_speaker_is_guest_uses_guest_witness_role(tmp_path, monkeypatch): + """T71.1: when the guest is the speaker, ``search_memories`` is + called with ``witness_role="guest"``, not the previously-hardcoded + ``"host"``. Pins the parametric witness role at the prompt call site + so guest-as-speaker honours the witness mask via Phase 2 T46. + """ + db = tmp_path / "t.db" + apply_migrations(db) + captured: dict = {} + + def _fake_search(conn, owner_id, witness_role, query, k=4): + captured["owner_id"] = owner_id + captured["witness_role"] = witness_role + captured["query"] = query + return [] + + # Patch the imported reference inside the prompt module so the + # production call site uses the fake. + import chat.services.prompt as prompt_mod + monkeypatch.setattr(prompt_mod, "search_memories", _fake_search) + + with open_db(db) as conn: + _seed_with_guest(conn) + # Guest as speaker — must request memories with witness_role="guest". + assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_b", + recent_dialogue=[], + # retrieved_memory_summaries=None forces the search path. + retrieved_memory_summaries=None, + ) + assert captured["owner_id"] == "bot_b" + assert captured["witness_role"] == "guest" + + +def test_speaker_is_host_uses_host_witness_role(tmp_path, monkeypatch): + """T71.1 (regression): host-as-speaker still queries with + ``witness_role="host"``.""" + db = tmp_path / "t.db" + apply_migrations(db) + captured: dict = {} + + def _fake_search(conn, owner_id, witness_role, query, k=4): + captured["witness_role"] = witness_role + return [] + + import chat.services.prompt as prompt_mod + monkeypatch.setattr(prompt_mod, "search_memories", _fake_search) + + with open_db(db) as conn: + _seed_with_guest(conn) + assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", # host as speaker + recent_dialogue=[], + retrieved_memory_summaries=None, + ) + assert captured["witness_role"] == "host" + + def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): """Under tight budget MUST blocks survive but SHOULD-tier guest activity is dropped first.""" From afd1a509586eac1e1a1c540ba81f72d7b951ccc8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:13:24 -0400 Subject: [PATCH 2/3] refactor: single ACTIVITIES: block with bullet-level trim (T71.2) Phase 2 T43 added a SECOND ACTIVITIES: block to render guest activity separately from you+speaker. Two consecutive ACTIVITIES: headers can read as a duplicate-section bug to the LLM and bloat the prompt. Consolidate to a single ACTIVITIES: block whose body is composed from up to three bullets (you, speaker, guest). The block itself is MUST-tier (always renders); bullet-level trim drops bullets in the order guest -> group node -> you -> other edges, with the speaker bullet as the MUST-tier floor (the speaker's own current activity is the load-bearing slice). Implementation chose Option B from the polish plan: pre-truncate the bullets list at trim time before _build_activity_block runs, rather than introduce a granular tier mode in the trim machinery. Rationale documented in code; the existing block-level trim ladder gains a single new toggle (include_you_activity) and the SHOULD-tier guest_activity_block is gone. Tests: - test_single_activities_block_with_three_bullets_when_3_entities: exactly one ACTIVITIES: header with all three entity bullets. - test_tight_budget_drops_guest_activity_bullet_first: speaker bullet survives, guest bullet absent under tight budget. - Existing test_assemble_with_tight_budget_drops_guest_activity_first still passes (asserts on bullet absence, not block-header absence). --- chat/services/prompt.py | 132 ++++++++++++++++++++++++++++------------ tests/test_prompt.py | 60 ++++++++++++++++++ 2 files changed, 152 insertions(+), 40 deletions(-) diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 5ed4905..c820136 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -368,34 +368,57 @@ def assemble_narrative_prompt( addressee_name, ) - # Activity for present entities. Core (MUST): you + speaker bot. - # Phase 2 (SHOULD-tier): when a third party (guest) is present in - # the chat, append their activity in a separate block so it can be - # trimmed independently under tight budget. - activities: list[dict] = [] + # Activity for present entities — single ACTIVITIES: block with up + # to three bullets (you, speaker, guest). The block itself is + # MUST-tier and survives all trims, but bullet-level trim drops + # bullets in the order guest -> you, keeping the speaker bullet + # (the speaker's own current activity is the load-bearing slice). + # + # T71.2 chose Option B from the polish plan: pre-truncate the + # bullets list at trim time before _build_activity_block runs, + # rather than introducing a granular tier mode in the trim + # machinery. The single-block render avoids the dual-ACTIVITIES: + # header that Phase 2 T43 introduced (read by some LLMs as a + # duplicate-section bug). + you_activity: dict | None = None you_act = get_activity(conn, "you") if you_act is not None: - you_act = dict(you_act) - you_act["_display_name"] = (you or {}).get("name") or "you" - activities.append(you_act) + you_activity = dict(you_act) + you_activity["_display_name"] = (you or {}).get("name") or "you" + + speaker_activity: dict | None = None bot_act = get_activity(conn, speaker_bot_id) if bot_act is not None: - bot_act = dict(bot_act) - bot_act["_display_name"] = bot["name"] - activities.append(bot_act) - activity_block = _build_activity_block(activities) + speaker_activity = dict(bot_act) + speaker_activity["_display_name"] = bot["name"] - # SHOULD-tier guest activity extension (Phase 2 / Task 43). - guest_activity_block: str | None = None + guest_activity: dict | None = None if guest_id is not None: guest_act = get_activity(conn, guest_id) if guest_act is not None: - guest_act = dict(guest_act) + guest_activity = dict(guest_act) guest_bot = get_bot(conn, guest_id) - guest_act["_display_name"] = ( + guest_activity["_display_name"] = ( guest_bot["name"] if guest_bot else guest_id ) - guest_activity_block = _build_activity_block([guest_act]) + + def _activity_block_for( + *, include_you: bool, include_guest: bool + ) -> str | None: + """Render the single ACTIVITIES: block with the requested bullets. + + Speaker bullet is always included (it's the MUST-tier baseline); + ``you`` and ``guest`` bullets are toggled by the caller during + trim. Returns None when no bullets remain. + """ + bullets: list[dict] = [] + if include_you and you_activity is not None: + bullets.append(you_activity) + if speaker_activity is not None: + bullets.append(speaker_activity) + if include_guest and guest_activity is not None: + bullets.append(guest_activity) + return _build_activity_block(bullets) # SHOULD-tier group-node block (Phase 2 / Task 43): rendered only # when the group_node row is present AND it covers all three of @@ -469,11 +492,18 @@ def assemble_narrative_prompt( last4 = dialogue_full[-4:] if dialogue_full else [] must_dialogue_block = _build_dialogue_block(last4, earlier_summary=None) + # MUST-tier ACTIVITIES floor: the speaker bullet alone (you and + # guest bullets are dropped first under bullet-level trim before + # the block bottoms out at speaker-only). + must_activity_block = _activity_block_for( + include_you=False, include_guest=False + ) + must_blocks: list[str | None] = [ speaker_identity, edge_to_addressee, scene_block, - activity_block, + must_activity_block, must_dialogue_block, closing, ] @@ -498,6 +528,7 @@ def assemble_narrative_prompt( include_previous_scene: bool, include_memories_top_k: int, dialogue_keep: int, + include_you_activity: bool = True, include_guest_activity: bool = True, include_group_node: bool = True, ) -> tuple[str, int, list[dict]]: @@ -520,13 +551,20 @@ def assemble_narrative_prompt( if include_previous_scene else None ) + # Single ACTIVITIES: block, bullet-level trim (T71.2). Guest + # bullet drops first, then the you bullet; speaker bullet is the + # MUST-tier floor and always present when an activity row exists. + activity_block = _activity_block_for( + include_you=include_you_activity, + include_guest=include_guest_activity, + ) + body = _join_blocks([ speaker_identity, edge_to_addressee, other_edges_block if include_other_edges else None, scene_block, activity_block, - guest_activity_block if include_guest_activity else None, group_node_block if include_group_node else None, prev_block, memories_block, @@ -544,16 +582,18 @@ def assemble_narrative_prompt( nice_memories_k = min(4, len(memory_summaries)) include_prev = previous_scene_summary is not None include_other = other_edges_block is not None - include_guest_activity = guest_activity_block is not None + include_you_activity = you_activity is not None + include_guest_activity = guest_activity is not None include_group_node = group_node_block is not None def _build(*, prev: bool, mem_k: int, dlg: int, other: bool, - guest_act: bool, group: bool) -> tuple[str, int]: + you_act: bool, guest_act: bool, group: bool) -> tuple[str, int]: body, total, _ = assemble( include_other_edges=other, include_previous_scene=prev, include_memories_top_k=mem_k, dialogue_keep=dlg, + include_you_activity=you_act, include_guest_activity=guest_act, include_group_node=group, ) @@ -561,8 +601,8 @@ def assemble_narrative_prompt( body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) # If under soft, we're done. @@ -575,8 +615,8 @@ def assemble_narrative_prompt( include_prev = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -585,8 +625,8 @@ def assemble_narrative_prompt( nice_memories_k = 2 body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -595,8 +635,8 @@ def assemble_narrative_prompt( nice_dialogue_keep = baseline_keep body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -606,35 +646,47 @@ def assemble_narrative_prompt( nice_memories_k = max(0, nice_memories_k - 1) body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) - # Drop SHOULD-tier blocks in order: guest activity → group node → - # other edges. (Guest activity goes first per Task 43 spec — it's - # the most expendable additive context.) + # Drop SHOULD-tier extras in order: + # 1. guest activity bullet (T71.2: bullet-level trim within the + # single ACTIVITIES: block — guest goes first per Task 43 spec) + # 2. group node block + # 3. you activity bullet (still SHOULD-tier; speaker bullet is the + # MUST-tier floor and never dropped) + # 4. other edges if include_guest_activity and total > budget_hard: include_guest_activity = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if include_group_node and total > budget_hard: include_group_node = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, + ) + + if include_you_activity and total > budget_hard: + include_you_activity = False + body, total = _build( + prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if include_other and total > budget_hard: include_other = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, - other=include_other, guest_act=include_guest_activity, - group=include_group_node, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, ) if total > budget_hard: diff --git a/tests/test_prompt.py b/tests/test_prompt.py index d4fc6a5..322ddab 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -514,6 +514,66 @@ def test_speaker_is_host_uses_host_witness_role(tmp_path, monkeypatch): assert captured["witness_role"] == "host" +def test_single_activities_block_with_three_bullets_when_3_entities(tmp_path): + """T71.2: with you + host + guest present, the assembled prompt + contains exactly ONE ``ACTIVITIES:`` header and bullets for all + three entities (no duplicate header from the prior dual-block + rendering). + """ + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_with_guest(conn) + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + # Exactly one ACTIVITIES: header. + assert body.count("ACTIVITIES:") == 1 + # Bullets for all three entities (you=Sam, host=Aria, guest=Iris) + # — pin on the unique action verbs from the seed data. + assert "finishing emails" in body # you bullet + assert "pretending to work" in body # speaker (host) bullet + assert "smirking-distinctively" in body # guest bullet + + +def test_tight_budget_drops_guest_activity_bullet_first(tmp_path): + """T71.2: under tight budget the speaker bullet survives but the + guest activity bullet is the first ACTIVITIES: bullet to drop. The + block as a whole stays present (it's MUST-tier); only its body + contracts. + """ + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_with_guest(conn) + dialogue = [ + {"speaker": "you", "text": "line-16 hi there"}, + {"speaker": "bot_a", "text": "line-17 hey"}, + {"speaker": "you", "text": "line-18 quiet night"}, + {"speaker": "bot_a", "text": "line-19 indeed"}, + ] + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=dialogue, + retrieved_memory_summaries=[], + budget_soft=250, + budget_hard=340, + ) + body = msgs[0].content + # Speaker bullet survives (MUST-tier floor). + assert "pretending to work" in body + assert "ACTIVITIES:" in body + # Guest bullet is dropped first under budget pressure. + assert "smirking-distinctively" not in body + + def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): """Under tight budget MUST blocks survive but SHOULD-tier guest activity is dropped first.""" From 73bb8c1f17201dacdd423cba407484528e46b9db Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:16:02 -0400 Subject: [PATCH 3/3] chore: document NICE trim order rationale (T71.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T18 review (Phase 1) noted the NICE-tier trim drops previous-scene FIRST while §6.3 spec lists previous-scene LAST in the NICE tier group. Decision: keep the existing greedy order (previous-scene first), and document why. Rationale (now in code at the trim ladder): 1. Cheapest-impact-first — a per-POV previous-scene summary loses less narrative continuity than the older dialogue turns or memory hits it competes with. 2. Greedy lookahead is more expensive than the marginal narrative loss. Dropping previous-scene typically clears the soft-budget slack in one step. Test added: test_nice_trim_order_documented pins the observed order (previous-scene -> memories -> dialogue) so a future refactor can't silently invert it. Sized so that all-NICE config overflows soft but dropping just previous-scene fits — proves memories and older dialogue turns survive while previous-scene is the FIRST drop. --- chat/services/prompt.py | 20 ++++++ tests/test_prompt.py | 145 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/chat/services/prompt.py b/chat/services/prompt.py index c820136..6e6d72c 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -611,6 +611,26 @@ def assemble_narrative_prompt( # Drop NICE in order: previous scene → memories beyond top-2 → # older dialogue turns (collapse to 4). + # + # T71.3 — order rationale: the §6.3 spec lists NICE-tier members + # with previous-scene LAST, which read as a literal trim order + # during T18 review. We deliberately keep the greedy order shown + # here (previous-scene FIRST) for two reasons: + # + # 1. Cheapest-impact-first: a per-POV previous-scene summary is + # a single short paragraph that loses very little narrative + # continuity when dropped, while the older dialogue turns it + # is competing with carry the speaker's last few beats — those + # ground the next response far more concretely. + # 2. Greedy lookahead is more expensive than the marginal + # narrative loss. Dropping previous-scene typically clears + # the soft-budget slack in one step; trying memories or + # dialogue first would routinely require multiple recompute + # passes through the assembler. + # + # The pin test test_nice_trim_order_documented locks this order so + # a future refactor can't quietly invert it without surfacing the + # decision. if include_prev: include_prev = False body, total = _build( diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 322ddab..f50fdea 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -574,6 +574,151 @@ def test_tight_budget_drops_guest_activity_bullet_first(tmp_path): assert "smirking-distinctively" not in body +def test_nice_trim_order_documented(tmp_path): + """T71.3: pin the NICE-tier trim order so a future refactor can't + quietly invert it. + + Order under NICE pressure is: + 1. previous-scene summary (dropped FIRST) + 2. memories beyond top-2 + 3. older dialogue turns (collapsed to last-4) + + We size the budget so that all-NICE-included is over soft, but + dropping ONLY previous-scene gets us back under soft. The observed + behaviour we pin: previous-scene gone, memories/dialogue intact. + """ + db = tmp_path / "t.db" + apply_migrations(db) + # Heavy previous-scene summary — large enough that dropping it + # alone clears the soft-budget overage. Defined out here so the + # marker is in scope for the assertions below. + prev_scene_blob = "PREVSCENE-MARKER " + ("filler " * 200) + with open_db(db) as conn: + # Append all events first, project once at the end (project is + # not idempotent — it replays every event in the log). + from chat.eventlog.log import append_event as _append + _append(conn, kind="bot_authored", payload={ + "id": "bot_a", + "name": "Aria", + "persona": "reserved coworker who notices things", + "voice_samples": ["I — sorry, I didn't mean to."], + "traits": ["introverted"], + "backstory": "An archivist who joined the firm last spring.", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "you stay late at the office", + }) + _append(conn, kind="you_authored", payload={ + "name": "Sam", + "pronouns": "they/them", + "persona": "tired analyst", + }) + _append(conn, kind="chat_created", payload={ + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "guest_bot_id": None, + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + }) + _append(conn, kind="container_created", payload={ + "chat_id": "chat_bot_a", + "name": "office bullpen", + "type": "workplace", + "properties": {"public": False, "moving": False, "audible_range": "room"}, + }) + _append(conn, kind="edge_update", payload={ + "source_id": "bot_a", + "target_id": "you", + "affinity_delta": 12, + "trust_delta": 5, + "knowledge_facts": ["they work on the same floor"], + }) + _append(conn, kind="activity_change", payload={ + "entity_id": "you", + "container_id": 1, + "posture": "sitting at your desk", + "action": {"verb": "finishing emails"}, + "attention": "the screen", + }) + _append(conn, kind="activity_change", payload={ + "entity_id": "bot_a", + "container_id": 1, + "posture": "sitting at her desk", + "action": {"verb": "pretending to work"}, + "attention": "you, in glances", + }) + _append(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"], + }) + # Close the seeded scene and write a per-POV summary memory so + # _resolve_previous_scene_summary returns a non-empty string. + _append(conn, kind="scene_closed", payload={ + "scene_id": 1, + "ended_at": "2026-04-26T20:30:00+00:00", + "significance": 2, + }) + _append(conn, kind="memory_written", payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "scene_id": 1, + "pov_summary": prev_scene_blob, + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "source": "direct", + "reliability": 1.0, + "significance": 2, + }) + project(conn) + + # Six dialogue turns — last 4 plus 2 older. If older turns are + # dropped under NICE pressure, the unique markers for turns 0/1 + # disappear; we'll assert they REMAIN to prove dialogue trim + # didn't fire. + dialogue = [ + {"speaker": "you", "text": "DLG-OLD-00 hello"}, + {"speaker": "bot_a", "text": "DLG-OLD-01 hi"}, + {"speaker": "you", "text": "DLG-LAST-16 ok"}, + {"speaker": "bot_a", "text": "DLG-LAST-17 sure"}, + {"speaker": "you", "text": "DLG-LAST-18 night"}, + {"speaker": "bot_a", "text": "DLG-LAST-19 indeed"}, + ] + # Four small memories — if "memories beyond top-2" trim fires, + # MEM-C/MEM-D disappear; we'll assert they REMAIN to prove + # memories trim didn't fire either. + memories = ["MEM-A short", "MEM-B short", "MEM-C short", "MEM-D short"] + + # Soft tuned so the all-NICE config (with the heavy previous + # scene summary) overflows, but dropping just previous-scene + # fits comfortably. Hard set high so SHOULD-tier never trims. + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=dialogue, + retrieved_memory_summaries=memories, + budget_soft=400, + budget_hard=8000, + ) + body = msgs[0].content + # Previous-scene summary was the FIRST NICE drop — its unique + # marker must be absent. + assert "PREVSCENE-MARKER" not in body + # Memories beyond top-2 stayed (proves memories trim did NOT fire). + assert "MEM-A" in body + assert "MEM-B" in body + assert "MEM-C" in body + assert "MEM-D" in body + # Older dialogue turns stayed (proves dialogue trim did NOT fire). + assert "DLG-OLD-00" in body + assert "DLG-OLD-01" in body + # Last-4 dialogue turns of course present. + assert "DLG-LAST-19" in body + + def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): """Under tight budget MUST blocks survive but SHOULD-tier guest activity is dropped first."""