From c265e4ce0f1e1b69d385e4cfc1339e66288bcd5e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 17:26:31 -0400 Subject: [PATCH] feat: first-meeting gate on drawer Add-guest form (T72.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a host->candidate edge already exists from a prior chat, the Add-guest form renders the prose textarea disabled with an "already know each other" note. Submission without the explicit "re-seed anyway" toggle skips seed_inter_bot_edges so existing edge content (affinity, trust, knowledge, summaries) survives — guest_added and group_node_initialized still fire. A small inline script enables / disables the textarea per-option based on a pre-computed existing_guest_edges dict surfaced by the GET handler. --- chat/templates/_drawer.html | 53 +++++++++++- chat/web/drawer.py | 47 ++++++++--- tests/test_drawer_guest.py | 156 ++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 14 deletions(-) diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index b6d2033..228a440 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -100,24 +100,71 @@

Add guest

{% if available_guests %} -
+

+ they already know each other (edge exists from a prior chat) +

+
+ {% else %}

No other bots authored yet.

{% endif %} diff --git a/chat/web/drawer.py b/chat/web/drawer.py index 341dce7..a2928ff 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -113,6 +113,14 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): available_guests = [ b for b in list_bots(conn) if b["id"] != chat["host_bot_id"] ] + # T72.2 first-meeting gate: pre-compute whether a host->candidate edge + # already exists. Template renders the prose textarea disabled and the + # POST handler skips ``seed_inter_bot_edges`` (preserving the existing + # edge content) unless the user explicitly toggles "re-seed anyway". + existing_guest_edges = { + b["id"]: get_edge(conn, chat["host_bot_id"], b["id"]) is not None + for b in available_guests + } group_node = get_group_node(conn, chat_id) # Recent memories from host's POV (witness_host = 1), most recent first. @@ -161,6 +169,7 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): "edge_y2g": edge_y2g, "edge_g2y": edge_g2y, "available_guests": available_guests, + "existing_guest_edges": existing_guest_edges, "group_node": group_node, "recent_memories": recent_memories, "pinned": pinned, @@ -602,6 +611,7 @@ async def add_guest( request: Request, guest_bot_id: str = Form(...), relationship_prose: str = Form(""), + reseed: str = Form(""), conn=Depends(get_conn), client=Depends(get_llm_client), ): @@ -633,17 +643,32 @@ async def add_guest( detail=f"host bot not found: {chat['host_bot_id']}", ) - settings = request.app.state.settings - seed = await seed_inter_bot_edges( - client, - classifier_model=settings.classifier_model, - bot_a_id=chat["host_bot_id"], - bot_a_name=host_bot["name"], - bot_b_id=guest_bot_id, - bot_b_name=guest_bot["name"], - relationship_prose=relationship_prose, - timeout_s=settings.classifier_timeout_s, + # T72.2 first-meeting gate: when an edge already exists from a prior + # chat, the textarea is rendered disabled. Submission without the + # explicit "re-seed anyway" toggle skips ``seed_inter_bot_edges`` + # entirely so the existing edge content (affinity, trust, knowledge, + # summaries) survives. ``guest_added`` and ``group_node_initialized`` + # still fire so the chat picks up the new participant. + existing_edge = ( + get_edge(conn, chat["host_bot_id"], guest_bot_id) is not None ) + reseed_requested = reseed.lower() in ("1", "true", "on", "yes") + skip_seed = existing_edge and not reseed_requested + + settings = request.app.state.settings + if skip_seed: + seed = None + else: + seed = await seed_inter_bot_edges( + client, + classifier_model=settings.classifier_model, + bot_a_id=chat["host_bot_id"], + bot_a_name=host_bot["name"], + bot_b_id=guest_bot_id, + bot_b_name=guest_bot["name"], + relationship_prose=relationship_prose, + timeout_s=settings.classifier_timeout_s, + ) append_and_apply( conn, @@ -658,7 +683,7 @@ async def add_guest( # per-direction summary is set via the per-pov scene-close path # (T27), not direct edge_update. We therefore drop seed.*_summary # here; the deltas + knowledge_facts are what materializes. - if not _seed_is_default(seed): + if seed is not None and not _seed_is_default(seed): append_and_apply( conn, kind="edge_update", diff --git a/tests/test_drawer_guest.py b/tests/test_drawer_guest.py index a599c03..f0a14d6 100644 --- a/tests/test_drawer_guest.py +++ b/tests/test_drawer_guest.py @@ -265,6 +265,162 @@ def test_drawer_remove_guest_clears_and_closes_scene(client, tmp_path): assert kinds.index("scene_closed") < kinds.index("guest_removed") +# --- T72.2 first-meeting gate ---------------------------------------------- + + +def _seed_host_to_guest_edge(db: Path) -> None: + """Materialise a bot_a -> bot_b edge so the gate's check fires.""" + from chat.eventlog.log import append_and_apply + + with open_db(db) as conn: + append_and_apply( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "bot_b", + "chat_id": "chat_bot_a", + "affinity_delta": 0, + "knowledge_facts": ["already met before"], + }, + ) + + +def test_add_guest_form_disables_prose_when_edge_exists(client, tmp_path): + """When host->candidate edge already exists, the GET partial renders + the textarea disabled and surfaces the "already know each other" + message so the user knows submitting will skip the seed. + """ + _seed_chat(tmp_path / "test.db") + _seed_host_to_guest_edge(tmp_path / "test.db") + + response = client.get("/chats/chat_bot_a/drawer") + assert response.status_code == 200 + body = response.text + # Note + disabled state both present. The textarea sits next to the + # ``add-guest-prose`` class so we can match it specifically. + assert "already know each other" in body + assert 'class="add-guest-prose"' in body + # The textarea for the first (auto-selected) candidate should be + # disabled in the initial markup since an edge exists. + assert "disabled" in body.split('class="add-guest-prose"', 1)[1].split(">", 1)[0] + # And the option carries the ``data-existing-edge="true"`` attribute + # the inline JS uses to flip state on subsequent select changes. + assert 'data-existing-edge="true"' in body + + +def test_add_guest_with_existing_edge_skips_seed_call(client, tmp_path): + """Submitting the Add-guest form WITHOUT toggling re-seed must skip + ``seed_inter_bot_edges`` entirely. We assert this via an empty mock + queue: if the seed function had been called it would have consumed + a canned response (or raised because none was available). + """ + _seed_chat(tmp_path / "test.db") + _seed_host_to_guest_edge(tmp_path / "test.db") + + # Empty queue: any classifier call would raise inside MockLLMClient. + canned_queue: list[str] = [] + _override_llm(canned_queue) + try: + response = client.post( + "/chats/chat_bot_a/drawer/guest/add", + data={ + "guest_bot_id": "bot_b", + "relationship_prose": "ignored prose", + # NO reseed flag — gate should suppress the seed call. + }, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() + + with open_db(tmp_path / "test.db") as conn: + from chat.state.edges import get_edge + from chat.state.world import get_chat + + chat = get_chat(conn, "chat_bot_a") + assert chat["guest_bot_id"] == "bot_b" + + # The pre-seeded knowledge fact survives — proof the seed didn't run + # and overwrite the existing edge. + edge = get_edge(conn, "bot_a", "bot_b") + assert "already met before" in edge["knowledge"] + + # Exactly one guest_added; no new edge_update events between + # bot_a and bot_b (the pre-seed edge_update from the test setup + # is the only edge_update on this pair). + added = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'" + ).fetchone()[0] + assert added == 1 + + edge_updates = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" + ).fetchall() + # Only the pre-seed edge_update from _seed_host_to_guest_edge. + ab_updates = [ + json.loads(p[0]) + for p in edge_updates + if { + json.loads(p[0]).get("source_id"), + json.loads(p[0]).get("target_id"), + } + == {"bot_a", "bot_b"} + ] + assert len(ab_updates) == 1 + assert ab_updates[0]["knowledge_facts"] == ["already met before"] + + +def test_add_guest_with_existing_edge_and_reseed_runs_seed(client, tmp_path): + """Toggling ``re-seed anyway`` flips the gate off — the existing + flow runs (seed produces deltas, two ``edge_update`` events fire). + """ + _seed_chat(tmp_path / "test.db") + _seed_host_to_guest_edge(tmp_path / "test.db") + + canned = json.dumps( + { + "a_to_b_summary": "reconnected", + "a_to_b_knowledge_facts": ["new fact"], + "a_to_b_affinity_delta": 2, + "a_to_b_trust_delta": 1, + "b_to_a_summary": "reconnected", + "b_to_a_knowledge_facts": [], + "b_to_a_affinity_delta": 1, + "b_to_a_trust_delta": 0, + } + ) + _override_llm([canned]) + try: + response = client.post( + "/chats/chat_bot_a/drawer/guest/add", + data={ + "guest_bot_id": "bot_b", + "relationship_prose": "fresh prose", + "reseed": "1", + }, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() + + with open_db(tmp_path / "test.db") as conn: + edge_updates = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" + ).fetchall() + # Pre-seed (1) + two from the re-seed = 3 edge_updates total. + ab_updates = [ + json.loads(p[0]) + for p in edge_updates + if { + json.loads(p[0]).get("source_id"), + json.loads(p[0]).get("target_id"), + } + == {"bot_a", "bot_b"} + ] + assert len(ab_updates) == 3 + + def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path): _seed_chat(tmp_path / "test.db") from chat.eventlog.log import append_and_apply