Skip to content

Matrix bang commands bypass split batching and skill-command active-session dispatch #58559

Description

@bbopen

Summary

Matrix accepts !command aliases because many Matrix clients reserve typed / for local client commands. Two Matrix command paths currently diverge from the gateway command contract:

  1. Long Matrix bang/slash commands near the client split threshold are dispatched immediately as commands instead of going through text batching, so continuation chunks can arrive as separate plain text.
  2. Skill/plugin bang commands can normalize to /skill-name, but should_bypass_active_session() does not treat skill commands as resolvable commands, so they do not bypass an active session the way built-in slash commands do.

Suggested labels: type/bug, comp/gateway, platform/matrix, P2, sweeper:risk-message-delivery

Repro

No Matrix account, Matrix homeserver, or credentials are required. This repro directly calls the Matrix adapter text handler with a mocked resolved message context and uses placeholder Matrix IDs.

  1. Check out the release baseline:
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent-repro
cd hermes-agent-repro
git checkout v2026.7.1
  1. Run this script from the repo root:
python - <<'PY'
import asyncio
from types import SimpleNamespace
from unittest.mock import patch

from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
from plugins.platforms.matrix.adapter import MatrixAdapter

ROOM_ID = "!room:example"
USER_ID = "@user:example"


def source(message_id):
    return SessionSource(
        platform=Platform.MATRIX,
        chat_id=ROOM_ID,
        chat_name="Element X DM",
        chat_type="dm",
        user_id=USER_ID,
        user_name="User",
        message_id=message_id,
        scope_id="matrix.example",
    )


async def check_long_matrix_command_batching():
    adapter = object.__new__(MatrixAdapter)
    adapter.config = SimpleNamespace(
        extra={"group_sessions_per_user": True, "thread_sessions_per_user": False}
    )
    adapter._text_batch_delay_seconds = 0.02
    adapter._text_batch_split_delay_seconds = 0.02
    adapter._pending_text_batches = {}
    adapter._pending_text_batch_tasks = {}

    async def resolve_context(room_id, sender, event_id, body, source_content, relates_to):
        return body, True, "dm", None, "User", source(event_id)

    captured = []

    async def capture(message: MessageEvent):
        captured.append(message)

    adapter._resolve_message_context = resolve_context
    adapter.handle_message = capture

    await adapter._handle_text_message(
        ROOM_ID,
        USER_ID,
        "$long-1",
        0,
        {"body": "!queue " + ("x" * 3900)},
        {},
    )
    await adapter._handle_text_message(
        ROOM_ID,
        USER_ID,
        "$long-2",
        0,
        {"body": "tail from client split"},
        {},
    )
    await asyncio.sleep(0.08)
    return {
        "long_matrix_command_is_batched": (
            len(captured) == 1 and "tail from client split" in captured[0].text
        ),
        "captured_count": len(captured),
        "captured": [m.text[:80] for m in captured],
    }


def check_skill_command_bypass():
    from hermes_cli.commands import should_bypass_active_session

    with patch(
        "agent.skill_commands.get_skill_commands",
        return_value={"/arxiv": {"description": "fake repro skill"}},
    ):
        return {
            "skill_command_bypasses_active_session": should_bypass_active_session(
                "arxiv"
            )
        }


async def main():
    print(await check_long_matrix_command_batching())
    print(check_skill_command_bypass())

asyncio.run(main())
PY
  1. Observe the release output:
{'long_matrix_command_is_batched': False, 'captured_count': 2, 'captured': ['/queue xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'tail from client split']}
{'skill_command_bypasses_active_session': False}
  1. Repeat on current main:
git fetch origin main
git checkout 7203898ce47c9ab90e64866d6cff0e6e9ad8d1cc
# Run the same python script again.
  1. Observe the same failing output on main:
{'long_matrix_command_is_batched': False, 'captured_count': 2, 'captured': ['/queue xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'tail from client split']}
{'skill_command_bypasses_active_session': False}

Expected

  • A Matrix command chunk at or above _SPLIT_THRESHOLD should be batched before dispatch.
  • The two simulated Matrix chunks should produce one captured command containing both /queue ... and tail from client split.
  • A Matrix skill/plugin bang command that normalizes to /arxiv should bypass active-session queueing like any other resolvable slash command.

Actual

On both release and origin/main, the long command is dispatched immediately and the continuation chunk is handled as separate text:

long_matrix_command_is_batched: false
captured_count: 2
captured: ["/queue ...", "tail from client split"]

The registered skill command also does not bypass active-session queueing:

skill_command_bypasses_active_session: false

The local patched tree flips both checks to true.

Root Cause

plugins/platforms/matrix/adapter.py documents that Matrix uses ! as the typed command prefix and has _SPLIT_THRESHOLD = 3900, but _handle_text_message() only batches MessageType.TEXT. Once !queue ... normalizes to /queue ..., it becomes MessageType.COMMAND and dispatches immediately.

Separately, hermes_cli/commands.py::should_bypass_active_session() only checks resolve_command(command_name). Skill commands are resolved through agent.skill_commands.get_skill_commands(), so normalized skill commands do not get active-session bypass.

Evidence

  • Reproduced on v2026.7.1 / 0.18.0 at 7c1a029553d87c43ecff8a3821336bc95872213b.
  • Reproduced on origin/main at 7203898ce47c9ab90e64866d6cff0e6e9ad8d1cc.
  • Local patched tree passes the repro checks.
  • Focused regression suite passes: 32 passed.

Debug report: not applicable; reproduced with isolated synthetic repros and no private Matrix server state.

Proposed Fix

  • Batch Matrix command events when the normalized command body length is at or above _SPLIT_THRESHOLD, while keeping short commands such as /stop and /status immediate.
  • In should_bypass_active_session(), treat both gateway-known commands and registered skill commands as bypassable.

Acceptance Criteria

  • Near-threshold !queue ... is held in the Matrix text batch and dispatched after the split-delay window.
  • Short Matrix commands still dispatch immediately.
  • A registered skill command such as !arxiv ... normalizes and bypasses active-session queueing.
  • Regression tests cover both cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low — cosmetic, nice to havearea/sessionsSession lifecycle, resume, persistence, historycomp/pluginsPlugin system and bundled pluginsplatform/matrixMatrix adapter (E2EE)type/bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions