fix(agents): let id-embedded kind markers override prefix-derived kind

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) <noreply@anthropic.com>
This commit is contained in:
Luke Boyett
2026-04-16 10:55:15 -04:00
committed by Gustavo Madeira Santana
parent 2efc727406
commit 09647e87a7
2 changed files with 61 additions and 0 deletions

View File

@@ -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";

View File

@@ -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("#")) {