diff --git a/api/routes.py b/api/routes.py index 85a1fd38548..842ad3efb8a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -22178,6 +22178,12 @@ def start_session_turn( PR #2279 next-turn drain delivers the wakeup when the active turn ends. """ msg = str(message or "").strip() + if _is_silent_control_message(msg): + return { + "status": "suppressed", + "reason": "silent_control_message", + "_status": 200, + } if not msg: return {"error": "message is required", "_status": 400} stale_response = _agent_runtime_barrier_response(runner_local_owned=True) @@ -22674,6 +22680,18 @@ def _legacy_goal_update(session_id: str, _action: str, text: str) -> dict: return j(handler, payload) +def _is_silent_control_message(message) -> bool: + """Return True only for the scheduler's exact suppression sentinel. + + ``[SILENT]`` is control-plane output, never conversation content. If a wake + relay POSTs it and 8701 restarts while the turn is pending, recovery + materializes it as a visible ``_recovered`` user message. Suppress it before + session lookup or pending-state mutation. Matching stays exact and + case-sensitive so ordinary user text is unaffected. + """ + return str(message or "").strip() == "[SILENT]" + + def _handle_chat_start(handler, body, diag=None): try: diag.stage("validate_session_id") if diag else None @@ -22681,6 +22699,12 @@ def _handle_chat_start(handler, body, diag=None): require(body, "session_id") except ValueError as e: return bad(handler, str(e)) + if _is_silent_control_message(body.get("message")): + return j( + handler, + {"status": "suppressed", "reason": "silent_control_message"}, + status=200, + ) # Reject a stale local Agent runtime before materialising, claiming, or # mutating any session state. Gateway-backed turns run in the gateway's # process and do not depend on this WebUI process's imported checkout. diff --git a/tests/test_silent_control_suppression.py b/tests/test_silent_control_suppression.py new file mode 100644 index 00000000000..b5a2c535c46 --- /dev/null +++ b/tests/test_silent_control_suppression.py @@ -0,0 +1,95 @@ +"""Reserved ``[SILENT]`` must never become a WebUI conversation turn. + +Cron agents use the exact final response ``[SILENT]`` as a delivery-suppression +sentinel. If an external wake relay accidentally POSTs that sentinel to +``/api/chat/start`` and 8701 restarts while the turn is pending, session repair +materializes it as ``{"role": "user", "_recovered": True}``. That creates a +visible user turn and can repeat on every restart. + +Both server-side entry points therefore treat the exact normalized sentinel as +a successful no-op *before* session lookup or pending-state mutation. +""" +from __future__ import annotations + +import io +import json + +from api import routes + + +class _JSONHandler: + headers = {} + + def __init__(self): + self.status = None + self.wfile = io.BytesIO() + self.headers_sent = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers_sent[key] = value + + def end_headers(self): + pass + + +def _payload(handler): + raw = handler.wfile.getvalue().decode("utf-8") + return json.loads(raw) if raw else {} + + +def test_http_chat_start_suppresses_silent_before_session_lookup(monkeypatch): + looked_up = [] + + def _unexpected_lookup(*args, **kwargs): + looked_up.append((args, kwargs)) + raise AssertionError("[SILENT] must be suppressed before session lookup") + + monkeypatch.setattr(routes, "_get_or_materialize_session", _unexpected_lookup) + handler = _JSONHandler() + + routes._handle_chat_start( + handler, + {"session_id": "does-not-need-to-exist", "message": " [SILENT]\n"}, + ) + + assert handler.status == 200 + assert _payload(handler) == { + "status": "suppressed", + "reason": "silent_control_message", + } + assert looked_up == [] + + +def test_server_side_start_suppresses_silent_before_session_lookup(monkeypatch): + looked_up = [] + + def _unexpected_lookup(*args, **kwargs): + looked_up.append((args, kwargs)) + raise AssertionError("[SILENT] must be suppressed before session lookup") + + monkeypatch.setattr(routes, "get_session", _unexpected_lookup) + + result = routes.start_session_turn( + "does-not-need-to-exist", + "\t[SILENT] ", + source="process_wakeup", + ) + + assert result == { + "status": "suppressed", + "reason": "silent_control_message", + "_status": 200, + } + assert looked_up == [] + + +def test_silent_suppression_is_exact_and_case_sensitive(): + assert routes._is_silent_control_message("[SILENT]") is True + assert routes._is_silent_control_message(" [SILENT]\n") is True + assert routes._is_silent_control_message("[silent]") is False + assert routes._is_silent_control_message("prefix [SILENT]") is False + assert routes._is_silent_control_message("") is False + assert routes._is_silent_control_message(None) is False