From d29a7936e40a9fa643036af29f7f1f9c3edafba0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 05:23:41 -0700 Subject: [PATCH] fix(bot-mode): DMs to a Desktop-owned Bot Chat land in the live session instead of being dropped (#100523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Desktop has a bot's "Bot Chat" open, that session holds the single-owner lease, so the `hermes -p chat -c "Bot Chat"` subprocess `bot_relay.deliver` spawns refuses with "already has a live owner" and the DM payload is dropped — the sender was already acked. bot_relay.deliver now looks up a live in-process session for the target profile whose title resolves to "Bot Chat" (same profile_home match as session.resume's _find_live_unpersisted, pending_title for lazy sessions, otherwise the db title) and, when found, submits the message through the existing prompt.submit handler — the composer's choke point — so it lands as a normal user turn (role alternation preserved, streams to the open window). No live owner → the subprocess path runs exactly as before. On the local message_agent subprocess path, the lease refusal is surfaced as a structured `target_busy` delivery failure telling the sender the message was NOT delivered, instead of a raw exit-1 with the text buried in stderr. Closes #100523 Supersedes #100544, #100542 Co-authored-by: fangliquanflq Co-authored-by: 686f6c61 --- tests/tools/test_bot_mode_dm.py | 23 +++++++++++++ tests/tui_gateway/test_bot_relay_methods.py | 38 +++++++++++++++++++++ tools/bot_mode_dm.py | 11 ++++++ tui_gateway/methods_bot_relay.py | 32 +++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/tests/tools/test_bot_mode_dm.py b/tests/tools/test_bot_mode_dm.py index e83ec30ae0..ed0936f4c9 100644 --- a/tests/tools/test_bot_mode_dm.py +++ b/tests/tools/test_bot_mode_dm.py @@ -422,6 +422,29 @@ def test_delivery_runner_preserves_child_failure_and_unlinks(tmp_path): assert not dm_file.exists() +def test_delivery_runner_surfaces_live_owner_refusal(tmp_path, capsys): + """#100523: the CLI's single-owner lease refusal is a delivery FAILURE the + sender can read, not a raw exit-1 with the payload silently gone.""" + dm_file = tmp_path / "message.txt" + dm_file.write_text("hi", encoding="utf-8") + child = tmp_path / "owned.py" + child.write_text( + "import sys\n" + "print('Session abc already has a live owner (desktop, pid 1).', file=sys.stderr)\n" + "raise SystemExit(1)\n", + encoding="utf-8", + ) + + returncode = bot_mode_dm._run_delivery( + [sys.executable, str(child), "-p", "ops"], str(dm_file), stdin_file=False + ) + + assert returncode == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["reason"] == "target_busy" + assert "NOT delivered" in payload["error"] + + def test_query_file_delivery_closes_stdin_for_initial_attempt_and_retry( tmp_path, monkeypatch ): diff --git a/tests/tui_gateway/test_bot_relay_methods.py b/tests/tui_gateway/test_bot_relay_methods.py index fb6d357744..f4090598ff 100644 --- a/tests/tui_gateway/test_bot_relay_methods.py +++ b/tests/tui_gateway/test_bot_relay_methods.py @@ -106,6 +106,44 @@ def test_deliver_requires_params(home): assert "error" in err +def test_deliver_lands_in_live_bot_chat_instead_of_subprocess(home, monkeypatch): + """#100523: a Desktop-owned Bot Chat receives the DM as a normal user turn. + + With the target's Bot Chat live in this gateway, the subprocess transport + would be fenced out by the single-owner lease and drop the payload. The + handler must route through prompt.submit (the composer's choke point) and + never spawn the CLI. + """ + spawned = [] + submitted = [] + monkeypatch.setattr("subprocess.run", lambda *a, **k: spawned.append(a) or None) + monkeypatch.setitem( + srv._methods, "prompt.submit", lambda rid, p: submitted.append(p) or srv._ok(rid, {"status": "streaming"}) + ) + monkeypatch.setattr(srv, "_profile_home", lambda name: home / "profiles" / name) + monkeypatch.setitem( + srv._sessions, + "live-ops", + {"profile_home": str(home / "profiles" / "ops"), "pending_title": "Bot Chat", "history": []}, + ) + out = _result(srv._methods["bot_relay.deliver"](1, {"profile": "ops", "message": "ping"})) + assert submitted == [{"session_id": "live-ops", "text": "ping"}] + assert not spawned + assert "reply" in out + + # A live session titled anything else for the same profile does not qualify: + # the subprocess path runs exactly as before. + srv._sessions["live-ops"]["pending_title"] = "Scratch" + submitted.clear() + + class _Proc: + returncode, stdout, stderr = 0, "pong", "" + + monkeypatch.setattr("subprocess.run", lambda *a, **k: spawned.append(a) or _Proc()) + out = _result(srv._methods["bot_relay.deliver"](2, {"profile": "ops", "message": "ping"})) + assert out["reply"] == "pong" and spawned and not submitted + + def test_reply_roundtrip_and_id_validation(home): envelope_id = "c" * 32 _result(srv._methods["bot_relay.reply"](1, {"id": envelope_id, "reply": "hi"})) diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 77da3d5015..cde6eec992 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -609,6 +609,17 @@ def _run_delivery(argv: list[str], dm_file: str, *, stdin_file: bool) -> int: ) # Re-emit the transport's streams: stdout is the reply text the # completion notification carries back to the sending agent. + if proc.returncode != 0 and "already has a live owner" in (proc.stderr or ""): + # #100523: the target's Bot Chat is held live by another + # surface (Desktop). The turn never ran, so tell the sender + # plainly instead of leaking a raw lease error + exit code. + who = argv[argv.index("-p") + 1] if "-p" in argv[:-1] else "the teammate" + print(json.dumps({ + "error": f"Delivery failed: @{who}'s Bot Chat is open on another " + "surface right now, so your message was NOT delivered. Try again later.", + "reason": "target_busy", + })) + return 1 if proc.stdout: sys.stdout.write(proc.stdout) sys.stdout.flush() diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py index 992bbc1704..e2e2e277fa 100644 --- a/tui_gateway/methods_bot_relay.py +++ b/tui_gateway/methods_bot_relay.py @@ -109,6 +109,38 @@ def _(rid, params: dict) -> dict: if resolved not in known: return _err(rid, 4092, f"no profile '{profile}' on this gateway") + # #100523: when THIS gateway already hosts the target's Bot Chat live + # (the Desktop has it open), the subprocess transport is fenced out by + # the single-owner lease ("already has a live owner") and the payload + # is dropped. Land the DM in the live session as a normal user turn + # via prompt.submit instead — same choke point the composer uses, so + # role alternation, persistence and streaming all behave as a typed + # message would. (Nested per method_ctx rebinding.) + def _live_bot_chat_sid(profile_name: str) -> str: + from tools.bot_mode_probe import BOT_CHAT_TITLE + + live_home = _profile_home(profile_name) + want_home = str(live_home) if live_home is not None else None + for live_sid, record in list(_sessions.items()): + if not isinstance(record, dict): + continue + if (record.get("profile_home") or None) != want_home: + continue + key = _session_lookup_key(record, fallback=live_sid) + if _session_live_title(record, key) == BOT_CHAT_TITLE: + return live_sid + return "" + + live_sid = _live_bot_chat_sid(resolved) + if live_sid: + submitted = _methods["prompt.submit"](rid, {"session_id": live_sid, "text": message}) + if "error" in submitted: + return submitted + return _ok( + rid, + {"reply": f"Delivered into @{resolved}'s open Bot Chat; the reply will appear there."}, + ) + fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True) try: with os.fdopen(fd, "w", encoding="utf-8") as f: