From 09647e87a71516c667f2805d4ad7a0a3968eae07 Mon Sep 17 00:00:00 2001 From: Luke Boyett Date: Thu, 16 Apr 2026 10:55:15 -0400 Subject: [PATCH] fix(agents): let id-embedded kind markers override prefix-derived kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix thread delivery encodes per-user DM targets as `room:@user:server` — the `room:` wrapper says "channel" but the embedded `@` id marker says "direct". The previous extractRequesterPeer gated the `@`/`!`/`#` heuristic on `!inferredKind`, so the prefix-derived kind won and a direct-kinded peer binding on the same user id was rejected by the kind-safety check in resolveFirstBoundAccountId. Cross-agent spawns whose target was bound as a direct Matrix peer could fall back to the caller account and send from the wrong identity. The fix removes the `!inferredKind` guard so id-embedded kind markers always have the final say — they are a more reliable signal than the delivery-target wrapper, because channel-side prefixes can wrap either a room or a user id. Regression test: sessions_spawn classifies Matrix `room:@user` targets as direct, not channel — the lifecycle suite now configures a conflicting `channel`-kinded binding on the same user id and asserts the `direct`-kinded binding wins when the caller's `agentTo` is `room:@user:example.org`. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...subagents.sessions-spawn.lifecycle.test.ts | 56 +++++++++++++++++++ src/agents/subagent-spawn.ts | 5 ++ 2 files changed, 61 insertions(+) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 3e5c71485ff..42133205b4f 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -754,6 +754,62 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(spawnAccountId).toBe("bot-alpha-line"); }); + it("sessions_spawn classifies Matrix room:@user targets as direct, not channel", async () => { + let spawnAccountId: string | undefined; + const rawUserId = "@other-user:example.org"; + setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + messages: { queue: { debounceMs: 0 } }, + agents: { defaults: { subagents: { allowAgents: ["bot-alpha"] } } }, + bindings: [ + // A conflicting channel-kinded binding on the same peer id — must + // not match a room:@user target because the embedded `@` marker + // identifies this as a direct peer. + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: rawUserId }, + accountId: "bot-alpha-wrong-kind", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "direct", id: rawUserId }, + accountId: "bot-alpha-dm", + }, + }, + ], + }); + setupSessionsSpawnGatewayMock({ + onAgentSubagentSpawn: (params) => { + const rec = params as { accountId?: string } | undefined; + spawnAccountId = rec?.accountId; + }, + }); + + // Matrix thread delivery encodes per-user DM targets as `room:@user:server`. + // The `room:` prefix must not override the embedded `@` direct-peer marker. + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: `room:${rawUserId}`, + }); + + const result = await tool.execute("call-room-at-user", { + task: "do thing", + agentId: "bot-alpha", + cleanup: "keep", + }); + expect(result.details).toMatchObject({ status: "accepted", runId: expect.any(String) }); + expect(spawnAccountId).toBe("bot-alpha-dm"); + }); + it("sessions_spawn strips conversation: prefix for Teams-style targets", async () => { let spawnAccountId: string | undefined; const rawConversationId = "19:example-conversation@thread.v2"; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 5f04fc70edd..8501f12dee7 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -345,6 +345,11 @@ function extractRequesterPeer( value = value.slice(prefix.length).trim(); } if (value) { + // Id-embedded kind markers (Matrix `!`/`@`, IRC `#`) win over prefix-derived + // inference — channel-side wrappers can wrap either a room or a user id + // (e.g. Matrix thread delivery encodes per-user DM targets as + // `room:@user:server`), and the id itself is the authoritative signal for + // what the peer actually is. if (value.startsWith("@")) { inferredKind = "direct"; } else if (value.startsWith("!") || value.startsWith("#")) {