# NousResearch/hermes-agent · Gateway busy-session interrupt and queue handling

## Open now

- 3 issues, 3 PRs open on GitHub.
- #26884: \[Feature\] Universal interrupt keywords for gateway sessions (platform-independent)
- #40724: \[Bug\]: WeChat platform: user message interrupt mechanism rarely triggers, causing message loss and wasted tokens
- #40727: \[Bug\]: WeChat platform: user message interrupt mechanism rarely triggers
- PR #27160: feat(gateway): add universal interrupt keywords for busy sessions
- PR #41172: fix(gateway): bypass WeChat text batching for active sessions
- PR #51666: fix(desktop): interrupt running turn when user sends a message while busy

These issues and fixes address how gateways handle user input while an agent session is active, covering interrupt mechanisms (slash commands, keywords, plain text), silent message dropping or queuing without feedback, and command dispatcher ordering during busy states across multiple platforms.

## TL;DR

- 29 member(s): 3 open issue(s), 11 closed issue(s), 3 open PR(s), 3 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #3104. Verify it addresses your case.
- **Recommended:** read PR #3104 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-3104`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.05`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #26884: \[Feature\] Universal interrupt keywords for gateway sessions (platform-independent)
- #40724: \[Bug\]: WeChat platform: user message interrupt mechanism rarely triggers, causing message loss and wasted tokens
- #40727: \[Bug\]: WeChat platform: user message interrupt mechanism rarely triggers

## Closed / duplicate issues

- #2491: Feature Request: /stop slash command to abort the current run from chat
- #5244: bug(gateway): commands (/new, /undo, etc.) are ignored while agent is running a tool
- #22176: CLI interrupt /stop command not working — agent runs uncontrollably
- #26813: Gateway: /stop and /interrupt are fed to agent as steer text instead of interrupting it
- #31588: Gateway: text messages while agent is busy are silently queued — no interrupt, no feedback
- #35267: \[Bug\]: Gateway multi-tool call chain cannot be interrupted mid-execution
- #38293: feat: Interrupt running agent from remote messaging platforms (WeChat/QQ/Telegram)
- #40683: \[Bug\]: /steer and other slash commands silently dropped during busy state due to early return in _handle_active_session_busy_message
- #41572: Bug: Slash commands get intercepted by busy handler before command dispatcher with busy_input_mode: steer
- #51665: Desktop app: queued messages ignored while agent runs 10+ minutes; Esc/Stop don't work under heavy tool output
- #58717: Desktop busy_input_mode=interrupt not working; user messages queue instead of interrupting agent

## Prior & competing PRs

