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."""