Gateway busy-session interrupt and queue handling

NousResearch/hermes-agent · cluster 2491 · agent

Data as of

6 open items. Fix merged in #3104. Check it covers your case.

The short answer

3open issues
3open PRs
29items grouped
Known?
Yes. 29 related items grouped here, 11 issues already closed.
Fix event
A fix was merged to main in PR #3104. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #3104. That is separate from the fix event.
Still open
3 issues and 3 PRs.

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.

Open issues3

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

Open PRs3

  • PR#27160feat(gateway): add universal interrupt keywords for busy sessions
  • PR#41172fix(gateway): bypass WeChat text batching for active sessions
  • PR#51666fix(desktop): interrupt running turn when user sends a message while busy

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3104 fix(gateway): recover from hung agents — /stop hard-kills session lock

    merged to main

    files: gateway/run.py, tests/gateway/test_session_race_guard.py

  • #12334 fix(gateway): slash commands never interrupt a running agent

    merged to main

    files: gateway/platforms/discord.py, gateway/run.py, hermes_cli/commands.py, tests/gateway/test_command_bypass_active_session.py

    Show the diff
    • gateway/platforms/discord.py
      @@ -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
      @@ -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
      @@ -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":
      @@ -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
      @@ -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",
      @@ -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
      @@ -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
       # ---------------------------------------------------------------------------
  • #40590 fix(gateway): plain text while busy interrupts by default again

    merged to main

    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

    Show the diff
    • agent/conversation_loop.py
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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
      @@ -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
      @@ -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
      @@ -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
       
      @@ -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))
       
      @@ -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
      @@ -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'}}"
               )
      @@ -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"
      @@ -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"

9 PRs closed without merging

From the close comments: done elsewhere 5 · closed without comment 1 · stale or abandoned 1 · superseded by another PR 1 · other 1

  • #2497 fix: /stop command bypasses running-agent guard to prevent message replay · closed without comment
  • #2531 fix: /stop bypasses all replay paths to prevent message replay · done elsewhere → 511ed4d
  • #26819 fix(gateway): always interrupt the agent on /stop and /interrupt during busy turns (#26813) · done elsewhere → #26813
  • #26983 fix(gateway): /stop and /interrupt bypass steer/queue to always interrupt agent (#26813) · done elsewhere → #26813
  • #31612 fix(gateway): send feedback when text messages are queued during active session · stale or abandoned → #31612
Show 4 more
  • #36256 fix(agent): handle interrupt during tool execution without losing state (#35267) · done elsewhere → 54e7b74, #40590, #35267
  • #40799 fix(gateway): slash commands no longer silently dropped in busy queue mode · superseded by another PR → #40706
  • #41578 fix(gateway): bypass busy handler for slash commands in all busy_input_modes · done elsewhere → 632a807, #12334
  • #51924 fix(weixin): 5 WeChat bug fixes — voice STT, polling hang, msg truncation, session split, interrupt · other → #51924
Closed issues · 11
  • Closed#2491Feature Request: /stop slash command to abort the current run from chat
  • Closed#5244bug(gateway): commands (/new, /undo, etc.) are ignored while agent is running a tool
  • Closed#22176CLI interrupt /stop command not working — agent runs uncontrollably
  • Closed#26813Gateway: /stop and /interrupt are fed to agent as steer text instead of interrupting it
  • Closed#31588Gateway: text messages while agent is busy are silently queued — no interrupt, no feedback
  • Closed#35267[Bug]: Gateway multi-tool call chain cannot be interrupted mid-execution
  • Closed#38293feat: Interrupt running agent from remote messaging platforms (WeChat/QQ/Telegram)
  • Closed#40683[Bug]: /steer and other slash commands silently dropped during busy state due to early return in _handle_active_session_busy_message
  • Closed#41572Bug: Slash commands get intercepted by busy handler before command dispatcher with busy_input_mode: steer
  • Closed#51665Desktop app: queued messages ignored while agent runs 10+ minutes; Esc/Stop don't work under heavy tool output
  • Closed#58717Desktop busy_input_mode=interrupt not working; user messages queue instead of interrupting agent
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#3104, #12334, #40590
Closed issues
11
Attempts
9 PRs closed without merging
Fix link checked
6 Jun 2026

For your agent

The same facts, typed.