From 2efc72740603aeb51de239b2491853de1b0e1069 Mon Sep 17 00:00:00 2001 From: Luke Boyett Date: Thu, 16 Apr 2026 09:44:03 -0400 Subject: [PATCH] fix(agents): generic `:` prefix peeler for delivery targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MS Teams inbound context sets OriginatingTo to `conversation:` while route bindings key on the bare conversationId. The previous hand-rolled prefix list (`room:`, `channel:`, `chat:`, `group:`, `user:`, `dm:`) missed `conversation:` (and any future channel-specific namespacing), so Teams cross-agent subagent spawns fell back to channel-only/caller account and posted from the wrong identity. extractRequesterPeer now uses a generic `^[a-z][a-z0-9_-]*:` regex to peel any lowercase-alpha token-colon prefix, looping until the raw peer id surfaces. Real-world peer ids never start with a lowercase-alpha token followed by `:` (Matrix uses `!`/`@`, IRC `#`, Slack/Discord/LINE alphanumerics, numeric Telegram/WhatsApp, or email-style `user@server`), so this is safe. Known prefixes are mapped to ChatType for peerKind inference (`conversation:`/`room:`/`channel:`/`chat:`/`thread:`/`topic:` → channel, `group:`/`team:` → group, `user:`/`dm:`/`pm:` → direct). Regression test: sessions_spawn strips `conversation:` prefix for Teams-style targets — a binding keyed on the raw conversation id resolves correctly when the caller's `agentTo` is `conversation:`. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...subagents.sessions-spawn.lifecycle.test.ts | 45 +++++++++++++++++++ src/agents/subagent-spawn.ts | 6 +++ 2 files changed, 51 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 69d5b6a5130..3e5c71485ff 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,51 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(spawnAccountId).toBe("bot-alpha-line"); }); + it("sessions_spawn strips conversation: prefix for Teams-style targets", async () => { + let spawnAccountId: string | undefined; + const rawConversationId = "19:example-conversation@thread.v2"; + setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + messages: { queue: { debounceMs: 0 } }, + agents: { defaults: { subagents: { allowAgents: ["bot-alpha"] } } }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "msteams", + peer: { kind: "channel", id: rawConversationId }, + accountId: "bot-alpha-teams", + }, + }, + ], + }); + setupSessionsSpawnGatewayMock({ + onAgentSubagentSpawn: (params) => { + const rec = params as { accountId?: string } | undefined; + spawnAccountId = rec?.accountId; + }, + }); + + // Teams inbound context sets OriginatingTo to `conversation:`. With the + // generic prefix peeler in extractRequesterPeer, the bound-account lookup + // should still find the binding keyed on the raw conversation id. + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "msteams", + agentAccountId: "bot-beta", + agentTo: `conversation:${rawConversationId}`, + }); + + const result = await tool.execute("call-teams-conversation", { + task: "do thing", + agentId: "bot-alpha", + cleanup: "keep", + }); + expect(result.details).toMatchObject({ status: "accepted", runId: expect.any(String) }); + expect(spawnAccountId).toBe("bot-alpha-teams"); + }); + it("sessions_spawn preserves the caller's account for same-agent subagent spawns", async () => { let spawnAccountId: string | undefined; const room = "!someRoom:example.org"; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index f5f969cd5ee..5f04fc70edd 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -308,6 +308,12 @@ const KIND_PREFIX_TO_CHAT_TYPE: Readonly> = { "pm:": "direct", }; +// Matches any `:` prefix. Real-world peer ids (Matrix `!`/`@`, +// IRC `#`, Slack/Discord/LINE alphanumerics, numeric Telegram/WhatsApp, or +// email-style `user@server`) never start with a lowercase-alpha token followed +// by `:`, so this cleanly peels namespace and kind prefixes without risking +// the raw id itself. When a peeled token maps to a known ChatType, we also +// record it as an inferred peerKind. const GENERIC_PREFIX_PATTERN = /^[a-z][a-z0-9_-]*:/i; function extractRequesterPeer(