- **#3104: fix(gateway): recover from hung agents — /stop hard-kills session lock**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `tests/gateway/test_session_race_guard.py`
- **#12334: fix(gateway): slash commands never interrupt a running agent**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/discord.py`, `gateway/run.py`, `hermes_cli/commands.py`, `tests/gateway/test_command_bypass_active_session.py`
  - fix hunks:
  - `gateway/platforms/discord.py`

    ```diff
    @@ -1933,6 +1933,24 @@ async def _run_simple_slash(
             the "thinking..." indicator is replaced with that text; otherwise it
             is deleted so the channel isn't cluttered.
             """
    +        # Log the invoker so ghost-command reports can be triaged.  Discord
    +        # native slash invocations are always user-initiated (no bot can fire
    +        # them), but mobile autocomplete / keyboard shortcuts / other users
    +        # in the same channel are easy to miss in post-mortems.
    +        try:
    +            _user = interaction.user
    +            _chan_id = getattr(interaction.channel, "id", None) or getattr(interaction, "channel_id", None)
    +            logger.info(
    +                "[Discord] slash '%s' invoked by user=%s id=%s channel=%s guild=%s",
    +                command_text,
    +                getattr(_user, "name", "?"),
    +                getattr(_user, "id", "?"),
    +                _chan_id,
    +                getattr(interaction, "guild_id", None),
    +            )
    +        except Exception:
    +            pass  # logging must never block command dispatch
    +
             await interaction.response.defer(ephemeral=True)
             event = self._build_slash_event(interaction, command_text)
             await self.handle_message(event)
    ```
  - `gateway/run.py`

    ```diff
    @@ -2987,8 +2987,8 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
     
                 # Resolve the command once for all early-intercept checks below.
                 from hermes_cli.commands import (
    +                ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS,
                     resolve_command as _resolve_cmd_inner,
    -                should_bypass_active_session as _should_bypass_active_inner,
                 )
                 _evt_cmd = event.get_command()
                 _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
    ```

    ```diff
    @@ -3123,11 +3123,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                 if _cmd_def_inner and _cmd_def_inner.name == "background":
                     return await self._handle_background_command(event)
     
    -            # Gateway-handled info/control commands must never fall through to
    -            # the interrupt path. If they are queued as pending text, the
    -            # slash-command safety net discards them before the user sees any
    -            # response.
    -            if _cmd_def_inner and _should_bypass_active_inner(_cmd_def_inner.name):
    +            # Gateway-handled info/control commands with dedicated
    +            # running-agent handlers.
    +            if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:
                     if _cmd_def_inner.name == "help":
                         return await self._handle_help_command(event)
                     if _cmd_def_inner.name == "commands":
    ```

    ```diff
    @@ -3137,6 +3135,21 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                     if _cmd_def_inner.name == "update":
                         return await self._handle_update_command(event)
     
    +            # Catch-all: any other recognized slash command reached the
    +            # running-agent guard. Reject gracefully rather than falling
    +            # through to interrupt + discard. Without this, commands
    +            # like /model, /reasoning, /voice, /insights, /title,
    +            # /resume, /retry, /undo, /compress, /usage, /provider,
    +            # /reload-mcp, /sethome, /reset (all registered as Discord
    +            # slash commands) would interrupt the agent AND get
    +            # silently discarded by the slash-command safety net,
    +            # producing a zero-char response. See #5057, #6252, #10370.
    +            if _cmd_def_inner:
    +                return (
    +                    f"⏳ Agent is running — `/{_cmd_def_inner.name}` can't run "
    +                    f"mid-turn. Wait for the current response or `/stop` first."
    +                )
    +
                 if event.message_type == MessageType.PHOTO:
                     logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
                     adapter = self.adapters.get(source.platform)
    ```
  - `hermes_cli/commands.py`

    ```diff
    @@ -260,10 +260,10 @@ def _build_description(cmd: CommandDef) -> str:
     )
     
     
    -# Commands that must never be queued behind an active gateway session.
    -# These are explicit control/info commands handled by the gateway itself;
    -# if they get queued as pending text, the safety net in gateway.run will
    -# discard them before they ever reach the user.
    +# Commands with explicit Level-2 running-agent handlers in gateway/run.py.
    +# Listed here for introspection / tests; semantically a subset of
    +# "all resolvable commands" — which is the real bypass set (see
    +# should_bypass_active_session below).
     ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset(
         {
             "agents",
    ```

    ```diff
    @@ -285,9 +285,26 @@ def _build_description(cmd: CommandDef) -> str:
     
     
     def should_bypass_active_session(command_name: str | None) -> bool:
    -    """Return True when a slash command must bypass active-session queuing."""
    -    cmd = resolve_command(command_name) if command_name else None
    -    return bool(cmd and cmd.name in ACTIVE_SESSION_BYPASS_COMMANDS)
    +    """Return True for any resolvable slash command.
    +
    +    Rationale: every gateway-registered slash command either has a
    +    specific Level-2 handler in gateway/run.py (/stop, /new, /model,
    +    /approve, etc.) or reaches the running-agent catch-all that returns
    +    a "busy — wait or /stop first" response. In both paths the command
    +    is dispatched, not queued.
    +
    +    Queueing is always wrong for a recognized slash command because the
    +    safety net in gateway.run discards any command text that reaches
    +    the pending queue — which meant a mid-run /model (or /reasoning,
    +    /voice, /insights, /title, /resume, /retry, /undo, /compress,
    +    /usage, /provider, /reload-mcp, /sethome, /reset) would silently
    +    interrupt the agent AND get discarded, producing a zero-char
    +    response. See issue #5057 / PRs #6252, #10370, #4665.
    +
    +    ACTIVE_SESSION_BYPASS_COMMANDS remains the subset of commands with
    +    explicit Level-2 handlers; the rest fall through to the catch-all.
    +    """
    +    return resolve_command(command_name) is not None if command_name else False
     
     
     def _resolve_config_gates() -> set[str]:
    ```
  - `tests/gateway/test_command_bypass_active_session.py`

    ```diff
    @@ -268,6 +268,82 @@ async def test_queue_bypasses_guard(self):
             )
     
     
    +# ---------------------------------------------------------------------------
    +# Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass
    +# instead of interrupting + being discarded.  Regression for the Discord
    +# ghost-slash-command bug where /model, /reasoning, /voice, /insights, /title,
    +# /resume, /retry, /undo, /compress, /usage, /provider, /reload-mcp,
    +# /sethome, /reset silently interrupted the running agent.
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestAllResolvableCommandsBypassGuard:
    +    """Every recognized slash command must bypass the Level-1 active-session
    +    guard. Without this, commands the user fires mid-run interrupt the agent
    +    AND get silently discarded by the slash-command safety net (zero-char
    +    response)."""
    +
    +    @pytest.mark.parametrize(
    +        "command_text,canonical",
    +        [
    +            ("/model claude-sonnet-4", "model"),
    +            ("/model", "model"),
    +            ("/reasoning high", "reasoning"),
    +            ("/personality default", "personality"),
    +            ("/voice on", "voice"),
    +            ("/insights 7", "insights"),
    +            ("/title my session", "title"),
    +            ("/resume yesterday", "resume"),
    +            ("/retry", "retry"),
    +            ("/undo", "undo"),
    +            ("/compress", "compress"),
    +            ("/usage", "usage"),
    +            ("/provider", "provider"),
    +            ("/reload-mcp", "reload-mcp"),
    +            ("/sethome", "sethome"),
    +        ],
    +    )
    +    @pytest.mark.asyncio
    +    async def test_command_bypasses_guard(self, command_text, canonical):
    +        """Any resolvable slash command bypasses instead of being queued."""
    +        adapter = _make_adapter()
    +        sk = _session_key()
    +        adapter._active_sessions[sk] = asyncio.Event()
    +
    +        await adapter.handle_message(_make_event(command_text))
    +
    +        assert sk not in adapter._pending_messages, (
    +            f"{command_text} was queued as pending — it should bypass the guard"
    +        )
    +        assert len(adapter.sent_responses) > 0, (
    +            f"{command_text} produced no response — it should be dispatched, "
    +            "not silently discarded"
    +        )
    +
    +    def test_should_bypass_returns_true_for_every_registered_command(self):
    +        """Spot-check: the commands previously-broken on Discord all bypass."""
    +        from hermes_cli.commands import should_bypass_active_session
    +
    +        for cmd in (
    +            "model", "reasoning", "personality", "voice", "insights", "title",
    +            "resume", "retry", "undo", "compress", "usage", "provider",
    +            "reload-mcp", "sethome", "reset",
    +        ):
    +            assert should_bypass_active_session(cmd) is True, (
    +                f"/{cmd} must bypass the active-session guard"
    +            )
    +
    +    def test_should_bypass_returns_false_for_unknown(self):
    +        """Unknown words don't bypass — they get queued as user text."""
    +        from hermes_cli.commands import should_bypass_active_session
    +
    +        assert should_bypass_active_session("foobar") is False
    +        assert should_bypass_active_session(None) is False
    +        assert should_bypass_active_session("") is False
    +        # A file path split on whitespace: '/path/to/file.py' -> 'path/to/file.py'
    +        assert should_bypass_active_session("path/to/file.py") is False
    +
    +
     # ---------------------------------------------------------------------------
     # Tests: non-bypass messages still get queued
     # ---------------------------------------------------------------------------
    ```
