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:
- 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.
- 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.
- 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
- 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
- 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}
- Repeat on current main:
git fetch origin main
git checkout 7203898ce47c9ab90e64866d6cff0e6e9ad8d1cc
# Run the same python script again.
- 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.
Summary
Matrix accepts
!commandaliases because many Matrix clients reserve typed/for local client commands. Two Matrix command paths currently diverge from the gateway command contract:/skill-name, butshould_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-deliveryRepro
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.
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent-repro cd hermes-agent-repro git checkout v2026.7.1git fetch origin main git checkout 7203898ce47c9ab90e64866d6cff0e6e9ad8d1cc # Run the same python script again.Expected
_SPLIT_THRESHOLDshould be batched before dispatch./queue ...andtail from client split./arxivshould 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:The registered skill command also does not bypass active-session queueing:
The local patched tree flips both checks to true.
Root Cause
plugins/platforms/matrix/adapter.pydocuments that Matrix uses!as the typed command prefix and has_SPLIT_THRESHOLD = 3900, but_handle_text_message()only batchesMessageType.TEXT. Once!queue ...normalizes to/queue ..., it becomesMessageType.COMMANDand dispatches immediately.Separately,
hermes_cli/commands.py::should_bypass_active_session()only checksresolve_command(command_name). Skill commands are resolved throughagent.skill_commands.get_skill_commands(), so normalized skill commands do not get active-session bypass.Evidence
v2026.7.1/0.18.0at7c1a029553d87c43ecff8a3821336bc95872213b.origin/mainat7203898ce47c9ab90e64866d6cff0e6e9ad8d1cc.32 passed.Debug report: not applicable; reproduced with isolated synthetic repros and no private Matrix server state.
Proposed Fix
_SPLIT_THRESHOLD, while keeping short commands such as/stopand/statusimmediate.should_bypass_active_session(), treat both gateway-known commands and registered skill commands as bypassable.Acceptance Criteria
!queue ...is held in the Matrix text batch and dispatched after the split-delay window.!arxiv ...normalizes and bypasses active-session queueing.