Skip to content

Commit a2a68d1

Browse files
steipetevincentkocpingfanfan
authored
fix(agent): preserve explicit recipient sessions (#101507)
* fix(agent): honor recipient session routing Co-authored-by: vincentkoc <[email protected]> Co-authored-by: pingfanfan <[email protected]> * fix(agent): preserve canonical recipient routes * fix(agent): require exact recipient routes Co-authored-by: vincentkoc <[email protected]> Co-authored-by: pingfanfan <[email protected]> * fix(agent): harden recipient route resolution * fix(imessage): require canonical recipient handles * fix(agent): refine provider recipient exactness * fix(agent): bound direct alias session routing * fix(signal): preserve direct alias route type * fix(agent): honor binding-scoped recipient sessions * fix(routing): honor configured main session aliases * fix(clickclack): align account-owned session routes * fix(twitch): preserve canonical recipient sessions * fix(routing): isolate stable outbound identities * chore: defer recipient session changelog * fix(sms): use dedicated channel SDK facade --------- Co-authored-by: vincentkoc <[email protected]> Co-authored-by: pingfanfan <[email protected]>
1 parent 13c1d3c commit a2a68d1

82 files changed

Lines changed: 3031 additions & 268 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/cli/agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ openclaw agent --agent ops --message "Run locally" --local
5353
- Pass exactly one of `--message` or `--message-file`. `--message-file` strips a leading UTF-8 BOM and preserves multiline content; it rejects files that are not valid UTF-8.
5454
- Slash commands (for example `/compact`) cannot run through `--message`. The CLI rejects them and points you at the first-class command instead (`openclaw sessions compact <key>` for compaction).
5555
- `--local` and embedded fallback runs are one-shot: bundled MCP loopback resources and warm Claude stdio sessions opened for the run are retired after the reply, so scripted invocations do not leave local child processes running. Gateway-backed runs keep Gateway-owned MCP loopback resources under the running Gateway process instead.
56-
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing.
56+
- With `--agent`, `--channel` and `--to` together, session routing follows the channel's canonical recipient and `session.dmScope`. Channels with a stable outbound-only recipient identity use a provider-owned session isolated from the agent's main session. `--reply-channel` and `--reply-account` affect delivery only.
5757
- `--session-key` selects an explicit session key. Agent-prefixed keys must use `agent:<agent-id>:<session-key>`, and `--agent` must match the key's agent id when both are given. Bare non-sentinel keys scope to `--agent` when supplied, or to the configured default agent otherwise; for example `--agent ops --session-key incident-42` routes to `agent:ops:incident-42`. The literal keys `global` and `unknown` stay unscoped only when no `--agent` is supplied.
5858
- `--json` reserves stdout for the JSON response; Gateway, plugin, and embedded-fallback diagnostics go to stderr so scripts can parse stdout directly.
5959
- Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can detect a fallback run.

docs/tools/agent-send.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ programmatic delivery. Full flag and behavior reference:
7575
| `--model <id>` | Model override for this run (`provider/model` or model id) |
7676
| `--local` | Force local embedded runtime (skip Gateway) |
7777
| `--deliver` | Send the reply to a chat channel |
78-
| `--channel <name>` | Delivery channel (discord, slack, telegram, whatsapp, etc.) |
78+
| `--channel <name>` | Delivery channel; with `--agent` + `--to`, also applies DM scope |
7979
| `--reply-to <target>` | Delivery target override |
8080
| `--reply-channel <name>` | Delivery channel override |
8181
| `--reply-account <id>` | Delivery account id override |
@@ -94,7 +94,10 @@ programmatic delivery. Full flag and behavior reference:
9494
run; a Gateway timeout falls back with a fresh session instead of racing the
9595
original transcript.
9696
- Session selection: `--to` derives the session key (group/channel targets
97-
preserve isolation; direct chats collapse to `main`).
97+
preserve isolation; direct chats collapse to `main`). With `--agent`,
98+
`--channel`, and `--to` together, routing follows the channel's canonical
99+
recipient and `session.dmScope`. Stable outbound-only identities use a
100+
provider-owned session isolated from the agent's main session.
98101
- `--session-key` selects an explicit key. Agent-prefixed keys must use
99102
`agent:<agent-id>:<session-key>`, and `--agent` must match that agent id when
100103
both are supplied. Bare non-sentinel keys are scoped to `--agent` when
@@ -103,8 +106,7 @@ programmatic delivery. Full flag and behavior reference:
103106
to the configured default agent. Literal `global` and `unknown` remain
104107
unscoped only when no `--agent` is supplied; the embedded fallback path
105108
resolves those sentinel sessions to the configured default agent.
106-
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery,
107-
not session routing.
109+
- `--reply-channel` and `--reply-account` affect delivery only.
108110
- Thinking and verbose flags persist into the session store.
109111
- Output: plain text by default, or `--json` for structured payload + metadata.
110112
- With `--json --deliver`, the JSON includes delivery status for sent,

extensions/clickclack/src/channel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export const clickClackPlugin: ChannelPlugin<ResolvedClickClackAccount> = create
120120
agentId,
121121
channel: CHANNEL_ID,
122122
accountId,
123+
recipientSessionExact: parsed.kind === "dm",
123124
peer: {
124125
kind: parsed.chatType === "direct" ? "direct" : "channel",
125126
id: buildClickClackTarget(parsed),
@@ -133,6 +134,7 @@ export const clickClackPlugin: ChannelPlugin<ResolvedClickClackAccount> = create
133134
replyToId,
134135
threadId: threadId ?? (parsed.kind === "thread" ? parsed.id : undefined),
135136
currentSessionKey,
137+
useSuffix: false,
136138
canRecoverCurrentThread: () => true,
137139
});
138140
},

extensions/clickclack/src/inbound.test.ts

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Clickclack tests cover inbound plugin behavior.
22
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
33
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
4+
import { buildAgentSessionKey, resolveAgentRoute } from "openclaw/plugin-sdk/routing";
45
import { describe, expect, it, vi } from "vitest";
56
import { handleClickClackInbound } from "./inbound.js";
67
import { setClickClackRuntime } from "./runtime.js";
@@ -34,28 +35,14 @@ function createRuntime(): PluginRuntime {
3435
},
3536
channel: {
3637
routing: {
37-
resolveAgentRoute({
38-
accountId,
39-
peer,
40-
}: Parameters<PluginRuntime["channel"]["routing"]["resolveAgentRoute"]>[0]) {
41-
return {
42-
agentId: "main",
43-
channel: "clickclack",
44-
accountId: accountId ?? "default",
45-
sessionKey: `agent:main:clickclack:${peer?.kind ?? "channel"}:${peer?.id ?? "general"}`,
46-
mainSessionKey: "agent:main:main",
47-
lastRoutePolicy: "session",
48-
matchedBy: "default",
49-
};
50-
},
51-
buildAgentSessionKey({
52-
agentId,
53-
channel,
54-
accountId,
55-
peer,
56-
}: Parameters<PluginRuntime["channel"]["routing"]["buildAgentSessionKey"]>[0]) {
57-
return `agent:${agentId}:${channel}:${accountId ?? "default"}:${peer?.kind ?? "channel"}:${peer?.id ?? "general"}`;
58-
},
38+
resolveAgentRoute: vi.fn(
39+
(params: Parameters<PluginRuntime["channel"]["routing"]["resolveAgentRoute"]>[0]) =>
40+
resolveAgentRoute(params),
41+
),
42+
buildAgentSessionKey: vi.fn(
43+
(params: Parameters<PluginRuntime["channel"]["routing"]["buildAgentSessionKey"]>[0]) =>
44+
buildAgentSessionKey(params),
45+
),
5946
},
6047
},
6148
llm: {
@@ -332,6 +319,87 @@ describe("handleClickClackInbound", () => {
332319
expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true);
333320
});
334321

322+
it("preserves session policy when an account overrides the routed agent", async () => {
323+
const runtime = createRuntime();
324+
setClickClackRuntime(runtime);
325+
const cfg = {
326+
session: {
327+
dmScope: "per-channel-peer",
328+
mainKey: "work",
329+
identityLinks: { alice: ["clickclack:dm:usr_owner"] },
330+
},
331+
bindings: [
332+
{
333+
agentId: "binding-agent",
334+
match: {
335+
channel: "clickclack",
336+
accountId: "default",
337+
peer: { kind: "direct", id: "dm:usr_owner" },
338+
},
339+
session: { dmScope: "per-account-channel-peer" },
340+
},
341+
],
342+
} satisfies CoreConfig;
343+
344+
await handleClickClackInbound({
345+
account: createAgentAccount({ agentId: "service-bot" }),
346+
config: cfg,
347+
message: createMessage({
348+
channel_id: undefined,
349+
direct_conversation_id: "dcn_1",
350+
}),
351+
});
352+
353+
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
354+
expect(dispatchReply.mock.calls[0]?.[0].routeSessionKey).toBe(
355+
"agent:service-bot:clickclack:direct:alice",
356+
);
357+
expect(runtime.channel.routing.buildAgentSessionKey).toHaveBeenCalledWith({
358+
agentId: "service-bot",
359+
mainKey: "work",
360+
channel: "clickclack",
361+
accountId: "default",
362+
peer: { kind: "direct", id: "dm:usr_owner" },
363+
dmScope: "per-channel-peer",
364+
identityLinks: { alice: ["clickclack:dm:usr_owner"] },
365+
});
366+
});
367+
368+
it("preserves binding scope for a canonically equivalent account agent", async () => {
369+
const runtime = createRuntime();
370+
setClickClackRuntime(runtime);
371+
const cfg = {
372+
agents: { list: [{ id: "service-bot" }] },
373+
session: { dmScope: "main" },
374+
bindings: [
375+
{
376+
agentId: "service-bot",
377+
match: {
378+
channel: "clickclack",
379+
accountId: "default",
380+
peer: { kind: "direct", id: "dm:usr_owner" },
381+
},
382+
session: { dmScope: "per-account-channel-peer" },
383+
},
384+
],
385+
} satisfies CoreConfig;
386+
387+
await handleClickClackInbound({
388+
account: createAgentAccount({ agentId: "SERVICE-BOT" }),
389+
config: cfg,
390+
message: createMessage({
391+
channel_id: undefined,
392+
direct_conversation_id: "dcn_1",
393+
}),
394+
});
395+
396+
const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply);
397+
expect(dispatchReply.mock.calls[0]?.[0]).toMatchObject({
398+
agentId: "service-bot",
399+
routeSessionKey: "agent:service-bot:clickclack:default:direct:dm:usr_owner",
400+
});
401+
});
402+
335403
it("does not dispatch agent turns from senders outside allowFrom", async () => {
336404
const runtime = createRuntime();
337405
vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true);

extensions/clickclack/src/inbound.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* routes resulting outbound text back to ClickClack.
44
*/
55
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
6+
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
67
import { resolveClickClackInboundAccess, type ClickClackInboundAccess } from "./access.js";
78
import { createClickClackActivityPublisher, type ClickClackActivityPublisher } from "./activity.js";
89
import { createClickClackClient } from "./http-client.js";
@@ -34,22 +35,40 @@ function resolveAccountAgentRoute(params: {
3435
id: params.target,
3536
},
3637
});
37-
const agentId = params.account.agentId ?? route.agentId;
38+
const agentId = normalizeAgentId(params.account.agentId ?? route.agentId);
3839
if (agentId === route.agentId) {
3940
return route;
4041
}
42+
const peer = {
43+
kind: params.isDirect ? ("direct" as const) : ("channel" as const),
44+
id: params.target,
45+
};
46+
const dmScope = params.cfg.session?.dmScope ?? "main";
47+
// Account-level agent ownership changes only the agent prefix. Preserve the
48+
// resolved session policy so outbound recipient routing reaches this key.
49+
const sessionKey = runtime.channel.routing.buildAgentSessionKey({
50+
agentId,
51+
mainKey: params.cfg.session?.mainKey,
52+
channel: CHANNEL_ID,
53+
accountId: params.account.accountId,
54+
peer,
55+
dmScope,
56+
identityLinks: params.cfg.session?.identityLinks,
57+
});
58+
const mainSessionKey = runtime.channel.routing.buildAgentSessionKey({
59+
agentId,
60+
mainKey: params.cfg.session?.mainKey,
61+
channel: CHANNEL_ID,
62+
accountId: params.account.accountId,
63+
dmScope: "main",
64+
});
4165
return {
4266
...route,
4367
agentId,
44-
sessionKey: runtime.channel.routing.buildAgentSessionKey({
45-
agentId,
46-
channel: CHANNEL_ID,
47-
accountId: params.account.accountId,
48-
peer: {
49-
kind: params.isDirect ? "direct" : "channel",
50-
id: params.target,
51-
},
52-
}),
68+
dmScope,
69+
sessionKey,
70+
mainSessionKey,
71+
lastRoutePolicy: sessionKey === mainSessionKey ? "main" : "session",
5372
};
5473
}
5574

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { clickClackPlugin } from "./channel.js";
3+
4+
describe("ClickClack outbound session routing", () => {
5+
it("claims exact sessions only for canonical DM user ids", async () => {
6+
const dmRoute = await clickClackPlugin.messaging?.resolveOutboundSessionRoute?.({
7+
cfg: {},
8+
agentId: "main",
9+
target: "dm:usr_1",
10+
});
11+
const channelRoute = await clickClackPlugin.messaging?.resolveOutboundSessionRoute?.({
12+
cfg: {},
13+
agentId: "main",
14+
target: "channel:general",
15+
});
16+
17+
expect(dmRoute?.recipientSessionExact).toBe(true);
18+
expect(channelRoute?.recipientSessionExact).toBe(false);
19+
});
20+
21+
it("keeps threaded DMs on the inbound base session", async () => {
22+
const route = await clickClackPlugin.messaging?.resolveOutboundSessionRoute?.({
23+
cfg: { session: { dmScope: "per-channel-peer" } },
24+
agentId: "main",
25+
target: "dm:usr_1",
26+
threadId: "msg_thread_root",
27+
});
28+
29+
expect(route).toMatchObject({
30+
sessionKey: "agent:main:clickclack:direct:dm:usr_1",
31+
baseSessionKey: "agent:main:clickclack:direct:dm:usr_1",
32+
recipientSessionExact: true,
33+
threadId: "msg_thread_root",
34+
});
35+
});
36+
});