- **#26819: fix(gateway): always interrupt the agent on /stop and /interrupt during busy turns (#26813)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#26813`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#26983: fix(gateway): /stop and /interrupt bypass steer/queue to always interrupt agent (#26813)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#26813`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#40590: fix(gateway): plain text while busy interrupts by default again**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/conversation_loop.py`, `gateway/platforms/base.py`, `gateway/run.py`, `tests/gateway/test_active_session_text_merge.py`, `tests/gateway/test_restart_drain.py`, `tests/run_agent/test_413_compression.py`, `tests/run_agent/test_run_agent.py`
  - fix hunks:
  - `agent/conversation_loop.py`

    ```diff
    @@ -2720,6 +2720,61 @@ def _perform_api_call(next_api_kwargs):
                     # compress history and retry, not abort immediately.
                     status_code = getattr(api_error, "status_code", None)
     
    +                # ── Respect disabled auto-compaction on overflow ──────
    +                # Ported from anomalyco/opencode#30749.  When the user has
    +                # turned auto-compaction off (``compression.enabled: false``),
    +                # NO automatic compaction trigger may fire — including the
    +                # provider/request-size overflow recovery paths below
    +                # (long-context-tier 429, 413 payload-too-large, and
    +                # context-overflow).  Without this guard the proactive
    +                # threshold path correctly honours the setting (see the
    +                # preflight check and the post-response ``should_compress``
    +                # gate) but a provider overflow error would still silently
    +                # compress + rotate the session, bypassing the user's
    +                # explicit choice.  Surface a terminal error instead so the
    +                # user can compact manually (``/compress``), start fresh
    +                # (``/new``), switch to a larger-context model, or reduce
    +                # attachments.  Forced compaction via ``/compress``
    +                # (``force=True``) is unaffected — it never reaches this loop.
    +                _overflow_reasons = {
    +                    FailoverReason.long_context_tier,
    +                    FailoverReason.payload_too_large,
    +                    FailoverReason.context_overflow,
    +                }
    +                if (
    +                    classified.reason in _overflow_reasons
    +                    and not getattr(agent, "compression_enabled", True)
    +                ):
    +                    agent._flush_status_buffer()
    +                    agent._vprint(
    +                        f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
    +                        f"(compression.enabled: false).",
    +                        force=True,
    +                    )
    +                    agent._vprint(
    +                        f"{agent.log_prefix}   💡 Run /compress to compact manually, /new to start fresh, "
    +                        f"switch to a larger-context model, or reduce attachments.",
    +                        force=True,
    +                    )
    +                    logger.error(
    +                        f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
    +                        f"auto-compaction disabled — not compressing."
    +                    )
    +                    agent._persist_session(messages, conversation_history)
    +                    return {
    +                        "messages": messages,
    +                        "completed": False,
    +                        "api_calls": api_call_count,
    +                        "error": (
    +                            "Context overflow and auto-compaction is disabled "
    +                            "(compression.enabled: false). Run /compress to compact manually, "
    +                            "/new to start fresh, or switch to a larger-context model."
    +                        ),
    +                        "partial": True,
    +                        "failed": True,
    +                        "compaction_disabled": True,
    +                    }
    +
                     # ── Anthropic Sonnet long-context tier gate ───────────
                     # Anthropic returns HTTP 429 "Extra usage is required for
                     # long context requests" when a Claude Max (or similar)
    ```
  - `gateway/platforms/base.py`

    ```diff
    @@ -1817,9 +1817,14 @@ def __init__(self, config: PlatformConfig, platform: Platform):
             self._active_sessions: Dict[str, asyncio.Event] = {}
             self._pending_messages: Dict[str, MessageEvent] = {}
             self._session_tasks: Dict[str, asyncio.Task] = {}
    +        # Legacy busy_text_mode env var; when unset the runner syncs the
    +        # resolved value (driven by busy_input_mode) onto the adapter after
    +        # construction (gateway/run.py). Default to "interrupt" so a stray
    +        # pre-sync read matches the single-knob default rather than silently
    +        # queueing.
             self._busy_text_mode: str = (
    -            os.environ.get("HERMES_GATEWAY_BUSY_TEXT_MODE", "queue").strip().lower()
    -            or "queue"
    +            os.environ.get("HERMES_GATEWAY_BUSY_TEXT_MODE", "interrupt").strip().lower()
    +            or "interrupt"
             )
             self._busy_text_debounce_seconds: float = _float_env(
                 "HERMES_GATEWAY_BUSY_TEXT_DEBOUNCE_SECONDS", 0.35
    ```

    ```diff
    @@ -3395,7 +3400,7 @@ def _text_debounce_store(self) -> dict[str, TextDebounceState]:
         def _is_queue_text_debounce_candidate(self, event: MessageEvent) -> bool:
             """Return True for normal text eligible for queue-mode debounce."""
             result = (
    -            getattr(self, "_busy_text_mode", "queue") == "queue"
    +            getattr(self, "_busy_text_mode", "interrupt") == "queue"
                 and event.message_type == MessageType.TEXT
                 and not getattr(event, "internal", False)
                 and not event.is_command()
    ```
  - `gateway/run.py`

    ```diff
    @@ -3195,14 +3195,27 @@ def _load_busy_input_mode() -> str:
     
         @staticmethod
         def _load_busy_text_mode() -> str:
    -        """Load normal busy TEXT follow-up behavior from config/env."""
    -        mode = os.getenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "").strip().lower()
    -        if not mode:
    +        """Resolve normal busy TEXT follow-up behavior.
    +
    +        ``busy_input_mode`` is the single source of truth (default
    +        ``interrupt``). The legacy ``busy_text_mode`` knob is honored only
    +        when a user explicitly set it, so existing queue setups keep
    +        working; new installs follow ``busy_input_mode``. Returns one of
    +        ``interrupt`` | ``queue`` (``steer`` is handled upstream by
    +        ``busy_input_mode`` and maps to non-queue text handling here).
    +        """
    +        # Legacy explicit override wins for backward compat.
    +        legacy = os.getenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "").strip().lower()
    +        if not legacy:
                 cfg = _load_gateway_runtime_config()
    -            mode = str(cfg_get(cfg, "display", "busy_text_mode", default="") or "").strip().lower()
    -        if mode == "interrupt":
    +            legacy = str(cfg_get(cfg, "display", "busy_text_mode", default="") or "").strip().lower()
    +        if legacy == "interrupt":
                 return "interrupt"
    -        return "queue"
    +        if legacy == "queue":
    +            return "queue"
    +        # No explicit legacy knob → follow busy_input_mode.
    +        input_mode = GatewayRunner._load_busy_input_mode()
    +        return "queue" if input_mode == "queue" else "interrupt"
     
         @staticmethod
         def _load_restart_drain_timeout() -> float:
    ```

    ```diff
    @@ -3390,7 +3403,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session
             running_agent = self._running_agents.get(session_key)
     
             effective_mode = self._busy_input_mode
    -        busy_text_mode = getattr(self, "_busy_text_mode", "queue")
    +        busy_text_mode = getattr(self, "_busy_text_mode", "interrupt")
             if (
                 event.message_type == MessageType.TEXT
                 and busy_text_mode == "queue"
    ```
  - `tests/gateway/test_active_session_text_merge.py`

    ```diff
    @@ -352,14 +352,15 @@ async def test_single_followup_is_stored_as_is():
         assert not adapter._active_sessions[session_key].is_set()
     
     
    -def test_adapter_defaults_to_queue_mode(monkeypatch):
    +def test_adapter_defaults_to_interrupt_mode(monkeypatch):
         monkeypatch.delenv("HERMES_GATEWAY_BUSY_TEXT_MODE", raising=False)
         adapter = _make_initialized_adapter()
    -    assert adapter._busy_text_mode == "queue"
    -    assert adapter._is_queue_text_debounce_candidate(_make_event("hello"))
    +    assert adapter._busy_text_mode == "interrupt"
    +    assert not adapter._is_queue_text_debounce_candidate(_make_event("hello"))
     
     
    -def test_adapter_is_queue_text_debounce_candidate_by_default():
    +def test_adapter_is_queue_text_debounce_candidate_when_queue_set():
    +    # _make_adapter() pins _busy_text_mode="queue" to exercise debounce.
         adapter = _make_adapter()
         assert adapter._is_queue_text_debounce_candidate(_make_event("hello world"))
     
    ```
  - `tests/gateway/test_restart_drain.py`

    ```diff
    @@ -116,22 +116,37 @@ def test_load_busy_input_mode_prefers_env_then_config_then_default(tmp_path, mon
         assert gateway_run.GatewayRunner._load_busy_input_mode() == "interrupt"
     
     
    -def test_load_busy_text_mode_defaults_to_queue_and_allows_interrupt(tmp_path, monkeypatch):
    +def test_load_busy_text_mode_follows_input_mode_and_honors_legacy(tmp_path, monkeypatch):
         monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
         monkeypatch.delenv("HERMES_GATEWAY_BUSY_TEXT_MODE", raising=False)
    +    monkeypatch.delenv("HERMES_GATEWAY_BUSY_INPUT_MODE", raising=False)
    +
    +    # No knobs set → follows busy_input_mode, which defaults to interrupt.
    +    assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
     
    +    # busy_input_mode=queue propagates to text handling (single source of truth).
    +    (tmp_path / "config.yaml").write_text(
    +        "display:\n  busy_input_mode: queue\n", encoding="utf-8"
    +    )
         assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
     
    +    # Legacy explicit busy_text_mode still wins for backward compat.
         (tmp_path / "config.yaml").write_text(
    -        "display:\n  busy_text_mode: interrupt\n", encoding="utf-8"
    +        "display:\n  busy_input_mode: interrupt\n  busy_text_mode: queue\n",
    +        encoding="utf-8",
         )
    -    assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
    +    assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
     
    +    # Legacy env override wins too.
    +    (tmp_path / "config.yaml").write_text(
    +        "display:\n  busy_input_mode: interrupt\n", encoding="utf-8"
    +    )
         monkeypatch.setenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "queue")
         assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
     
    +    # Bogus legacy value is ignored → falls through to busy_input_mode (interrupt).
         monkeypatch.setenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "bogus")
    -    assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
    +    assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
     
     
     def test_load_restart_drain_timeout_prefers_env_then_config_then_default(
    ```
  - `tests/run_agent/test_413_compression.py`

    ```diff
    @@ -94,7 +94,11 @@ def agent():
             a._cached_system_prompt = "You are helpful."
             a._use_prompt_caching = False
             a.tool_delay = 0
    -        a.compression_enabled = False
    +        # Default matches production (`compression.enabled` defaults to True).
    +        # Overflow-recovery tests below verify that 413 / context-overflow
    +        # errors DO trigger compression; the disabled-path behavior is
    +        # covered explicitly by TestOverflowWithCompactionDisabled.
    +        a.compression_enabled = True
             a.save_trajectories = False
             return a
     
    ```

    ```diff
    @@ -415,6 +419,13 @@ class TestPreflightCompression:
     
         def test_compress_context_emits_lifecycle_status_before_work(self, agent):
             """Direct context compression should tell gateway users why the turn paused."""
    +        # This test calls _compress_context directly and asserts the FIRST
    +        # status event is the lifecycle "Compacting context" message. With
    +        # compaction enabled the lazy feasibility probe would emit an
    +        # aux-provider warning first (no aux key in the hermetic test env),
    +        # displacing events[0]. The flag value is irrelevant to what this
    +        # test asserts, so disable it to suppress the probe.
    +        agent.compression_enabled = False
             events = []
             agent.status_callback = lambda ev, msg: events.append((ev, msg))
     
    ```

    ```diff
    @@ -802,3 +813,95 @@ def test_anthropic_prompt_too_long_safety_net(self, agent):
     
             mock_compress.assert_called_once()
             assert result["completed"] is True
    +
    +
    +# ---------------------------------------------------------------------------
    +# Disabled auto-compaction on overflow (port of anomalyco/opencode#30749)
    +# ---------------------------------------------------------------------------
    +
    +class TestOverflowWithCompactionDisabled:
    +    """When ``compression.enabled`` is False, NO automatic compaction may
    +    fire — including the provider/request-size overflow recovery paths.
    +
    +    Ported from anomalyco/opencode#30749: the proactive token-threshold
    +    path already honoured the setting, but provider overflow errors
    +    (413 payload-too-large, context-overflow, long-context-tier 429) still
    +    silently compressed + rotated the session. The fix surfaces a terminal
    +    error so the user can compact manually, start fresh, or switch models.
    +    """
    +
    +    @staticmethod
    +    def _prefill():
    +        return [
    +            {"role": "user", "content": "previous question"},
    +            {"role": "assistant", "content": "previous answer"},
    +        ]
    +
    +    def test_413_does_not_compress_when_disabled(self, agent):
    +        """413 must NOT call _compress_context when compaction is disabled."""
    +        agent.compression_enabled = False
    +        err_413 = _make_413_error()
    +        # If the guard fails, a second (success) response would be consumed.
    +        agent.client.chat.completions.create.side_effect = [err_413, _mock_response()]
    +
    +        with (
    +            patch.object(agent, "_compress_context") as mock_compress,
    +            patch.object(agent, "_persist_session") as mock_persist,
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            result = agent.run_conversation("hello", conversation_history=self._prefill())
    +
    +        mock_compress.assert_not_called()
    +        mock_persist.assert_called()
    +        assert result.get("failed") is True
    +        assert result.get("compaction_disabled") is True
    +        assert "auto-compaction is disabled" in result["error"]
    +
    +    def test_context_overflow_does_not_compress_when_disabled(self, agent):
    +        """400 'prompt is too long' must NOT compress when compaction disabled."""
    +        agent.compression_enabled = False
    +        err_400 = Exception(
    +            "Error code: 400 - {'type': 'error', 'error': {'type': "
    +            "'invalid_request_error', 'message': 'prompt is too long: "
    +            "233153 tokens > 200000 maximum'}}"
    +        )
    +        err_400.status_code = 400
    +        agent.client.chat.completions.create.side_effect = [err_400, _mock_response()]
    +
    +        with (
    +            patch.object(agent, "_compress_context") as mock_compress,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            result = agent.run_conversation("hello", conversation_history=self._prefill())
    +
    +        mock_compress.assert_not_called()
    +        assert result.get("compaction_disabled") is True
    +
    +    def test_413_still_compresses_when_enabled(self, agent):
    +        """Control: with compaction enabled, 413 still triggers compression.
    +
    +        Guards against the disabled-path guard accidentally swallowing the
    +        enabled path.
    +        """
    +        agent.compression_enabled = True
    +        err_413 = _make_413_error()
    +        ok_resp = _mock_response(content="Recovered", finish_reason="stop")
    +        agent.client.chat.completions.create.side_effect = [err_413, ok_resp]
    +
    +        with (
    +            patch.object(agent, "_compress_context") as mock_compress,
    +            patch.object(agent, "_persist_session"),
    +            patch.object(agent, "_save_trajectory"),
    +            patch.object(agent, "_cleanup_task_resources"),
    +        ):
    +            mock_compress.return_value = (
    +                [{"role": "user", "content": "hello"}], "compressed",
    +            )
    +            result = agent.run_conversation("hello", conversation_history=self._prefill())
    +
    +        mock_compress.assert_called_once()
    +        assert result["completed"] is True
    +        assert result.get("compaction_disabled") is not True
    ```
  - `tests/run_agent/test_run_agent.py`

    ```diff
    @@ -3903,6 +3903,7 @@ def test_context_compression_triggered(self, agent):
         def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent):
             """GLM/Z.AI uses 'Prompt exceeds max length' for context overflow."""
             self._setup_agent(agent)
    +        agent.compression_enabled = True  # this test verifies overflow→compression fires
             err_400 = Exception(
                 "Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}"
             )
    ```

    ```diff
    @@ -3937,6 +3938,7 @@ def test_minimax_delta_overflow_keeps_known_context_length(self, agent):
             to the generic 128K fallback tier.
             """
             self._setup_agent(agent)
    +        agent.compression_enabled = True  # this test verifies overflow→compression fires
             agent.provider = "minimax"
             agent.model = "MiniMax-M2.7-highspeed"
             agent.base_url = "https://api.minimax.io/anthropic"
    ```

    ```diff
    @@ -3982,6 +3984,7 @@ def test_non_minimax_overflow_without_provider_limit_keeps_context(self, agent):
             rely on compression — see #33669 / PR #33826.
             """
             self._setup_agent(agent)
    +        agent.compression_enabled = True  # this test verifies overflow→compression fires
             agent.provider = "openrouter"
             agent.model = "some/unknown-model"
             agent.base_url = "https://openrouter.ai/api/v1"
    ```
