From 54d94dd861245b06e798fc3871c7a9b02289d26d Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Fri, 14 Aug 2026 19:37:28 +0800 Subject: [PATCH] fix(chat): suppress reserved SILENT control turns at ingress Cron agents use the exact final response `[SILENT]` as a delivery-suppression sentinel. If a wake relay accidentally POSTs that sentinel to `/api/chat/start` and 8701 restarts while the turn is pending, session repair materializes it as a visible `{role: user, _recovered: true}` message. The pending value can then be recovered again on later restarts, creating repeated `[SILENT]` turns. Treat the exact normalized sentinel as a successful no-op at both server-side turn entry points: the HTTP `/api/chat/start` handler and `start_session_turn`. Both checks run before session lookup, runtime barriers, or pending-state mutation. Matching is deliberately exact and case-sensitive, so `[silent]`, prose containing `[SILENT]`, and ordinary user text are not suppressed. Add regression tests proving both paths return HTTP/status 200 without session lookup, plus negative cases for non-exact text. The new tests fail 3/3 before the fix. Targeted and neighbouring chat-start suites pass 21/21. --- api/routes.py | 24 ++++++ tests/test_silent_control_suppression.py | 95 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/test_silent_control_suppression.py 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