extensions/discord/src/outbound-session-route.test.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,31 @@ describe("resolveDiscordOutboundSessionRoute", () => {
1212
});
1313

1414
expect(route).toEqual({
15-
baseSessionKey: "agent:main:discord:channel:123",
15+
baseSessionKey: "agent:main:discord:channel:thread-1",
1616
chatType: "channel",
17-
from: "discord:channel:123",
18-
peer: { kind: "channel", id: "123" },
19-
sessionKey: "agent:main:discord:channel:123",
17+
from: "discord:channel:thread-1",
18+
peer: { kind: "channel", id: "thread-1" },
19+
recipientSessionExact: false,
20+
sessionKey: "agent:main:discord:channel:thread-1",
2021
threadId: "thread-1",
21-
to: "channel:123",
22+
to: "channel:thread-1",
23+
});
24+
});
25+
26+
it("uses numeric thread channel ids as exact inbound sessions", () => {
27+
const route = resolveDiscordOutboundSessionRoute({
28+
cfg: {},
29+
agentId: "main",
30+
target: "channel:123",
31+
threadId: "456",
32+
});
33+
34+
expect(route).toMatchObject({
35+
baseSessionKey: "agent:main:discord:channel:456",
36+
peer: { kind: "channel", id: "456" },
37+
recipientSessionExact: true,
38+
sessionKey: "agent:main:discord:channel:456",
39+
to: "channel:456",
2240
});
2341
});
2442

@@ -35,6 +53,7 @@ describe("resolveDiscordOutboundSessionRoute", () => {
3553
chatType: "channel",
3654
from: "discord:channel:123",
3755
peer: { kind: "channel", id: "123" },
56+
recipientSessionExact: true,
3857
sessionKey: "agent:main:discord:channel:123",
3958
to: "channel:123",
4059
});
@@ -57,4 +76,14 @@ describe("resolveDiscordOutboundSessionRoute", () => {
5776
to: "channel:123",
5877
});
5978
});
79+
80+
it("does not claim channel names as canonical recipient sessions", () => {
81+
const route = resolveDiscordOutboundSessionRoute({
82+
cfg: {},
83+
agentId: "main",
84+
target: "channel:general",
85+
});
86+
87+
expect(route?.recipientSessionExact).toBe(false);
88+
});
6089
});

extensions/discord/src/outbound-session-route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ export function resolveDiscordOutboundSessionRoute(
2323
if (!parsed) {
2424
return null;
2525
}
26-
const isDm = parsed.kind === "user";
26+
const explicitThreadId = params.threadId == null ? undefined : String(params.threadId).trim();
27+
const peerId = explicitThreadId || parsed.id;
28+
const isDm = parsed.kind === "user" && !explicitThreadId;
29+
const recipientSessionExact = /^\d+$/.test(peerId);
2730
const peer: RoutePeer = {
2831
kind: isDm ? "direct" : "channel",
29-
id: parsed.id,
32+
id: peerId,
3033
};
3134
const baseSessionKey = buildOutboundBaseSessionKey({
3235
cfg: params.cfg,
@@ -39,10 +42,11 @@ export function resolveDiscordOutboundSessionRoute(
3942
route: {
4043
sessionKey: baseSessionKey,
4144
baseSessionKey,
45+
recipientSessionExact,
4246
peer,
4347
chatType: isDm ? ("direct" as const) : ("channel" as const),
44-
from: isDm ? `discord:${parsed.id}` : `discord:channel:${parsed.id}`,
45-
to: isDm ? `user:${parsed.id}` : `channel:${parsed.id}`,
48+
from: isDm ? `discord:${peerId}` : `discord:channel:${peerId}`,
49+
to: isDm ? `user:${peerId}` : `channel:${peerId}`,
4650
},
4751
threadId: params.threadId,
4852
precedence: ["threadId"],

0 commit comments

Comments
 (0)