From f88da75ed925bc4177f893684dab4211a48201f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 23:16:44 +0100 Subject: [PATCH] refactor(channels): centralize runtime binding routes --- .../bluebubbles/src/conversation-route.ts | 47 +++----- .../server.agent-contract.test-harness.ts | 41 ++++++- .../src/monitor/message-handler.preflight.ts | 25 ++-- extensions/feishu/src/bot.test.ts | 26 +++- extensions/feishu/src/bot.ts | 36 +++--- .../feishu/src/lifecycle.test-support.ts | 34 +++++- extensions/imessage/src/conversation-route.ts | 47 +++----- extensions/line/src/bot-message-context.ts | 40 +++--- .../bot-native-commands.session-meta.test.ts | 30 +++++ .../src/bot-native-commands.test-helpers.ts | 4 + extensions/telegram/src/conversation-route.ts | 38 ++---- src/channels/plugins/binding-routing.test.ts | 114 ++++++++++++++++++ ...vitest.extension-provider-openai.config.ts | 17 ++- 13 files changed, 351 insertions(+), 148 deletions(-) create mode 100644 src/channels/plugins/binding-routing.test.ts diff --git a/extensions/bluebubbles/src/conversation-route.ts b/extensions/bluebubbles/src/conversation-route.ts index 830c6da23ce..41bd58dc45c 100644 --- a/extensions/bluebubbles/src/conversation-route.ts +++ b/extensions/bluebubbles/src/conversation-route.ts @@ -1,14 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { - getSessionBindingService, - isPluginOwnedSessionBindingRecord, resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; -import { - deriveLastRoutePolicy, - resolveAgentIdFromSessionKey, - resolveAgentRoute, -} from "openclaw/plugin-sdk/routing"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveBlueBubblesInboundConversationId } from "./conversation-id.js"; @@ -53,31 +48,21 @@ export function resolveBlueBubblesConversationRoute(params: { }, }).route; - const runtimeBinding = getSessionBindingService().resolveByConversation({ - channel: "bluebubbles", - accountId: params.accountId, - conversationId, + const runtimeRoute = resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "bluebubbles", + accountId: params.accountId, + conversationId, + }, }); - const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); - if (!runtimeBinding || !boundSessionKey) { - return route; - } - - getSessionBindingService().touch(runtimeBinding.bindingId); - if (isPluginOwnedSessionBindingRecord(runtimeBinding)) { + route = runtimeRoute.route; + if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) { logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`); - return route; + } else if (runtimeRoute.boundSessionKey) { + logVerbose( + `bluebubbles: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`, + ); } - - logVerbose(`bluebubbles: routed via bound conversation ${conversationId} -> ${boundSessionKey}`); - return { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey), - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; + return route; } diff --git a/extensions/browser/src/browser/server.agent-contract.test-harness.ts b/extensions/browser/src/browser/server.agent-contract.test-harness.ts index ea73714075f..898f16d67a8 100644 --- a/extensions/browser/src/browser/server.agent-contract.test-harness.ts +++ b/extensions/browser/src/browser/server.agent-contract.test-harness.ts @@ -9,11 +9,50 @@ export function installAgentContractHooks() { installBrowserControlServerHooks(); } +function isTransientStartupFetchError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const record = error as { code?: unknown; cause?: unknown }; + if (record.code === "ECONNRESET" || record.code === "ECONNREFUSED") { + return true; + } + return isTransientStartupFetchError(record.cause); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function postStartWithRetry(params: { + fetch: ReturnType; + url: string; +}): Promise { + const delaysMs = [0, 25, 50, 100, 200] as const; + let lastError: unknown; + for (const delayMs of delaysMs) { + if (delayMs > 0) { + await sleep(delayMs); + } + try { + const response = await params.fetch(params.url, { method: "POST" }); + await response.json(); + return; + } catch (error) { + lastError = error; + if (!isTransientStartupFetchError(error)) { + throw error; + } + } + } + throw lastError; +} + export async function startServerAndBase(): Promise { await startBrowserControlServerFromConfig(); const base = getBrowserControlServerBaseUrl(); const realFetch = getBrowserTestFetch(); - await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + await postStartWithRetry({ fetch: realFetch, url: `${base}/start` }); return base; } diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index bf6a6115b43..a94b281ba71 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -629,13 +629,16 @@ export async function preflightDiscordMessage( }) ?? `user:${author.id}`) : messageChannelId; let threadBinding: SessionBindingRecord | undefined; - threadBinding = - conversationRuntime.getSessionBindingService().resolveByConversation({ + const runtimeRoute = conversationRuntime.resolveRuntimeConversationBindingRoute({ + route, + conversation: { channel: "discord", accountId: params.accountId, conversationId: bindingConversationId, parentConversationId: earlyThreadParentId, - }) ?? undefined; + }, + }); + threadBinding = runtimeRoute.bindingRecord ?? undefined; const configuredRoute = threadBinding == null ? conversationRuntime.resolveConfiguredBindingRoute({ @@ -666,13 +669,15 @@ export async function preflightDiscordMessage( } const boundSessionKey = conversationRuntime.isPluginOwnedSessionBindingRecord(threadBinding) ? "" - : threadBinding?.targetSessionKey?.trim(); - const effectiveRoute = resolveDiscordEffectiveRoute({ - route, - boundSessionKey, - configuredRoute, - matchedBy: "binding.channel", - }); + : (runtimeRoute.boundSessionKey ?? threadBinding?.targetSessionKey?.trim()); + const effectiveRoute = runtimeRoute.boundSessionKey + ? runtimeRoute.route + : resolveDiscordEffectiveRoute({ + route, + boundSessionKey, + configuredRoute, + matchedBy: "binding.channel", + }); const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined; const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel); const bypassMentionRequirement = isBoundThreadSession; diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index a7d718e3534..34816ca9d52 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -262,7 +262,7 @@ const { mockEnsureConfiguredBindingRouteReady: vi.fn( async (_params?: unknown): Promise => ({ ok: true }), ), - mockResolveBoundConversation: vi.fn(() => null as BoundConversation), + mockResolveBoundConversation: vi.fn((_ref?: unknown) => null as BoundConversation), mockTouchBinding: vi.fn(), mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false), })); @@ -297,6 +297,30 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { ...actual, resolveConfiguredBindingRoute: (params: unknown) => mockResolveConfiguredBindingRoute(params as { route: ResolvedAgentRoute }), + resolveRuntimeConversationBindingRoute: (params: { + route: ResolvedAgentRoute; + conversation: Parameters< + ReturnType["resolveByConversation"] + >[0]; + }) => { + const bindingRecord = mockResolveBoundConversation(params.conversation); + const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); + if (!bindingRecord || !boundSessionKey) { + return { bindingRecord: null, route: params.route }; + } + mockTouchBinding(bindingRecord.bindingId); + return { + bindingRecord, + boundSessionKey, + boundAgentId: params.route.agentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", + matchedBy: "binding.channel", + }, + }; + }, ensureConfiguredBindingRouteReady: (params: unknown) => mockEnsureConfiguredBindingRouteReady(params), getSessionBindingService: () => ({ diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index d9029d7de57..fb80695af80 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -2,8 +2,8 @@ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pair import { ensureConfiguredBindingRouteReady, resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; -import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime"; import { buildPendingHistoryContextFromMap, @@ -12,8 +12,6 @@ import { recordPendingHistoryEntryIfEnabled, type HistoryEntry, } from "openclaw/plugin-sdk/reply-history"; -import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; -import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing"; import { resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, @@ -651,28 +649,22 @@ export async function handleFeishuMessage(params: { // Bound Feishu conversations intentionally require an exact live conversation-id match. // Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while // configured ACP bindings may still inherit the shared `chat:topic:root` topic session. - const threadBinding = getSessionBindingService().resolveByConversation({ - channel: "feishu", - accountId: account.accountId, - conversationId: currentConversationId, - ...(parentConversationId ? { parentConversationId } : {}), + const runtimeRoute = resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "feishu", + accountId: account.accountId, + conversationId: currentConversationId, + ...(parentConversationId ? { parentConversationId } : {}), + }, }); - const boundSessionKey = threadBinding?.targetSessionKey?.trim(); - if (threadBinding && boundSessionKey) { - route = { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId, - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; + route = runtimeRoute.route; + if (runtimeRoute.bindingRecord) { configuredBinding = null; - getSessionBindingService().touch(threadBinding.bindingId); log( - `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`, + runtimeRoute.boundSessionKey + ? `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${runtimeRoute.boundSessionKey}` + : `feishu[${account.accountId}]: plugin-bound conversation ${currentConversationId}`, ); } } diff --git a/extensions/feishu/src/lifecycle.test-support.ts b/extensions/feishu/src/lifecycle.test-support.ts index da95e6409d1..fd6b0eb7865 100644 --- a/extensions/feishu/src/lifecycle.test-support.ts +++ b/extensions/feishu/src/lifecycle.test-support.ts @@ -45,7 +45,7 @@ type FeishuLifecycleTestMocks = { monitorWebhookMock: AsyncUnknownMock; createFeishuThreadBindingManagerMock: UnknownMock; createFeishuReplyDispatcherMock: CreateFeishuReplyDispatcherMock; - resolveBoundConversationMock: Mock<() => BoundConversation | null>; + resolveBoundConversationMock: Mock<(ref?: unknown) => BoundConversation | null>; touchBindingMock: UnknownMock; resolveAgentRouteMock: UnknownMock; resolveConfiguredBindingRouteMock: UnknownMock; @@ -66,7 +66,7 @@ const feishuLifecycleTestMocks = vi.hoisted( monitorWebhookMock: vi.fn(async () => {}), createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })), createFeishuReplyDispatcherMock: vi.fn(), - resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null), + resolveBoundConversationMock: vi.fn<(ref?: unknown) => BoundConversation | null>(() => null), touchBindingMock: vi.fn(), resolveAgentRouteMock: vi.fn(), resolveConfiguredBindingRouteMock: vi.fn(), @@ -155,6 +155,36 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { resolveConfiguredBindingRouteMock.getMockImplementation() ? resolveConfiguredBindingRouteMock(params) : actual.resolveConfiguredBindingRoute(params), + resolveRuntimeConversationBindingRoute: ( + params: Parameters[0], + ) => { + const conversation = + "conversation" in params + ? params.conversation + : { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }; + const bindingRecord = resolveBoundConversationMock(conversation); + const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); + if (!bindingRecord || !boundSessionKey) { + return { bindingRecord: null, route: params.route }; + } + touchBindingMock(bindingRecord.bindingId); + return { + bindingRecord, + boundSessionKey, + boundAgentId: params.route.agentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", + matchedBy: "binding.channel", + }, + }; + }, ensureConfiguredBindingRouteReady: ( params: Parameters[0], ) => diff --git a/extensions/imessage/src/conversation-route.ts b/extensions/imessage/src/conversation-route.ts index 1e8fa5f44ae..fecc6810a88 100644 --- a/extensions/imessage/src/conversation-route.ts +++ b/extensions/imessage/src/conversation-route.ts @@ -1,14 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { - getSessionBindingService, - isPluginOwnedSessionBindingRecord, resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; -import { - deriveLastRoutePolicy, - resolveAgentIdFromSessionKey, - resolveAgentRoute, -} from "openclaw/plugin-sdk/routing"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveIMessageInboundConversationId } from "./conversation-id.js"; @@ -49,31 +44,21 @@ export function resolveIMessageConversationRoute(params: { }, }).route; - const runtimeBinding = getSessionBindingService().resolveByConversation({ - channel: "imessage", - accountId: params.accountId, - conversationId, + const runtimeRoute = resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "imessage", + accountId: params.accountId, + conversationId, + }, }); - const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); - if (!runtimeBinding || !boundSessionKey) { - return route; - } - - getSessionBindingService().touch(runtimeBinding.bindingId); - if (isPluginOwnedSessionBindingRecord(runtimeBinding)) { + route = runtimeRoute.route; + if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) { logVerbose(`imessage: plugin-bound conversation ${conversationId}`); - return route; + } else if (runtimeRoute.boundSessionKey) { + logVerbose( + `imessage: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`, + ); } - - logVerbose(`imessage: routed via bound conversation ${conversationId} -> ${boundSessionKey}`); - return { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey), - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; + return route; } diff --git a/extensions/line/src/bot-message-context.ts b/extensions/line/src/bot-message-context.ts index 8719f23fcd5..cb01b941541 100644 --- a/extensions/line/src/bot-message-context.ts +++ b/extensions/line/src/bot-message-context.ts @@ -8,19 +8,15 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { ensureConfiguredBindingRouteReady, - getSessionBindingService, recordInboundSession, resolvePinnedMainDmOwnerFromAllowlist, resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, } from "openclaw/plugin-sdk/conversation-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; -import { - deriveLastRoutePolicy, - resolveAgentIdFromSessionKey, - resolveAgentRoute, -} from "openclaw/plugin-sdk/routing"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeAllowFrom } from "./bot-access.js"; @@ -132,26 +128,22 @@ async function resolveLineInboundRoute(params: { const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; route = configuredRoute.route; - const boundConversation = getSessionBindingService().resolveByConversation({ - channel: "line", - accountId: params.account.accountId, - conversationId: peerId, + const runtimeRoute = resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "line", + accountId: params.account.accountId, + conversationId: peerId, + }, }); - const boundSessionKey = boundConversation?.targetSessionKey?.trim(); - if (boundConversation && boundSessionKey) { - route = { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId, - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; + route = runtimeRoute.route; + if (runtimeRoute.bindingRecord) { configuredBinding = null; - getSessionBindingService().touch(boundConversation.bindingId); - logVerbose(`line: routed via bound conversation ${peerId} -> ${boundSessionKey}`); + logVerbose( + runtimeRoute.boundSessionKey + ? `line: routed via bound conversation ${peerId} -> ${runtimeRoute.boundSessionKey}` + : `line: plugin-bound conversation ${peerId}`, + ); } if (configuredBinding) { diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 9f22092fe0d..c2d33a93719 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -71,6 +71,36 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => { return { ...actual, resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute: ( + params: Parameters[0], + ) => { + const conversation = + "conversation" in params + ? params.conversation + : { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }; + const bindingRecord = sessionBindingMocks.resolveByConversation(conversation); + const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); + if (!bindingRecord || !boundSessionKey) { + return { bindingRecord: null, route: params.route }; + } + sessionBindingMocks.touch(bindingRecord.bindingId, undefined); + return { + bindingRecord, + boundSessionKey, + boundAgentId: params.route.agentId, + route: { + ...params.route, + sessionKey: boundSessionKey, + lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session", + matchedBy: "binding.channel", + }, + }; + }, ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady, recordInboundSessionMetaSafe: vi.fn( async (params: { diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 296ce9aa680..dec12fd2e2f 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -108,6 +108,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ bindingResolution: null, boundSessionKey: "", })), + resolveRuntimeConversationBindingRoute: vi.fn(({ route }: { route: unknown }) => ({ + bindingRecord: null, + route, + })), getSessionBindingService: vi.fn(() => ({ resolveByConversation: vi.fn(() => null), touch: vi.fn(), diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 0d476c7faa4..5b99423c600 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -1,10 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, type ConfiguredBindingRouteResult, } from "openclaw/plugin-sdk/conversation-runtime"; -import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime"; -import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import { buildAgentSessionKey, deriveLastRoutePolicy, @@ -13,7 +12,6 @@ import { import { buildAgentMainSessionKey, DEFAULT_ACCOUNT_ID, - resolveAgentIdFromSessionKey, sanitizeAgentId, } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -116,32 +114,22 @@ export function resolveTelegramConversationRoute(params: { ? String(params.chatId) : undefined; if (threadBindingConversationId) { - const threadBinding = getSessionBindingService().resolveByConversation({ - channel: "telegram", - accountId: params.accountId, - conversationId: threadBindingConversationId, + const runtimeRoute = resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "telegram", + accountId: params.accountId, + conversationId: threadBindingConversationId, + }, }); - const boundSessionKey = threadBinding?.targetSessionKey?.trim(); - if (threadBinding && boundSessionKey) { - if (!isPluginOwnedSessionBindingRecord(threadBinding)) { - route = { - ...route, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey), - lastRoutePolicy: deriveLastRoutePolicy({ - sessionKey: boundSessionKey, - mainSessionKey: route.mainSessionKey, - }), - matchedBy: "binding.channel", - }; - } + route = runtimeRoute.route; + if (runtimeRoute.bindingRecord) { configuredBinding = null; configuredBindingSessionKey = ""; - getSessionBindingService().touch(threadBinding.bindingId); logVerbose( - isPluginOwnedSessionBindingRecord(threadBinding) - ? `telegram: plugin-bound conversation ${threadBindingConversationId}` - : `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`, + runtimeRoute.boundSessionKey + ? `telegram: routed via bound conversation ${threadBindingConversationId} -> ${runtimeRoute.boundSessionKey}` + : `telegram: plugin-bound conversation ${threadBindingConversationId}`, ); } } diff --git a/src/channels/plugins/binding-routing.test.ts b/src/channels/plugins/binding-routing.test.ts new file mode 100644 index 00000000000..b2bba2cb120 --- /dev/null +++ b/src/channels/plugins/binding-routing.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + __testing, + registerSessionBindingAdapter, + type SessionBindingAdapter, + type SessionBindingRecord, +} from "../../infra/outbound/session-binding-service.js"; +import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; +import { resolveRuntimeConversationBindingRoute } from "./binding-routing.js"; + +function createRoute(): ResolvedAgentRoute { + return { + agentId: "main", + channel: "demo", + accountId: "default", + sessionKey: "agent:main:main", + mainSessionKey: "agent:main:main", + lastRoutePolicy: "main", + matchedBy: "default", + }; +} + +function createBinding(overrides?: Partial): SessionBindingRecord { + return { + bindingId: "binding-1", + targetSessionKey: "agent:review:acp:session-1", + targetKind: "session", + conversation: { + channel: "demo", + accountId: "default", + conversationId: "room-1", + }, + status: "active", + boundAt: 1, + ...overrides, + }; +} + +function registerAdapter(record: SessionBindingRecord | null): { + resolveByConversation: ReturnType; + touch: ReturnType; +} { + const resolveByConversation = vi.fn(() => record); + const touch = vi.fn>(); + registerSessionBindingAdapter({ + channel: "demo", + accountId: "default", + listBySession: () => [], + resolveByConversation, + touch, + }); + return { resolveByConversation, touch }; +} + +describe("runtime conversation binding route", () => { + beforeEach(() => { + __testing.resetSessionBindingAdaptersForTests(); + }); + + it("rewrites the route to a runtime-bound ACP session and touches the binding", () => { + const binding = createBinding(); + const { resolveByConversation, touch } = registerAdapter(binding); + + const result = resolveRuntimeConversationBindingRoute({ + route: createRoute(), + conversation: { + channel: "demo", + accountId: "default", + conversationId: "room-1", + }, + }); + + expect(resolveByConversation).toHaveBeenCalledWith({ + channel: "demo", + accountId: "default", + conversationId: "room-1", + }); + expect(touch).toHaveBeenCalledWith("binding-1", undefined); + expect(result.boundSessionKey).toBe("agent:review:acp:session-1"); + expect(result.boundAgentId).toBe("review"); + expect(result.route).toMatchObject({ + agentId: "review", + sessionKey: "agent:review:acp:session-1", + lastRoutePolicy: "session", + matchedBy: "binding.channel", + }); + }); + + it("touches plugin-owned bindings without rewriting the channel route", () => { + const route = createRoute(); + const binding = createBinding({ + metadata: { + pluginBindingOwner: "plugin", + pluginId: "demo-plugin", + pluginRoot: "/tmp/demo-plugin", + }, + }); + const { touch } = registerAdapter(binding); + + const result = resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "demo", + accountId: "default", + conversationId: "room-1", + }, + }); + + expect(touch).toHaveBeenCalledWith("binding-1", undefined); + expect(result.bindingRecord).toBe(binding); + expect(result.boundSessionKey).toBeUndefined(); + expect(result.route).toBe(route); + }); +}); diff --git a/test/vitest/vitest.extension-provider-openai.config.ts b/test/vitest/vitest.extension-provider-openai.config.ts index 635ee045a04..cb103c3a305 100644 --- a/test/vitest/vitest.extension-provider-openai.config.ts +++ b/test/vitest/vitest.extension-provider-openai.config.ts @@ -1,6 +1,8 @@ +import path from "node:path"; import { providerOpenAiExtensionTestRoots } from "./vitest.extension-provider-paths.mjs"; import { loadPatternListFromEnv } from "./vitest.pattern-file.ts"; import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; +import { repoRoot } from "./vitest.shared.config.ts"; export function loadIncludePatternsFromEnv( env: Record = process.env, @@ -11,7 +13,7 @@ export function loadIncludePatternsFromEnv( export function createExtensionProviderOpenAiVitestConfig( env: Record = process.env, ) { - return createScopedVitestConfig( + const config = createScopedVitestConfig( loadIncludePatternsFromEnv(env) ?? providerOpenAiExtensionTestRoots.map((root) => `${root}/**/*.test.ts`), { @@ -22,6 +24,19 @@ export function createExtensionProviderOpenAiVitestConfig( setupFiles: ["test/setup.extensions.ts"], }, ); + return { + ...config, + resolve: { + ...config.resolve, + alias: [ + ...(Array.isArray(config.resolve?.alias) ? config.resolve.alias : []), + { + find: /^ws$/u, + replacement: path.join(repoRoot, "node_modules", "ws", "wrapper.mjs"), + }, + ], + }, + }; } export default createExtensionProviderOpenAiVitestConfig();