Skip to content

Commit 6a8081a

Browse files
committed
refactor(routing): centralize inbound last-route policy
1 parent b2f8f5e commit 6a8081a

File tree

10 files changed

+172
-9
lines changed

10 files changed

+172
-9
lines changed

src/acp/persistent-bindings.route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig } from "../config/config.js";
22
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
3+
import { deriveLastRoutePolicy } from "../routing/resolve-route.js";
34
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
45
import {
56
ensureConfiguredAcpBindingSession,
@@ -50,6 +51,10 @@ export function resolveConfiguredAcpRoute(params: {
5051
...params.route,
5152
sessionKey: boundSessionKey,
5253
agentId: boundAgentId,
54+
lastRoutePolicy: deriveLastRoutePolicy({
55+
sessionKey: boundSessionKey,
56+
mainSessionKey: params.route.mainSessionKey,
57+
}),
5358
matchedBy: "binding.channel",
5459
},
5560
};

src/discord/monitor/route-resolution.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe("discord route resolution helpers", () => {
3030
accountId: "default",
3131
sessionKey: "agent:main:discord:channel:c1",
3232
mainSessionKey: "agent:main:main",
33+
lastRoutePolicy: "session",
3334
matchedBy: "default",
3435
};
3536

@@ -54,6 +55,7 @@ describe("discord route resolution helpers", () => {
5455
accountId: "default",
5556
sessionKey: "agent:main:discord:channel:c1",
5657
mainSessionKey: "agent:main:main",
58+
lastRoutePolicy: "session",
5759
matchedBy: "default",
5860
};
5961
const configuredRoute = {
@@ -62,6 +64,7 @@ describe("discord route resolution helpers", () => {
6264
agentId: "worker",
6365
sessionKey: "agent:worker:discord:channel:c1",
6466
mainSessionKey: "agent:worker:main",
67+
lastRoutePolicy: "session" as const,
6568
matchedBy: "binding.peer" as const,
6669
},
6770
};

src/discord/monitor/route-resolution.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig } from "../../config/config.js";
22
import {
3+
deriveLastRoutePolicy,
34
resolveAgentRoute,
45
type ResolvedAgentRoute,
56
type RoutePeer,
@@ -90,6 +91,10 @@ export function resolveDiscordEffectiveRoute(params: {
9091
...params.route,
9192
sessionKey: boundSessionKey,
9293
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
94+
lastRoutePolicy: deriveLastRoutePolicy({
95+
sessionKey: boundSessionKey,
96+
mainSessionKey: params.route.mainSessionKey,
97+
}),
9398
...(params.matchedBy ? { matchedBy: params.matchedBy } : {}),
9499
};
95100
}

src/routing/resolve-route.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { describe, expect, test, vi } from "vitest";
22
import type { ChatType } from "../channels/chat-type.js";
33
import type { OpenClawConfig } from "../config/config.js";
44
import * as routingBindings from "./bindings.js";
5-
import { resolveAgentRoute } from "./resolve-route.js";
5+
import {
6+
deriveLastRoutePolicy,
7+
resolveAgentRoute,
8+
resolveInboundLastRouteSessionKey,
9+
} from "./resolve-route.js";
610

711
describe("resolveAgentRoute", () => {
812
const resolveDiscordGuildRoute = (cfg: OpenClawConfig) =>
@@ -25,6 +29,7 @@ describe("resolveAgentRoute", () => {
2529
expect(route.agentId).toBe("main");
2630
expect(route.accountId).toBe("default");
2731
expect(route.sessionKey).toBe("agent:main:main");
32+
expect(route.lastRoutePolicy).toBe("main");
2833
expect(route.matchedBy).toBe("default");
2934
});
3035

@@ -47,9 +52,47 @@ describe("resolveAgentRoute", () => {
4752
peer: { kind: "direct", id: "+15551234567" },
4853
});
4954
expect(route.sessionKey).toBe(testCase.expected);
55+
expect(route.lastRoutePolicy).toBe("session");
5056
}
5157
});
5258

59+
test("resolveInboundLastRouteSessionKey follows route policy", () => {
60+
expect(
61+
resolveInboundLastRouteSessionKey({
62+
route: {
63+
mainSessionKey: "agent:main:main",
64+
lastRoutePolicy: "main",
65+
},
66+
sessionKey: "agent:main:discord:direct:user-1",
67+
}),
68+
).toBe("agent:main:main");
69+
70+
expect(
71+
resolveInboundLastRouteSessionKey({
72+
route: {
73+
mainSessionKey: "agent:main:main",
74+
lastRoutePolicy: "session",
75+
},
76+
sessionKey: "agent:main:telegram:atlas:direct:123",
77+
}),
78+
).toBe("agent:main:telegram:atlas:direct:123");
79+
});
80+
81+
test("deriveLastRoutePolicy collapses only main-session routes", () => {
82+
expect(
83+
deriveLastRoutePolicy({
84+
sessionKey: "agent:main:main",
85+
mainSessionKey: "agent:main:main",
86+
}),
87+
).toBe("main");
88+
expect(
89+
deriveLastRoutePolicy({
90+
sessionKey: "agent:main:telegram:direct:123",
91+
mainSessionKey: "agent:main:main",
92+
}),
93+
).toBe("session");
94+
});
95+
5396
test("identityLinks applies to direct-message scopes", () => {
5497
const cases = [
5598
{

src/routing/resolve-route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export type ResolvedAgentRoute = {
4444
sessionKey: string;
4545
/** Convenience alias for direct-chat collapse. */
4646
mainSessionKey: string;
47+
/** Which session should receive inbound last-route updates. */
48+
lastRoutePolicy: "main" | "session";
4749
/** Match description for debugging/logging. */
4850
matchedBy:
4951
| "binding.peer"
@@ -58,6 +60,20 @@ export type ResolvedAgentRoute = {
5860

5961
export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js";
6062

63+
export function deriveLastRoutePolicy(params: {
64+
sessionKey: string;
65+
mainSessionKey: string;
66+
}): ResolvedAgentRoute["lastRoutePolicy"] {
67+
return params.sessionKey === params.mainSessionKey ? "main" : "session";
68+
}
69+
70+
export function resolveInboundLastRouteSessionKey(params: {
71+
route: Pick<ResolvedAgentRoute, "lastRoutePolicy" | "mainSessionKey">;
72+
sessionKey: string;
73+
}): string {
74+
return params.route.lastRoutePolicy === "main" ? params.route.mainSessionKey : params.sessionKey;
75+
}
76+
6177
function normalizeToken(value: string | undefined | null): string {
6278
return (value ?? "").trim().toLowerCase();
6379
}
@@ -662,6 +678,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
662678
accountId,
663679
sessionKey,
664680
mainSessionKey,
681+
lastRoutePolicy: deriveLastRoutePolicy({ sessionKey, mainSessionKey }),
665682
matchedBy,
666683
};
667684
if (routeCache && routeCacheKey) {

src/telegram/bot-message-context.named-account-dm.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { afterEach, describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
33
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
44

5+
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
6+
vi.mock("../channels/session.js", () => ({
7+
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
8+
}));
9+
510
describe("buildTelegramMessageContext named-account DM fallback", () => {
611
const baseCfg = {
712
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
@@ -11,8 +16,16 @@ describe("buildTelegramMessageContext named-account DM fallback", () => {
1116

1217
afterEach(() => {
1318
clearRuntimeConfigSnapshot();
19+
recordInboundSessionMock.mockClear();
1420
});
1521

22+
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
23+
const callArgs = recordInboundSessionMock.mock.calls.at(-1)?.[0] as {
24+
updateLastRoute?: { sessionKey?: string };
25+
};
26+
return callArgs?.updateLastRoute;
27+
}
28+
1629
it("allows DM through for a named account with no explicit binding", async () => {
1730
setRuntimeConfigSnapshot(baseCfg);
1831

@@ -51,6 +64,25 @@ describe("buildTelegramMessageContext named-account DM fallback", () => {
5164
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
5265
});
5366

67+
it("keeps named-account fallback lastRoute on the isolated DM session", async () => {
68+
setRuntimeConfigSnapshot(baseCfg);
69+
70+
const ctx = await buildTelegramMessageContextForTest({
71+
cfg: baseCfg,
72+
accountId: "atlas",
73+
message: {
74+
message_id: 1,
75+
chat: { id: 814912386, type: "private" },
76+
date: 1700000000,
77+
text: "hello",
78+
from: { id: 814912386, first_name: "Alice" },
79+
},
80+
});
81+
82+
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
83+
expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
84+
});
85+
5486
it("isolates sessions between named accounts that share the default agent", async () => {
5587
setRuntimeConfigSnapshot(baseCfg);
5688

src/telegram/bot-message-context.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ import type {
3939
} from "../config/types.js";
4040
import { logVerbose, shouldLogVerbose } from "../globals.js";
4141
import { recordChannelActivity } from "../infra/channel-activity.js";
42-
import { buildAgentSessionKey } from "../routing/resolve-route.js";
42+
import {
43+
buildAgentSessionKey,
44+
deriveLastRoutePolicy,
45+
resolveInboundLastRouteSessionKey,
46+
} from "../routing/resolve-route.js";
4347
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js";
4448
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
4549
import { withTelegramApiErrorLogging } from "./api-logging.js";
@@ -362,6 +366,14 @@ export const buildTelegramMessageContext = async ({
362366
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
363367
: null;
364368
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
369+
route = {
370+
...route,
371+
sessionKey,
372+
lastRoutePolicy: deriveLastRoutePolicy({
373+
sessionKey,
374+
mainSessionKey: route.mainSessionKey,
375+
}),
376+
};
365377
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
366378
// Compute requireMention after access checks and final route selection.
367379
const activationOverride = resolveGroupActivation({
@@ -832,21 +844,25 @@ export const buildTelegramMessageContext = async ({
832844
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
833845
})
834846
: null;
847+
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({
848+
route,
849+
sessionKey,
850+
});
835851

836852
await recordInboundSession({
837853
storePath,
838854
sessionKey: ctxPayload.SessionKey ?? sessionKey,
839855
ctx: ctxPayload,
840856
updateLastRoute: !isGroup
841857
? {
842-
sessionKey: route.mainSessionKey,
858+
sessionKey: updateLastRouteSessionKey,
843859
channel: "telegram",
844860
to: `telegram:${chatId}`,
845861
accountId: route.accountId,
846862
// Preserve DM topic threadId for replies (fixes #8891)
847863
threadId: dmThreadId != null ? String(dmThreadId) : undefined,
848864
mainDmOwnerPin:
849-
pinnedMainDmOwner && senderId
865+
updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId
850866
? {
851867
ownerRecipient: pinnedMainDmOwner,
852868
senderRecipient: senderId,

src/telegram/conversation-route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { logVerbose } from "../globals.js";
44
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
55
import {
66
buildAgentSessionKey,
7+
deriveLastRoutePolicy,
78
pickFirstExistingAgentId,
89
resolveAgentRoute,
910
} from "../routing/resolve-route.js";
@@ -67,6 +68,19 @@ export function resolveTelegramConversationRoute(params: {
6768
mainSessionKey: buildAgentMainSessionKey({
6869
agentId: topicAgentId,
6970
}).toLowerCase(),
71+
lastRoutePolicy: deriveLastRoutePolicy({
72+
sessionKey: buildAgentSessionKey({
73+
agentId: topicAgentId,
74+
channel: "telegram",
75+
accountId: params.accountId,
76+
peer: { kind: params.isGroup ? "group" : "direct", id: peerId },
77+
dmScope: params.cfg.session?.dmScope,
78+
identityLinks: params.cfg.session?.identityLinks,
79+
}).toLowerCase(),
80+
mainSessionKey: buildAgentMainSessionKey({
81+
agentId: topicAgentId,
82+
}).toLowerCase(),
83+
}),
7084
};
7185
logVerbose(
7286
`telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`,
@@ -103,6 +117,10 @@ export function resolveTelegramConversationRoute(params: {
103117
...route,
104118
sessionKey: boundSessionKey,
105119
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
120+
lastRoutePolicy: deriveLastRoutePolicy({
121+
sessionKey: boundSessionKey,
122+
mainSessionKey: route.mainSessionKey,
123+
}),
106124
matchedBy: "binding.channel",
107125
};
108126
configuredBinding = null;

src/web/auto-reply/monitor/broadcast.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { loadConfig } from "../../../config/config.js";
22
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
3-
import { buildAgentSessionKey } from "../../../routing/resolve-route.js";
3+
import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../routing/resolve-route.js";
44
import {
55
buildAgentMainSessionKey,
66
DEFAULT_MAIN_KEY,
@@ -70,6 +70,23 @@ export async function maybeBroadcastMessage(params: {
7070
agentId: normalizedAgentId,
7171
mainKey: DEFAULT_MAIN_KEY,
7272
}),
73+
lastRoutePolicy: deriveLastRoutePolicy({
74+
sessionKey: buildAgentSessionKey({
75+
agentId: normalizedAgentId,
76+
channel: "whatsapp",
77+
accountId: params.route.accountId,
78+
peer: {
79+
kind: params.msg.chatType === "group" ? "group" : "direct",
80+
id: params.peerId,
81+
},
82+
dmScope: params.cfg.session?.dmScope,
83+
identityLinks: params.cfg.session?.identityLinks,
84+
}),
85+
mainSessionKey: buildAgentMainSessionKey({
86+
agentId: normalizedAgentId,
87+
mainKey: DEFAULT_MAIN_KEY,
88+
}),
89+
}),
7390
};
7491

7592
try {

src/web/auto-reply/monitor/process-message.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import { recordSessionMetaFromInbound } from "../../../config/sessions.js";
1919
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
2020
import type { getChildLogger } from "../../../logging.js";
2121
import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
22-
import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
22+
import {
23+
resolveInboundLastRouteSessionKey,
24+
type resolveAgentRoute,
25+
} from "../../../routing/resolve-route.js";
2326
import {
2427
readStoreAllowFromForDmPolicy,
2528
resolvePinnedMainDmOwnerFromAllowlist,
@@ -339,9 +342,13 @@ export async function processMessage(params: {
339342
});
340343
const shouldUpdateMainLastRoute =
341344
!pinnedMainDmRecipient || pinnedMainDmRecipient === dmRouteTarget;
345+
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
346+
route: params.route,
347+
sessionKey: params.route.sessionKey,
348+
});
342349
if (
343350
dmRouteTarget &&
344-
params.route.sessionKey === params.route.mainSessionKey &&
351+
inboundLastRouteSessionKey === params.route.mainSessionKey &&
345352
shouldUpdateMainLastRoute
346353
) {
347354
updateLastRouteInBackground({
@@ -357,7 +364,7 @@ export async function processMessage(params: {
357364
});
358365
} else if (
359366
dmRouteTarget &&
360-
params.route.sessionKey === params.route.mainSessionKey &&
367+
inboundLastRouteSessionKey === params.route.mainSessionKey &&
361368
pinnedMainDmRecipient
362369
) {
363370
logVerbose(

0 commit comments

Comments
 (0)