Skip to content

Commit d1cd74b

Browse files
committed
fix(channels): scope dm last-route updates
1 parent 7d6e45e commit d1cd74b

15 files changed

Lines changed: 428 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
6262
- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.
6363
- Gateway/protocol: restore Gateway WS protocol v4 and keep `message.action` room-event metadata on the existing `inboundTurnKind` wire field while preserving internal inbound-event classification.
6464
- Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant.
65+
- Channels: keep direct-message last-route writes on isolated `per-channel-peer` sessions instead of contaminating the agent main session with channel delivery context. Fixes #36614. Thanks @aspenas.
6566
- Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width.
6667
- Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content.
6768
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.

extensions/discord/src/monitor/agent-components.dispatch.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export async function dispatchDiscordComponentEvent(params: {
111111
const sessionKey = params.routeOverrides?.sessionKey ?? route.sessionKey;
112112
const agentId = params.routeOverrides?.agentId ?? route.agentId;
113113
const accountId = params.routeOverrides?.accountId ?? route.accountId;
114+
const inboundLastRouteSessionKey = sessionKey;
114115
const fromLabel = buildDiscordComponentConversationLabel({
115116
interactionCtx,
116117
interaction,
@@ -286,23 +287,24 @@ export async function dispatchDiscordComponentEvent(params: {
286287
record: {
287288
updateLastRoute: interactionCtx.isDirectMessage
288289
? {
289-
sessionKey: route.mainSessionKey,
290+
sessionKey: inboundLastRouteSessionKey,
290291
channel: "discord",
291292
to:
292293
resolveDiscordComponentOriginatingTo(interactionCtx) ??
293294
`user:${interactionCtx.userId}`,
294295
accountId,
295-
mainDmOwnerPin: pinnedMainDmOwner
296-
? {
297-
ownerRecipient: pinnedMainDmOwner,
298-
senderRecipient: interactionCtx.userId,
299-
onSkip: ({ ownerRecipient, senderRecipient }) => {
300-
logVerbose(
301-
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
302-
);
303-
},
304-
}
305-
: undefined,
296+
mainDmOwnerPin:
297+
inboundLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner
298+
? {
299+
ownerRecipient: pinnedMainDmOwner,
300+
senderRecipient: interactionCtx.userId,
301+
onSkip: ({ ownerRecipient, senderRecipient }) => {
302+
logVerbose(
303+
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
304+
);
305+
},
306+
}
307+
: undefined,
306308
}
307309
: undefined,
308310
onRecordError: (err) => {

extensions/discord/src/monitor/monitor.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,19 @@ describe("discord component interactions", () => {
366366
expect(lastDispatchCtx?.To).toBe("channel:dm-channel");
367367
expect(getLastRecordedCtx()?.OriginatingTo).toBe("user:123456789");
368368
expect(getLastRecordedCtx()?.To).toBe("channel:dm-channel");
369+
const recordParams = mockCallArg(recordInboundSessionMock, -1, "recordInboundSession") as {
370+
updateLastRoute?: {
371+
channel?: string;
372+
mainDmOwnerPin?: unknown;
373+
sessionKey?: string;
374+
to?: string;
375+
};
376+
};
377+
expect(recordParams.updateLastRoute?.sessionKey).toBe("session-1");
378+
expect(recordParams.updateLastRoute?.sessionKey).not.toBe("agent:agent-1:main");
379+
expect(recordParams.updateLastRoute?.channel).toBe("discord");
380+
expect(recordParams.updateLastRoute?.to).toBe("user:123456789");
381+
expect(recordParams.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
369382
});
370383

371384
it("uses raw callbackData for built-in fallback when no plugin handler matches", async () => {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
2+
import type { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import type { createIMessageRpcClient } from "./client.js";
5+
import { monitorIMessageProvider } from "./monitor.js";
6+
7+
const waitForTransportReadyMock = vi.hoisted(() =>
8+
vi.fn<typeof waitForTransportReady>(async () => {}),
9+
);
10+
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn<typeof createIMessageRpcClient>());
11+
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn(async () => [] as string[]));
12+
const recordInboundSessionMock = vi.hoisted(() => vi.fn(async (_params: unknown) => {}));
13+
const dispatchInboundMessageMock = vi.hoisted(() =>
14+
vi.fn(
15+
async (_params: { ctx: MsgContext }) =>
16+
({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }) as const,
17+
),
18+
);
19+
20+
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
21+
waitForTransportReady: waitForTransportReadyMock,
22+
}));
23+
24+
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
25+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
26+
return {
27+
...actual,
28+
readChannelAllowFromStore: readChannelAllowFromStoreMock,
29+
recordInboundSession: recordInboundSessionMock,
30+
upsertChannelPairingRequest: vi.fn(),
31+
};
32+
});
33+
34+
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
35+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
36+
return {
37+
...actual,
38+
createChannelInboundDebouncer: vi.fn((opts) => ({
39+
debouncer: {
40+
enqueue: async (entry: unknown) => await opts.onFlush([entry]),
41+
},
42+
})),
43+
shouldDebounceTextInbound: vi.fn(() => false),
44+
};
45+
});
46+
47+
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
48+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
49+
return {
50+
...actual,
51+
dispatchInboundMessage: dispatchInboundMessageMock,
52+
};
53+
});
54+
55+
vi.mock("./client.js", () => ({
56+
createIMessageRpcClient: createIMessageRpcClientMock,
57+
}));
58+
59+
vi.mock("./monitor/abort-handler.js", () => ({
60+
attachIMessageMonitorAbortHandler: vi.fn(() => () => {}),
61+
}));
62+
63+
describe("iMessage monitor last-route updates", () => {
64+
beforeEach(() => {
65+
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
66+
createIMessageRpcClientMock.mockReset();
67+
readChannelAllowFromStoreMock.mockReset().mockResolvedValue([]);
68+
recordInboundSessionMock.mockClear();
69+
dispatchInboundMessageMock.mockClear();
70+
});
71+
72+
it("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
73+
const runtimeErrorMock = vi.fn();
74+
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
75+
const client = {
76+
request: vi.fn(async () => ({ subscription: 1 })),
77+
waitForClose: vi.fn(async () => {
78+
onNotification?.({
79+
method: "message",
80+
params: {
81+
message: {
82+
id: 1,
83+
chat_id: 123,
84+
sender: "+15550001111",
85+
is_from_me: false,
86+
text: "hello from imessage",
87+
is_group: false,
88+
date: 1_714_000_000_000,
89+
},
90+
},
91+
});
92+
await Promise.resolve();
93+
await Promise.resolve();
94+
}),
95+
stop: vi.fn(async () => {}),
96+
};
97+
createIMessageRpcClientMock.mockImplementation(async (params) => {
98+
if (!params?.onNotification) {
99+
throw new Error("expected iMessage notification handler");
100+
}
101+
onNotification = params.onNotification;
102+
return client as never;
103+
});
104+
105+
await monitorIMessageProvider({
106+
config: {
107+
channels: { imessage: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
108+
messages: { inbound: { debounceMs: 0 } },
109+
session: { dmScope: "per-channel-peer", mainKey: "main" },
110+
} as never,
111+
runtime: { error: runtimeErrorMock, exit: vi.fn(), log: vi.fn() },
112+
});
113+
114+
await vi.waitFor(() => {
115+
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(1);
116+
});
117+
expect(runtimeErrorMock).not.toHaveBeenCalled();
118+
await vi.waitFor(() => {
119+
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
120+
});
121+
const recordParams = recordInboundSessionMock.mock.calls.at(0)?.[0] as
122+
| {
123+
sessionKey?: string;
124+
updateLastRoute?: {
125+
channel?: string;
126+
mainDmOwnerPin?: unknown;
127+
sessionKey?: string;
128+
to?: string;
129+
};
130+
}
131+
| undefined;
132+
expect(recordParams?.sessionKey).toBe("agent:main:imessage:direct:+15550001111");
133+
expect(recordParams?.updateLastRoute?.sessionKey).toBe(recordParams?.sessionKey);
134+
expect(recordParams?.updateLastRoute?.sessionKey).not.toBe("agent:main:main");
135+
expect(recordParams?.updateLastRoute?.channel).toBe("imessage");
136+
expect(recordParams?.updateLastRoute?.to).toBe("+15550001111");
137+
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
138+
});
139+
});

extensions/imessage/src/monitor/monitor-provider.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
2323
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
2424
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
2525
import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
26+
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
2627
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
2728
import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
2829
import {
@@ -675,6 +676,10 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
675676
runtime.error?.(danger(`imessage ${info.kind} reply failed: ${String(err)}`));
676677
},
677678
});
679+
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
680+
route: decision.route,
681+
sessionKey: decision.route.sessionKey,
682+
});
678683

679684
await runInboundReplyTurn({
680685
channel: "imessage",
@@ -700,12 +705,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
700705
updateLastRoute:
701706
!decision.isGroup && updateTarget
702707
? {
703-
sessionKey: decision.route.mainSessionKey,
708+
sessionKey: inboundLastRouteSessionKey,
704709
channel: "imessage",
705710
to: updateTarget,
706711
accountId: decision.route.accountId,
707712
mainDmOwnerPin:
708-
pinnedMainDmOwner && decision.senderNormalized
713+
inboundLastRouteSessionKey === decision.route.mainSessionKey &&
714+
pinnedMainDmOwner &&
715+
decision.senderNormalized
709716
? {
710717
ownerRecipient: pinnedMainDmOwner,
711718
senderRecipient: decision.senderNormalized,

extensions/line/src/bot-message-context.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,32 @@ describe("buildLineMessageContext", () => {
233233
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
234234
});
235235

236+
it("keeps per-channel-peer direct-message last-route writes on the isolated session", async () => {
237+
const event = createMessageEvent({ type: "user", userId: "user-1" });
238+
const directCfg: OpenClawConfig = {
239+
session: { store: storePath, dmScope: "per-channel-peer" },
240+
};
241+
242+
const context = await buildLineMessageContext({
243+
event,
244+
allMedia: [],
245+
cfg: directCfg,
246+
account: {
247+
...account,
248+
config: { allowFrom: ["user-1"] },
249+
},
250+
commandAuthorized: true,
251+
});
252+
253+
expect(context?.route.sessionKey).toBe("agent:main:line:direct:user-1");
254+
const updateLastRoute = context?.turn.record.updateLastRoute;
255+
expect(updateLastRoute?.sessionKey).toBe(context?.route.sessionKey);
256+
expect(updateLastRoute?.sessionKey).not.toBe("agent:main:main");
257+
expect(updateLastRoute?.channel).toBe("line");
258+
expect(updateLastRoute?.to).toBe("user-1");
259+
expect(updateLastRoute?.mainDmOwnerPin).toBeUndefined();
260+
});
261+
236262
it("sets CommandAuthorized on postback context", async () => {
237263
const event = createPostbackEvent({ type: "user", userId: "user-pb" });
238264

extensions/line/src/bot-message-context.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from "openclaw/plugin-sdk/conversation-runtime";
1616
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
1717
import { createChannelHistoryWindow, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
18-
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
18+
import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
1919
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
2020
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
2121
import { normalizeAllowFrom } from "./bot-access.js";
@@ -377,6 +377,10 @@ async function finalizeLineInboundContext(params: {
377377
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
378378
})
379379
: null;
380+
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
381+
route: params.route,
382+
sessionKey: params.route.sessionKey,
383+
});
380384
if (shouldLogVerbose()) {
381385
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
382386
const mediaInfo =
@@ -397,12 +401,14 @@ async function finalizeLineInboundContext(params: {
397401
record: {
398402
updateLastRoute: !params.source.isGroup
399403
? {
400-
sessionKey: params.route.mainSessionKey,
404+
sessionKey: inboundLastRouteSessionKey,
401405
channel: "line",
402406
to: params.source.userId ?? params.source.peerId,
403407
accountId: params.route.accountId,
404408
mainDmOwnerPin:
405-
pinnedMainDmOwner && params.source.userId
409+
inboundLastRouteSessionKey === params.route.mainSessionKey &&
410+
pinnedMainDmOwner &&
411+
params.source.userId
406412
? {
407413
ownerRecipient: pinnedMainDmOwner,
408414
senderRecipient: params.source.userId,

extensions/matrix/src/matrix/monitor/handler.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
getReplyPayloadTtsSupplement,
3131
} from "openclaw/plugin-sdk/reply-payload";
3232
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
33+
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
3334
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
3435
import {
3536
loadSessionStore,
@@ -2052,6 +2053,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
20522053
})()
20532054
: null;
20542055

2056+
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
2057+
route: _route,
2058+
sessionKey: _route.sessionKey,
2059+
});
2060+
20552061
const turnResult = await core.channel.turn.run({
20562062
channel: "matrix",
20572063
accountId: _route.accountId,
@@ -2075,27 +2081,28 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
20752081
record: {
20762082
updateLastRoute: isDirectMessage
20772083
? {
2078-
sessionKey: _route.mainSessionKey,
2084+
sessionKey: inboundLastRouteSessionKey,
20792085
channel: "matrix",
20802086
to: `room:${roomId}`,
20812087
accountId: _route.accountId,
2082-
mainDmOwnerPin: pinnedMainDmOwner
2083-
? {
2084-
ownerRecipient: pinnedMainDmOwner,
2085-
senderRecipient: normalizeMatrixUserId(senderId),
2086-
onSkip: ({
2087-
ownerRecipient,
2088-
senderRecipient,
2089-
}: {
2090-
ownerRecipient: string;
2091-
senderRecipient: string;
2092-
}) => {
2093-
logVerboseMessage(
2094-
`matrix: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
2095-
);
2096-
},
2097-
}
2098-
: undefined,
2088+
mainDmOwnerPin:
2089+
inboundLastRouteSessionKey === _route.mainSessionKey && pinnedMainDmOwner
2090+
? {
2091+
ownerRecipient: pinnedMainDmOwner,
2092+
senderRecipient: normalizeMatrixUserId(senderId),
2093+
onSkip: ({
2094+
ownerRecipient,
2095+
senderRecipient,
2096+
}: {
2097+
ownerRecipient: string;
2098+
senderRecipient: string;
2099+
}) => {
2100+
logVerboseMessage(
2101+
`matrix: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
2102+
);
2103+
},
2104+
}
2105+
: undefined,
20992106
}
21002107
: undefined,
21012108
onRecordError: (err) => {

0 commit comments

Comments
 (0)