- **#40799: fix(gateway): slash commands no longer silently dropped in busy queue mode**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#40706`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#2497: fix: /stop command bypasses running-agent guard to prevent message replay**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#2531: fix: /stop bypasses all replay paths to prevent message replay**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `511ed4dacc`
- **#27160: feat(gateway): add universal interrupt keywords for busy sessions**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - files: `gateway/run.py`, `tests/gateway/test_busy_session_ack.py`
- **#31612: fix(gateway): send feedback when text messages are queued during active session**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#31612`
- **#36256: fix(agent): handle interrupt during tool execution without losing state (#35267)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `54e7b74f7f476b7d0de31e5f3a97376647e68e64`, `#40590`, `#35267`
- **#41172: fix(gateway): bypass WeChat text batching for active sessions**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#40724`, `#40727`
  - files: `gateway/platforms/base.py`, `gateway/platforms/weixin.py`, `plugins/platforms/wecom/adapter.py`, `tests/gateway/test_text_batching.py`, `tests/gateway/test_weixin.py`
- **#41578: fix(gateway): bypass busy handler for slash commands in all busy_input_modes**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `632a807a3e528169bd39baf8fc3aa1d641580e96`, `#12334`
- **#51666: fix(desktop): interrupt running turn when user sends a message while busy**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `773a3703bfc1f8ff2f3aef40d7a565e7f4fe1404`
  - files: `apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx`, `apps/desktop/src/app/chat/composer/index.tsx`
- **#51924: fix(weixin): 5 WeChat bug fixes — voice STT, polling hang, msg truncation, session split, interrupt**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#51924`

## Fix status

- Merged to main in PR #3104 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-06`.
- Subsystem: `agent`
