diff --git a/tests/tui_gateway/test_bot_mode_silence_delivery.py b/tests/tui_gateway/test_bot_mode_silence_delivery.py new file mode 100644 index 0000000000..bb481c4fb1 --- /dev/null +++ b/tests/tui_gateway/test_bot_mode_silence_delivery.py @@ -0,0 +1,56 @@ +"""Regression coverage for Bot Mode's shared completed-reply delivery boundary.""" + +import contextlib +from types import SimpleNamespace + +import pytest + +import tui_gateway.server as srv +from tui_gateway.prompt_turn import _bot_mode_delivery_text, _is_bot_mode_session + + +@pytest.mark.parametrize("response", [ + "NO_REPLY", " [silent] ", "silent", "no reply", "*NO_REPLY*", +]) +def test_bot_mode_delivery_hides_successful_canonical_silence_markers(response): + assert _bot_mode_delivery_text(response, successful=True) == "" + + +@pytest.mark.parametrize("response", [ + "The NO_REPLY marker means do not answer.", + "[SILENT] is mentioned here, but this is a real answer.", +]) +def test_bot_mode_delivery_keeps_substantive_marker_mentions(response): + assert _bot_mode_delivery_text(response, successful=True) == response + + +def test_bot_mode_delivery_keeps_failed_marker_response_visible(): + assert _bot_mode_delivery_text("NO_REPLY", successful=False) == "NO_REPLY" + + +def test_only_canonical_bot_chat_sessions_use_the_live_delivery_boundary(): + assert _is_bot_mode_session({"pending_title": "Bot Chat"}) + assert _is_bot_mode_session({"title": "Bot Chat"}) + assert not _is_bot_mode_session({"pending_title": "Scratch"}) + + +def test_live_bot_chat_completion_suppresses_markers_but_failed_turns_fail_open(monkeypatch): + """The prompt.submit completion path applies the shared delivery boundary.""" + monkeypatch.setattr(srv, "_get_usage", lambda _agent: {}) + monkeypatch.setattr(srv, "render_message", lambda _text, _cols: None) + monkeypatch.setattr(srv, "_clear_inflight_turn", lambda _session: None) + + session = {"pending_title": "Bot Chat", "history_lock": contextlib.nullcontext()} + turn = SimpleNamespace( + result={"final_response": "NO_REPLY"}, agent=object(), terminal_callback=None, + receipt_committed=True, receipt_attempted=False, marker_key="", error_retained=False, + error_detail="", prompt_text="ping", + ) + payload, _, status = srv._complete_turn_payload(session, turn, None, 80) + assert status == "complete" + assert payload["text"] == "" + + turn.result = {"final_response": "NO_REPLY", "error": "provider failed", "failed": True} + payload, _, status = srv._complete_turn_payload(session, turn, None, 80) + assert status == "error" + assert payload["text"] == "NO_REPLY" diff --git a/tests/tui_gateway/test_bot_relay_methods.py b/tests/tui_gateway/test_bot_relay_methods.py index 4ce12bf763..e5270d7cb2 100644 --- a/tests/tui_gateway/test_bot_relay_methods.py +++ b/tests/tui_gateway/test_bot_relay_methods.py @@ -108,6 +108,40 @@ def test_deliver_requires_params(home): assert "error" in err +@pytest.mark.parametrize("reply", [ + "NO_REPLY", " [silent] ", "silent", "no reply", "*NO_REPLY*", +]) +def test_deliver_suppresses_successful_silence_markers(home, monkeypatch, reply): + """A successful subprocess relay never returns a bare silence marker to Bot Mode.""" + class _Proc: + returncode = 0 + stdout = reply + stderr = "" + + monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: _Proc()) + + out = _result(srv._methods["bot_relay.deliver"](1, {"profile": "ops", "message": "ping"})) + + assert out["reply"] == "" + + +@pytest.mark.parametrize("reply", [ + "The NO_REPLY marker means do not answer.", + "[SILENT] is mentioned here, but this is a real answer.", +]) +def test_deliver_keeps_substantive_marker_mentions(home, monkeypatch, reply): + class _Proc: + returncode = 0 + stdout = reply + stderr = "" + + monkeypatch.setattr("subprocess.run", lambda *_args, **_kwargs: _Proc()) + + out = _result(srv._methods["bot_relay.deliver"](1, {"profile": "ops", "message": "ping"})) + + assert out["reply"] == reply + + 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. diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py index a638654d65..082e891c80 100644 --- a/tui_gateway/methods_bot_relay.py +++ b/tui_gateway/methods_bot_relay.py @@ -147,7 +147,12 @@ def _(rid, params: dict, _root=_relay_root, _run=_run_delivery) -> dict: detail = _detail(proc) return _err(rid, 5092, f"delivery turn failed: {detail or proc.returncode}", data={"reason": classify_agent_error(detail)}) - return _ok(rid, {"reply": (proc.stdout or "").strip()}) + # Use the same canonical whole-response predicate as live Bot Chat + # completion. A marker remains a successful turn, but is never sent + # back to the relay caller as visible prose. + from tui_gateway.prompt_turn import _bot_mode_delivery_text + reply = _bot_mode_delivery_text((proc.stdout or "").strip(), successful=True) + return _ok(rid, {"reply": reply}) except subprocess.TimeoutExpired: return _err(rid, 5093, "delivery turn timed out") except Exception as e: diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 97f9b90fbe..6eb940abeb 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -16,6 +16,24 @@ from .method_ctx import HandlerRegistry, bind_module _registry = HandlerRegistry() +def _bot_mode_delivery_text(response: Any, *, successful: bool) -> Any: + """Return the text Bot Mode may render or relay after a completed turn. + + The gateway owns the canonical marker set. Keep the row and completion + event intact, but make a successful bare marker invisible at every Bot + Mode delivery boundary. Failed turns intentionally fail open so their + diagnostic text is never swallowed. + """ + from gateway.response_filters import is_intentional_silence_response + return "" if successful and is_intentional_silence_response(response) else response + + +def _is_bot_mode_session(session: dict) -> bool: + """Whether this completion belongs to the canonical Bot Chat surface.""" + from tools.bot_mode_probe import BOT_CHAT_TITLE + return any(session.get(field) == BOT_CHAT_TITLE for field in ("pending_title", "title")) + + def _hook_failure(what: str, exc: BaseException) -> None: print(f"[tui_gateway] {what} failed: {type(exc).__name__}: {exc}", file=sys.stderr) @@ -638,6 +656,8 @@ def _complete_turn_payload(session: dict, st: _TurnRun, status_note: str | None, except Exception: _error_surface = None raw, status, last_reasoning = _turn_outcome(result, _error_surface) + if _is_bot_mode_session(session): + raw = _bot_mode_delivery_text(raw, successful=status == "complete") payload = {"text": raw, "usage": _get_usage(agent), "status": status} if last_reasoning: payload["reasoning"] = last_reasoning