diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index d0f22e2d1c9a..fbeb034a84ac 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1200,7 +1200,6 @@ extensions/slack/src/monitor/message-handler/dispatch-helpers.ts 2 extensions/slack/src/monitor/message-handler/dispatch-streaming.ts 6 extensions/slack/src/monitor/message-handler/dispatch.ts 2 extensions/slack/src/monitor/message-handler/prepare-dm-history.ts 1 -extensions/slack/src/monitor/message-handler/prepare-routing.ts 1 extensions/slack/src/monitor/message-handler/prepare-thread-context.ts 1 extensions/slack/src/monitor/message-handler/prepare.ts 3 extensions/slack/src/monitor/message-handler/preview-finalize.ts 3 diff --git a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 index edb97b8c7480..c9bb70248b94 100644 --- a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 +++ b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 @@ -1 +1 @@ -73cfbac3e2ef8a75d561165d9798c805e0a8c726c1bcd1c814c7cae777194b2b sqlite-session-transcript-schema-baseline.sql +fecbb8adccfa0be0b452f646d3bee8d5a17faeb2c2d8a2300fb75ef173c709cd sqlite-session-transcript-schema-baseline.sql diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 71090108eb79..b2cbfa03485d 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -350,11 +350,41 @@ normalizes numeric thread ids the same way core does, so prefer it over ad hoc should expose `messaging.resolveOutboundSessionRoute(...)` so core gets provider-native session and thread identity without parser shims. +### Conversation route ownership + +Implement `messaging.resolveConversationRouteOwner(...)` when generic route +matching cannot reproduce the channel's configured and runtime binding rules. +The resolver receives the current config, account, and recorded conversation +identity, including a delivery `target` when it differs from the routing peer. +It must reuse the same precedence and provider identity grammar as inbound +routing. + +Ownership inspection is synchronous and read-only. Do not refresh binding +liveness, perform network requests, or infer missing provider facts. Return: + +- `{ kind: "agent", agentId }` for an agent-owned route. +- `{ kind: "plugin", pluginId, fallbackAgentId }` for a plugin-owned runtime + binding. `fallbackAgentId` is the route used when that plugin has no active + inbound claim handler. +- `{ kind: "unavailable" }` when authoritative owner state is temporarily + unavailable and the caller should retry. +- `null` when the supplied identity is invalid or cannot be authorized. +- `undefined` to delegate to core's generic owner resolution. + +Keep temporary unavailability distinct from `null`: an adapter restart is not +proof that a previously bound conversation is unowned. +Use `inspectConversationBinding(...)` from +`openclaw/plugin-sdk/conversation-binding-inspection-runtime` when the resolver needs this +available/unavailable distinction. + ### Account-scoped conversation binding support Set `conversationBindings.supportsCurrentConversationBinding` when the channel supports generic current-conversation bindings. `createChatChannelPlugin(...)` -sets this static capability to `true` by default. +sets this static capability to `true` by default. Channels whose monitor owns a custom binding +adapter must also set `bindingStore: "adapter"`; core then fails closed while +that adapter is unavailable instead of reading or writing generic binding rows. +Older `createManager`-only plugins retain the same adapter-owned behavior. If support differs by configured account, also implement `conversationBindings.isCurrentConversationBindingSupported({ accountId })`. diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index f9823357f554..1ed7512e56c0 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -38,6 +38,12 @@ operator's explicit offline-device abandonment decision so restart recovery cannot accidentally resume remote reconciliation. Older readers ignore the column and can reopen the same database safely. +Conversation associations use the same rule for the nullable bare +`route_context_json TEXT` column. The database-open repair ensures the column +for updated binaries. Older readers ignore it and can reopen and update the +same database safely; their association update invalidates context captured by +a newer writer so it cannot be replayed after re-upgrade. + Installing OpenClaw manually through npm bypasses the updater guard. Database open checks still refuse an incompatible build. ## Preflight a target release diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 25b400a0b51d..abc37c80d27b 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -59,6 +59,7 @@ import { probeDiscordStatusAccount, } from "./channel.loaders.js"; import { openDiscordCommandDeployHashStore } from "./command-deploy-store.js"; +import { inspectDiscordConversationRouteOwner } from "./conversation-route-owner.js"; import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js"; import { resolveDiscordGroupRequireMention, @@ -259,6 +260,7 @@ export const discordPlugin: ChannelPlugin ], }, messaging: { + resolveConversationRouteOwner: inspectDiscordConversationRouteOwner, targetPrefixes: ["discord"], directTargetStyle: "user-prefixed", targetIdComparison: "lowercase", @@ -424,6 +426,7 @@ export const discordPlugin: ChannelPlugin }, conversationBindings: { supportsCurrentConversationBinding: true, + bindingStore: "adapter", defaultTopLevelPlacement, createManager: async ({ cfg, accountId }) => (await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({ diff --git a/extensions/discord/src/conversation-identity.ts b/extensions/discord/src/conversation-identity.ts index 74a23dd55dc7..301959d92511 100644 --- a/extensions/discord/src/conversation-identity.ts +++ b/extensions/discord/src/conversation-identity.ts @@ -34,6 +34,18 @@ export function resolveDiscordConversationIdentity(params: { : buildDiscordConversationIdentity("channel", params.channelId); } +export function resolveDiscordRuntimeBindingConversationId(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + userId?: string | null; + channelId: string; +}): string { + if (params.isDirectMessage && !params.isGroupDm) { + return buildDiscordConversationIdentity("user", params.userId) ?? params.channelId; + } + return params.channelId; +} + export function resolveDiscordCurrentConversationIdentity(params: { chatType?: string | null; from?: string | null; diff --git a/extensions/discord/src/conversation-route-owner.test.ts b/extensions/discord/src/conversation-route-owner.test.ts new file mode 100644 index 000000000000..4d888076a16c --- /dev/null +++ b/extensions/discord/src/conversation-route-owner.test.ts @@ -0,0 +1,149 @@ +import { + registerSessionBindingAdapter, + type SessionBindingAdapter, + testing as sessionBindingTesting, + unregisterSessionBindingAdapter, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { inspectDiscordConversationRouteOwner } from "./conversation-route-owner.js"; + +describe("inspectDiscordConversationRouteOwner", () => { + let adapter: SessionBindingAdapter; + + beforeEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: () => ({ stop: () => undefined }), + }, + }, + }, + ]), + ); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + adapter = { + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: () => null, + }; + registerSessionBindingAdapter(adapter); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + }); + + it("uses the direct-user runtime identity without touching liveness", () => { + const touch = vi.fn(); + const resolveByConversation = vi.fn((conversation) => ({ + bindingId: "binding-direct", + targetSessionKey: "agent:finance:bound", + targetKind: "session" as const, + conversation, + status: "active" as const, + boundAt: 1, + })); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation, + touch, + }); + + expect( + inspectDiscordConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "direct", peerId: "user-1", nativeChannelId: "dm-1" }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + expect(resolveByConversation).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "user:user-1" }), + ); + expect(touch).not.toHaveBeenCalled(); + }); + + it.each([ + { kind: "group" as const, peerId: "group-dm-1" }, + { kind: "channel" as const, peerId: "channel-1" }, + ])("uses the native channel runtime identity for $kind conversations", ({ kind, peerId }) => { + const resolveByConversation = vi.fn(() => null); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation, + }); + + inspectDiscordConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind, peerId, nativeChannelId: peerId }, + }); + + expect(resolveByConversation).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: peerId }), + ); + }); + + it("reports temporary adapter unavailability only while bindings are enabled", () => { + unregisterSessionBindingAdapter({ channel: "discord", accountId: "default", adapter }); + const conversation = { kind: "channel" as const, peerId: "channel-1" }; + + expect( + inspectDiscordConversationRouteOwner({ cfg: {}, accountId: "default", conversation }), + ).toEqual({ kind: "unavailable" }); + expect( + inspectDiscordConversationRouteOwner({ + cfg: { channels: { discord: { threadBindings: { enabled: false } } } }, + accountId: "default", + conversation, + }), + ).toEqual({ kind: "agent", agentId: "main" }); + }); + + it("preserves explicit plugin ownership independently of the target session key", () => { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (conversation) => ({ + bindingId: "binding-plugin", + targetSessionKey: "agent:review:looks-owned", + targetKind: "session", + conversation, + status: "active", + boundAt: 1, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "review-plugin", + pluginRoot: "/plugins/review", + }, + }), + }); + + expect( + inspectDiscordConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "channel-1" }, + }), + ).toEqual({ kind: "plugin", pluginId: "review-plugin", fallbackAgentId: "main" }); + }); +}); diff --git a/extensions/discord/src/conversation-route-owner.ts b/extensions/discord/src/conversation-route-owner.ts new file mode 100644 index 000000000000..1772987b465b --- /dev/null +++ b/extensions/discord/src/conversation-route-owner.ts @@ -0,0 +1,72 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveThreadBindingSpawnPolicy } from "openclaw/plugin-sdk/conversation-runtime"; +import { resolveDiscordRuntimeBindingConversationId } from "./conversation-identity.js"; +import { resolveDiscordConversationBindingRoute } from "./monitor/conversation-binding-route.js"; +import { resolveDiscordConversationRoute } from "./monitor/route-resolution.js"; + +export function inspectDiscordConversationRouteOwner(params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + threadId?: string; + nativeChannelId?: string; + context?: { + parentPeerId?: string; + guildId?: string; + memberRoleIds?: string[]; + }; + }; +}) { + const direct = params.conversation.kind === "direct"; + const nativeConversationId = params.conversation.nativeChannelId ?? params.conversation.peerId; + const threadConversationId = direct ? undefined : params.conversation.threadId; + const runtimeConversationId = + threadConversationId ?? + resolveDiscordRuntimeBindingConversationId({ + isDirectMessage: direct, + isGroupDm: params.conversation.kind === "group", + userId: direct ? params.conversation.peerId : undefined, + channelId: nativeConversationId, + }); + const route = resolveDiscordConversationRoute({ + cfg: params.cfg, + accountId: params.accountId, + guildId: params.conversation.context?.guildId, + memberRoleIds: params.conversation.context?.memberRoleIds, + peer: { kind: params.conversation.kind, id: params.conversation.peerId }, + parentConversationId: params.conversation.context?.parentPeerId, + }); + const { runtimeRoute, configuredRoute } = resolveDiscordConversationBindingRoute({ + cfg: params.cfg, + route, + accountId: params.accountId, + runtimeConversationId, + configuredConversationId: threadConversationId ?? nativeConversationId, + parentConversationId: params.conversation.context?.parentPeerId, + touchBinding: false, + }); + if ( + !runtimeRoute.bindingOwnerAvailable && + resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel: "discord", + accountId: params.accountId, + kind: "subagent", + }).enabled + ) { + return { kind: "unavailable" as const }; + } + if (runtimeRoute.pluginId) { + return { + kind: "plugin" as const, + pluginId: runtimeRoute.pluginId, + fallbackAgentId: route.agentId, + }; + } + return { + kind: "agent" as const, + agentId: runtimeRoute.boundAgentId ?? configuredRoute?.boundAgentId ?? route.agentId, + }; +} diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index 069ab3e823ea..96124be017f1 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -36,6 +36,7 @@ import { } from "./inbound-context.js"; import { buildDirectLabel, buildGuildLabel } from "./reply-context.js"; import { deliverDiscordReply } from "./reply-delivery.js"; +import { buildDiscordConversationRouteContext } from "./route-resolution.js"; const loadConversationRuntime = createLazyRuntimeModule( () => import("./agent-components.runtime.js"), @@ -202,6 +203,14 @@ export async function dispatchDiscordComponentEvent(params: { SessionKey: sessionKey, AccountId: accountId, ChatType: chatType, + ...buildDiscordConversationRouteContext({ + isDirectMessage: interactionCtx.isDirectMessage, + isGroupDm: interactionCtx.isGroupDm, + directUserId: interactionCtx.userId, + conversationId: interactionCtx.channelId, + isThread: channelCtx.isThread, + parentConversationId: channelCtx.parentId, + }), ConversationLabel: fromLabel, SenderName: senderName, SenderId: interactionCtx.userId, diff --git a/extensions/discord/src/monitor/conversation-binding-route.ts b/extensions/discord/src/monitor/conversation-binding-route.ts new file mode 100644 index 000000000000..bffc72693013 --- /dev/null +++ b/extensions/discord/src/monitor/conversation-binding-route.ts @@ -0,0 +1,53 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, +} from "openclaw/plugin-sdk/conversation-binding-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { shouldIgnoreStaleDiscordRouteBinding } from "./route-resolution.js"; + +export function resolveDiscordConversationBindingRoute(params: { + cfg: OpenClawConfig; + route: ResolvedAgentRoute; + accountId: string; + runtimeConversationId: string; + configuredConversationId: string; + parentConversationId?: string; + touchBinding?: boolean; +}) { + let runtimeRoute = resolveRuntimeConversationBindingRoute({ + route: params.route, + touchBinding: params.touchBinding, + conversation: { + channel: "discord", + accountId: params.accountId, + conversationId: params.runtimeConversationId, + parentConversationId: params.parentConversationId, + }, + }); + if ( + shouldIgnoreStaleDiscordRouteBinding({ + bindingRecord: runtimeRoute.bindingRecord, + route: params.route, + }) + ) { + logVerbose( + `discord: ignoring stale route binding for conversation ${params.runtimeConversationId} (${runtimeRoute.bindingRecord?.targetSessionKey} -> ${params.route.sessionKey})`, + ); + runtimeRoute = { bindingOwnerAvailable: true, bindingRecord: null, route: params.route }; + } + const configuredRoute = runtimeRoute.bindingRecord + ? null + : resolveConfiguredBindingRoute({ + cfg: params.cfg, + route: params.route, + conversation: { + channel: "discord", + accountId: params.accountId, + conversationId: params.configuredConversationId, + parentConversationId: params.parentConversationId, + }, + }); + return { runtimeRoute, configuredRoute }; +} diff --git a/extensions/discord/src/monitor/message-handler.context.test.ts b/extensions/discord/src/monitor/message-handler.context.test.ts index c8444f52572b..885d9ced50b8 100644 --- a/extensions/discord/src/monitor/message-handler.context.test.ts +++ b/extensions/discord/src/monitor/message-handler.context.test.ts @@ -31,6 +31,7 @@ describe("discord buildDiscordMessageProcessContext sender bot status", () => { } expect(result.ctxPayload.NativeChannelId).toBe(ctx.messageChannelId); + expect(result.ctxPayload.ConversationRoutePeerId).toBe(ctx.messageChannelId); }); it("projects a cached conversation avatar into channel-owned context", async () => { @@ -55,6 +56,22 @@ describe("discord buildDiscordMessageProcessContext sender bot status", () => { expect(result?.ctxPayload.GroupSpace).toBe("guild-id"); }); + it("records the source channel as the parent of an auto-threaded turn", async () => { + const ctx = await createBaseDiscordMessageContext({ + channelConfig: { allowed: true, autoThread: true }, + client: { + rest: { + get: async () => ({ thread: { id: "auto-thread-1" } }), + }, + }, + }); + + const result = await buildDiscordMessageProcessContext({ ctx, text: "hi", mediaList: [] }); + + expect(result?.ctxPayload.MessageThreadId).toBe("auto-thread-1"); + expect(result?.ctxPayload.ThreadParentId).toBe("c1"); + }); + it("forwards bot author status to ctxPayload.SenderIsBot", async () => { const ctx = await createBaseDiscordMessageContext({ author: { id: "U1", username: "alice", discriminator: "0", globalName: "Alice", bot: true }, diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index ff2d04d95de7..e5545c32a357 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -43,6 +43,7 @@ import { type DiscordMediaInfo, } from "./message-utils.js"; import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js"; +import { buildDiscordRoutePeer } from "./route-resolution.js"; import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js"; import { DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, @@ -88,6 +89,7 @@ export async function buildDiscordMessageProcessContext(params: { messageChannelId, isGuildMessage, isDirectMessage, + isGroupDm, baseText, preflightAudioTranscript, threadChannel, @@ -330,6 +332,11 @@ export async function buildDiscordMessageProcessContext(params: { const replyTarget = replyPlan.replyTarget; const replyReference = replyPlan.replyReference; const autoThreadContext = replyPlan.autoThreadContext; + const conversationParentId = threadChannel + ? threadParentId + : autoThreadContext + ? messageChannelId + : undefined; const effectiveFrom = isDirectMessage ? `discord:${author.id}` @@ -372,7 +379,7 @@ export async function buildDiscordMessageProcessContext(params: { inboundEventKind: ctx.inboundEventKind, }, { - parentId: threadChannel ? threadParentId : undefined, + parentId: conversationParentId, threadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, }, ); @@ -398,15 +405,21 @@ export async function buildDiscordMessageProcessContext(params: { isBot: author.bot && !sender.isPluralKit ? true : undefined, }, conversation: { - kind: isDirectMessage ? "direct" : "channel", + kind: isGroupDm ? "group" : isDirectMessage ? "direct" : "channel", id: messageChannelId, + routePeer: buildDiscordRoutePeer({ + isDirectMessage, + isGroupDm, + directUserId: author.id, + conversationId: messageChannelId, + }), nativeChannelId: messageChannelId, avatar: ctx.conversationAvatar, label: fromLabel, spaceId: isGuildMessage ? (guildInfo?.id ?? data.guild?.id ?? data.guild_id ?? guildSlug) || undefined : undefined, - parentId: threadChannel ? threadParentId : undefined, + parentId: conversationParentId, threadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, }, route: { diff --git a/extensions/discord/src/monitor/message-handler.routing-preflight.ts b/extensions/discord/src/monitor/message-handler.routing-preflight.ts index 858c82939f7b..9a26346f0f49 100644 --- a/extensions/discord/src/monitor/message-handler.routing-preflight.ts +++ b/extensions/discord/src/monitor/message-handler.routing-preflight.ts @@ -1,14 +1,13 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Discord plugin module implements message handler.routing preflight behavior. -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; +import { resolveDiscordRuntimeBindingConversationId } from "../conversation-identity.js"; import type { User } from "../internal/discord.js"; +import { resolveDiscordConversationBindingRoute } from "./conversation-binding-route.js"; import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js"; import { buildDiscordRoutePeer, resolveDiscordConversationRoute, resolveDiscordEffectiveRoute, - shouldIgnoreStaleDiscordRouteBinding, } from "./route-resolution.js"; const loadConversationRuntime = createLazyRuntimeModule( @@ -38,49 +37,21 @@ export async function resolveDiscordPreflightRoute(params: { }), parentConversationId: params.earlyThreadParentId, }); - const bindingConversationId = params.isDirectMessage - ? (resolveDiscordConversationIdentity({ - isDirectMessage: true, - userId: params.author.id, - }) ?? `user:${params.author.id}`) - : params.messageChannelId; - let runtimeRoute = conversationRuntime.resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "discord", - accountId: params.preflight.accountId, - conversationId: bindingConversationId, - parentConversationId: params.earlyThreadParentId, - }, + const bindingConversationId = resolveDiscordRuntimeBindingConversationId({ + isDirectMessage: params.isDirectMessage, + isGroupDm: params.isGroupDm, + userId: params.author.id, + channelId: params.messageChannelId, + }); + const { runtimeRoute, configuredRoute } = resolveDiscordConversationBindingRoute({ + cfg: params.preflight.cfg, + route, + accountId: params.preflight.accountId, + runtimeConversationId: bindingConversationId, + configuredConversationId: params.messageChannelId, + parentConversationId: params.earlyThreadParentId, }); - if ( - shouldIgnoreStaleDiscordRouteBinding({ - bindingRecord: runtimeRoute.bindingRecord, - route, - }) - ) { - logVerbose( - `discord: ignoring stale route binding for conversation ${bindingConversationId} (${runtimeRoute.bindingRecord?.targetSessionKey} -> ${route.sessionKey})`, - ); - runtimeRoute = { - bindingRecord: null, - route, - }; - } let threadBinding = runtimeRoute.bindingRecord ?? undefined; - const configuredRoute = - threadBinding == null - ? conversationRuntime.resolveConfiguredBindingRoute({ - cfg: params.preflight.cfg, - route, - conversation: { - channel: "discord", - accountId: params.preflight.accountId, - conversationId: params.messageChannelId, - parentConversationId: params.earlyThreadParentId, - }, - }) - : null; const configuredBinding = configuredRoute?.bindingResolution ?? null; if (!threadBinding && configuredBinding) { threadBinding = configuredBinding.record; diff --git a/extensions/discord/src/monitor/native-command-context.test.ts b/extensions/discord/src/monitor/native-command-context.test.ts index 2afdca5d8568..41785eef85ca 100644 --- a/extensions/discord/src/monitor/native-command-context.test.ts +++ b/extensions/discord/src/monitor/native-command-context.test.ts @@ -35,6 +35,10 @@ describe("buildDiscordNativeCommandContext", () => { expect(ctx.ConversationLabel).toBe("Tester"); expect(ctx.SessionKey).toBe("agent:codex:discord:slash:user-1"); expect(ctx.CommandTargetSessionKey).toBe("agent:codex:discord:direct:user-1"); + expect(ctx.ConversationRouteContextObserved).toBe(true); + expect(ctx.ConversationRoutePeerId).toBe("user-1"); + expect(ctx.NativeChannelId).toBe("dm-1"); + expect(ctx.InboundAccessAuthorized).toBe(true); expect(ctx.OriginatingTo).toBe("user:user-1"); expect(ctx.ChannelPromptContext).toBeUndefined(); expect(ctx.ChannelStructuredContext).toBeUndefined(); @@ -87,6 +91,10 @@ describe("buildDiscordNativeCommandContext", () => { expect(ctx.GroupSubject).toBe("Ops"); expect(ctx.GroupSpace).toBe("guild-1"); expect(ctx.MemberRoleIds).toEqual(["admin"]); + expect(ctx.ConversationRouteContextObserved).toBe(true); + expect(ctx.ConversationRoutePeerId).toBe("chan-1"); + expect(ctx.NativeChannelId).toBe("chan-1"); + expect(ctx.InboundAccessAuthorized).toBe(true); expect(ctx.GroupSystemPrompt).toBe("Use the runbook."); expect(ctx.OwnerAllowFrom).toEqual(["user-1"]); expect(ctx.MessageThreadId).toBe("chan-1"); diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 527147f7afb4..52099d4549a6 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -4,6 +4,7 @@ import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runti import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; +import { buildDiscordConversationRouteContext } from "./route-resolution.js"; type BuildDiscordNativeCommandContextParams = { prompt: string; @@ -69,6 +70,14 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma CommandTargetSessionKey: params.commandTargetSessionKey, AccountId: params.accountId ?? undefined, ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", + ...buildDiscordConversationRouteContext({ + isDirectMessage: params.isDirectMessage, + isGroupDm: params.isGroupDm, + directUserId: params.user.id, + conversationId: params.channelId, + isThread: params.isThreadChannel, + parentConversationId: params.threadParentId, + }), ConversationLabel: conversationLabel, GroupSubject: params.isGuild ? params.guildName : undefined, GroupSpace: params.isGuild @@ -86,7 +95,6 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma Surface: "discord" as const, WasMentioned: true, MessageSid: params.interactionId, - MessageThreadId: params.isThreadChannel ? params.channelId : undefined, Timestamp: params.timestampMs ?? Date.now(), CommandAuthorized: params.commandAuthorized, CommandTurn: { @@ -106,6 +114,5 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma userId: params.user.id, channelId: params.channelId, }) ?? (params.isDirectMessage ? `user:${params.user.id}` : `channel:${params.channelId}`), - ThreadParentId: params.isThreadChannel ? params.threadParentId : undefined, }); } diff --git a/extensions/discord/src/monitor/route-resolution.test.ts b/extensions/discord/src/monitor/route-resolution.test.ts index 9612a10f6622..9300df98d987 100644 --- a/extensions/discord/src/monitor/route-resolution.test.ts +++ b/extensions/discord/src/monitor/route-resolution.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { describe, expect, it } from "vitest"; import { + buildDiscordConversationRouteContext, buildDiscordRoutePeer, resolveDiscordBoundConversationRoute, resolveDiscordConversationRoute, @@ -46,6 +47,53 @@ describe("discord route resolution helpers", () => { }); }); + it("keeps a group DM keyed by its conversation instead of one sender", () => { + expect( + buildDiscordRoutePeer({ + isDirectMessage: true, + isGroupDm: true, + directUserId: "user-1", + conversationId: "group-dm-1", + }), + ).toEqual({ kind: "group", id: "group-dm-1" }); + }); + + it("records the direct routing peer separately from the native DM channel", () => { + expect( + buildDiscordConversationRouteContext({ + isDirectMessage: true, + isGroupDm: false, + directUserId: "user-1", + conversationId: "dm-1", + isThread: false, + }), + ).toEqual({ + ConversationRouteContextObserved: true, + ConversationRoutePeerId: "user-1", + NativeChannelId: "dm-1", + InboundAccessAuthorized: true, + MessageThreadId: undefined, + ThreadParentId: undefined, + }); + }); + + it("records a thread and its routing parent", () => { + expect( + buildDiscordConversationRouteContext({ + isDirectMessage: false, + isGroupDm: false, + conversationId: "thread-1", + isThread: true, + parentConversationId: "parent-1", + }), + ).toMatchObject({ + ConversationRoutePeerId: "thread-1", + NativeChannelId: "thread-1", + MessageThreadId: "thread-1", + ThreadParentId: "parent-1", + }); + }); + it("resolves bound session keys on top of the routed session", () => { const route: ResolvedAgentRoute = { agentId: "main", diff --git a/extensions/discord/src/monitor/route-resolution.ts b/extensions/discord/src/monitor/route-resolution.ts index 3556754a3f7e..110a8d8b6cfd 100644 --- a/extensions/discord/src/monitor/route-resolution.ts +++ b/extensions/discord/src/monitor/route-resolution.ts @@ -19,10 +19,29 @@ export function buildDiscordRoutePeer(params: { conversationId: string; }): RoutePeer { return { - kind: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", - id: params.isDirectMessage - ? params.directUserId?.trim() || params.conversationId - : params.conversationId, + kind: params.isGroupDm ? "group" : params.isDirectMessage ? "direct" : "channel", + id: + params.isDirectMessage && !params.isGroupDm + ? params.directUserId?.trim() || params.conversationId + : params.conversationId, + }; +} + +export function buildDiscordConversationRouteContext(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + directUserId?: string | null; + conversationId: string; + isThread: boolean; + parentConversationId?: string; +}) { + return { + ConversationRouteContextObserved: true as const, + ConversationRoutePeerId: buildDiscordRoutePeer(params).id, + NativeChannelId: params.conversationId, + InboundAccessAuthorized: true as const, + MessageThreadId: params.isThread ? params.conversationId : undefined, + ThreadParentId: params.isThread ? params.parentConversationId : undefined, }; } diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 914b0070b9c0..f6da4ff6c4f3 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -598,6 +598,9 @@ describe("handleFeishuMessage ACP routing", () => { expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ ConversationRoutePeerId: "ou_sender_1" }), + ); }); it("surfaces configured ACP initialization failures to the Feishu conversation", async () => { @@ -687,6 +690,12 @@ describe("handleFeishuMessage ACP routing", () => { expect(conversationRef.channel).toBe("feishu"); expect(conversationRef.conversationId).toBe("oc_group_chat:topic:om_topic_root"); expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root"); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + ConversationRoutePeerId: "oc_group_chat:topic:om_topic_root", + ThreadParentId: "oc_group_chat", + }), + ); }); it("records Feishu DM last-route updates on the resolved session", async () => { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index ffe53aee5c79..c139f19935c4 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1424,7 +1424,9 @@ export async function handleFeishuMessage(params: { conversation: { kind: isGroup ? "group" : "direct", id: ctx.chatId, + routePeer: { kind: isGroup ? "group" : "direct", id: peerId }, nativeChannelId: ctx.chatId, + parentId: parentPeer?.id, label: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined, threadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined, }, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 99267b707e73..8f3c121ccc22 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -993,6 +993,7 @@ export const feishuPlugin: ChannelPlugin buildFeishuModelOverrideParentCandidates(parentConversationId), diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 121587c03072..897e582c784a 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -318,6 +318,7 @@ export const imessagePlugin: ChannelPlugin createIMessageConversationBindingManager({ cfg, diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts index d6f2c15a8e0b..5ff0f14e1c9f 100644 --- a/extensions/imessage/src/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -900,6 +900,7 @@ describe("resolveIMessageInboundDecision command auth", () => { }); expect(ctxPayload.CommandAuthorized).toBe(true); + expect(ctxPayload.ConversationRoutePeerId).toBe("+15555550123"); expect(ctxPayload.CommandSource).toBe("text"); expect(ctxPayload.CommandTurn).toMatchObject({ kind: "text-slash", diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 984c847171c8..77ed7d280056 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1039,6 +1039,14 @@ export async function buildIMessageInboundContext(params: { conversation: { kind: decision.isGroup ? "group" : "direct", id: chatId != null ? String(chatId) : decision.sender, + ...(decision.isGroup && chatId == null + ? {} + : { + routePeer: { + kind: decision.isGroup ? ("group" as const) : ("direct" as const), + id: decision.isGroup ? String(chatId) : decision.senderNormalized, + }, + }), label: fromLabel, }, route: { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index fe2d42038d1b..6b5e4af221f2 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -58,6 +58,7 @@ import { resolveMatrixAccountConfig, type ResolvedMatrixAccount, } from "./matrix/accounts.js"; +import { resolveMatrixConversationRouteOwner } from "./matrix/conversation-route-owner.js"; import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; import type { MatrixProbe } from "./matrix/probe.js"; import { @@ -435,6 +436,7 @@ export const matrixPlugin: ChannelPlugin = }, conversationBindings: { supportsCurrentConversationBinding: true, + bindingStore: "adapter", defaultTopLevelPlacement, setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) => setMatrixThreadBindingIdleTimeoutBySessionKey({ @@ -463,6 +465,7 @@ export const matrixPlugin: ChannelPlugin = resolveDeliveryTarget: ({ conversationId, parentConversationId }) => resolveMatrixDeliveryTarget({ conversationId, parentConversationId }), resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params), + resolveConversationRouteOwner: resolveMatrixConversationRouteOwner, targetResolver: { looksLikeId: (raw) => { const trimmed = raw.trim(); diff --git a/extensions/matrix/src/matrix/conversation-route-owner.test.ts b/extensions/matrix/src/matrix/conversation-route-owner.test.ts new file mode 100644 index 000000000000..a15efe7f26ce --- /dev/null +++ b/extensions/matrix/src/matrix/conversation-route-owner.test.ts @@ -0,0 +1,90 @@ +import { + registerSessionBindingAdapter, + type SessionBindingAdapter, + testing as sessionBindingTesting, + unregisterSessionBindingAdapter, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveMatrixConversationRouteOwner } from "./conversation-route-owner.js"; + +describe("resolveMatrixConversationRouteOwner", () => { + let adapter: SessionBindingAdapter; + + beforeEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: { + id: "matrix", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: () => ({ stop: () => undefined }), + }, + }, + }, + ]), + ); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + adapter = { + channel: "matrix", + accountId: "default", + listBySession: () => [], + resolveByConversation: (conversation) => ({ + bindingId: "binding-room", + targetSessionKey: "agent:finance:bound", + targetKind: "session", + conversation, + status: "active", + boundAt: 1, + }), + }; + registerSessionBindingAdapter(adapter); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + }); + + it("uses the native DM room and a channel peer's canonical room identity", () => { + expect( + resolveMatrixConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { + kind: "direct", + peerId: "@alice:example.org", + nativeChannelId: "!dm:example.org", + }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + expect( + resolveMatrixConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "!room:example.org" }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + }); + + it("reports temporary binding-store unavailability", () => { + unregisterSessionBindingAdapter({ channel: "matrix", accountId: "default", adapter }); + + expect( + resolveMatrixConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "!room:example.org" }, + }), + ).toEqual({ kind: "unavailable" }); + }); +}); diff --git a/extensions/matrix/src/matrix/conversation-route-owner.ts b/extensions/matrix/src/matrix/conversation-route-owner.ts new file mode 100644 index 000000000000..d96ba2d99daa --- /dev/null +++ b/extensions/matrix/src/matrix/conversation-route-owner.ts @@ -0,0 +1,43 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { parseAgentSessionKey, resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveMatrixAccount } from "./accounts.js"; +import { resolveMatrixInboundRoute } from "./monitor/route.js"; + +export function resolveMatrixConversationRouteOwner(params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + threadId?: string; + nativeChannelId?: string; + }; +}) { + const { cfg, accountId, conversation } = params; + const roomId = + conversation.nativeChannelId?.trim() || + (conversation.kind === "direct" ? "" : conversation.peerId.trim()); + if (!roomId) { + return null; + } + const isDirectMessage = conversation.kind === "direct"; + const result = resolveMatrixInboundRoute({ + cfg, + accountId, + roomId, + senderId: conversation.peerId, + isDirectMessage, + dmSessionScope: resolveMatrixAccount({ cfg, accountId }).config.dm?.sessionScope, + threadId: conversation.threadId, + resolveAgentRoute, + }); + if (!result.bindingOwnerAvailable) { + return { kind: "unavailable" as const }; + } + if (result.runtimeBindingId && !parseAgentSessionKey(result.route.sessionKey)?.agentId) { + // Matrix's store cannot project plugin metadata. A non-agent runtime target therefore + // cannot authorize detached delivery through an inferred fallback owner. + return null; + } + return { kind: "agent" as const, agentId: result.route.agentId }; +} diff --git a/extensions/matrix/src/matrix/monitor/route.ts b/extensions/matrix/src/matrix/monitor/route.ts index 0c8d9c3d39bb..7cfa99edf2ca 100644 --- a/extensions/matrix/src/matrix/monitor/route.ts +++ b/extensions/matrix/src/matrix/monitor/route.ts @@ -6,7 +6,7 @@ import { deriveLastRoutePolicy, resolveAgentIdFromSessionKey, } from "openclaw/plugin-sdk/routing"; -import { getSessionBindingService } from "openclaw/plugin-sdk/session-binding-runtime"; +import { inspectSessionBindingByConversation } from "openclaw/plugin-sdk/session-binding-runtime"; import type { CoreConfig } from "../../types.js"; import { resolveMatrixThreadSessionKeys } from "./threads.js"; @@ -53,6 +53,7 @@ export function resolveMatrixInboundRoute(params: { }): { route: MatrixResolvedRoute; configuredBinding: ReturnType; + bindingOwnerAvailable: boolean; runtimeBindingId: string | null; } { const baseRoute = params.resolveAgentRoute({ @@ -74,13 +75,15 @@ export function resolveMatrixInboundRoute(params: { }); const bindingConversationId = params.threadId ?? params.roomId; const bindingParentConversationId = params.threadId ? params.roomId : undefined; - const sessionBindingService = getSessionBindingService(); - const runtimeBinding = sessionBindingService.resolveByConversation({ + const bindingRef = { channel: "matrix", accountId: params.accountId, conversationId: bindingConversationId, parentConversationId: bindingParentConversationId, - }); + }; + const bindingInspection = inspectSessionBindingByConversation(bindingRef); + const runtimeBinding = + bindingInspection.status === "available" ? bindingInspection.binding : null; const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); if (runtimeBinding && boundSessionKey) { @@ -96,6 +99,7 @@ export function resolveMatrixInboundRoute(params: { matchedBy: "binding.channel", }, configuredBinding: null, + bindingOwnerAvailable: true, runtimeBindingId: runtimeBinding.bindingId, }; } @@ -168,13 +172,15 @@ export function resolveMatrixInboundRoute(params: { }), }, configuredBinding, - runtimeBindingId: null, + bindingOwnerAvailable: bindingInspection.status === "available", + runtimeBindingId: runtimeBinding?.bindingId ?? null, }; } return { route: routeWithDmScope, configuredBinding, - runtimeBindingId: null, + bindingOwnerAvailable: bindingInspection.status === "available", + runtimeBindingId: runtimeBinding?.bindingId ?? null, }; } diff --git a/extensions/mattermost/src/mattermost/directory.test.ts b/extensions/mattermost/src/mattermost/directory.test.ts index d67288a858d4..bb95cac1a775 100644 --- a/extensions/mattermost/src/mattermost/directory.test.ts +++ b/extensions/mattermost/src/mattermost/directory.test.ts @@ -62,6 +62,31 @@ describe("mattermost directory", () => { expect(createMattermostClientMock).toHaveBeenCalledOnce(); }); + it("uses only the requested account for scoped directory discovery", async () => { + const personalClient = { token: "token-personal", request: vi.fn().mockResolvedValue([]) }; + listMattermostAccountIdsMock.mockReturnValue(["personal", "finance"]); + resolveMattermostAccountMock.mockImplementation(({ accountId }) => ({ + enabled: true, + botToken: `token-${accountId}`, + baseUrl: "https://chat.example.com", + })); + createMattermostClientMock.mockReturnValue(personalClient); + fetchMattermostMeMock.mockResolvedValue({ id: "me-1" }); + + await expect( + listMattermostDirectoryGroups({ + cfg: {} as never, + accountId: "personal", + runtime: {} as never, + }), + ).resolves.toEqual([]); + expect(resolveMattermostAccountMock).toHaveBeenCalledOnce(); + expect(resolveMattermostAccountMock).toHaveBeenCalledWith({ + cfg: {}, + accountId: "personal", + }); + }); + it("deduplicates channels across enabled accounts and skips failing accounts", async () => { const clientA = { token: "token-a", diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 07914bfa4229..a762b139a4c1 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -35,17 +35,12 @@ function buildClient(params: { }); } -/** - * Build clients from ALL enabled accounts (deduplicated by token). - * - * We always scan every account because: - * - Private channels are only visible to bots that are members - * - The requesting agent's account may have an expired/invalid token - * - * This means a single healthy bot token is enough for directory discovery. - */ +/** Build the requested account client, or aggregate accounts for an explicitly unscoped lookup. */ function buildClients(params: MattermostDirectoryParams): MattermostClient[] { - const accountIds = listMattermostAccountIds(params.cfg); + const requestedAccountId = params.accountId?.trim(); + const accountIds = requestedAccountId + ? [requestedAccountId] + : listMattermostAccountIds(params.cfg); const seen = new Set(); const clients: MattermostClient[] = []; for (const id of accountIds) { diff --git a/extensions/mattermost/src/mattermost/monitor-event-plan.ts b/extensions/mattermost/src/mattermost/monitor-event-plan.ts index 6fa3a74927b1..cc3a45ace343 100644 --- a/extensions/mattermost/src/mattermost/monitor-event-plan.ts +++ b/extensions/mattermost/src/mattermost/monitor-event-plan.ts @@ -87,6 +87,8 @@ export async function buildMattermostEventPlan( ParentSessionKey: thread.parentSessionKey, AccountId: route.accountId, ChatType: kind, + ConversationRouteContextObserved: true, + ConversationRoutePeerId: kind === "direct" ? params.senderId : params.channelId, GroupChannel: channelName ? `#${channelName}` : undefined, GroupSpace: teamId, SenderId: params.senderId, @@ -94,6 +96,8 @@ export async function buildMattermostEventPlan( Surface: "mattermost" as const, ReplyToId: thread.effectiveReplyToId, MessageThreadId: thread.effectiveReplyToId, + NativeChannelId: params.channelId, + InboundAccessAuthorized: true, OriginatingChannel: "mattermost" as const, OriginatingTo: to, }), diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 5e1d3c50577d..560de63afd68 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -860,6 +860,11 @@ describe("mattermost inbound user posts", () => { expect(ctx?.BodyForAgent).toBe("hello from mattermost"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular"); + expect(ctx?.ConversationRouteContextObserved).toBe(true); + expect(ctx?.ConversationRoutePeerId).toBe("chan-1"); + expect(ctx?.GroupSpace).toBe("team-1"); + expect(ctx?.NativeChannelId).toBe("chan-1"); + expect(ctx?.InboundAccessAuthorized).toBe(true); expect(ctx?.OriginatingChannel).toBe("mattermost"); expect(ctx?.Provider).toBe("mattermost"); }); diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index d4f66b4c447f..826525683b3e 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -19,7 +19,7 @@ const mockState = vi.hoisted(() => ({ })), resolveCommandText: vi.fn((_trigger: string, text: string) => text), buildModelsProviderData: vi.fn(async () => ({ providers: [], modelNames: new Map() })), - resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })), + resolveMattermostModelPickerEntry: vi.fn((): { kind: string } | null => ({ kind: "summary" })), authorizeMattermostCommandInvocation: vi.fn(() => ({ ok: true, commandAuthorized: true, @@ -49,6 +49,7 @@ const mockState = vi.hoisted(() => ({ delete_at: 0, })), listMattermostCommands: vi.fn(async () => []), + dispatchInbound: vi.fn(async () => undefined), })); vi.mock("./runtime-api.js", () => { @@ -83,7 +84,10 @@ vi.mock("../runtime.js", () => ({ }, text: { hasControlCommand: () => false, + resolveTextChunkLimit: () => 4000, + resolveMarkdownTableMode: () => "off", }, + inbound: { dispatch: mockState.dispatchInbound }, pairing: { readAllowFromStore: vi.fn(async () => []), }, @@ -123,6 +127,11 @@ vi.mock("./monitor-auth.js", () => ({ })); vi.mock("./reply-delivery.js", () => ({ + createMattermostReplyDeliveryBarrier: vi.fn(() => ({ + markDeliverySettled: vi.fn(), + resolveTimeoutPolicy: vi.fn(), + trackDmChannelResolution: vi.fn(), + })), deliverMattermostReplyPayload: vi.fn(), })); @@ -225,6 +234,7 @@ describe("slash-http cfg threading", () => { mockState.normalizeMattermostAllowList.mockClear(); mockState.getMattermostCommand.mockClear(); mockState.listMattermostCommands.mockClear(); + mockState.dispatchInbound.mockClear(); ({ createSlashCommandHttpHandler } = await import("./slash-http.js")); }); @@ -267,6 +277,66 @@ describe("slash-http cfg threading", () => { ); }); + it("keeps the slash team scope on direct conversations", async () => { + mockState.resolveMattermostModelPickerEntry.mockReturnValueOnce(null); + mockState.parseSlashCommandPayload.mockReturnValueOnce({ + token: "valid-token", + command: "/oc_status", + text: "status", + channel_id: "dm-1", + user_id: "user-1", + user_name: "alice", + team_id: "team-1", + }); + mockState.getMattermostCommand.mockResolvedValueOnce({ + id: "cmd-status", + token: "valid-token", + team_id: "team-1", + trigger: "oc_status", + method: "P", + url: callbackUrlFixture, + delete_at: 0, + }); + mockState.authorizeMattermostCommandInvocation.mockReturnValueOnce({ + ok: true, + commandAuthorized: true, + channelInfo: { id: "dm-1", type: "D", name: "alice", display_name: "Alice" }, + kind: "direct", + chatType: "direct", + channelName: "alice", + channelDisplay: "Alice", + roomLabel: "Alice", + }); + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + registeredCommands: [ + { + id: "cmd-status", + teamId: "team-1", + trigger: "oc_status", + token: "valid-token", + url: callbackUrlFixture, + managed: false, + }, + ], + }); + + await handler(createRequest(), createResponse().res); + + expect(mockState.dispatchInbound).toHaveBeenCalledWith( + expect.objectContaining({ + ctxPayload: expect.objectContaining({ + ChatType: "direct", + ConversationRouteContextObserved: true, + ConversationRoutePeerId: "user-1", + GroupSpace: "team-1", + }), + }), + ); + }); + it("rejects a callback when Mattermost reports a different current command token", async () => { mockState.parseSlashCommandPayload.mockReturnValueOnce({ token: "old-token", diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index dec992722100..db53b80f9091 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -845,7 +845,10 @@ async function handleSlashCommandAsync(params: { SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: chatType, + ConversationRouteContextObserved: true, + ConversationRoutePeerId: kind === "direct" ? senderId : channelId, ConversationLabel: fromLabel, + GroupSpace: teamId, GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined, SenderName: senderName, SenderId: senderId, @@ -855,6 +858,7 @@ async function handleSlashCommandAsync(params: { Timestamp: Date.now(), WasMentioned: true, CommandAuthorized: commandAuthorized, + InboundAccessAuthorized: true, CommandSource: "native" as const, OriginatingChannel: "mattermost" as const, OriginatingTo: to, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index a4b7c70a2cb2..302db982146b 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -56,6 +56,7 @@ import { } from "./channel-api.js"; import { resolveSlackChannelType, resolveSlackConversationInfo } from "./channel-type.js"; import { getSlackWriteClient } from "./client.js"; +import { inspectSlackConversationRouteOwner } from "./conversation-route-owner.js"; import { assertSlackDetachedTargetAllowed } from "./detached-target-admission.js"; import { formatSlackError } from "./errors.js"; import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js"; @@ -667,6 +668,7 @@ export const slackPlugin: ChannelPlugin = crea isSlackWorkspaceInstallation(accountId), }, messaging: { + resolveConversationRouteOwner: inspectSlackConversationRouteOwner, targetPrefixes: ["slack"], directTargetStyle: "user-prefixed", targetIdComparison: "lowercase", diff --git a/extensions/slack/src/conversation-binding-route.ts b/extensions/slack/src/conversation-binding-route.ts new file mode 100644 index 000000000000..d341c8ffddce --- /dev/null +++ b/extensions/slack/src/conversation-binding-route.ts @@ -0,0 +1,153 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, + type RuntimeConversationBindingRouteResult, +} from "openclaw/plugin-sdk/conversation-runtime"; +import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { parseSlackTarget, type SlackTargetKind } from "./targets.js"; + +type SlackRouteBinding = NonNullable[number]; +type SlackRouteBindingPeer = NonNullable; + +const slackRouteBindingConfigCache = new WeakMap< + OpenClawConfig, + { bindingsRef: OpenClawConfig["bindings"]; normalizedCfg: OpenClawConfig } +>(); + +function slackTargetDefaultKindForPeer(kind: SlackRouteBindingPeer["kind"]): SlackTargetKind { + return kind === "direct" ? "user" : "channel"; +} + +function slackTargetKindMatchesPeer( + peerKind: SlackRouteBindingPeer["kind"], + targetKind: SlackTargetKind, +): boolean { + if (targetKind === "user") { + return peerKind === "direct"; + } + return peerKind === "channel" || peerKind === "group"; +} + +function normalizeSlackRouteBindingPeer(peer: SlackRouteBindingPeer): SlackRouteBindingPeer { + const rawId = peer.id.trim(); + if (!rawId || rawId === "*") { + return peer; + } + + const target = (() => { + try { + return parseSlackTarget(rawId, { + defaultKind: slackTargetDefaultKindForPeer(peer.kind), + }); + } catch { + return undefined; + } + })(); + if (!target || !slackTargetKindMatchesPeer(peer.kind, target.kind)) { + return peer; + } + const normalizedId = target.teamId + ? `team:${target.teamId}:${target.kind}:${target.id}` + : target.id; + return normalizedId === peer.id ? peer : { ...peer, id: normalizedId }; +} + +export function normalizeSlackRouteBindingConfig(cfg: OpenClawConfig): OpenClawConfig { + const bindings = cfg.bindings; + const cached = slackRouteBindingConfigCache.get(cfg); + if (cached && cached.bindingsRef === bindings) { + return cached.normalizedCfg; + } + if (!Array.isArray(bindings)) { + return cfg; + } + + let changed = false; + const normalizedBindings: NonNullable = bindings.map((binding) => { + if (binding.type === "acp" || binding.match.channel.trim().toLowerCase() !== "slack") { + return binding; + } + const peer = binding.match.peer; + if (!peer) { + return binding; + } + const normalizedPeer = normalizeSlackRouteBindingPeer(peer); + if (normalizedPeer === peer) { + return binding; + } + changed = true; + return { + ...binding, + match: { + ...binding.match, + peer: normalizedPeer, + }, + }; + }); + + const normalizedCfg: OpenClawConfig = changed ? { ...cfg, bindings: normalizedBindings } : cfg; + slackRouteBindingConfigCache.set(cfg, { bindingsRef: bindings, normalizedCfg }); + return normalizedCfg; +} + +export function resolveSlackConversationBindingRoute(params: { + cfg: OpenClawConfig; + route: ReturnType; + accountId: string; + baseConversationId: string; + runtimeBindingThreadId?: string; + bindingsEnabled: boolean; + touchBinding?: boolean; +}) { + const boundThreadRoute = + params.bindingsEnabled && params.runtimeBindingThreadId + ? resolveRuntimeConversationBindingRoute({ + route: params.route, + touchBinding: params.touchBinding, + conversation: { + channel: "slack", + accountId: params.accountId, + conversationId: params.runtimeBindingThreadId, + parentConversationId: params.baseConversationId, + }, + }) + : null; + const runtimeRoute: RuntimeConversationBindingRouteResult = !params.bindingsEnabled + ? { + bindingOwnerAvailable: true, + route: params.route, + bindingRecord: null, + boundSessionKey: undefined, + } + : boundThreadRoute?.boundSessionKey || boundThreadRoute?.bindingRecord + ? boundThreadRoute + : resolveRuntimeConversationBindingRoute({ + route: params.route, + touchBinding: params.touchBinding, + conversation: { + channel: "slack", + accountId: params.accountId, + conversationId: params.baseConversationId, + }, + }); + const configuredRoute = + params.bindingsEnabled && !runtimeRoute.boundSessionKey && !runtimeRoute.bindingRecord + ? resolveConfiguredBindingRoute({ + cfg: params.cfg, + route: params.route, + conversation: { + channel: "slack", + accountId: params.accountId, + conversationId: params.baseConversationId, + }, + }) + : null; + return { + runtimeRoute, + configuredRoute, + route: runtimeRoute.boundSessionKey + ? runtimeRoute.route + : (configuredRoute?.route ?? params.route), + }; +} diff --git a/extensions/slack/src/conversation-route-owner.test.ts b/extensions/slack/src/conversation-route-owner.test.ts new file mode 100644 index 000000000000..796f08b86980 --- /dev/null +++ b/extensions/slack/src/conversation-route-owner.test.ts @@ -0,0 +1,110 @@ +import { + registerSessionBindingAdapter, + testing as sessionBindingTesting, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { inspectSlackConversationRouteOwner } from "./conversation-route-owner.js"; +import { registerSlackInstallationState } from "./installation-identity-state.js"; + +describe("inspectSlackConversationRouteOwner", () => { + let releaseInstallation: (() => void) | undefined; + + beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + releaseInstallation = registerSlackInstallationState("default", "workspace").release; + }); + + afterEach(() => { + releaseInstallation?.(); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + }); + + it("checks the thread before its parent without touching liveness", () => { + const touch = vi.fn(); + const resolveByConversation = vi.fn((conversation) => + conversation.conversationId === "thread-1" + ? { + bindingId: "binding-thread", + targetSessionKey: "agent:finance:bound", + targetKind: "session" as const, + conversation, + status: "active" as const, + boundAt: 1, + } + : null, + ); + registerSessionBindingAdapter({ + channel: "slack", + accountId: "default", + listBySession: () => [], + resolveByConversation, + touch, + }); + + expect( + inspectSlackConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "channel-1", threadId: "thread-1" }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + expect(resolveByConversation).toHaveBeenCalledWith({ + channel: "slack", + accountId: "default", + conversationId: "thread-1", + parentConversationId: "channel-1", + }); + expect(touch).not.toHaveBeenCalled(); + }); + + it("distinguishes degraded identity from a qualified target conflict", () => { + releaseInstallation?.(); + const installation = registerSlackInstallationState("default", "degraded"); + releaseInstallation = installation.release; + + expect( + inspectSlackConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "C456" }, + }), + ).toEqual({ kind: "unavailable" }); + installation.update("workspace"); + expect( + inspectSlackConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "team:T123:channel:C456" }, + }), + ).toBeNull(); + }); + + it("fails closed when workspace identity is released during binding inspection", () => { + registerSessionBindingAdapter({ + channel: "slack", + accountId: "default", + listBySession: () => [], + resolveByConversation: (conversation) => ({ + bindingId: "binding-channel", + targetSessionKey: "agent:finance:bound", + targetKind: "session", + conversation, + status: "active", + boundAt: 1, + }), + }); + const input = { + cfg: {}, + accountId: "default", + conversation: { kind: "channel" as const, peerId: "C456" }, + }; + + expect(inspectSlackConversationRouteOwner(input)).toEqual({ + kind: "agent", + agentId: "finance", + }); + releaseInstallation?.(); + releaseInstallation = undefined; + expect(inspectSlackConversationRouteOwner(input)).toEqual({ kind: "unavailable" }); + }); +}); diff --git a/extensions/slack/src/conversation-route-owner.ts b/extensions/slack/src/conversation-route-owner.ts new file mode 100644 index 000000000000..429d3317697c --- /dev/null +++ b/extensions/slack/src/conversation-route-owner.ts @@ -0,0 +1,107 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { + normalizeSlackRouteBindingConfig, + resolveSlackConversationBindingRoute, +} from "./conversation-binding-route.js"; +import { getSlackInstallationKind } from "./installation-identity-state.js"; +import { + qualifySlackConversationId, + qualifySlackRoutePeerId, +} from "./monitor/workspace-routing.js"; +import { parseSlackTarget } from "./targets.js"; + +export function inspectSlackConversationRouteOwner(params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + threadId?: string; + nativeChannelId?: string; + context?: { teamId?: string }; + }; +}) { + const installationKind = getSlackInstallationKind(params.accountId); + const direct = params.conversation.kind === "direct"; + const target = parseSlackTarget(params.conversation.peerId, { + defaultKind: direct ? "user" : "channel", + }); + if (!target || target.kind !== (direct ? "user" : "channel")) { + return null; + } + // Qualified targets remain durable Enterprise evidence after monitor teardown. Only an + // unqualified target is ambiguous while installation identity is temporarily degraded. + const targetIsEnterprise = Boolean(target.teamId); + if (!targetIsEnterprise && (installationKind === "degraded" || !installationKind)) { + return { kind: "unavailable" as const }; + } + if (targetIsEnterprise && installationKind === "workspace") { + return null; + } + const contextTeamId = params.conversation.context?.teamId?.trim(); + if ( + contextTeamId && + target.teamId && + contextTeamId.toLowerCase() !== target.teamId.toLowerCase() + ) { + return null; + } + const teamId = contextTeamId ?? target.teamId; + if ( + !direct && + params.conversation.nativeChannelId && + params.conversation.nativeChannelId.toLowerCase() !== target.id.toLowerCase() + ) { + return null; + } + const enterpriseRoute = installationKind === "enterprise" || targetIsEnterprise; + if (enterpriseRoute && !teamId) { + return null; + } + const enterpriseScope = enterpriseRoute && teamId ? { teamId } : undefined; + const route = resolveAgentRoute({ + cfg: normalizeSlackRouteBindingConfig(params.cfg), + channel: "slack", + accountId: params.accountId, + teamId, + peer: { + kind: params.conversation.kind, + id: qualifySlackRoutePeerId({ + id: target.id, + kind: direct ? "user" : "channel", + eventScope: enterpriseScope, + }), + }, + }); + const baseConversationId = qualifySlackConversationId( + direct ? `user:${target.id}` : target.id, + enterpriseScope, + ); + const bindingRoute = resolveSlackConversationBindingRoute({ + cfg: params.cfg, + route, + accountId: params.accountId, + baseConversationId, + runtimeBindingThreadId: params.conversation.threadId, + bindingsEnabled: !enterpriseRoute, + touchBinding: false, + }); + if (!bindingRoute.runtimeRoute.bindingOwnerAvailable) { + return { kind: "unavailable" as const }; + } + if (bindingRoute.runtimeRoute.pluginId) { + return { + kind: "plugin" as const, + pluginId: bindingRoute.runtimeRoute.pluginId, + fallbackAgentId: route.agentId, + }; + } + return { + kind: "agent" as const, + agentId: + bindingRoute.runtimeRoute.boundAgentId ?? + bindingRoute.configuredRoute?.boundAgentId ?? + route.agentId, + }; +} diff --git a/extensions/slack/src/monitor/message-handler/prepare-routing.ts b/extensions/slack/src/monitor/message-handler/prepare-routing.ts index d4fd57295279..3fba0ec7c6ae 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-routing.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-routing.ts @@ -1,15 +1,16 @@ // Slack plugin module implements prepare routing behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - resolveConfiguredBindingRoute, - resolveRuntimeConversationBindingRoute, - type ConfiguredBindingRouteResult, - type RuntimeConversationBindingRouteResult, +import type { + ConfiguredBindingRouteResult, + RuntimeConversationBindingRouteResult, } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveAgentRoute, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { resolveSlackReplyToMode } from "../../account-reply-mode.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; -import { parseSlackTarget, type SlackTargetKind } from "../../targets.js"; +import { + normalizeSlackRouteBindingConfig, + resolveSlackConversationBindingRoute, +} from "../../conversation-binding-route.js"; import { resolveSlackThreadContext } from "../../threading.js"; import type { SlackMessageEvent } from "../../types.js"; import type { SlackChannelConfigResolved } from "../channel-config.js"; @@ -43,92 +44,6 @@ type SlackRoutingContext = { historyKey: string; }; -type SlackRouteBinding = NonNullable[number]; -type SlackRouteBindingPeer = NonNullable; - -const slackRouteBindingConfigCache = new WeakMap< - OpenClawConfig, - { bindingsRef: OpenClawConfig["bindings"]; normalizedCfg: OpenClawConfig } ->(); - -function slackTargetDefaultKindForPeer(kind: SlackRouteBindingPeer["kind"]): SlackTargetKind { - return kind === "direct" ? "user" : "channel"; -} - -function slackTargetKindMatchesPeer( - peerKind: SlackRouteBindingPeer["kind"], - targetKind: SlackTargetKind, -): boolean { - if (targetKind === "user") { - return peerKind === "direct"; - } - return peerKind === "channel" || peerKind === "group"; -} - -function normalizeSlackRouteBindingPeer(peer: SlackRouteBindingPeer): SlackRouteBindingPeer { - const rawId = peer.id.trim(); - if (!rawId || rawId === "*") { - return peer; - } - - const target = (() => { - try { - return parseSlackTarget(rawId, { - defaultKind: slackTargetDefaultKindForPeer(peer.kind), - }); - } catch { - return undefined; - } - })(); - if (!target || !slackTargetKindMatchesPeer(peer.kind, target.kind)) { - return peer; - } - const normalizedId = target.teamId - ? `team:${target.teamId}:${target.kind}:${target.id}` - : target.id; - return normalizedId === peer.id ? peer : { ...peer, id: normalizedId }; -} - -function normalizeSlackRouteBindingConfig(cfg: OpenClawConfig): OpenClawConfig { - const bindings = cfg.bindings; - const cached = slackRouteBindingConfigCache.get(cfg); - if (cached && cached.bindingsRef === bindings) { - return cached.normalizedCfg; - } - if (!Array.isArray(bindings)) { - return cfg; - } - - let changed = false; - const normalizedBindings = bindings.map((binding) => { - if (binding.type === "acp" || binding.match.channel.trim().toLowerCase() !== "slack") { - return binding; - } - const peer = binding.match.peer; - if (!peer) { - return binding; - } - const normalizedPeer = normalizeSlackRouteBindingPeer(peer); - if (normalizedPeer === peer) { - return binding; - } - changed = true; - return { - ...binding, - match: { - ...binding.match, - peer: normalizedPeer, - }, - }; - }); - - const normalizedCfg = changed - ? ({ ...cfg, bindings: normalizedBindings } as OpenClawConfig) - : cfg; - slackRouteBindingConfigCache.set(cfg, { bindingsRef: bindings, normalizedCfg }); - return normalizedCfg; -} - function resolveSlackBaseConversationId(params: { message: SlackMessageEvent; isDirectMessage: boolean; @@ -259,48 +174,18 @@ export function resolveSlackRoutingContext(params: { }); const runtimeBindingThreadId = routedThreadId ?? (isDirectMessage && isThreadReply ? threadTs : undefined); - const boundThreadRoute = - !eventScope && runtimeBindingThreadId - ? resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "slack", - accountId: account.accountId, - conversationId: runtimeBindingThreadId, - parentConversationId: baseConversationId, - }, - }) - : null; - const runtimeRoute = eventScope - ? { route, bindingRecord: null, boundSessionKey: undefined } - : boundThreadRoute?.boundSessionKey || boundThreadRoute?.bindingRecord - ? boundThreadRoute - : resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "slack", - accountId: account.accountId, - conversationId: baseConversationId, - }, - }); - let configuredBinding: ConfiguredBindingRouteResult["bindingResolution"] = null; - let configuredBindingSessionKey = ""; - if (runtimeRoute.boundSessionKey || runtimeRoute.bindingRecord) { - route = runtimeRoute.route; - } else if (!eventScope) { - const configuredRoute = resolveConfiguredBindingRoute({ - cfg: ctx.cfg, - route, - conversation: { - channel: "slack", - accountId: account.accountId, - conversationId: baseConversationId, - }, - }); - configuredBinding = configuredRoute.bindingResolution; - configuredBindingSessionKey = configuredRoute.boundSessionKey ?? ""; - route = configuredRoute.route; - } + const bindingRoute = resolveSlackConversationBindingRoute({ + cfg: ctx.cfg, + route, + accountId: account.accountId, + baseConversationId, + runtimeBindingThreadId, + bindingsEnabled: !eventScope, + }); + const runtimeRoute = bindingRoute.runtimeRoute; + const configuredBinding = bindingRoute.configuredRoute?.bindingResolution ?? null; + const configuredBindingSessionKey = bindingRoute.configuredRoute?.boundSessionKey ?? ""; + route = bindingRoute.route; const threadKeys = runtimeRoute.boundSessionKey || configuredBindingSessionKey ? { sessionKey: route.sessionKey, parentSessionKey: undefined } diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 8793e4cd2575..0037148a7d90 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -505,6 +505,8 @@ describe("slack prepareSlackMessage inbound contract", () => { assertPrepared(prepared, "org-wide Slack DM"); expect(prepared.ctxPayload.GroupSpace).toBe("T123ENTERPRISE"); + expect(prepared.ctxPayload.ConversationRouteContextObserved).toBe(true); + expect(prepared.ctxPayload.ConversationRoutePeerId).toBe("team:T123ENTERPRISE:user:U123"); expect(prepared.ctxPayload.To).toBe("team:T123ENTERPRISE:user:U123"); expect(prepared.ctxPayload.OriginatingTo).toBe("team:T123ENTERPRISE:user:U123"); expect(prepared.ctxPayload.NativeChannelId).toBe("D999"); diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 7898edc16864..1dd1b9e443c6 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -77,6 +77,7 @@ import { escapeSlackMrkdwn } from "../mrkdwn.js"; import { resolveSlackRoomContextHints } from "../room-context.js"; import { sendMessageSlack } from "../send.runtime.js"; import { resolveSlackThreadStarter, type SlackThreadStarter } from "../thread.js"; +import { qualifySlackRoutePeerId } from "../workspace-routing.js"; import { discardSlackPreflightMedia, findCaptionlessSlackAudioFile, @@ -1691,6 +1692,14 @@ export async function prepareSlackMessage(params: { conversation: { kind: chatType, id: message.channel, + routePeer: { + kind: chatType, + id: qualifySlackRoutePeerId({ + id: isDirectMessage ? (message.user ?? "unknown") : message.channel, + kind: isDirectMessage ? "user" : "channel", + eventScope: opts.eventScope, + }), + }, label: envelopeFrom, spaceId: opts.eventScope?.teamId || ctx.teamId || undefined, threadId: boundMessageThreadId, diff --git a/extensions/slack/src/monitor/workspace-routing.ts b/extensions/slack/src/monitor/workspace-routing.ts index 9a2b207082af..22d57577aceb 100644 --- a/extensions/slack/src/monitor/workspace-routing.ts +++ b/extensions/slack/src/monitor/workspace-routing.ts @@ -14,7 +14,7 @@ export function resolveSlackEnterpriseMainDmSessionKey(params: { export function qualifySlackRoutePeerId(params: { id: string; kind: "user" | "channel"; - eventScope?: SlackEventScope; + eventScope?: Pick; }): string { if (!params.eventScope) { return params.id; @@ -24,7 +24,7 @@ export function qualifySlackRoutePeerId(params: { export function qualifySlackConversationId( conversationId: string, - eventScope?: SlackEventScope, + eventScope?: Pick, ): string { return eventScope ? `team:${encodeURIComponent(eventScope.teamId)}:${conversationId}` diff --git a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index 09279bcb0433..988e9c9811e4 100644 --- a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -113,6 +113,8 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 expect(buildChannelInboundEventContextMock).toHaveBeenCalledOnce(); const [turnOptions] = buildChannelInboundEventContextMock.mock.calls.at(0) ?? []; expect(turnOptions?.channel).toBe("telegram"); + expect(turnOptions?.conversation.routePeer).toEqual({ kind: "direct", id: "42" }); + expect(turnOptions?.conversation.parentId).toBeUndefined(); expect(turnOptions?.from).toBe("telegram:1234"); expect(turnOptions?.sender?.isBot).toBe(true); expect(turnOptions?.message.rawBody).toBe("hello"); diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index af2a8602e010..86e5f02d7e41 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -39,6 +39,7 @@ import { buildSenderName, buildTelegramGroupFrom, buildTelegramInboundOriginTarget, + buildTelegramParentPeer, describeReplyTarget, getTelegramTextParts, normalizeForwardedContext, @@ -49,6 +50,7 @@ import { } from "./bot/helpers.js"; import { renderTelegramTextEntities } from "./bot/inbound-text-entities.js"; import type { TelegramContext } from "./bot/types.js"; +import { resolveTelegramDirectPeerId } from "./dm-session-key.js"; import { resolveTelegramDirectToolPolicy, resolveTelegramGroupPromptSettings, @@ -62,6 +64,7 @@ import { } from "./group-history-window.js"; import { TELEGRAM_REPLY_CHAIN_MAX_DEPTH, type TelegramReplyChainEntry } from "./message-cache.js"; import { resolveTelegramPromptMediaPath } from "./prompt-media-path.js"; +import { buildTelegramConversationId } from "./topic-conversation.js"; type TelegramMentionFacts = NonNullable< NonNullable["mentions"] @@ -673,7 +676,18 @@ export async function buildTelegramInboundContextPayload(params: { conversation: { kind: conversationKind, id: String(chatId), + routePeer: { + kind: conversationKind, + id: isGroup + ? buildTelegramConversationId({ chatId, thread: threadSpec }) + : resolveTelegramDirectPeerId({ chatId, senderId }), + }, label: conversationLabel, + parentId: buildTelegramParentPeer({ + isGroup, + resolvedThreadId: threadSpec.id, + chatId, + })?.id, threadId: threadSpec.id != null ? String(threadSpec.id) : undefined, }, route: { diff --git a/extensions/telegram/src/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts index de7c675d64e9..b74c6ed15328 100644 --- a/extensions/telegram/src/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -117,7 +117,10 @@ describe("buildTelegramMessageContext thread binding override", () => { accountId: "default", sessionKey: "plugin-binding:openclaw-codex-app-server:session-1", agentId: "main", - bindingMode: { kind: "plugin-owned-runtime" }, + bindingMode: { + kind: "plugin-owned-runtime", + pluginId: "openclaw-codex-app-server", + }, }), ); diff --git a/extensions/telegram/src/bot-message-context.typing.test.ts b/extensions/telegram/src/bot-message-context.typing.test.ts index b157e4edb8b1..691e79708310 100644 --- a/extensions/telegram/src/bot-message-context.typing.test.ts +++ b/extensions/telegram/src/bot-message-context.typing.test.ts @@ -197,6 +197,14 @@ describe("buildTelegramMessageContext typing", () => { expect(first).toHaveBeenCalledExactlyOnceWith(expectedBinding); expect(last).toHaveBeenCalledExactlyOnceWith(expectedBinding); expect(ctx?.ctxPayload.MessageSid).toBe("102"); + expect(buildInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + conversation: expect.objectContaining({ + routePeer: { kind: "group", id: "-1001234567890:topic:99" }, + parentId: "-1001234567890", + }), + }), + ); }); it("does not send forum topic typing for unaddressed require-mention messages", async () => { diff --git a/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts index 30146d6946bd..11c0391383c4 100644 --- a/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts +++ b/extensions/telegram/src/bot-native-command-dispatch.routing.test.ts @@ -334,8 +334,10 @@ describe("Telegram native command dispatch routing", () => { { ctx?: { CommandTargetSessionKey?: string; + ConversationRoutePeerId?: string; MessageThreadId?: number; OriginatingTo?: string; + ThreadParentId?: string; }; }, ] @@ -345,8 +347,10 @@ describe("Telegram native command dispatch routing", () => { dispatchCall?.ctx, { CommandTargetSessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + ConversationRoutePeerId: "-1001234567890:topic:42", MessageThreadId: 42, OriginatingTo: "telegram:-1001234567890:topic:42", + ThreadParentId: "-1001234567890", }, "topic dispatch context", ); diff --git a/extensions/telegram/src/bot-native-command-dispatch.ts b/extensions/telegram/src/bot-native-command-dispatch.ts index 2ac8fe605005..6f9cf4d39322 100644 --- a/extensions/telegram/src/bot-native-command-dispatch.ts +++ b/extensions/telegram/src/bot-native-command-dispatch.ts @@ -48,6 +48,7 @@ import { } from "./bot/helpers.js"; import type { TelegramGetChat } from "./bot/types.js"; import { + buildTelegramConversationRouteContext, resolveTelegramConversationRoute, resolveTelegramTargetSession, } from "./conversation-route.js"; @@ -545,6 +546,7 @@ export async function dispatchTelegramBuiltinTurn(params: { : `telegram:${dispatch.chatId}`, To: `slash:${dispatch.senderId || dispatch.chatId}`, ChatType: dispatch.isGroup ? "group" : "direct", + ...buildTelegramConversationRouteContext(dispatch), ConversationToolPolicy: dispatch.isGroup ? undefined : resolveTelegramDirectToolPolicy({ diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index e132384bf4ad..76de7dfcf66f 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -62,6 +62,7 @@ import { resolveTelegramConfigAccessorAccount, telegramConfigAdapter, } from "./config-adapter.js"; +import { inspectTelegramConversationRouteOwner } from "./conversation-route-owner.js"; import { resolveTelegramConversationBaseSessionKey } from "./conversation-route.js"; import { listTelegramDirectoryGroupsFromConfig, @@ -804,6 +805,7 @@ export const telegramPlugin = createChatChannelPlugin({ }, conversationBindings: { supportsCurrentConversationBinding: true, + bindingStore: "adapter", defaultTopLevelPlacement: "current", resolveConversationRef: ({ accountId: _accountId, @@ -897,6 +899,7 @@ export const telegramPlugin = createChatChannelPlugin({ messaging: { defaultMarkdownTableMode: "block", targetPrefixes: ["telegram", "tg"], + resolveConversationRouteOwner: inspectTelegramConversationRouteOwner, numericTopicShorthand: true, normalizeTarget: normalizeTelegramMessagingTarget, resolveInboundConversation: ({ to, conversationId, threadId }) => diff --git a/extensions/telegram/src/conversation-route-owner.test.ts b/extensions/telegram/src/conversation-route-owner.test.ts new file mode 100644 index 000000000000..d09bb6a89bf2 --- /dev/null +++ b/extensions/telegram/src/conversation-route-owner.test.ts @@ -0,0 +1,133 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + registerSessionBindingAdapter, + type SessionBindingAdapter, + testing, + unregisterSessionBindingAdapter, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { inspectTelegramConversationRouteOwner } from "./conversation-route-owner.js"; + +describe("inspectTelegramConversationRouteOwner", () => { + let adapter: SessionBindingAdapter; + + beforeEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: { + id: "telegram", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: () => ({ stop: () => undefined }), + }, + }, + }, + ]), + ); + testing.resetSessionBindingAdaptersForTests(); + adapter = { + channel: "telegram", + accountId: "default", + listBySession: () => [], + resolveByConversation: () => null, + }; + registerSessionBindingAdapter(adapter); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + testing.resetSessionBindingAdaptersForTests(); + }); + + it("replays topic config and runtime precedence without touching liveness", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + groups: { "-100123": { topics: { "42": { agentId: "configured" } } } }, + }, + }, + }; + const touch = vi.fn(); + registerSessionBindingAdapter({ + channel: "telegram", + accountId: "default", + listBySession: () => [], + resolveByConversation: (conversation) => ({ + bindingId: "binding-topic", + targetSessionKey: "agent:runtime:bound", + targetKind: "session", + conversation, + status: "active", + boundAt: 1, + }), + touch, + }); + + expect( + inspectTelegramConversationRouteOwner({ + cfg, + accountId: "default", + conversation: { kind: "group", peerId: "-100123:topic:42", threadId: "42" }, + }), + ).toEqual({ kind: "agent", agentId: "runtime" }); + expect(touch).not.toHaveBeenCalled(); + }); + + it("reports a temporary adapter gap only while thread bindings are enabled", () => { + unregisterSessionBindingAdapter({ channel: "telegram", accountId: "default", adapter }); + const conversation = { + kind: "group" as const, + peerId: "-100123:topic:42", + threadId: "42", + }; + + expect( + inspectTelegramConversationRouteOwner({ cfg: {}, accountId: "default", conversation }), + ).toEqual({ kind: "unavailable" }); + expect( + inspectTelegramConversationRouteOwner({ + cfg: { channels: { telegram: { threadBindings: { enabled: false } } } }, + accountId: "default", + conversation, + }), + ).toEqual({ kind: "agent", agentId: "main" }); + }); + + it("keeps the direct sender route separate from its delivery chat", () => { + const resolveByConversation = vi.fn((conversation) => ({ + bindingId: "binding-dm", + targetSessionKey: "agent:runtime:bound", + targetKind: "session" as const, + conversation, + status: "active" as const, + boundAt: 1, + })); + registerSessionBindingAdapter({ + channel: "telegram", + accountId: "default", + listBySession: () => [], + resolveByConversation, + }); + + expect( + inspectTelegramConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "direct", peerId: "1001", target: "2002" }, + }), + ).toEqual({ kind: "agent", agentId: "runtime" }); + expect(resolveByConversation).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "2002" }), + ); + }); +}); diff --git a/extensions/telegram/src/conversation-route-owner.ts b/extensions/telegram/src/conversation-route-owner.ts new file mode 100644 index 000000000000..5478ba962dc5 --- /dev/null +++ b/extensions/telegram/src/conversation-route-owner.ts @@ -0,0 +1,90 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveThreadBindingSpawnPolicy } from "openclaw/plugin-sdk/conversation-runtime"; +import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime"; +import { resolveTelegramAccount } from "./accounts.js"; +import { inspectTelegramConversationRoute } from "./conversation-route.js"; +import { resolveTelegramScopedGroupConfig } from "./group-config-helpers.js"; +import { parseTelegramTarget } from "./targets.js"; +import type { TelegramThreadSpec } from "./thread-spec.js"; + +function resolveInspectionThread(params: { + kind: "direct" | "group" | "channel"; + peerId: string; + target?: string; + threadId?: string; +}): { chatId: string; threadSpec: TelegramThreadSpec } | null { + const target = parseTelegramTarget(params.target?.trim() || params.peerId); + const chatId = target.chatId.trim(); + if (!chatId) { + return null; + } + if (target.directMessagesTopicId != null) { + return { + chatId, + threadSpec: { id: target.directMessagesTopicId, scope: "direct-messages" }, + }; + } + if (target.messageThreadId != null) { + return { chatId, threadSpec: { id: target.messageThreadId, scope: "forum" } }; + } + const threadId = parseStrictNonNegativeInteger(params.threadId); + return { + chatId, + threadSpec: + threadId == null + ? { scope: "none" } + : { id: threadId, scope: params.kind === "direct" ? "dm" : "forum" }, + }; +} + +export function inspectTelegramConversationRouteOwner(params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + target?: string; + threadId?: string; + }; +}) { + const parsed = resolveInspectionThread(params.conversation); + if (!parsed) { + return null; + } + const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId }); + const { topicConfig } = resolveTelegramScopedGroupConfig( + account.config, + parsed.chatId, + parsed.threadSpec.id, + ); + const result = inspectTelegramConversationRoute({ + cfg: params.cfg, + accountId: account.accountId, + chatId: parsed.chatId, + isGroup: params.conversation.kind !== "direct", + threadSpec: parsed.threadSpec, + senderId: params.conversation.kind === "direct" ? params.conversation.peerId : undefined, + topicAgentId: topicConfig?.agentId, + }); + if ( + !result.bindingOwnerAvailable && + resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel: "telegram", + accountId: params.accountId, + kind: "subagent", + }).enabled + ) { + return { kind: "unavailable" as const }; + } + if (result.bindingMode.kind !== "plugin-owned-runtime") { + return { kind: "agent" as const, agentId: result.route.agentId }; + } + return result.bindingMode.pluginId + ? { + kind: "plugin" as const, + pluginId: result.bindingMode.pluginId, + fallbackAgentId: result.route.agentId, + } + : null; +} diff --git a/extensions/telegram/src/conversation-route.base-session-key.test.ts b/extensions/telegram/src/conversation-route.base-session-key.test.ts index ef1a629473ab..8c29371b53d2 100644 --- a/extensions/telegram/src/conversation-route.base-session-key.test.ts +++ b/extensions/telegram/src/conversation-route.base-session-key.test.ts @@ -204,7 +204,10 @@ describe("resolveTelegramConversationBaseSessionKey", () => { }); expect(touch).toHaveBeenCalledWith("binding-plugin-owned", undefined); - expect(result.bindingMode).toEqual({ kind: "plugin-owned-runtime" }); + expect(result.bindingMode).toEqual({ + kind: "plugin-owned-runtime", + pluginId: "openclaw-codex-app-server", + }); expect(result.route.agentId).toBe("main"); expect(result.route.sessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:11"); expect(result.route.matchedBy).toBe("default"); diff --git a/extensions/telegram/src/conversation-route.ts b/extensions/telegram/src/conversation-route.ts index 1bd2f523b017..e253e8eaf255 100644 --- a/extensions/telegram/src/conversation-route.ts +++ b/extensions/telegram/src/conversation-route.ts @@ -38,14 +38,15 @@ type TelegramConversationBindingMode = kind: "runtime-bound"; sessionKey: string; } - | { kind: "plugin-owned-runtime" }; + | { kind: "plugin-owned-runtime"; pluginId: string }; type TelegramConversationRouteResult = { route: TelegramResolvedRoute; bindingMode: TelegramConversationBindingMode; + bindingOwnerAvailable: boolean; }; -export function resolveTelegramConversationRoute(params: { +type ResolveTelegramConversationRouteParams = { cfg: OpenClawConfig; accountId: string; chatId: number | string; @@ -53,7 +54,28 @@ export function resolveTelegramConversationRoute(params: { threadSpec: TelegramThreadSpec; senderId?: string | number | null; topicAgentId?: string | null; -}): TelegramConversationRouteResult { +}; + +export function buildTelegramConversationRouteContext(params: { + chatId: number | string; + isGroup: boolean; + threadSpec: TelegramThreadSpec; + senderId?: string | number | null; + resolvedThreadId?: number; +}) { + return { + ConversationRouteContextObserved: true, + ConversationRoutePeerId: params.isGroup + ? buildTelegramConversationId({ chatId: params.chatId, thread: params.threadSpec }) + : resolveTelegramDirectPeerId(params), + ThreadParentId: buildTelegramParentPeer(params)?.id, + }; +} + +function resolveTelegramConversationRouteWithRuntimePolicy( + params: ResolveTelegramConversationRouteParams, + touchRuntimeBinding: boolean, +): TelegramConversationRouteResult { const resolvedThreadId = params.threadSpec.id; const conversationId = buildTelegramConversationId({ chatId: params.chatId, @@ -141,6 +163,7 @@ export function resolveTelegramConversationRoute(params: { const runtimeBindingConversationId = conversationId; const runtimeRoute = resolveRuntimeConversationBindingRoute({ route, + touchBinding: touchRuntimeBinding, conversation: { channel: "telegram", accountId: params.accountId, @@ -151,7 +174,7 @@ export function resolveTelegramConversationRoute(params: { if (runtimeRoute.bindingRecord) { bindingMode = runtimeRoute.boundSessionKey ? { kind: "runtime-bound", sessionKey: runtimeRoute.boundSessionKey } - : { kind: "plugin-owned-runtime" }; + : { kind: "plugin-owned-runtime", pluginId: runtimeRoute.pluginId ?? "" }; logVerbose( runtimeRoute.boundSessionKey ? `telegram: routed via bound conversation ${runtimeBindingConversationId} -> ${runtimeRoute.boundSessionKey}` @@ -162,9 +185,23 @@ export function resolveTelegramConversationRoute(params: { return { route, bindingMode, + bindingOwnerAvailable: runtimeRoute.bindingOwnerAvailable ?? true, }; } +export function resolveTelegramConversationRoute( + params: ResolveTelegramConversationRouteParams, +): TelegramConversationRouteResult { + return resolveTelegramConversationRouteWithRuntimePolicy(params, true); +} + +/** Revalidates route ownership without extending runtime-binding liveness. */ +export function inspectTelegramConversationRoute( + params: ResolveTelegramConversationRouteParams, +): TelegramConversationRouteResult { + return resolveTelegramConversationRouteWithRuntimePolicy(params, false); +} + export function resolveTelegramConversationBaseSessionKey( params: Parameters[1], ): string { diff --git a/package.json b/package.json index 0187123a4831..b334f187d3bd 100644 --- a/package.json +++ b/package.json @@ -703,6 +703,10 @@ "./plugin-sdk/media-generation-runtime": { "default": "./dist/plugin-sdk/media-generation-runtime.js" }, + "./plugin-sdk/conversation-binding-inspection-runtime": { + "types": "./dist/plugin-sdk/conversation-binding-inspection-runtime.d.ts", + "default": "./dist/plugin-sdk/conversation-binding-inspection-runtime.js" + }, "./plugin-sdk/conversation-binding-runtime": { "default": "./dist/plugin-sdk/conversation-binding-runtime.js" }, diff --git a/scripts/fixtures/packed-plugin-sdk-type-smoke.ts b/scripts/fixtures/packed-plugin-sdk-type-smoke.ts index f7cb6a4f678e..42ab11010943 100644 --- a/scripts/fixtures/packed-plugin-sdk-type-smoke.ts +++ b/scripts/fixtures/packed-plugin-sdk-type-smoke.ts @@ -1,4 +1,9 @@ // Packed Plugin Sdk Type Smoke script supports OpenClaw repository automation. +import { + inspectConversationBinding, + type ConversationBindingInspection, +} from "openclaw/plugin-sdk/conversation-binding-inspection-runtime"; +import type { ChannelMessagingAdapter } from "openclaw/plugin-sdk/core"; type PublicPluginSdkModules = [ typeof import("openclaw/plugin-sdk/core"), typeof import("openclaw/plugin-sdk/channel-entry-contract"), @@ -8,5 +13,17 @@ type PublicPluginSdkModules = [ ]; const resolvedModules = null as unknown as PublicPluginSdkModules; +const routeOwnerResolver: NonNullable = ({ + accountId, + conversation, +}) => { + const inspection: ConversationBindingInspection = inspectConversationBinding({ + channel: "fixture-channel", + accountId, + conversationId: conversation.target ?? conversation.peerId, + }); + return inspection.status === "unavailable" ? { kind: "unavailable" } : undefined; +}; void resolvedModules; +void routeOwnerResolver; diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 5a3bbbaf8e2c..4313e218e596 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -90,6 +90,9 @@ export const pluginSdkDocMetadata = { "session-store-runtime": { category: "runtime", }, + "conversation-binding-inspection-runtime": { + category: "runtime", + }, "agent-scope-runtime": { category: "runtime", }, diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 5f9e98ad5d0c..717aa626aeff 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -86,6 +86,7 @@ "media-mime", "embedding-providers", "media-generation-runtime", + "conversation-binding-inspection-runtime", "conversation-binding-runtime", "conversation-runtime", "thread-bindings-runtime", diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 218fa3454d81..288c75e13adf 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -196,7 +196,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: canonical Computer Use wire contract and node-host provider seam. // -1: retire the deprecated messaging-targets subpath. // +2: bounded provider streams and read-only SecretRef resolution. - 146, + // +1: read-only authoritative conversation-binding inspection for route-owner plugins. + 147, env, ), publicExports: readPluginSdkSurfaceBudgetEnv( @@ -307,7 +308,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: named bounded structured-input surface for native harness protocol adapters. // +1: OpenAI-compatible video execution in the existing media-understanding owner. // -2: retire the uncalled secret-plan target resolver and its result type. - 4335, + // +2: conversation-binding inspection result and runtime inspector. + 4337, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -396,7 +398,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -1: remove the test-only channel activity reset export. // +1: OpenAI-compatible video execution in the existing media-understanding owner. // -1: retire the uncalled secret-plan target resolver. - 2577, + // +1: read-only authoritative conversation-binding inspector. + 2578, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 2c34cffdadc8..4797dcda301b 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -77,6 +77,12 @@ function createAcpCommandSessionBindingService() { return { bind: (input: unknown) => hoisted.sessionBindingBindMock(input), getCapabilities: forward((params: unknown) => hoisted.sessionBindingCapabilitiesMock(params)), + inspectByConversation: ( + ref: unknown, + ): { status: "available"; binding: SessionBindingRecord | null } => ({ + status: "available", + binding: hoisted.sessionBindingResolveByConversationMock(ref), + }), listBySession: (targetSessionKey: string) => hoisted.sessionBindingListBySessionMock(targetSessionKey), resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index dc9ba4480760..4106a917d6e6 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -11,6 +11,7 @@ import { clearAllCliSessions, getCliSessionBinding } from "../../agents/cli-sess import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js"; import { cleanupBrowserSessionsForLifecycleEnd } from "../../browser-lifecycle-cleanup.js"; import { normalizeChatType } from "../../channels/chat-type.js"; +import { conversationRouteContextFromMsgContext } from "../../config/sessions/conversation-route-context.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; import { hasTerminalMainSessionTranscriptNewerThanRegistry, @@ -1032,6 +1033,11 @@ async function initSessionStateAttemptLocked( } }, previousEntry: previousSessionEntry, + ...(!isSystemEvent && + sessionCtxForState.InboundAccessAuthorized === true && + sessionCtxForState.ConversationRouteContextObserved === true + ? { routeContext: conversationRouteContextFromMsgContext(sessionCtxForState) ?? null } + : {}), retiredEntry: retiredLegacyMainDelivery, sessionEntry, sessionKey, diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index b239a05e3d9f..cc651a8b9e5a 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -413,6 +413,10 @@ export type MsgContext = Partial & { * Correlation interceptors must fail closed when this proof is absent. */ InboundAccessAuthorized?: boolean; + /** Internal marker that channel ingress authoritatively observed route-context facts. */ + ConversationRouteContextObserved?: boolean; + /** Canonical peer used by route selection; delivery targets may use a different namespace. */ + ConversationRoutePeerId?: string; /** * Internal flag for channels that emit message_received through a channel-specific * privacy gate before entering the shared reply dispatcher. diff --git a/src/channels/direct-dm.test.ts b/src/channels/direct-dm.test.ts index c714d743d5e8..55a99d6d1dd2 100644 --- a/src/channels/direct-dm.test.ts +++ b/src/channels/direct-dm.test.ts @@ -205,5 +205,6 @@ describe("dispatchInboundDirectDm", () => { to: "reef:bot-1", originatingTo: "reef:peer-1", }); + expect(contextParams?.conversation.routePeer).toEqual({ kind: "direct", id: "peer-1" }); }); }); diff --git a/src/channels/direct-dm.ts b/src/channels/direct-dm.ts index df70923db4f1..7bcfe7c5c970 100644 --- a/src/channels/direct-dm.ts +++ b/src/channels/direct-dm.ts @@ -92,7 +92,12 @@ async function buildDirectDmContext( timestamp: params.timestamp, from: params.senderAddress, sender: { id: params.senderId, name: params.conversationLabel }, - conversation: { kind: "direct", id: params.peer.id, label: params.conversationLabel }, + conversation: { + kind: "direct", + id: params.peer.id, + routePeer: params.peer, + label: params.conversationLabel, + }, route: { agentId: route.agentId, accountId: route.accountId, @@ -231,6 +236,10 @@ export async function dispatchInboundDirectDmWithRuntime( Timestamp: params.timestamp, CommandAuthorized: params.commandAuthorized, ...(params.inboundAccessAuthorized === true ? { InboundAccessAuthorized: true } : {}), + ...(params.inboundAccessAuthorized === true + ? { ConversationRouteContextObserved: true as const } + : {}), + ConversationRoutePeerId: params.peer.id, OriginatingChannel: params.originatingChannel ?? params.channel, OriginatingTo: params.originatingTo ?? params.senderAddress, NativeDirectUserId: params.peer.id, diff --git a/src/channels/feedback-reflection.test.ts b/src/channels/feedback-reflection.test.ts index b7beea0f5466..7200ce58fad6 100644 --- a/src/channels/feedback-reflection.test.ts +++ b/src/channels/feedback-reflection.test.ts @@ -92,7 +92,10 @@ describe("channel feedback reflection", () => { cfg, channel: "msteams", route: { agentId: "main", sessionKey: params.sessionKey }, - ctxPayload: expect.objectContaining({ ChatType: "group" }), + ctxPayload: expect.objectContaining({ + ChatType: "group", + ConversationRouteContextObserved: false, + }), }), ); await expect(runChannelFeedbackReflection(params)).resolves.toEqual({ status: "cooldown" }); diff --git a/src/channels/feedback-reflection.ts b/src/channels/feedback-reflection.ts index 6f25acaa8e07..21a387cfc85b 100644 --- a/src/channels/feedback-reflection.ts +++ b/src/channels/feedback-reflection.ts @@ -151,6 +151,7 @@ export async function runChannelFeedbackReflection(params: { reply: { to: target, originatingTo: target }, message: { body, bodyForAgent: prompt, rawBody: prompt, commandBody: prompt }, access: { commands: { authorized: false } }, + extra: { ConversationRouteContextObserved: false }, }); const responses: string[] = []; await dispatchRoutedChannelTurn({ diff --git a/src/channels/inbound-event/context.test.ts b/src/channels/inbound-event/context.test.ts index 5e793767e094..b27d4311eccc 100644 --- a/src/channels/inbound-event/context.test.ts +++ b/src/channels/inbound-event/context.test.ts @@ -21,6 +21,7 @@ function createBaseContextParams( conversation: { kind: "group", id: "room-1", + routePeer: { kind: "group", id: "route-room-1" }, }, route: { agentId: "main", @@ -108,6 +109,14 @@ describe("resolveInboundSupplementalSenderAllowed", () => { }); describe("buildChannelInboundEventContext", () => { + it("does not claim authoritative route facts when the producer omits the route peer", () => { + const ctx = buildTestInboundEventContext({ + conversation: { kind: "group", id: "room-1" }, + }); + + expect(ctx.ConversationRouteContextObserved).toBeUndefined(); + }); + it("maps normalized inbound facts into a finalized message context", async () => { const ctx = buildChannelInboundEventContext({ channel: "test", @@ -128,6 +137,7 @@ describe("buildChannelInboundEventContext", () => { conversation: { kind: "group", id: "room-1", + routePeer: { kind: "group", id: "route-room-1" }, label: "Room One", spaceId: "workspace", threadId: "thread-1", @@ -204,6 +214,8 @@ describe("buildChannelInboundEventContext", () => { }); expect(ctx.InboundAccessAuthorized).toBe(true); + expect(ctx.ConversationRouteContextObserved).toBe(true); + expect(ctx.ConversationRoutePeerId).toBe("route-room-1"); const expectedFields = { Body: "[User One] hello", diff --git a/src/channels/inbound-event/context.ts b/src/channels/inbound-event/context.ts index adae40418027..1d8f6c041ae6 100644 --- a/src/channels/inbound-event/context.ts +++ b/src/channels/inbound-event/context.ts @@ -554,6 +554,7 @@ function buildChannelInboundEventContextValue( ReplyToIdFull: params.reply.replyToIdFull, ChatType: params.conversation.kind, ChatId: params.conversation.id, + ConversationRoutePeerId: params.conversation.routePeer?.id, ConversationLabel: params.conversation.label, GroupSubject: params.conversation.kind !== "direct" ? params.conversation.label : undefined, GroupSpace: params.conversation.spaceId, @@ -586,6 +587,7 @@ function buildChannelInboundEventContextValue( // This builder is the post-admission boundary for channel events. Preserve // that fact so interceptors cannot bypass sender, route, or pairing gates. InboundAccessAuthorized: true, + ConversationRouteContextObserved: params.conversation.routePeer ? true : undefined, ...params.extra, }; const finalizeParams = { diff --git a/src/channels/plugins/binding-routing.test.ts b/src/channels/plugins/binding-routing.test.ts index 72482911e613..484fc67eb2b8 100644 --- a/src/channels/plugins/binding-routing.test.ts +++ b/src/channels/plugins/binding-routing.test.ts @@ -10,6 +10,7 @@ import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; import { ensureConfiguredBindingRouteReady, resolveRuntimeConversationBindingRoute, + type RuntimeConversationBindingRouteResult, } from "./binding-routing.js"; import { registerStatefulBindingTargetDriver } from "./stateful-target-drivers.js"; @@ -62,6 +63,15 @@ describe("runtime conversation binding route", () => { testing.resetSessionBindingAdaptersForTests(); }); + it("keeps the stable runtime-route result structurally assignable", () => { + const result: RuntimeConversationBindingRouteResult = { + bindingRecord: null, + route: createRoute(), + }; + + expect(result.bindingOwnerAvailable).toBeUndefined(); + }); + it("rewrites the route to a runtime-bound ACP session and touches the binding", () => { const binding = createBinding(); const { resolveByConversation, touch } = registerAdapter(binding); @@ -120,6 +130,24 @@ describe("runtime conversation binding route", () => { expect(result.route).toBe(route); }); + it("inspects a runtime-bound route without touching the binding", () => { + const { touch } = registerAdapter(createBinding()); + + const result = resolveRuntimeConversationBindingRoute({ + route: createRoute(), + touchBinding: false, + conversation: { + channel: "demo", + accountId: "default", + conversationId: "room-1", + }, + }); + + expect(touch).not.toHaveBeenCalled(); + expect(result.bindingOwnerAvailable).toBe(true); + expect(result.boundSessionKey).toBe("agent:review:acp:session-1"); + }); + it("ignores runtime bindings that target isolated cron run sessions", () => { const route = createRoute(); const binding = createBinding({ diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts index a34d9469b8f0..399135f2b72a 100644 --- a/src/channels/plugins/binding-routing.ts +++ b/src/channels/plugins/binding-routing.ts @@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { getSessionBindingService, + inspectSessionBindingByConversation, type ConversationRef, type SessionBindingRecord, } from "../../infra/outbound/session-binding-service.js"; @@ -34,10 +35,13 @@ export type ConfiguredBindingRouteResult = { * Route resolution after applying a runtime conversation binding record. */ export type RuntimeConversationBindingRouteResult = { + /** False only when the authoritative channel-owned binding store is temporarily unavailable. */ + bindingOwnerAvailable?: boolean; bindingRecord: SessionBindingRecord | null; route: ResolvedAgentRoute; boundSessionKey?: string; boundAgentId?: string; + pluginId?: string; }; type ConfiguredBindingRouteConversationInput = @@ -65,16 +69,19 @@ function resolveConfiguredBindingConversationRef( }; } -function isPluginOwnedRuntimeBindingRecord(record: SessionBindingRecord | null): boolean { +function resolvePluginOwnedRuntimeBindingPluginId( + record: SessionBindingRecord | null, +): string | undefined { const metadata = record?.metadata; if (!metadata || typeof metadata !== "object") { - return false; + return undefined; } - return ( + const pluginId = metadata.pluginId; + const isPluginOwned = metadata.pluginBindingOwner === "plugin" && - typeof metadata.pluginId === "string" && - typeof metadata.pluginRoot === "string" - ); + typeof pluginId === "string" && + typeof metadata.pluginRoot === "string"; + return isPluginOwned ? pluginId.trim() || undefined : undefined; } /** @@ -132,14 +139,25 @@ export function resolveConfiguredBindingRoute( export function resolveRuntimeConversationBindingRoute( params: { route: ResolvedAgentRoute; + /** Set false for read-only ownership checks that must not extend binding liveness. */ + touchBinding?: boolean; } & ConfiguredBindingRouteConversationInput, ): RuntimeConversationBindingRouteResult { - const bindingRecord = getSessionBindingService().resolveByConversation( + const inspection = inspectSessionBindingByConversation( resolveConfiguredBindingConversationRef(params), ); + if (inspection.status === "unavailable") { + return { + bindingOwnerAvailable: false, + bindingRecord: null, + route: params.route, + }; + } + const bindingRecord = inspection.binding; const boundSessionKey = bindingRecord?.targetSessionKey?.trim(); if (!bindingRecord || !boundSessionKey) { return { + bindingOwnerAvailable: true, bindingRecord: null, route: params.route, }; @@ -151,23 +169,30 @@ export function resolveRuntimeConversationBindingRoute( `ignored runtime conversation binding ${bindingRecord.bindingId} to isolated cron run session ${boundSessionKey}`, ); return { + bindingOwnerAvailable: true, bindingRecord: null, route: params.route, }; } - getSessionBindingService().touch(bindingRecord.bindingId); - if (isPluginOwnedRuntimeBindingRecord(bindingRecord)) { + if (params.touchBinding !== false) { + getSessionBindingService().touch(bindingRecord.bindingId); + } + const pluginId = resolvePluginOwnedRuntimeBindingPluginId(bindingRecord); + if (pluginId) { // Plugin-owned binding records are observed but not route-rewritten by core; the owning // plugin is responsible for its runtime target handoff. return { + bindingOwnerAvailable: true, bindingRecord, + pluginId, route: params.route, }; } const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId; return { + bindingOwnerAvailable: true, bindingRecord, boundSessionKey, boundAgentId, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 81fa497ad149..bfd397e8a524 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -739,6 +739,8 @@ export type ChannelConfiguredBindingProvider = { export type ChannelConversationBindingSupport = { supportsCurrentConversationBinding?: boolean; isCurrentConversationBindingSupported?: (params: { accountId: string }) => boolean; + /** Declares that live bindings come from a channel-registered adapter, never generic storage. */ + bindingStore?: "adapter"; /** * Preferred placement when a command is started from a top-level conversation * without an existing native thread id. diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index d0d7a52980cf..3812c293793e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -494,6 +494,32 @@ export type ChannelMessagingAdapter = { * targets before plugin-specific normalization. */ targetPrefixes?: readonly string[]; + /** Re-resolve the current owner when channel behavior exceeds generic bindings. */ + resolveConversationRouteOwner?: (params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + /** Canonical delivery target when it differs from the routing peer. */ + target?: string; + threadId?: string; + nativeChannelId?: string; + context?: { + parentPeerId?: string; + guildId?: string; + teamId?: string; + memberRoleIds?: string[]; + }; + }; + }) => + // `undefined` delegates to core, `null` denies ownership, and `unavailable` + // preserves temporary owner-store outages as retryable delivery failures. + | { kind: "agent"; agentId: string } + | { kind: "plugin"; pluginId: string; fallbackAgentId: string } + | { kind: "unavailable" } + | null + | undefined; /** DM targets rebuilt from session keys require an explicit `user:` kind prefix. */ directTargetStyle?: "user-prefixed"; /** Equality rule for ids carried by prefixed outbound targets. */ diff --git a/src/config/sessions/conversation-identity.ts b/src/config/sessions/conversation-identity.ts index d464df793b04..098862efa78c 100644 --- a/src/config/sessions/conversation-identity.ts +++ b/src/config/sessions/conversation-identity.ts @@ -14,6 +14,10 @@ import { sessionDeliveryOrigin, } from "../../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; +import { + conversationRouteContextFromMsgContext, + type ConversationRouteContext, +} from "./conversation-route-context.js"; import { resolveGroupSessionKey } from "./group.js"; import { deriveSessionOrigin } from "./metadata.js"; import type { GroupKeyResolution, SessionEntry } from "./types.js"; @@ -155,6 +159,7 @@ export function buildConversationIdentity(params: { /** Derives a transport address from the canonical route snapshot persisted on a session. */ export function conversationIdentityFromSessionEntry( entry: SessionEntry, + routeContext?: ConversationRouteContext | null, ): ConversationIdentity | null { const deliveryContext = deliveryContextFromSession(entry); const origin = sessionDeliveryOrigin(entry); @@ -179,7 +184,7 @@ export function conversationIdentityFromSessionEntry( accountId: routeOwnsTarget ? deliveryContext?.accountId : origin?.accountId, kind, // Native ids remain descriptive metadata and cannot redirect a stored conversation ref. - peerId: pairedOriginPeerId ?? deliveryTarget, + peerId: routeContext?.peerId ?? pairedOriginPeerId ?? deliveryTarget, deliveryTarget, threadId: routeOwnsTarget ? deliveryContext?.threadId : origin?.threadId, nativeChannelId: origin?.nativeChannelId, @@ -204,6 +209,7 @@ export function conversationIdentityFromMsgContext(params: { }); const deliveryContext = mergeDeliveryContext(explicitDeliveryContext, routeDeliveryContext); const groupResolution = params.groupResolution ?? resolveGroupSessionKey(params.ctx); + const routeContext = conversationRouteContextFromMsgContext(params.ctx); const kind = groupResolution?.chatType ?? normalizeKind(params.ctx.ChatType); const directIngressTarget = kind === "direct" ? normalizeText(params.ctx.From) : undefined; // An explicit delivery context is already a paired route. Otherwise direct ingress @@ -229,7 +235,7 @@ export function conversationIdentityFromMsgContext(params: { ? (route?.accountId ?? params.ctx.AccountId) : (deliveryContext?.accountId ?? route?.accountId ?? params.ctx.AccountId), kind, - peerId: deliveryTarget, + peerId: routeContext?.peerId ?? deliveryTarget, deliveryTarget, threadId: useDirectIngressTarget ? (route?.threadId ?? params.ctx.MessageThreadId) diff --git a/src/config/sessions/conversation-registry.test.ts b/src/config/sessions/conversation-registry.test.ts index eed55166c256..260171dc0cf4 100644 --- a/src/config/sessions/conversation-registry.test.ts +++ b/src/config/sessions/conversation-registry.test.ts @@ -15,7 +15,9 @@ import { resolveConversation, } from "./conversation-registry.js"; import { + commitReplySessionInitialization, deleteSessionEntryLifecycle, + loadReplySessionInitializationSnapshot, upsertSessionEntryCore as upsertCanonicalSessionEntry, } from "./session-accessor.js"; import { @@ -110,6 +112,185 @@ describe("conversation registry", () => { ); }); + it("round-trips authoritative route context on its conversation association", async () => { + const sessionKey = "agent:main:discord:channel:ops"; + const scope = { agentId: "main", sessionKey, storePath }; + await upsertSessionEntry(scope, { + sessionId: "ops-session", + updatedAt: 100, + chatType: "channel", + deliveryContext: { channel: "discord", accountId: "default", to: "channel:ops" }, + }); + const snapshot = loadReplySessionInitializationSnapshot(scope); + + const committed = await commitReplySessionInitialization({ + activeSessionKey: sessionKey, + agentId: "main", + expectedRevision: snapshot.revision, + routeContext: { + peerId: "canonical-ops", + guildId: "guild-a", + parentPeerId: "parent-a", + memberRoleIds: ["support", "admin"], + }, + sessionEntry: snapshot.currentEntry!, + sessionKey, + snapshotEntry: snapshot.currentEntry, + storePath, + }); + + expect(committed.ok).toBe(true); + const canonicalConversation = listConversations(scope).find( + (conversation) => conversation.peerId === "canonical-ops", + ); + expect(canonicalConversation).toBeDefined(); + const conversationRef = canonicalConversation!.conversationRef; + expect(resolveConversation({ agentId: "main", storePath }, conversationRef)).toMatchObject({ + peerId: "canonical-ops", + observedFromSession: true, + routeContextObserved: true, + routeContext: { + peerId: "canonical-ops", + guildId: "guild-a", + parentPeerId: "parent-a", + memberRoleIds: ["admin", "support"], + }, + }); + + await upsertCanonicalSessionEntry(scope, { label: "generic current write", updatedAt: 200 }); + expect( + listConversations(scope).filter((conversation) => conversation.role === "primary"), + ).toEqual([expect.objectContaining({ conversationRef, peerId: "canonical-ops" })]); + const afterCurrentWrite = resolveConversation({ agentId: "main", storePath }, conversationRef); + expect(afterCurrentWrite).toMatchObject({ + routeContextObserved: true, + routeContext: { guildId: "guild-a" }, + }); + + const resolved = resolveSqliteReadScope(scope); + const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved)); + database.db + .prepare( + `INSERT INTO session_conversations ( + session_id, conversation_id, role, first_seen_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_id, conversation_id, role) DO UPDATE SET + last_seen_at = excluded.last_seen_at`, + ) + .run( + "ops-session", + conversationRef, + "primary", + afterCurrentWrite!.firstSeenAt, + afterCurrentWrite!.lastSeenAt, + ); + closeOpenClawAgentDatabasesForTest(); + + expect(resolveConversation({ agentId: "main", storePath }, conversationRef)).not.toMatchObject({ + routeContextObserved: true, + }); + await upsertCanonicalSessionEntry(scope, { + label: "after older writer", + updatedAt: afterCurrentWrite!.lastSeenAt + 1, + }); + expect(resolveConversation({ agentId: "main", storePath }, conversationRef)).not.toMatchObject({ + routeContextObserved: true, + }); + }); + + it("keeps route context with each conversation when a shared session changes primary", async () => { + const sessionKey = "agent:main:discord:channel:shared"; + const scope = { agentId: "main", sessionKey, storePath }; + const writeRoute = async (target: string, guildId: string, updatedAt: number) => { + await upsertSessionEntry(scope, { + sessionId: "shared-session", + updatedAt, + chatType: "channel", + deliveryContext: { channel: "discord", accountId: "default", to: target }, + }); + const snapshot = loadReplySessionInitializationSnapshot(scope); + const committed = await commitReplySessionInitialization({ + activeSessionKey: sessionKey, + agentId: "main", + expectedRevision: snapshot.revision, + routeContext: { guildId }, + sessionEntry: snapshot.currentEntry!, + sessionKey, + snapshotEntry: snapshot.currentEntry, + storePath, + }); + expect(committed.ok).toBe(true); + }; + + await writeRoute("channel:alpha", "guild-alpha", 100); + await writeRoute("channel:beta", "guild-beta", 200); + + expect( + listConversations(scope, { channel: "discord" }) + .map(({ target, routeContext }) => ({ target, routeContext })) + .toSorted((left, right) => left.target.localeCompare(right.target)), + ).toEqual([ + { target: "channel:alpha", routeContext: { guildId: "guild-alpha" } }, + { target: "channel:beta", routeContext: { guildId: "guild-beta" } }, + ]); + }); + + it("preserves context across an unobserved rollover and clears it on observed-empty ingress", async () => { + const sessionKey = "agent:main:discord:channel:rollover"; + const scope = { agentId: "main", sessionKey, storePath }; + await upsertSessionEntry(scope, { + sessionId: "before-rollover", + updatedAt: 100, + chatType: "channel", + deliveryContext: { channel: "discord", accountId: "default", to: "channel:rollover" }, + }); + let snapshot = loadReplySessionInitializationSnapshot(scope); + await commitReplySessionInitialization({ + activeSessionKey: sessionKey, + agentId: "main", + expectedRevision: snapshot.revision, + routeContext: { guildId: "guild-a", memberRoleIds: ["support"] }, + sessionEntry: snapshot.currentEntry!, + sessionKey, + snapshotEntry: snapshot.currentEntry, + storePath, + }); + + snapshot = loadReplySessionInitializationSnapshot(scope); + const rollover = await commitReplySessionInitialization({ + activeSessionKey: sessionKey, + agentId: "main", + expectedRevision: snapshot.revision, + sessionEntry: { ...snapshot.currentEntry!, sessionId: "after-rollover", updatedAt: 200 }, + sessionKey, + snapshotEntry: snapshot.currentEntry, + storePath, + }); + expect(rollover.ok).toBe(true); + expect(listConversations(scope)[0]).toMatchObject({ + sessionId: "after-rollover", + routeContextObserved: true, + routeContext: { guildId: "guild-a", memberRoleIds: ["support"] }, + }); + + snapshot = loadReplySessionInitializationSnapshot(scope); + await commitReplySessionInitialization({ + activeSessionKey: sessionKey, + agentId: "main", + expectedRevision: snapshot.revision, + routeContext: null, + sessionEntry: snapshot.currentEntry!, + sessionKey, + snapshotEntry: snapshot.currentEntry, + storePath, + }); + expect(listConversations(scope)[0]).toMatchObject({ + sessionId: "after-rollover", + routeContextObserved: true, + }); + expect(listConversations(scope)[0]?.routeContext).toBeUndefined(); + }); + it("orders fresh directory addresses with session-backed conversation activity", async () => { await upsertSessionEntry( { agentId: "main", sessionKey: "agent:main:reef:direct:peer-a", storePath }, diff --git a/src/config/sessions/conversation-registry.ts b/src/config/sessions/conversation-registry.ts index 8212bb7201e6..ae9ad31a986c 100644 --- a/src/config/sessions/conversation-registry.ts +++ b/src/config/sessions/conversation-registry.ts @@ -3,6 +3,10 @@ import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import type { ConversationIdentity, ConversationKind } from "./conversation-identity.js"; +import { + parseStoredConversationRouteContext, + type ConversationRouteContext, +} from "./conversation-route-context.js"; import { resolveSessionStorePathCore } from "./paths.js"; import { upsertConversationIdentity } from "./session-accessor.sqlite-conversation.js"; import { @@ -19,6 +23,7 @@ export type ConversationRecord = { channel: string; accountId: string; kind: ConversationKind; + peerId: string; target: string; parentConversationRef?: string; threadId?: string; @@ -28,6 +33,12 @@ export type ConversationRecord = { sessionId?: string; sessionKey?: string; role?: "participant" | "primary" | "related"; + /** True when this address has been linked to a session in this agent store. */ + observedFromSession?: true; + /** Exact contextual facts from the authoritative inbound route. */ + routeContext?: ConversationRouteContext; + /** True when authoritative ingress observed empty or populated route context. */ + routeContextObserved?: true; firstSeenAt: number; lastSeenAt: number; }; @@ -59,8 +70,14 @@ function normalizeConversationRef(value: string): string { return normalized; } +type MappedConversationRow = { + associationIsCurrent: boolean; + record: ConversationRecord; +}; + function mapConversationRow(row: { account_id: string; + associated_session_id: string | null; channel: string; conversation_id: string; conversation_created_at: number; @@ -75,11 +92,12 @@ function mapConversationRow(row: { parent_conversation_id: string | null; peer_id: string; role: string | null; + route_context_json: string | null; current_session_id: string | null; current_entry_json: string | null; current_session_key: string | null; thread_id: string | null; -}): ConversationRecord | null { +}): MappedConversationRow | null { if (row.kind !== "direct" && row.kind !== "group" && row.kind !== "channel") { return null; } @@ -91,28 +109,41 @@ function mapConversationRow(row: { ? parseSessionEntryJson({ entry_json: row.current_entry_json }) : null; const hasCurrentBinding = currentEntry?.sessionId === row.current_session_id; + const associationIsCurrent = + hasCurrentBinding && row.associated_session_id === row.current_session_id; + const routeContext = parseStoredConversationRouteContext( + row.route_context_json, + row.last_seen_at, + ); return { - conversationRef: row.conversation_id, - channel: row.channel, - accountId: row.account_id, - kind: row.kind, - target: row.delivery_target, - ...(row.parent_conversation_id ? { parentConversationRef: row.parent_conversation_id } : {}), - ...(row.thread_id ? { threadId: row.thread_id } : {}), - ...(row.native_channel_id ? { nativeChannelId: row.native_channel_id } : {}), - ...(row.native_direct_user_id ? { nativeDirectUserId: row.native_direct_user_id } : {}), - ...(row.label ? { label: row.label } : {}), - // Only the current session_nodes row can bind an address. The joined - // window row may be historical after reset, rebind, or deletion. - ...(role && hasCurrentBinding && row.current_session_id && row.current_session_key - ? { - sessionId: row.current_session_id, - sessionKey: row.current_session_key, - role, - } - : {}), - firstSeenAt: row.first_seen_at ?? row.conversation_created_at, - lastSeenAt: row.last_seen_at ?? row.conversation_updated_at, + associationIsCurrent, + record: { + conversationRef: row.conversation_id, + channel: row.channel, + accountId: row.account_id, + kind: row.kind, + peerId: row.peer_id, + target: row.delivery_target, + ...(row.parent_conversation_id ? { parentConversationRef: row.parent_conversation_id } : {}), + ...(row.thread_id ? { threadId: row.thread_id } : {}), + ...(row.native_channel_id ? { nativeChannelId: row.native_channel_id } : {}), + ...(row.native_direct_user_id ? { nativeDirectUserId: row.native_direct_user_id } : {}), + ...(row.label ? { label: row.label } : {}), + // Only the current session_nodes row can bind an address. The joined + // window row may be historical after reset, rebind, or deletion. + ...(role && hasCurrentBinding && row.current_session_id && row.current_session_key + ? { + sessionId: row.current_session_id, + sessionKey: row.current_session_key, + role, + } + : {}), + ...(role ? { observedFromSession: true as const } : {}), + ...(routeContext ? { routeContextObserved: true as const } : {}), + ...(routeContext?.context ? { routeContext: routeContext.context } : {}), + firstSeenAt: row.first_seen_at ?? row.conversation_created_at, + lastSeenAt: row.last_seen_at ?? row.conversation_updated_at, + }, }; } @@ -149,8 +180,10 @@ function selectConversationRows( "c.created_at as conversation_created_at", "c.updated_at as conversation_updated_at", "sc.role", + "sc.route_context_json", "sc.first_seen_at", "sc.last_seen_at", + "s.session_id as associated_session_id", "sn.current_session_id as current_session_id", "sn.entry_json as current_entry_json", "sn.session_key as current_session_key", @@ -172,29 +205,45 @@ function selectConversationRows( .orderBy((eb) => eb.fn.coalesce("sc.last_seen_at", "c.updated_at"), "desc") .orderBy("sn.updated_at", "desc"), ).rows; - const unique = new Map(); + const unique = new Map(); for (const row of rows) { const mapped = mapConversationRow(row); if (!mapped) { continue; } - const existing = unique.get(mapped.conversationRef); + const existing = unique.get(mapped.record.conversationRef); if (!existing) { - unique.set(mapped.conversationRef, mapped); + unique.set(mapped.record.conversationRef, mapped); continue; } - if (!existing.sessionId && mapped.sessionId && mapped.sessionKey && mapped.role) { + if ( + !existing.associationIsCurrent && + mapped.associationIsCurrent && + mapped.record.sessionId && + mapped.record.sessionKey && + mapped.record.role + ) { // Keep the newest address activity while carrying forward the live binding // when a newer historical association has no current session entry. - unique.set(mapped.conversationRef, { - ...existing, - sessionId: mapped.sessionId, - sessionKey: mapped.sessionKey, - role: mapped.role, + const { + routeContext: _staleRouteContext, + routeContextObserved: _staleRouteContextObserved, + ...existingRecord + } = existing.record; + unique.set(mapped.record.conversationRef, { + associationIsCurrent: true, + record: { + ...existingRecord, + sessionId: mapped.record.sessionId, + sessionKey: mapped.record.sessionKey, + role: mapped.record.role, + ...(mapped.record.routeContextObserved ? { routeContextObserved: true as const } : {}), + ...(mapped.record.routeContext ? { routeContext: mapped.record.routeContext } : {}), + }, }); } } - const values = [...unique.values()]; + const values = [...unique.values()].map(({ record }) => record); return options.limit === undefined ? values : values.slice(0, options.limit); } diff --git a/src/config/sessions/conversation-route-context.test.ts b/src/config/sessions/conversation-route-context.test.ts new file mode 100644 index 000000000000..97e2969a6e7a --- /dev/null +++ b/src/config/sessions/conversation-route-context.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + conversationRouteContextFromMsgContext, + parseConversationRouteContext, + parseStoredConversationRouteContext, + serializeStoredConversationRouteContext, +} from "./conversation-route-context.js"; + +describe("conversation route context", () => { + it("captures configured channel scopes deterministically", () => { + expect( + conversationRouteContextFromMsgContext({ + OriginatingChannel: "Discord", + ConversationRoutePeerId: "channel-a", + GroupSpace: "guild-a", + ThreadParentId: "parent-a", + MemberRoleIds: ["support", "admin", "support"], + }), + ).toEqual({ + peerId: "channel-a", + guildId: "guild-a", + parentPeerId: "parent-a", + memberRoleIds: ["admin", "support"], + }); + expect( + conversationRouteContextFromMsgContext({ + OriginatingChannel: "mattermost", + GroupSpace: "team-a", + }), + ).toEqual({ teamId: "team-a" }); + }); + + it("rejects oversized route facts", () => { + expect(parseConversationRouteContext({ peerId: "x".repeat(513) })).toBeUndefined(); + expect(parseConversationRouteContext({ guildId: "x".repeat(513) })).toBeUndefined(); + expect( + parseConversationRouteContext({ guildId: "guild-a", parentPeerId: "x".repeat(513) }), + ).toBeUndefined(); + expect( + parseConversationRouteContext({ + memberRoleIds: Array.from({ length: 257 }, (_, i) => `${i}`), + }), + ).toBeUndefined(); + expect( + parseConversationRouteContext({ peerId: "peer-a", memberRoleIds: ["role-a", ""] }), + ).toBeUndefined(); + }); + + it("invalidates an envelope when an older writer advances association activity", () => { + const stored = serializeStoredConversationRouteContext({ guildId: "guild-a" }, 100); + + expect(parseStoredConversationRouteContext(stored, 100)).toEqual({ + context: { guildId: "guild-a" }, + }); + expect(parseStoredConversationRouteContext(stored, 200)).toBeUndefined(); + }); +}); diff --git a/src/config/sessions/conversation-route-context.ts b/src/config/sessions/conversation-route-context.ts new file mode 100644 index 000000000000..e3565afa9411 --- /dev/null +++ b/src/config/sessions/conversation-route-context.ts @@ -0,0 +1,165 @@ +import { randomUUID } from "node:crypto"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; +import type { MsgContext } from "../../auto-reply/templating.js"; + +const MAX_ROUTE_CONTEXT_ID_LENGTH = 512; +// Discord currently caps server roles below this; keep persisted authorization input bounded. +const MAX_ROUTE_CONTEXT_ROLE_IDS = 256; +const MAX_STORED_ROUTE_CONTEXT_LENGTH = 140_000; + +export type ConversationRouteContext = { + peerId?: string; + guildId?: string; + teamId?: string; + parentPeerId?: string; + memberRoleIds?: string[]; +}; + +type ConversationRouteContextObservation = { + context?: ConversationRouteContext; +}; + +function normalizeBoundedId(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + return normalized && normalized.length <= MAX_ROUTE_CONTEXT_ID_LENGTH ? normalized : undefined; +} + +function normalizeRoleIds(value: unknown): { valid: boolean; value?: string[] } { + if (value === undefined) { + return { valid: true }; + } + if (!Array.isArray(value) || value.length > MAX_ROUTE_CONTEXT_ROLE_IDS) { + return { valid: false }; + } + const roleIds: string[] = []; + for (const item of value) { + const roleId = normalizeBoundedId(item); + if (!roleId) { + return { valid: false }; + } + roleIds.push(roleId); + } + const unique = [...new Set(roleIds)].toSorted(); + return unique.length > 0 ? { valid: true, value: unique } : { valid: true }; +} + +/** Parses the closed, bounded route facts used to replay configured routing precedence. */ +export function parseConversationRouteContext( + value: unknown, +): ConversationRouteContext | undefined { + if (!isRecord(value)) { + return undefined; + } + const guildId = normalizeBoundedId(value.guildId); + const peerId = normalizeBoundedId(value.peerId); + const teamId = normalizeBoundedId(value.teamId); + const parentPeerId = normalizeBoundedId(value.parentPeerId); + const memberRoleIds = normalizeRoleIds(value.memberRoleIds); + if ( + (value.peerId !== undefined && !peerId) || + (value.guildId !== undefined && !guildId) || + (value.teamId !== undefined && !teamId) || + (value.parentPeerId !== undefined && !parentPeerId) || + !memberRoleIds.valid + ) { + return undefined; + } + if (!peerId && !guildId && !teamId && !parentPeerId && !memberRoleIds.value) { + return undefined; + } + return { + ...(peerId ? { peerId } : {}), + ...(guildId ? { guildId } : {}), + ...(teamId ? { teamId } : {}), + ...(parentPeerId ? { parentPeerId } : {}), + ...(memberRoleIds.value ? { memberRoleIds: memberRoleIds.value } : {}), + }; +} + +/** Captures only authoritative inbound facts needed to replay configured route precedence. */ +export function conversationRouteContextFromMsgContext( + ctx: MsgContext, +): ConversationRouteContext | undefined { + const channel = normalizeOptionalLowercaseString(ctx.OriginatingChannel ?? ctx.Provider); + const spaceId = normalizeBoundedId(ctx.GroupSpace); + const parentPeerId = normalizeBoundedId(ctx.ThreadParentId); + return parseConversationRouteContext({ + ...(ctx.ConversationRoutePeerId !== undefined ? { peerId: ctx.ConversationRoutePeerId } : {}), + ...(channel === "discord" && spaceId ? { guildId: spaceId } : {}), + ...((channel === "slack" || channel === "mattermost" || channel === "msteams") && spaceId + ? { teamId: spaceId } + : {}), + ...(parentPeerId ? { parentPeerId } : {}), + ...(ctx.MemberRoleIds !== undefined ? { memberRoleIds: ctx.MemberRoleIds } : {}), + }); +} + +type StoredConversationRouteContext = { + version: 1; + // Current writers rotate this on every association update. Older writers preserve it, + // allowing the SQLite trigger to invalidate route facts even when activity time is unchanged. + writeId: string; + observedAt: number; + context: ConversationRouteContext | null; +}; + +export function serializeStoredConversationRouteContext( + context: ConversationRouteContext | null, + observedAt: number, +): string { + const canonical = context === null ? null : parseConversationRouteContext(context); + if (context !== null && !canonical) { + throw new Error("Invalid conversation route context"); + } + return JSON.stringify({ + version: 1, + writeId: randomUUID(), + observedAt, + context: canonical ?? null, + } satisfies StoredConversationRouteContext); +} + +export function parseStoredConversationRouteContext( + value: string | null, + expectedObservedAt: number | null, +): ConversationRouteContextObservation | undefined { + if (!value || value.length > MAX_STORED_ROUTE_CONTEXT_LENGTH) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(value) as unknown; + } catch { + return undefined; + } + if ( + !isRecord(parsed) || + parsed.version !== 1 || + typeof parsed.writeId !== "string" || + parsed.writeId.length === 0 || + typeof parsed.observedAt !== "number" || + parsed.observedAt !== expectedObservedAt + ) { + return undefined; + } + const context = parseConversationRouteContext(parsed.context); + if (parsed.context !== null && !context) { + return undefined; + } + return context ? { context } : {}; +} + +export function refreshStoredConversationRouteContext( + value: string | null, + previousObservedAt: number, + observedAt: number, +): string | null { + const stored = parseStoredConversationRouteContext(value, previousObservedAt); + return stored + ? serializeStoredConversationRouteContext(stored.context ?? null, observedAt) + : null; +} diff --git a/src/config/sessions/conversation-route-fingerprint.test.ts b/src/config/sessions/conversation-route-fingerprint.test.ts new file mode 100644 index 000000000000..a74109783458 --- /dev/null +++ b/src/config/sessions/conversation-route-fingerprint.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveConversationRouteFingerprint } from "./conversation-route-fingerprint.js"; + +const route = { + accountId: "default", + channel: "reef", + kind: "direct" as const, + peerId: "molty", + target: "user:molty", +}; + +describe("resolveConversationRouteFingerprint", () => { + it("binds both canonical ownership identity and physical delivery address", () => { + const expected = resolveConversationRouteFingerprint(route); + + expect(resolveConversationRouteFingerprint({ ...route, target: "user:other" })).not.toBe( + expected, + ); + expect(resolveConversationRouteFingerprint({ ...route, nativeDirectUserId: "other" })).not.toBe( + expected, + ); + }); + + it("canonicalizes contextual role order before hashing", () => { + const first = resolveConversationRouteFingerprint({ + ...route, + routeContextObserved: true, + routeContext: { guildId: "guild-1", memberRoleIds: ["role-b", "role-a"] }, + }); + const second = resolveConversationRouteFingerprint({ + ...route, + routeContextObserved: true, + routeContext: { memberRoleIds: ["role-a", "role-b", "role-a"], guildId: "guild-1" }, + }); + + expect(first).toBe(second); + }); +}); diff --git a/src/config/sessions/conversation-route-fingerprint.ts b/src/config/sessions/conversation-route-fingerprint.ts new file mode 100644 index 000000000000..75d5e62b1588 --- /dev/null +++ b/src/config/sessions/conversation-route-fingerprint.ts @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; +import type { ConversationRecord } from "./conversation-registry.js"; +import { + parseConversationRouteContext, + type ConversationRouteContext, +} from "./conversation-route-context.js"; + +type ConversationRouteFingerprintInput = Pick< + ConversationRecord, + | "accountId" + | "channel" + | "kind" + | "nativeDirectUserId" + | "parentConversationRef" + | "peerId" + | "target" + | "threadId" +> & { + nativeChannelId?: string; + routeContext?: ConversationRouteContext; + routeContextObserved?: true; +}; + +/** Binds queued authority to the exact route facts admitted by the Gateway. */ +export function resolveConversationRouteFingerprint( + route: ConversationRouteFingerprintInput, +): string { + const context = route.routeContext + ? parseConversationRouteContext(route.routeContext) + : undefined; + return createHash("sha256") + .update( + JSON.stringify([ + route.channel, + route.accountId, + route.kind, + route.peerId, + route.target, + route.parentConversationRef ?? null, + route.threadId ?? null, + route.nativeChannelId ?? null, + route.nativeDirectUserId ?? null, + route.routeContextObserved === true, + context ?? null, + ]), + ) + .digest("hex"); +} diff --git a/src/config/sessions/session-accessor.lifecycle-types.ts b/src/config/sessions/session-accessor.lifecycle-types.ts index 9b4fac3f00a1..b18cd3eed85c 100644 --- a/src/config/sessions/session-accessor.lifecycle-types.ts +++ b/src/config/sessions/session-accessor.lifecycle-types.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../types.openclaw.js"; +import type { ConversationRouteContext } from "./conversation-route-context.js"; import type { SessionStateDeleteSnapshot } from "./session-accessor.sqlite-delete-snapshot.types.js"; import type { SessionResetBoundaryReason } from "./session-reset-boundary-event.js"; import type { InternalSessionEntry as SessionEntry } from "./types.js"; @@ -140,6 +141,8 @@ export class SessionEntryLifecycleUpsertConflictError extends Error { export type SessionEntryLifecycleUpsert = { sessionKey: string; resetBoundaryReason?: SessionResetBoundaryReason; + /** Authoritative route observation for this write; omitted writes preserve valid evidence. */ + routeContext?: ConversationRouteContext | null; } & ( | { entry: SessionEntry; diff --git a/src/config/sessions/session-accessor.reset.ts b/src/config/sessions/session-accessor.reset.ts index 3aad11263c71..65dc12745014 100644 --- a/src/config/sessions/session-accessor.reset.ts +++ b/src/config/sessions/session-accessor.reset.ts @@ -4,6 +4,7 @@ import { parseAgentSessionKey, } from "../../routing/session-key.js"; import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; +import type { ConversationRouteContext } from "./conversation-route-context.js"; import { cloneSessionEntries, mergeConcurrentReplySessionMetadata, @@ -156,6 +157,8 @@ export async function commitReplySessionInitialization(params: { context: ReplySessionInitializationCommitContext, ) => Promise | SessionEntry; resetBoundaryReason?: import("./session-reset-boundary-event.js").SessionResetBoundaryReason; + /** Authoritative contextual route facts observed by the admitted inbound turn. */ + routeContext?: ConversationRouteContext | null; previousEntry?: SessionEntry; retiredEntry?: SessionEntryRetirement; sessionEntry: SessionEntry; @@ -207,6 +210,7 @@ export async function commitReplySessionInitialization(params: { const upserts: SessionEntryLifecycleUpsert[] = [ { sessionKey: resolved.normalizedKey, + ...(params.routeContext !== undefined ? { routeContext: params.routeContext } : {}), ...(params.resetBoundaryReason ? { resetBoundaryReason: params.resetBoundaryReason } : {}), buildEntry: async ({ store: currentStore }) => { const commitResolved = resolveSessionEntryFromStore({ diff --git a/src/config/sessions/session-accessor.sqlite-conversation.ts b/src/config/sessions/session-accessor.sqlite-conversation.ts index c82bf725c6ae..7f7421f53991 100644 --- a/src/config/sessions/session-accessor.sqlite-conversation.ts +++ b/src/config/sessions/session-accessor.sqlite-conversation.ts @@ -1,9 +1,16 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { conversationIdentityFromSessionEntry, type ConversationIdentity, } from "./conversation-identity.js"; +import { + parseConversationRouteContext, + refreshStoredConversationRouteContext, + serializeStoredConversationRouteContext, + type ConversationRouteContext, +} from "./conversation-route-context.js"; import { getSessionKysely } from "./session-accessor.sqlite-scope.js"; import type { SessionEntry } from "./types.js"; @@ -12,14 +19,25 @@ type SessionConversationRole = "participant" | "primary" | "related"; type PreparedSessionConversation = { identity: ConversationIdentity; role: SessionConversationRole; + routeContext?: ConversationRouteContext | null; }; /** Shared-main DMs multiplex peers through one context; every other routed session has one primary. */ -export function prepareSessionConversation(params: { +function prepareSessionConversation(params: { entry: SessionEntry; + routeContext?: ConversationRouteContext | null; sessionScope: string; }): PreparedSessionConversation | null { - const identity = conversationIdentityFromSessionEntry(params.entry); + const routeContext = + params.routeContext === null + ? null + : params.routeContext === undefined + ? undefined + : parseConversationRouteContext(params.routeContext); + if (params.routeContext !== undefined && params.routeContext !== null && !routeContext) { + throw new Error("Invalid conversation route context"); + } + const identity = conversationIdentityFromSessionEntry(params.entry, routeContext); if (!identity) { return null; } @@ -29,9 +47,101 @@ export function prepareSessionConversation(params: { params.sessionScope === "shared-main" && identity.kind === "direct" ? "participant" : "primary", + ...(routeContext !== undefined ? { routeContext } : {}), }; } +/** Keeps a previously observed route peer when a generic session writer has no route facts. */ +function preserveSessionConversationIdentity(params: { + database: OpenClawAgentDatabase; + identity: ConversationIdentity; + sessionIds: string[]; +}): ConversationIdentity { + if (params.sessionIds.length === 0) { + return params.identity; + } + const db = getSessionKysely(params.database.db); + const row = executeSqliteQuerySync( + params.database.db, + db + .selectFrom("session_conversations as sc") + .innerJoin("conversations as c", "c.conversation_id", "sc.conversation_id") + .select([ + "c.conversation_id", + "c.channel", + "c.account_id", + "c.kind", + "c.peer_id", + "c.delivery_target", + "c.parent_conversation_id", + "c.thread_id", + "c.native_channel_id", + "c.native_direct_user_id", + "c.label", + "c.metadata_json", + ]) + .where("sc.session_id", "in", params.sessionIds) + .where("c.channel", "=", params.identity.channel) + .where("c.account_id", "=", params.identity.accountId) + .where("c.kind", "=", params.identity.kind) + .where("c.delivery_target", "=", params.identity.deliveryTarget) + .where("sc.role", "in", ["primary", "participant"]) + .where("c.thread_id", params.identity.threadId ? "=" : "is", params.identity.threadId ?? null) + .orderBy("sc.last_seen_at", "desc") + .limit(1), + ).rows[0]; + let metadata: Record | undefined; + if (row?.metadata_json) { + try { + const parsed = JSON.parse(row.metadata_json) as unknown; + metadata = isRecord(parsed) ? parsed : undefined; + } catch { + metadata = undefined; + } + } + return row + ? { + conversationRef: row.conversation_id, + channel: row.channel, + accountId: row.account_id, + kind: params.identity.kind, + peerId: row.peer_id, + deliveryTarget: row.delivery_target, + ...(row.parent_conversation_id + ? { parentConversationRef: row.parent_conversation_id } + : {}), + ...(row.thread_id ? { threadId: row.thread_id } : {}), + ...(row.native_channel_id ? { nativeChannelId: row.native_channel_id } : {}), + ...(row.native_direct_user_id ? { nativeDirectUserId: row.native_direct_user_id } : {}), + ...((params.identity.label ?? row.label) + ? { label: params.identity.label ?? row.label! } + : {}), + ...(metadata ? { metadata } : {}), + } + : params.identity; +} + +export function prepareSessionConversationForWrite(params: { + database: OpenClawAgentDatabase; + entry: SessionEntry; + previousEntry?: SessionEntry | null; + routeContext?: ConversationRouteContext | null; + sessionScope: string; +}): PreparedSessionConversation | null { + const conversation = prepareSessionConversation(params); + if (!conversation || params.routeContext !== undefined) { + return conversation; + } + conversation.identity = preserveSessionConversationIdentity({ + database: params.database, + identity: conversation.identity, + sessionIds: [params.entry.sessionId, params.previousEntry?.sessionId].filter( + (sessionId): sessionId is string => Boolean(sessionId), + ), + }); + return conversation; +} + /** Upserts the address before the session row so its primary-conversation FK is always valid. */ export function upsertConversationIdentity( database: OpenClawAgentDatabase, @@ -81,18 +191,45 @@ export function upsertConversationIdentity( /** Links one external address to its local context without conflating the two identities. */ export function linkSessionConversation(params: { database: OpenClawAgentDatabase; + previousSessionId?: string; sessionId: string; conversation: PreparedSessionConversation; updatedAt: number; }): void { const { database, sessionId, conversation, updatedAt } = params; const db = getSessionKysely(database.db); + const readAssociation = (candidateSessionId: string) => + executeSqliteQuerySync( + database.db, + db + .selectFrom("session_conversations") + .select(["last_seen_at", "route_context_json"]) + .where("session_id", "=", candidateSessionId) + .where("conversation_id", "=", conversation.identity.conversationRef) + .orderBy("last_seen_at", "desc") + .limit(1), + ).rows[0]; + const existingAssociation = + readAssociation(sessionId) ?? + (params.previousSessionId && params.previousSessionId !== sessionId + ? readAssociation(params.previousSessionId) + : undefined); + const routeContextJson = + conversation.routeContext === undefined + ? existingAssociation + ? refreshStoredConversationRouteContext( + existingAssociation.route_context_json, + existingAssociation.last_seen_at, + updatedAt, + ) + : null + : serializeStoredConversationRouteContext(conversation.routeContext, updatedAt); if (conversation.role === "primary") { const stalePrimaryRows = executeSqliteQuerySync( database.db, db .selectFrom("session_conversations") - .select(["conversation_id", "first_seen_at"]) + .select(["conversation_id", "first_seen_at", "last_seen_at", "route_context_json"]) .where("session_id", "=", sessionId) .where("role", "=", "primary") .where("conversation_id", "!=", conversation.identity.conversationRef), @@ -107,14 +244,20 @@ export function linkSessionConversation(params: { session_id: sessionId, conversation_id: row.conversation_id, role: "related", + route_context_json: refreshStoredConversationRouteContext( + row.route_context_json, + row.last_seen_at, + updatedAt, + ), first_seen_at: row.first_seen_at, last_seen_at: updatedAt, })), ) .onConflict((conflict) => - conflict.columns(["session_id", "conversation_id", "role"]).doUpdateSet({ + conflict.columns(["session_id", "conversation_id", "role"]).doUpdateSet((eb) => ({ + route_context_json: eb.ref("excluded.route_context_json"), last_seen_at: updatedAt, - }), + })), ), ); executeSqliteQuerySync( @@ -146,11 +289,13 @@ export function linkSessionConversation(params: { session_id: sessionId, conversation_id: conversation.identity.conversationRef, role: conversation.role, + route_context_json: routeContextJson, first_seen_at: updatedAt, last_seen_at: updatedAt, }) .onConflict((conflict) => conflict.columns(["session_id", "conversation_id", "role"]).doUpdateSet({ + route_context_json: routeContextJson, last_seen_at: updatedAt, }), ), diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index 1955dd3ecc0a..ebedde3b3620 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -6,9 +6,10 @@ import { } from "../../infra/kysely-sync.js"; import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; +import type { ConversationRouteContext } from "./conversation-route-context.js"; import { linkSessionConversation, - prepareSessionConversation, + prepareSessionConversationForWrite, upsertConversationIdentity, } from "./session-accessor.sqlite-conversation.js"; import { @@ -577,6 +578,7 @@ export function writeSessionEntry( allowStoredAliases?: boolean; preserveNodeSuggestions?: boolean; previousEntry?: SessionEntry | null; + routeContext?: ConversationRouteContext | null; } = {}, ): void { const db = getSessionKysely(database.db); @@ -628,8 +630,11 @@ export function writeSessionEntry( readTranscriptMutationStateInTransaction(database, normalizedEntry.sessionId).updatedAt ?? updatedAt; const boundSessionRoot = bindSessionRoot({ entry: normalizedEntry, sessionKey, updatedAt }); - const conversation = prepareSessionConversation({ + const conversation = prepareSessionConversationForWrite({ + database, entry: normalizedEntry, + previousEntry, + ...(options.routeContext !== undefined ? { routeContext: options.routeContext } : {}), sessionScope: boundSessionRoot.session_scope, }); if (conversation) { @@ -724,6 +729,7 @@ export function writeSessionEntry( if (conversation) { linkSessionConversation({ database, + ...(previousEntry?.sessionId ? { previousSessionId: previousEntry.sessionId } : {}), sessionId: sessionRow.session_id, conversation, updatedAt, diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index 957d44cf67bb..061b80fa1fc6 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -704,6 +704,7 @@ export async function updateSessionLastRoute(params: { ctx?: MsgContext; groupResolution?: GroupKeyResolution | null; createIfMissing?: boolean; + assertCommitAllowed?: () => void; }): Promise { const createIfMissing = params.createIfMissing ?? true; return await patchSessionEntryCore( @@ -738,6 +739,7 @@ export async function updateSessionLastRoute(params: { { // Route updates must not refresh activity timestamps (#49515). preserveActivity: true, + ...(params.assertCommitAllowed ? { assertCommitAllowed: params.assertCommitAllowed } : {}), ...(createIfMissing ? { fallbackEntry: mergeSessionEntry(undefined, {}) } : {}), }, ); diff --git a/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts b/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts index c3e7bc0a3aa1..803b0030f7a6 100644 --- a/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts +++ b/src/config/sessions/session-accessor.sqlite-lifecycle-state.ts @@ -405,6 +405,7 @@ export async function projectSessionEntryLifecycleMutation( expectedEntry, sessionKey, entry: cloned, + ...(upsert.routeContext !== undefined ? { routeContext: upsert.routeContext } : {}), ...(resetBoundaryPlan ? { resetBoundaryPlan } : {}), }); } diff --git a/src/config/sessions/session-accessor.sqlite-lifecycle-types.ts b/src/config/sessions/session-accessor.sqlite-lifecycle-types.ts index 8256307f28d8..45fccb08d7b0 100644 --- a/src/config/sessions/session-accessor.sqlite-lifecycle-types.ts +++ b/src/config/sessions/session-accessor.sqlite-lifecycle-types.ts @@ -1,3 +1,4 @@ +import type { ConversationRouteContext } from "./conversation-route-context.js"; import type { SessionLifecycleArchivedTranscript } from "./session-accessor.lifecycle-types.js"; import type { SessionStateDeletePlan } from "./session-accessor.sqlite-archive.js"; import type { SessionEntryLifecycleRemoval } from "./session-accessor.sqlite-contract.js"; @@ -37,6 +38,7 @@ export type ProjectedLifecycleMutation = { upsertedEntries: Array<{ entry: SessionEntry; expectedEntry: SessionEntry | undefined; + routeContext?: ConversationRouteContext | null; resetBoundaryPlan?: SessionResetBoundaryPlan; sessionKey: string; }>; diff --git a/src/config/sessions/session-accessor.sqlite-projection.ts b/src/config/sessions/session-accessor.sqlite-projection.ts index 952c1ef53c9d..c7be6899fe17 100644 --- a/src/config/sessions/session-accessor.sqlite-projection.ts +++ b/src/config/sessions/session-accessor.sqlite-projection.ts @@ -339,6 +339,7 @@ export async function applySessionEntryLifecycleMutation(params: { sessionKey, entry, expectedEntry, + routeContext, resetBoundaryPlan, } of projected.upsertedEntries) { const sameKeyRemoval = validatedRemovals.find( @@ -377,6 +378,7 @@ export async function applySessionEntryLifecycleMutation(params: { allowStoredAliases: params.allowCanonicalRepair === true, preserveNodeSuggestions: params.allowCanonicalRepair === true, previousEntry: expectedCurrentEntry ?? null, + ...(routeContext !== undefined ? { routeContext } : {}), }); const relatedRemovalKeys = validatedRemovals.flatMap((removal) => { const removedSessionId = removal.expectedEntry.sessionId; diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index bd35f1cfbf9e..1f61a3af75e9 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -839,6 +839,24 @@ describe("session accessor seam", () => { expect(loadSessionEntry({ sessionKey, storePath })).toBeUndefined(); }); + it("runs the last-route ownership guard at the SQLite commit edge", async () => { + const sessionKey = "agent:main:webchat:dm:revoked-route"; + + await expect( + updateSessionLastRoute({ + storePath, + sessionKey, + channel: "webchat", + to: "webchat:revoked-route", + assertCommitAllowed: () => { + throw new Error("route owner changed"); + }, + }), + ).rejects.toThrow("route owner changed"); + + expect(loadSessionEntry({ sessionKey, storePath })).toBeUndefined(); + }); + it("stamps last-route creation from the participant, never the conversation route", async () => { const participantKey = "agent:main:webchat:dm:route-participant"; const participant = await updateSessionLastRoute({ diff --git a/src/gateway/conversation-list.test.ts b/src/gateway/conversation-list.test.ts index b144c902f0dc..3ca6af0b23e0 100644 --- a/src/gateway/conversation-list.test.ts +++ b/src/gateway/conversation-list.test.ts @@ -3,6 +3,127 @@ import type { ConversationIdentity } from "../config/sessions/conversation-ident import { runGatewayConversationList } from "./conversation-list.js"; describe("runGatewayConversationList", () => { + it("discovers only routes owned by the active agent", async () => { + let discovered: ConversationIdentity[] = []; + const deps = { + resolveOutboundChannelPlugin: vi.fn(() => ({ + id: "reef", + config: { + listAccountIds: () => ["personal", "finance"], + resolveAccount: () => ({ enabled: true, configured: true }), + isEnabled: () => true, + isConfigured: () => true, + }, + directory: { + listPeers: async ({ accountId }: { accountId: string }) => [ + { kind: "user" as const, id: `${accountId}-peer`, name: accountId }, + ], + }, + })), + resolveOutboundSessionRoute: vi.fn(async ({ target }: { target: string }) => ({ + sessionKey: `agent:personal:reef:direct:${target}`, + baseSessionKey: `agent:personal:reef:direct:${target}`, + peer: { kind: "direct" as const, id: target }, + chatType: "direct" as const, + from: `reef:${target}`, + to: `reef:${target}`, + })), + registerConversationAddresses: vi.fn((_scope, identities) => { + discovered = [...identities]; + }), + listConversations: vi.fn(() => []), + }; + + await runGatewayConversationList( + { + config: { + agents: { entries: { personal: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "personal", + match: { channel: "reef", accountId: "personal" }, + }, + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "finance" }, + }, + ], + }, + agentId: "personal", + channel: "reef", + limit: 50, + }, + deps as never, + ); + + expect(discovered).toEqual([ + expect.objectContaining({ accountId: "personal", peerId: "personal-peer" }), + ]); + }); + + it("filters persisted routes before applying the result limit", async () => { + const rows = [ + { + conversationRef: "conv_11111111111111111111111111111111", + channel: "reef", + accountId: "finance", + kind: "direct" as const, + peerId: "finance-peer", + target: "reef:finance-peer", + firstSeenAt: 200, + lastSeenAt: 200, + }, + { + conversationRef: "conv_22222222222222222222222222222222", + channel: "reef", + accountId: "personal", + kind: "direct" as const, + peerId: "personal-peer", + target: "reef:personal-peer", + firstSeenAt: 100, + lastSeenAt: 100, + }, + ]; + const listConversations = vi.fn((_scope, options: { limit?: number }) => + options.limit === undefined ? rows : rows.slice(0, options.limit), + ); + + const result = await runGatewayConversationList( + { + config: { + agents: { entries: { personal: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "personal", + match: { channel: "reef", accountId: "personal" }, + }, + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "finance" }, + }, + ], + }, + agentId: "personal", + limit: 1, + }, + { + listConversations, + registerConversationAddresses: vi.fn(), + resolveOutboundChannelPlugin: vi.fn(), + resolveOutboundSessionRoute: vi.fn(), + } as never, + ); + + expect(listConversations).toHaveBeenCalledWith({ agentId: "personal" }, {}); + expect(result.conversations).toEqual([ + expect.objectContaining({ accountId: "personal", target: "reef:personal-peer" }), + ]); + }); + it("discovers a trusted directory peer without creating a session", async () => { let discovered: ConversationIdentity[] = []; const listPeers = vi.fn(async () => [ @@ -37,6 +158,7 @@ describe("runGatewayConversationList", () => { channel: identity.channel, accountId: identity.accountId, kind: identity.kind, + peerId: identity.peerId, target: identity.deliveryTarget, label: identity.label, firstSeenAt: 100, diff --git a/src/gateway/conversation-list.ts b/src/gateway/conversation-list.ts index 6f3def8298bb..8105675cfaa8 100644 --- a/src/gateway/conversation-list.ts +++ b/src/gateway/conversation-list.ts @@ -20,6 +20,7 @@ import { resolveOutboundChannelPlugin } from "../infra/outbound/channel-resoluti import { resolveOutboundSessionRoute } from "../infra/outbound/outbound-session.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveConversationRouteEligibilityForAgent } from "./conversation-route-ownership.js"; const log = createSubsystemLogger("gateway/conversations"); @@ -124,6 +125,7 @@ async function discoverChannelAddresses(params: { limit: number; scope: ConversationRegistryScope; deps: ConversationListDeps; + readCurrentConfig?: () => OpenClawConfig; }): Promise<{ channel: string; discoveredConversationRefs: ReadonlySet }> { const plugin = params.deps.resolveOutboundChannelPlugin({ channel: params.channel, @@ -193,8 +195,25 @@ async function discoverChannelAddresses(params: { } } } - params.deps.registerConversationAddresses(params.scope, [...identities.values()]); - return { channel: plugin.id, discoveredConversationRefs: new Set(identities.keys()) }; + const currentConfig = params.readCurrentConfig?.() ?? params.config; + const eligibleIdentities = [...identities.values()].filter((identity) => { + const eligibility = resolveConversationRouteEligibilityForAgent({ + config: currentConfig, + agentId: params.agentId, + conversation: { ...identity, target: identity.deliveryTarget }, + }); + if (eligibility === "unavailable") { + throw new Error("Conversation route ownership is temporarily unavailable"); + } + return eligibility === "eligible"; + }); + params.deps.registerConversationAddresses(params.scope, eligibleIdentities); + return { + channel: plugin.id, + discoveredConversationRefs: new Set( + eligibleIdentities.map((identity) => identity.conversationRef), + ), + }; } function matchesConversationQuery(conversation: ConversationRecord, rawQuery: string): boolean { @@ -213,6 +232,7 @@ function matchesConversationQuery(conversation: ConversationRecord, rawQuery: st export async function runGatewayConversationList( params: { config: OpenClawConfig; + readCurrentConfig?: () => OpenClawConfig; agentId: string; channel?: string; query?: string; @@ -231,20 +251,33 @@ export async function runGatewayConversationList( limit: params.limit, scope, deps, + ...(params.readCurrentConfig ? { readCurrentConfig: params.readCurrentConfig } : {}), }) : undefined; - const conversations = deps.listConversations(scope, { - ...(query ? {} : { limit: params.limit }), - ...(discovery ? { channel: discovery.channel } : {}), - }); - const selected = query - ? conversations - .filter( - (entry) => - discovery?.discoveredConversationRefs.has(entry.conversationRef) === true || - matchesConversationQuery(entry, query), - ) - .slice(0, params.limit) - : conversations; + const conversations = deps.listConversations( + scope, + discovery ? { channel: discovery.channel } : {}, + ); + const currentConfig = params.readCurrentConfig?.() ?? params.config; + const selected = conversations + .filter((entry) => { + if ( + query && + discovery?.discoveredConversationRefs.has(entry.conversationRef) !== true && + !matchesConversationQuery(entry, query) + ) { + return false; + } + const eligibility = resolveConversationRouteEligibilityForAgent({ + config: currentConfig, + agentId: params.agentId, + conversation: entry, + }); + if (eligibility === "unavailable") { + throw new Error("Conversation route ownership is temporarily unavailable"); + } + return eligibility === "eligible"; + }) + .slice(0, params.limit); return { conversations: selected.map(presentConversation) }; } diff --git a/src/gateway/conversation-route-ownership.test.ts b/src/gateway/conversation-route-ownership.test.ts new file mode 100644 index 000000000000..abc75e65da25 --- /dev/null +++ b/src/gateway/conversation-route-ownership.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveConversationRouteEligibilityForAgent } from "./conversation-route-ownership.js"; + +const baseConversation = { + accountId: "default", + channel: "reef", + kind: "group" as const, + peerId: "topic-42", + target: "group:topic-42", +}; + +function configWithBindings(bindings: NonNullable): OpenClawConfig { + return { + agents: { entries: { main: { default: true }, finance: {} } }, + bindings, + }; +} + +describe("resolveConversationRouteEligibilityForAgent", () => { + it("replays authoritative parent context when selecting the route owner", () => { + const config = configWithBindings([ + { + type: "route", + agentId: "finance", + match: { channel: "reef", peer: { kind: "group", id: "parent-room" } }, + }, + ]); + const conversation = { + ...baseConversation, + routeContextObserved: true as const, + routeContext: { parentPeerId: "parent-room" }, + }; + + expect( + resolveConversationRouteEligibilityForAgent({ config, agentId: "main", conversation }), + ).toBe("denied"); + expect( + resolveConversationRouteEligibilityForAgent({ config, agentId: "finance", conversation }), + ).toBe("eligible"); + }); + + it("does not treat an unrelated peer binding as a possible parent owner for a legacy thread", () => { + const config = configWithBindings([ + { + type: "route", + agentId: "finance", + match: { channel: "reef", peer: { kind: "group", id: "unrelated-room" } }, + }, + ]); + + expect( + resolveConversationRouteEligibilityForAgent({ + config, + agentId: "main", + conversation: { ...baseConversation, threadId: "topic-7" }, + }), + ).toBe("eligible"); + }); + + it("replays a legacy thread parent binding from its retained route peer", () => { + const config = configWithBindings([ + { + type: "route", + agentId: "finance", + match: { channel: "reef", peer: { kind: "group", id: "parent-room" } }, + }, + ]); + const conversation = { ...baseConversation, peerId: "parent-room", threadId: "topic-7" }; + + expect( + resolveConversationRouteEligibilityForAgent({ config, agentId: "main", conversation }), + ).toBe("denied"); + expect( + resolveConversationRouteEligibilityForAgent({ config, agentId: "finance", conversation }), + ).toBe("eligible"); + }); + + it("fails closed for a matching contextual wildcard when legacy context is absent", () => { + const config = configWithBindings([ + { + type: "route", + agentId: "finance", + match: { channel: "reef", peer: { kind: "group", id: "*" }, teamId: "finance" }, + }, + ]); + + expect( + resolveConversationRouteEligibilityForAgent({ + config, + agentId: "main", + conversation: baseConversation, + }), + ).toBe("denied"); + + expect( + resolveConversationRouteEligibilityForAgent({ + config, + agentId: "main", + conversation: { ...baseConversation, routeContextObserved: true }, + }), + ).toBe("eligible"); + }); +}); diff --git a/src/gateway/conversation-route-ownership.ts b/src/gateway/conversation-route-ownership.ts new file mode 100644 index 000000000000..b8df52b5a079 --- /dev/null +++ b/src/gateway/conversation-route-ownership.ts @@ -0,0 +1,354 @@ +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { AgentSelectionRequiredError } from "../agents/agent-scope-config.js"; +import { normalizeChatType } from "../channels/chat-type.js"; +import { + resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, +} from "../channels/plugins/binding-routing.js"; +import { getLoadedChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { listRouteBindings } from "../config/bindings.js"; +import { getConversationDeliveryOperation } from "../config/sessions/conversation-delivery-store.js"; +import { + resolveConversation, + type ConversationRecord, + type ConversationRegistryScope, +} from "../config/sessions/conversation-registry.js"; +import type { ConversationRouteContext } from "../config/sessions/conversation-route-context.js"; +import { resolveConversationRouteFingerprint } from "../config/sessions/conversation-route-fingerprint.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { PlatformMessageNotDispatchedError } from "../infra/outbound/deliver-types.js"; +import { getGlobalPluginRegistry } from "../plugins/hook-runner-global.js"; +import { normalizeAccountId } from "../routing/account-id.js"; +import { normalizeRouteBindingId } from "../routing/binding-scope.js"; +import { peerKindMatches } from "../routing/peer-kind-match.js"; +import { resolveAgentRoute, type ResolvedAgentRoute } from "../routing/resolve-route.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { ConversationInputError } from "./conversation-errors.js"; + +type ConversationRouteCandidate = Pick< + ConversationRecord, + "accountId" | "channel" | "kind" | "parentConversationRef" | "peerId" | "target" | "threadId" +> & { + nativeChannelId?: string; + routeContext?: ConversationRouteContext; + routeContextObserved?: true; +}; + +type ConversationRouteEligibility = "eligible" | "denied" | "unavailable"; + +type RouteOwnerResolution = { kind: "available"; agentId?: string } | { kind: "unavailable" }; + +function hasActivePluginClaimOwner(pluginId: string): boolean { + return ( + getGlobalPluginRegistry()?.typedHooks.some( + (hook) => hook.pluginId === pluginId && hook.hookName === "inbound_claim", + ) === true + ); +} + +function resolvePluginRouteOwner( + config: OpenClawConfig, + conversation: ConversationRouteCandidate, +): RouteOwnerResolution | undefined { + const channelId = normalizeChannelId(conversation.channel); + const resolver = channelId + ? getLoadedChannelPlugin(channelId)?.messaging?.resolveConversationRouteOwner + : undefined; + if (!resolver) { + return undefined; + } + try { + const owner = resolver({ + cfg: config, + accountId: normalizeAccountId(conversation.accountId), + conversation: { + kind: conversation.kind, + peerId: conversation.peerId, + target: conversation.target, + ...(conversation.threadId ? { threadId: conversation.threadId } : {}), + ...(conversation.nativeChannelId ? { nativeChannelId: conversation.nativeChannelId } : {}), + ...(conversation.routeContext ? { context: conversation.routeContext } : {}), + }, + }); + if (owner === undefined) { + return undefined; + } + if (owner === null) { + return { kind: "available" }; + } + if (owner.kind === "unavailable") { + return owner; + } + if (owner.kind === "plugin") { + return hasActivePluginClaimOwner(owner.pluginId) + ? { kind: "available" } + : { kind: "available", agentId: normalizeAgentId(owner.fallbackAgentId) }; + } + return { kind: "available", agentId: normalizeAgentId(owner.agentId) }; + } catch (error) { + if (error instanceof AgentSelectionRequiredError) { + return { kind: "available" }; + } + throw error; + } +} + +function resolveConfiguredRouteOwner( + config: OpenClawConfig, + conversation: ConversationRouteCandidate, + context?: ConversationRouteContext, +): ResolvedAgentRoute | undefined { + try { + return resolveAgentRoute({ + cfg: config, + channel: conversation.channel, + accountId: conversation.accountId, + peer: { kind: conversation.kind, id: conversation.peerId }, + ...(context?.parentPeerId && conversation.kind !== "direct" + ? { parentPeer: { kind: conversation.kind, id: context.parentPeerId } } + : {}), + ...(context?.guildId ? { guildId: context.guildId } : {}), + ...(context?.teamId ? { teamId: context.teamId } : {}), + ...(context?.memberRoleIds ? { memberRoleIds: context.memberRoleIds } : {}), + }); + } catch (error) { + if (error instanceof AgentSelectionRequiredError) { + return undefined; + } + throw error; + } +} + +function resolveGenericRouteOwner(params: { + config: OpenClawConfig; + conversation: ConversationRouteCandidate; + route: ResolvedAgentRoute; + context?: ConversationRouteContext; +}): RouteOwnerResolution { + const conversation = { + channel: params.conversation.channel, + accountId: normalizeAccountId(params.conversation.accountId), + conversationId: params.conversation.peerId, + ...(params.context?.parentPeerId ? { parentConversationId: params.context.parentPeerId } : {}), + }; + // Generic ingress applies configured ACP routing before runtime bindings. Discord and Slack + // have different precedence and bypass this path through their channel-owned resolvers. + const configured = resolveConfiguredBindingRoute({ + cfg: params.config, + route: params.route, + conversation, + }); + const runtime = resolveRuntimeConversationBindingRoute({ + route: configured.route, + conversation, + touchBinding: false, + }); + if (runtime.bindingOwnerAvailable === false) { + return { kind: "unavailable" }; + } + if (runtime.pluginId && hasActivePluginClaimOwner(runtime.pluginId)) { + return { kind: "available" }; + } + return { kind: "available", agentId: normalizeAgentId(runtime.route.agentId) }; +} + +function bindingPeerCouldMatchConversation( + binding: ReturnType[number], + conversation: ConversationRouteCandidate, +): boolean { + // Before routePeer persistence, migration derived peerId from the delivery target, so topic + // rows retain their parent chat there. Current child peers always carry observed parent context. + // Treating every same-kind peer as a possible parent would let unrelated bindings deny valid routes. + const peer = binding.match.peer; + if (!peer) { + return true; + } + const kind = normalizeChatType(peer.kind); + const id = normalizeRouteBindingId(peer.id); + if (!kind || !id) { + return false; + } + return peerKindMatches(kind, conversation.kind) && (id === "*" || id === conversation.peerId); +} + +function hasUnrecordedContextualBinding(params: { + config: OpenClawConfig; + conversation: ConversationRouteCandidate; + resolvedAgentId: string; +}): boolean { + const channel = normalizeLowercaseStringOrEmpty(params.conversation.channel); + const accountId = normalizeAccountId(params.conversation.accountId); + const hasThreadContext = Boolean( + params.conversation.parentConversationRef || params.conversation.threadId, + ); + const hasGuildContext = params.conversation.kind === "channel"; + return listRouteBindings(params.config).some((binding) => { + const pattern = binding.match.accountId?.trim() ?? ""; + const contextualScope = Boolean( + (hasGuildContext && normalizeRouteBindingId(binding.match.guildId)) || + normalizeRouteBindingId(binding.match.teamId) || + (hasGuildContext && binding.match.roles?.length) || + (hasThreadContext && + binding.match.peer?.kind !== "direct" && + normalizeRouteBindingId(binding.match.peer?.id)), + ); + return ( + contextualScope && + normalizeAgentId(binding.agentId) !== params.resolvedAgentId && + normalizeLowercaseStringOrEmpty(binding.match.channel) === channel && + (pattern === "*" || normalizeAccountId(pattern) === accountId) && + bindingPeerCouldMatchConversation(binding, params.conversation) + ); + }); +} + +/** Replays current configured and plugin-owned routing for a persisted conversation address. */ +export function resolveConversationRouteEligibilityForAgent(params: { + config: OpenClawConfig; + agentId: string; + conversation: ConversationRouteCandidate; +}): ConversationRouteEligibility { + const requestedAgentId = normalizeAgentId(params.agentId); + const hasObservedContext = Boolean( + params.conversation.routeContextObserved || params.conversation.routeContext, + ); + const pluginOwner = resolvePluginRouteOwner(params.config, params.conversation); + if (pluginOwner) { + if (pluginOwner.kind === "unavailable") { + return "unavailable"; + } + return pluginOwner.agentId === requestedAgentId && + !( + !hasObservedContext && + pluginOwner.agentId && + hasUnrecordedContextualBinding({ + config: params.config, + conversation: params.conversation, + resolvedAgentId: pluginOwner.agentId, + }) + ) + ? "eligible" + : "denied"; + } + + const route = resolveConfiguredRouteOwner( + params.config, + params.conversation, + params.conversation.routeContext, + ); + if (!route) { + return "denied"; + } + const owner = resolveGenericRouteOwner({ + config: params.config, + conversation: params.conversation, + route, + ...(params.conversation.routeContext ? { context: params.conversation.routeContext } : {}), + }); + if (owner.kind === "unavailable") { + return "unavailable"; + } + if (owner.agentId !== requestedAgentId) { + return "denied"; + } + return !hasObservedContext && + hasUnrecordedContextualBinding({ + config: params.config, + conversation: params.conversation, + resolvedAgentId: owner.agentId, + }) + ? "denied" + : "eligible"; +} + +/** Enforces current route ownership at a Gateway request boundary. */ +export function assertConversationRouteEligibleForAgent(params: { + config: OpenClawConfig; + agentId: string; + conversation: ConversationRouteCandidate & Pick; +}): void { + const eligibility = resolveConversationRouteEligibilityForAgent(params); + if (eligibility === "eligible") { + return; + } + if (eligibility === "denied") { + throw new ConversationInputError( + `Conversation is not available to this agent: ${params.conversation.conversationRef}`, + ); + } + throw new Error( + `Conversation ownership is temporarily unavailable: ${params.conversation.conversationRef}`, + ); +} + +type ResolveConversation = typeof resolveConversation; + +export function assertConversationDeliveryAttemptAuthorized(params: { + config: OpenClawConfig; + agentId: string; + conversationRef: string; + expectedRouteFingerprint: string; + expectedSessionId?: string; + expectedSessionKey?: string; + scope: ConversationRegistryScope; + resolveConversation?: ResolveConversation; +}): void { + const conversation = (params.resolveConversation ?? resolveConversation)( + params.scope, + params.conversationRef, + ); + if ( + !conversation || + resolveConversationRouteFingerprint(conversation) !== params.expectedRouteFingerprint || + (params.expectedSessionId !== undefined && + conversation.sessionId !== params.expectedSessionId) || + (params.expectedSessionKey !== undefined && + conversation.sessionKey !== params.expectedSessionKey) + ) { + throw new PlatformMessageNotDispatchedError( + `Conversation is no longer available to this agent: ${params.conversationRef}`, + { cause: undefined, retryable: false }, + ); + } + const eligibility = resolveConversationRouteEligibilityForAgent({ + config: params.config, + agentId: params.agentId, + conversation, + }); + if (eligibility === "eligible") { + return; + } + throw new PlatformMessageNotDispatchedError( + eligibility === "unavailable" + ? `Conversation ownership is temporarily unavailable: ${params.conversationRef}` + : `Conversation is no longer available to this agent: ${params.conversationRef}`, + { cause: undefined, retryable: eligibility === "unavailable" }, + ); +} + +export function assertQueuedConversationDeliveryAttemptAuthorized(params: { + config: OpenClawConfig; + agentId: string; + operationId: string; + storePath?: string; + routeFingerprint: string; +}): void { + const scope = { + agentId: params.agentId, + ...(params.storePath ? { storePath: params.storePath } : {}), + }; + const operation = getConversationDeliveryOperation(scope, params.operationId); + if (!operation) { + throw new PlatformMessageNotDispatchedError( + `Conversation delivery operation no longer exists: ${params.operationId}`, + { cause: undefined, retryable: false }, + ); + } + assertConversationDeliveryAttemptAuthorized({ + config: params.config, + agentId: params.agentId, + conversationRef: operation.conversationRef, + expectedRouteFingerprint: params.routeFingerprint, + scope, + }); +} diff --git a/src/gateway/conversation-send.test.ts b/src/gateway/conversation-send.test.ts index 9e4f8b699730..ed902905bb96 100644 --- a/src/gateway/conversation-send.test.ts +++ b/src/gateway/conversation-send.test.ts @@ -15,6 +15,7 @@ const conversation = { channel: "reef", accountId: "default", kind: "direct" as const, + peerId: "molty", target: "reef:molty", sessionId: "reef-session", sessionKey: "agent:main:reef:direct:molty", @@ -190,8 +191,6 @@ describe("runGatewayConversationSend", () => { createdAt: 100, updatedAt: 200, }); - deps.resolveConversation.mockReturnValue(undefined); - await expect( runGatewayConversationSend( { @@ -205,10 +204,128 @@ describe("runGatewayConversationSend", () => { deps, ), ).resolves.toMatchObject({ status: "sent", messageId: "reef-existing" }); - expect(deps.resolveConversation).not.toHaveBeenCalled(); + expect(deps.resolveConversation).toHaveBeenCalled(); expect(deps.runMessageAction).not.toHaveBeenCalled(); }); + it("does not reveal completed send state after the route owner changes", async () => { + const deps = createDeps(); + deps.operations.set("send-reassigned", { + operationId: "send-reassigned", + operationKind: "send", + conversationRef: conversation.conversationRef, + channel: conversation.channel, + messageHash: "hello", + status: "sent", + platformMessageId: "reef-private-message", + createdAt: 100, + updatedAt: 200, + }); + + await expect( + runGatewayConversationSend( + { + config: { + agents: { entries: { main: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "default" }, + }, + ], + }, + agentId: "main", + senderIsOwner: true, + operationId: "send-reassigned", + conversationRef: conversation.conversationRef, + message: "hello", + }, + deps, + ), + ).rejects.toBeInstanceOf(ConversationInputError); + }); + + it("rejects a stored conversation route owned by another agent", async () => { + const deps = createDeps(); + + await expect( + runGatewayConversationSend( + { + config: { + agents: { entries: { main: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "default" }, + }, + ], + }, + agentId: "main", + senderIsOwner: true, + operationId: "send-sibling-route", + conversationRef: conversation.conversationRef, + message: "hello", + }, + deps, + ), + ).rejects.toBeInstanceOf(ConversationInputError); + expect(deps.beginOperation).not.toHaveBeenCalled(); + expect(deps.runMessageAction).not.toHaveBeenCalled(); + }); + + it("revalidates a route-owner change at the durable delivery attempt", async () => { + const deps = createDeps(); + deps.runMessageAction = vi.fn(async (input: Record) => { + const onDeliveryIntent = input.onDeliveryIntent as (intent: { + id: string; + channel: string; + to: string; + durability: "required"; + }) => void; + onDeliveryIntent({ + id: "queue-revoked-route", + channel: "reef", + to: "molty", + durability: "required", + }); + await (input.onDeliveryAttempt as () => Promise)(); + return sentResult(); + }) as never; + const readCurrentConfig = vi + .fn() + .mockReturnValueOnce({}) + .mockReturnValue({ + agents: { entries: { main: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "default" }, + }, + ], + }); + + await expect( + runGatewayConversationSend( + { + config: {}, + readCurrentConfig, + agentId: "main", + senderIsOwner: true, + operationId: "send-revoked-route", + conversationRef: conversation.conversationRef, + message: "hello", + }, + deps, + ), + ).resolves.toMatchObject({ status: "queued", queueId: "queue-revoked-route" }); + + expect(readCurrentConfig).toHaveBeenCalledTimes(2); + expect(deps.markSent).not.toHaveBeenCalled(); + }); + it("namespaces stable queue intents across agents", async () => { const mainDeps = createDeps(); const workerDeps = createDeps(); @@ -226,7 +343,9 @@ describe("runGatewayConversationSend", () => { ); await runGatewayConversationSend( { - config: {}, + config: { + agents: { entries: { worker: { default: true } } }, + }, agentId: "worker", senderIsOwner: true, operationId: "shared-operation", diff --git a/src/gateway/conversation-send.ts b/src/gateway/conversation-send.ts index 6a0a280c62e8..d4016be48cae 100644 --- a/src/gateway/conversation-send.ts +++ b/src/gateway/conversation-send.ts @@ -7,6 +7,7 @@ import { resolveConversation, resolveConversationRegistryScope, } from "../config/sessions/conversation-registry.js"; +import { resolveConversationRouteFingerprint } from "../config/sessions/conversation-route-fingerprint.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { ConversationDeliveryRejectedError, @@ -18,6 +19,10 @@ import { ConversationInputError, ConversationOperationConflictError, } from "./conversation-errors.js"; +import { + assertConversationDeliveryAttemptAuthorized, + assertConversationRouteEligibleForAgent, +} from "./conversation-route-ownership.js"; type ConversationSendDeps = ConversationDeliveryDeps & { resolveConversation: typeof resolveConversation; @@ -70,6 +75,7 @@ function resultForCompletedOperation( export async function runGatewayConversationSend( params: { config: OpenClawConfig; + readCurrentConfig?: () => OpenClawConfig; agentId: string; senderIsOwner: boolean; sourceSessionKey?: string; @@ -92,10 +98,6 @@ export async function runGatewayConversationSend( ...(params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}), message: params.message, }).record; - const completed = resultForCompletedOperation(operation); - if (completed) { - return completed; - } } const conversation = deps.resolveConversation(scope, params.conversationRef); @@ -104,18 +106,42 @@ export async function runGatewayConversationSend( `Conversation not found: ${params.conversationRef} (use conversations_list)`, ); } + const currentConfig = params.readCurrentConfig?.() ?? params.config; + assertConversationRouteEligibleForAgent({ + config: currentConfig, + agentId: params.agentId, + conversation, + }); + const routeFingerprint = resolveConversationRouteFingerprint(conversation); + if (operation) { + const completed = resultForCompletedOperation(operation); + if (completed) { + return completed; + } + } const sent = await sendGatewayConversationMessage({ deps, context: { agentId: params.agentId, ...(params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}), - config: params.config, + config: currentConfig, senderIsOwner: params.senderIsOwner, }, conversation, message: params.message, operationId: params.operationId, operationKind: "send", + routeFingerprint, + onDeliveryAttempt: async () => { + assertConversationDeliveryAttemptAuthorized({ + config: params.readCurrentConfig?.() ?? currentConfig, + agentId: params.agentId, + conversationRef: conversation.conversationRef, + expectedRouteFingerprint: routeFingerprint, + scope, + resolveConversation: deps.resolveConversation, + }); + }, ...(operation ? { operation } : {}), ...(params.signal ? { signal: params.signal } : {}), }); diff --git a/src/gateway/conversation-turn.test.ts b/src/gateway/conversation-turn.test.ts index 004f67f97e92..c6a0813d8aa1 100644 --- a/src/gateway/conversation-turn.test.ts +++ b/src/gateway/conversation-turn.test.ts @@ -18,6 +18,7 @@ const conversation = { channel: "reef", accountId: "default", kind: "direct" as const, + peerId: "molty", target: "reef:molty", sessionId: "reef-session", sessionKey: "agent:main:reef:direct:molty", @@ -136,7 +137,9 @@ function createDeps() { from: "reef:molty", to: conversation.target, })), - bindOutboundSessionEntry: vi.fn(async () => undefined), + bindOutboundSessionEntry: vi.fn( + async (_params: { assertCommitAllowed?: () => void }) => undefined, + ), runMessageAction: vi.fn(async () => sentResult()) as never, operations, update, @@ -159,6 +162,37 @@ function persistIntent(input: Record): void { } describe("runGatewayConversationTurn", () => { + it("rejects a stored conversation route owned by another agent", async () => { + const deps = createDeps(); + + await expect( + runGatewayConversationTurn( + { + config: { + agents: { entries: { main: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "default" }, + }, + ], + }, + agentId: "main", + senderIsOwner: true, + turnId: "turn-sibling-route", + conversationRef: conversation.conversationRef, + message: "hello", + timeoutMs: 1, + }, + deps, + ), + ).rejects.toBeInstanceOf(ConversationInputError); + expect(deps.resolveOutboundChannelPlugin).not.toHaveBeenCalled(); + expect(deps.beginOperation).not.toHaveBeenCalled(); + expect(deps.runMessageAction).not.toHaveBeenCalled(); + }); + it("creates a context binding only when a discovered address starts a turn", async () => { const deps = createDeps(); const { @@ -168,6 +202,11 @@ describe("runGatewayConversationTurn", () => { ...unbound } = conversation; deps.resolveConversation.mockReturnValueOnce(unbound).mockReturnValue(conversation); + deps.bindOutboundSessionEntry.mockImplementationOnce( + async (params: { assertCommitAllowed?: () => void }) => { + params.assertCommitAllowed?.(); + }, + ); deps.runMessageAction = vi.fn(async (input: Record) => { persistIntent(input); return sentResult(); @@ -197,6 +236,54 @@ describe("runGatewayConversationTurn", () => { ); }); + it("rejects a route-owner change at the outbound session binding commit", async () => { + const deps = createDeps(); + const { + sessionId: _sessionId, + sessionKey: _sessionKey, + role: _role, + ...unbound + } = conversation; + deps.resolveConversation.mockReturnValue(unbound); + deps.bindOutboundSessionEntry.mockImplementationOnce( + async (params: { assertCommitAllowed?: () => void }) => { + params.assertCommitAllowed?.(); + }, + ); + const readCurrentConfig = vi + .fn() + .mockReturnValueOnce({}) + .mockReturnValue({ + agents: { entries: { main: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "default" }, + }, + ], + }); + + await expect( + runGatewayConversationTurn( + { + config: {}, + readCurrentConfig, + agentId: "main", + senderIsOwner: true, + turnId: "turn-revoked-during-bind", + conversationRef: conversation.conversationRef, + message: "hello molty", + timeoutMs: 1, + }, + deps, + ), + ).rejects.toBeInstanceOf(PlatformMessageNotDispatchedError); + + expect(deps.beginOperation).not.toHaveBeenCalled(); + expect(deps.runMessageAction).not.toHaveBeenCalled(); + }); + it("registers correlation before durable delivery and consumes a fast reply inline", async () => { const deps = createDeps(); let capture: Promise | undefined; @@ -319,8 +406,6 @@ describe("runGatewayConversationTurn", () => { createdAt: 100, updatedAt: 300, }); - deps.resolveConversation.mockReturnValue(undefined); - await expect( runGatewayConversationTurn( { @@ -335,11 +420,57 @@ describe("runGatewayConversationTurn", () => { deps, ), ).resolves.toMatchObject({ status: "replied", reply: { text: "ack" } }); - expect(deps.resolveConversation).not.toHaveBeenCalled(); + expect(deps.resolveConversation).toHaveBeenCalled(); expect(deps.runMessageAction).not.toHaveBeenCalled(); expect(deps.resolveOutboundChannelPlugin).not.toHaveBeenCalled(); }); + it("does not reveal a completed reply after the route owner changes", async () => { + const deps = createDeps(); + deps.operations.set("turn-reassigned", { + operationId: "turn-reassigned", + operationKind: "turn", + conversationRef: conversation.conversationRef, + channel: conversation.channel, + messageHash: "hello", + status: "replied", + preparedMessageId: "reef-outbound-1", + platformMessageId: "reef-outbound-1", + reply: { + messageId: "reply-private", + replyToId: "reef-outbound-1", + text: "private finance reply", + timestamp: 300, + }, + createdAt: 100, + updatedAt: 300, + }); + + await expect( + runGatewayConversationTurn( + { + config: { + agents: { entries: { main: {}, finance: {} } }, + bindings: [ + { + type: "route", + agentId: "finance", + match: { channel: "reef", accountId: "default" }, + }, + ], + }, + agentId: "main", + senderIsOwner: true, + turnId: "turn-reassigned", + conversationRef: conversation.conversationRef, + message: "hello", + timeoutMs: 1_000, + }, + deps, + ), + ).rejects.toBeInstanceOf(ConversationInputError); + }); + it("returns queued state without retrying recipient-visible I/O", async () => { const deps = createDeps(); deps.operations.set("turn-queued", { @@ -512,6 +643,46 @@ describe("runGatewayConversationTurn", () => { }); }); + it("rejects delivery after the admitted session generation is replaced", async () => { + const deps = createDeps(); + let current = conversation; + deps.resolveConversation.mockImplementation(() => current); + deps.runMessageAction = vi.fn(async (input: Record) => { + persistIntent(input); + current = { + ...conversation, + sessionId: "replacement-session", + sessionKey: "agent:main:reef:direct:replacement", + }; + try { + await (input.onDeliveryAttempt as () => Promise)(); + } catch (error) { + deps.update("turn-replaced-session", { + status: "rejected", + rejectionError: error instanceof Error ? error.message : String(error), + }); + throw error; + } + return sentResult(); + }) as never; + + await expect( + runGatewayConversationTurn( + { + config: {}, + agentId: "main", + senderIsOwner: true, + turnId: "turn-replaced-session", + conversationRef: conversation.conversationRef, + message: "hello", + timeoutMs: 1_000, + }, + deps, + ), + ).rejects.toMatchObject({ name: "ConversationInputError" }); + expect(deps.runMessageAction).toHaveBeenCalledOnce(); + }); + it("rejects unsupported channels before registering or sending", async () => { const deps = createDeps(); const { diff --git a/src/gateway/conversation-turn.ts b/src/gateway/conversation-turn.ts index 1f53581099d2..62d4686315a7 100644 --- a/src/gateway/conversation-turn.ts +++ b/src/gateway/conversation-turn.ts @@ -7,6 +7,7 @@ import { type ConversationRecord, type ConversationRegistryScope, } from "../config/sessions/conversation-registry.js"; +import { resolveConversationRouteFingerprint } from "../config/sessions/conversation-route-fingerprint.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveOutboundChannelPlugin } from "../infra/outbound/channel-resolution.js"; import { @@ -24,6 +25,10 @@ import { ConversationInputError, ConversationOperationConflictError, } from "./conversation-errors.js"; +import { + assertConversationDeliveryAttemptAuthorized, + assertConversationRouteEligibleForAgent, +} from "./conversation-route-ownership.js"; type ConversationTurnDeps = ConversationDeliveryDeps & { registerPendingConversationTurn: typeof registerPendingConversationTurn; @@ -166,6 +171,8 @@ async function ensureConversationContextBinding(params: { agentId: string; conversation: ConversationRecord; plugin: ReturnType; + expectedRouteFingerprint: string; + readCurrentConfig: () => OpenClawConfig; }): Promise { if (hasConversationSessionBinding(params.conversation)) { return params.conversation; @@ -190,6 +197,18 @@ async function ensureConversationContextBinding(params: { channel, accountId: params.conversation.accountId, route, + // Route resolution can await plugin work; replay authority at the exact + // session-binding commit so a concurrent config change cannot persist it. + assertCommitAllowed: () => { + assertConversationDeliveryAttemptAuthorized({ + config: params.readCurrentConfig(), + agentId: params.agentId, + conversationRef: params.conversation.conversationRef, + expectedRouteFingerprint: params.expectedRouteFingerprint, + scope: params.scope, + resolveConversation: params.deps.resolveConversation, + }); + }, }); const bound = params.deps.resolveConversation(params.scope, params.conversation.conversationRef); if (!bound || !hasConversationSessionBinding(bound)) { @@ -204,6 +223,7 @@ async function ensureConversationContextBinding(params: { export async function runGatewayConversationTurn( params: { config: OpenClawConfig; + readCurrentConfig?: () => OpenClawConfig; agentId: string; senderIsOwner: boolean; sourceSessionKey?: string; @@ -227,10 +247,6 @@ export async function runGatewayConversationTurn( message: params.message, ...(prior.preparedMessageId ? { preparedMessageId: prior.preparedMessageId } : {}), }); - const completed = resultForCompletedOperation({ operation: begun.record }); - if (completed) { - return completed; - } } } catch (error) { if (error instanceof ConversationDeliveryInputError) { @@ -245,15 +261,29 @@ export async function runGatewayConversationTurn( `Conversation not found: ${params.conversationRef} (use conversations_list)`, ); } + const readCurrentConfig = params.readCurrentConfig ?? (() => params.config); + const currentConfig = readCurrentConfig(); + assertConversationRouteEligibleForAgent({ + config: currentConfig, + agentId: params.agentId, + conversation: discoveredConversation, + }); + const discoveredRouteFingerprint = resolveConversationRouteFingerprint(discoveredConversation); + if (begun) { + const completed = resultForCompletedOperation({ operation: begun.record }); + if (completed) { + return completed; + } + } const plugin = deps.resolveOutboundChannelPlugin({ channel: discoveredConversation.channel, - cfg: params.config, + cfg: currentConfig, }); const candidatePreparedMessageId = begun ? begun.record.preparedMessageId : prepareConversationMessageId({ plugin, - config: params.config, + config: currentConfig, conversation: discoveredConversation, message: params.message, }); @@ -265,11 +295,20 @@ export async function runGatewayConversationTurn( const conversation = await ensureConversationContextBinding({ deps, scope, - config: params.config, + config: currentConfig, agentId: params.agentId, conversation: discoveredConversation, plugin, + expectedRouteFingerprint: discoveredRouteFingerprint, + readCurrentConfig, }); + const authorizedConfig = readCurrentConfig(); + assertConversationRouteEligibleForAgent({ + config: authorizedConfig, + agentId: params.agentId, + conversation, + }); + const routeFingerprint = resolveConversationRouteFingerprint(conversation); if (!begun) { try { begun = deps.beginOperation(scope, { @@ -317,7 +356,7 @@ export async function runGatewayConversationTurn( context: { agentId: params.agentId, ...(params.sourceSessionKey ? { sourceSessionKey: params.sourceSessionKey } : {}), - config: params.config, + config: authorizedConfig, senderIsOwner: params.senderIsOwner, }, conversation, @@ -326,6 +365,19 @@ export async function runGatewayConversationTurn( operationKind: "turn", operation: begun.record, preparedMessageId, + routeFingerprint, + onDeliveryAttempt: async () => { + assertConversationDeliveryAttemptAuthorized({ + config: readCurrentConfig(), + agentId: params.agentId, + conversationRef: conversation.conversationRef, + expectedRouteFingerprint: routeFingerprint, + expectedSessionId: conversation.sessionId, + expectedSessionKey: conversation.sessionKey, + scope, + resolveConversation: deps.resolveConversation, + }); + }, }); if (sent.deliveryStatus !== "sent") { pending.cancel(); diff --git a/src/gateway/server-methods/conversations.ts b/src/gateway/server-methods/conversations.ts index 3846576d5498..cefdd97bdcb0 100644 --- a/src/gateway/server-methods/conversations.ts +++ b/src/gateway/server-methods/conversations.ts @@ -245,11 +245,14 @@ export function createConversationHandlers( return; } const request = params as ConversationListParams; + const readCurrentConfig = () => + resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); try { respond( true, await deps.runConversationList({ - config: resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }), + config: readCurrentConfig(), + readCurrentConfig, agentId: request.agentId, ...(request.channel ? { channel: request.channel } : {}), ...(request.query ? { query: request.query } : {}), @@ -275,7 +278,9 @@ export function createConversationHandlers( return; } const request = params as ConversationSendParams; - const config = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); + const readCurrentConfig = () => + resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); + const config = readCurrentConfig(); if ( !validateConversationSourceSession({ config, @@ -318,6 +323,7 @@ export function createConversationHandlers( execute: async () => await deps.runConversationSend({ config, + readCurrentConfig, agentId: request.agentId, senderIsOwner: isAuthenticatedOwner(client), ...(request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}), @@ -357,7 +363,9 @@ export function createConversationHandlers( return; } const request = params as ConversationTurnParams; - const config = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); + const readCurrentConfig = () => + resolveGatewayPluginConfig({ config: context.getRuntimeConfig() }); + const config = readCurrentConfig(); if ( !validateConversationSourceSession({ config, @@ -401,6 +409,7 @@ export function createConversationHandlers( execute: async () => await deps.runConversationTurn({ config, + readCurrentConfig, agentId: request.agentId, senderIsOwner: isAuthenticatedOwner(client), ...(request.sourceSessionKey ? { sourceSessionKey: request.sourceSessionKey } : {}), diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 0decf206d5b5..b1ca12b13c5e 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -63,6 +63,7 @@ const hoisted = vi.hoisted(() => { deliverQueuedSessionDelivery: vi.fn(async () => undefined), settleQueuedSessionDelivery: vi.fn(async () => undefined), deliverOutboundPayloads: vi.fn(), + assertQueuedConversationDeliveryAttemptAuthorized: vi.fn(), }; }); @@ -92,6 +93,11 @@ vi.mock("../infra/outbound/delivery-queue-migration.js", () => ({ migrateLegacyPendingOutboundDeliveries: hoisted.migrateLegacyPendingOutboundDeliveries, })); +vi.mock("./conversation-route-ownership.js", () => ({ + assertQueuedConversationDeliveryAttemptAuthorized: + hoisted.assertQueuedConversationDeliveryAttemptAuthorized, +})); + vi.mock("../infra/session-delivery-queue-runtime.js", () => ({ startSessionDeliveryRuntime: hoisted.startSessionDeliveryRuntime, schedulePendingSessionDeliveries: hoisted.schedulePendingSessionDeliveries, @@ -159,6 +165,7 @@ describe("server-runtime-services", () => { hoisted.deliverQueuedSessionDelivery.mockClear(); hoisted.settleQueuedSessionDelivery.mockClear(); hoisted.deliverOutboundPayloads.mockClear(); + hoisted.assertQueuedConversationDeliveryAttemptAuthorized.mockReset(); }); afterEach(() => { @@ -380,7 +387,7 @@ describe("server-runtime-services", () => { throw new Error("Expected delivery recovery log children"); } expect(hoisted.recoverPendingDeliveries).toHaveBeenCalledWith({ - deliver: hoisted.deliverOutboundPayloads, + deliver: expect.any(Function), cfg: {}, log: deliveryLog, }); @@ -664,7 +671,7 @@ describe("server-runtime-services", () => { const [drain] = hoisted.drainPendingDeliveries.mock.calls[0] ?? []; expect(drain).toMatchObject({ drainKey: "gateway:outbound", - deliver: hoisted.deliverOutboundPayloads, + deliver: expect.any(Function), }); expect(drain?.selectEntry({ channel: "discord" } as never, Date.now())).toEqual({ match: true, @@ -673,6 +680,49 @@ describe("server-runtime-services", () => { services.heartbeatRunner.stop(); }); + it("reconstructs conversation route authorization for a recovered delivery attempt", async () => { + vi.useFakeTimers(); + const { services } = activateScheduledServicesForTest({ startCron: false }); + await vi.dynamicImportSettled(); + const recovery = hoisted.recoverPendingDeliveries.mock.calls[0]?.[0]; + if (!recovery) { + throw new Error("Expected outbound recovery to start"); + } + hoisted.deliverOutboundPayloads.mockImplementationOnce(async (params) => { + await params.onDeliveryAttempt?.(); + return []; + }); + const denial = new Error("conversation route reassigned"); + hoisted.assertQueuedConversationDeliveryAttemptAuthorized.mockImplementationOnce(() => { + throw denial; + }); + + await expect( + recovery.deliver({ + cfg: {}, + channel: "reef", + to: "reef:molty", + payloads: [{ text: "hello" }], + conversationDeliveryAttemptAuthority: { + agentId: "main", + operationId: "operation-recovery", + storePath: "/tmp/agent.sqlite", + routeFingerprint: "route-recovery", + }, + }), + ).rejects.toBe(denial); + + expect(hoisted.assertQueuedConversationDeliveryAttemptAuthorized).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "main", + operationId: "operation-recovery", + storePath: "/tmp/agent.sqlite", + routeFingerprint: "route-recovery", + }), + ); + services.heartbeatRunner.stop(); + }); + it("uses the current runtime config when retrying queued outbound deliveries", async () => { vi.useFakeTimers(); const configModule = await import("../config/config.js"); diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index fccc68e65955..a3e1c74491d8 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -10,6 +10,7 @@ import { runHeartbeatOnce, } from "../infra/heartbeat-runner.js"; import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js"; +import type { DeliverOutboundPayloadsParams } from "../infra/outbound/deliver.js"; import { schedulePendingSessionDeliveries, startSessionDeliveryRuntime, @@ -19,6 +20,8 @@ import { runWithGatewayIndependentRootWorkAdmission, } from "../process/gateway-work-admission.js"; import { startSessionUpstreamMonitor } from "../sessions/session-upstream-monitor.js"; +import { assertQueuedConversationDeliveryAttemptAuthorized } from "./conversation-route-ownership.js"; +import { resolveGatewayPluginConfig } from "./runtime-plugin-config.js"; import { fenceScheduledGatewayContextResolver, runWithScheduledGatewayContext, @@ -215,6 +218,34 @@ function startPendingOutboundDeliveryRecovery(params: { if (stopped) { return; } + const deliverWithCurrentConversationAuthority = async ( + deliveryParams: DeliverOutboundPayloadsParams, + ) => { + const completion = deliveryParams.deliveryCompletion; + const attemptAuthority = + completion?.kind === "conversation" + ? completion + : deliveryParams.conversationDeliveryAttemptAuthority; + if (!attemptAuthority) { + return await deliverOutboundPayloadsInternal(deliveryParams); + } + return await deliverOutboundPayloadsInternal({ + ...deliveryParams, + onDeliveryAttempt: async () => { + await deliveryParams.onDeliveryAttempt?.(); + if (!attemptAuthority.routeFingerprint) { + return; + } + assertQueuedConversationDeliveryAttemptAuthorized({ + config: resolveGatewayPluginConfig({ config: getRuntimeConfig() }), + agentId: attemptAuthority.agentId, + operationId: attemptAuthority.operationId, + ...(attemptAuthority.storePath ? { storePath: attemptAuthority.storePath } : {}), + routeFingerprint: attemptAuthority.routeFingerprint, + }); + }, + }); + }; logRecovery ??= params.log.child("delivery-recovery"); if (migrationPending) { const cfg = initialPass ? params.cfg : getRuntimeConfig(); @@ -229,7 +260,7 @@ function startPendingOutboundDeliveryRecovery(params: { // one pass neither skipped ownership nor left retired rows behind. migrationPending = migration.skipped > 0 || migration.remaining > 0; await recoverPendingDeliveries({ - deliver: deliverOutboundPayloadsInternal, + deliver: deliverWithCurrentConversationAuthority, log: logRecovery, cfg, }); @@ -242,7 +273,7 @@ function startPendingOutboundDeliveryRecovery(params: { logLabel: "Outbound delivery retry", cfg: getRuntimeConfig(), log: logRecovery, - deliver: deliverOutboundPayloadsInternal, + deliver: deliverWithCurrentConversationAuthority, selectEntry: () => ({ match: true, bypassBackoff: false }), }); }).catch((err: unknown) => params.log.error(`Delivery recovery failed: ${String(err)}`)); diff --git a/src/infra/outbound/conversation-delivery.ts b/src/infra/outbound/conversation-delivery.ts index 3150ece20fc3..80ee6c792ad2 100644 --- a/src/infra/outbound/conversation-delivery.ts +++ b/src/infra/outbound/conversation-delivery.ts @@ -147,6 +147,8 @@ export async function sendGatewayConversationMessage(params: { operationKind: ConversationDeliveryRecord["operationKind"]; operation?: ConversationDeliveryRecord; preparedMessageId?: string; + routeFingerprint: string; + onDeliveryAttempt: () => Promise; signal?: AbortSignal; }): Promise { const scope = resolveConversationDeliveryStoreScope(params.context); @@ -203,8 +205,10 @@ export async function sendGatewayConversationMessage(params: { agentId: scope.agentId, operationId: begun.record.operationId, ...(scope.storePath ? { storePath: scope.storePath } : {}), + routeFingerprint: params.routeFingerprint, }, onDeliveryIntent, + onDeliveryAttempt: params.onDeliveryAttempt, ...(begun.record.preparedMessageId ? { preparedMessageId: begun.record.preparedMessageId } : {}), diff --git a/src/infra/outbound/current-conversation-bindings.ts b/src/infra/outbound/current-conversation-bindings.ts index c1130160dd15..c7a3331c63fd 100644 --- a/src/infra/outbound/current-conversation-bindings.ts +++ b/src/infra/outbound/current-conversation-bindings.ts @@ -321,15 +321,12 @@ export function deleteCurrentConversationBindingRecordsBySession( }); } -function resolveChannelSupportsCurrentConversationBinding(params: { - channel: string; - accountId: string; -}): boolean { +function resolveChannelConversationBindingSupport(params: { channel: string; accountId: string }) { const normalized = normalizeAnyChannelId(params.channel) ?? normalizeOptionalLowercaseString(normalizeConversationText(params.channel)); if (!normalized) { - return false; + return undefined; } const matchesPluginId = (plugin: { id?: string | null; @@ -345,8 +342,19 @@ function resolveChannelSupportsCurrentConversationBinding(params: { const plugin = (getActivePluginChannelRegistryFromState()?.channels ?? []).find((entry) => matchesPluginId(entry.plugin), )?.plugin; - const bindingSupport = plugin?.conversationBindings; - if (bindingSupport?.supportsCurrentConversationBinding !== true) { + return plugin?.conversationBindings; +} + +function resolveChannelSupportsCurrentConversationBinding(params: { + channel: string; + accountId: string; +}): boolean { + const bindingSupport = resolveChannelConversationBindingSupport(params); + if ( + bindingSupport?.supportsCurrentConversationBinding !== true || + bindingSupport.bindingStore === "adapter" || + typeof bindingSupport.createManager === "function" + ) { return false; } return ( @@ -354,6 +362,15 @@ function resolveChannelSupportsCurrentConversationBinding(params: { ); } +/** True when an active channel lifecycle owns bindings through a registered adapter. */ +export function requiresRegisteredSessionBindingAdapter(params: { + channel: string; + accountId: string; +}): boolean { + const support = resolveChannelConversationBindingSupport(params); + return support?.bindingStore === "adapter" || typeof support?.createManager === "function"; +} + function supportsGenericCurrentConversationBinding(ref: { channel: string; accountId: string; diff --git a/src/infra/outbound/deliver-contracts.ts b/src/infra/outbound/deliver-contracts.ts index 8e335e1ce05b..d0b292322cb2 100644 --- a/src/infra/outbound/deliver-contracts.ts +++ b/src/infra/outbound/deliver-contracts.ts @@ -28,6 +28,11 @@ import type { PreparedOutboundBatch } from "./prepared-batch.js"; import type { OutboundSendDeps } from "./send-deps.js"; import type { OutboundSessionContext } from "./session-context.js"; +type ConversationDeliveryAttemptAuthority = Omit< + Extract, + "kind" +>; + export type OutboundDeliveryQueuePolicy = "required" | "best_effort"; export type OutboundDeliveryIntent = { @@ -196,6 +201,10 @@ export type DeliverOutboundPayloadsCoreParams = { reusePendingDeliveryIntent?: boolean; /** @internal Serializable owner state finalized after live or recovered delivery. */ deliveryCompletion?: DurableDeliveryCompletion; + /** @internal Ephemeral route authority for a recovered attempt; never owns completion. */ + conversationDeliveryAttemptAuthority?: ConversationDeliveryAttemptAuthority; + /** @internal Revalidates authority once per durable queue execution, before adapter fanout. */ + onDeliveryAttempt?: () => Promise; /** @internal Channel-valid id reserved before a correlated conversation turn is sent. */ preparedMessageId?: string; /** @internal Recheck the concrete post-hook send shape before platform I/O. */ diff --git a/src/infra/outbound/deliver-queue-execute.ts b/src/infra/outbound/deliver-queue-execute.ts index ef650e64c343..84ff49a347ab 100644 --- a/src/infra/outbound/deliver-queue-execute.ts +++ b/src/infra/outbound/deliver-queue-execute.ts @@ -20,6 +20,7 @@ import { } from "./deliver-queue-state.js"; import { OutboundDeliveryError, + PlatformMessageNotDispatchedError, type OutboundDeliveryResult, type OutboundPayloadDeliveryOutcome, } from "./deliver-types.js"; @@ -307,6 +308,24 @@ export async function deliverOutboundPayloadsWithQueueCleanup( try { throwIfProducerLeaseLost(); + const conversationAttemptAuthority = + params.deliveryCompletion?.kind === "conversation" + ? params.deliveryCompletion + : params.conversationDeliveryAttemptAuthority; + if (conversationAttemptAuthority) { + // Conversation delivery was not stable-shipped before route fingerprints. An unfinished + // legacy intent cannot be rebound safely after upgrade, so missing authority fails closed. + if (!conversationAttemptAuthority.routeFingerprint || !params.onDeliveryAttempt) { + throw new PlatformMessageNotDispatchedError( + "Conversation delivery is missing its current route authorization", + { cause: undefined, retryable: false }, + ); + } + // One durable attempt admits its bounded adapter fanout/retries. A later queue or recovery + // attempt rechecks from the serialized fingerprint; in-flight revocation is not promised. + await params.onDeliveryAttempt(); + throwIfProducerLeaseLost(); + } const results = await deliverOutboundPayloadsCore(wrappedParams); // Core reconciles adapter progress objects with hook-bearing final results. deliveredResults = results; diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 374baee69921..9ced43989c15 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -880,7 +880,9 @@ describe("deliverOutboundPayloads", () => { kind: "conversation", agentId: "main", operationId: "operation-1", + routeFingerprint: "route-1", }, + onDeliveryAttempt: async () => {}, }); expect(order).toEqual([ @@ -968,6 +970,33 @@ describe("deliverOutboundPayloads", () => { expect(messageSendText).not.toHaveBeenCalled(); }); + it("fails closed for an unfinished conversation intent without route authority", async () => { + const messageSendText = vi.fn(async () => ({ + messageId: "should-not-send", + receipt: createMessageReceiptFromOutboundResults({ + results: [{ channel: "matrix", messageId: "should-not-send" }], + kind: "text", + }), + })); + setMatrixMessageAdapter({ + id: "matrix", + durableFinal: { capabilities: { text: true } }, + send: { text: messageSendText }, + }); + + await expect( + deliverMatrix({ + deliveryCompletion: { + kind: "conversation", + agentId: "main", + operationId: "legacy-operation", + }, + onDeliveryAttempt: async () => {}, + }), + ).rejects.toMatchObject({ retryable: false }); + expect(messageSendText).not.toHaveBeenCalled(); + }); + it("does not claim platform custody when message adapter preflight fails", async () => { const messageSendText = vi.fn(); setMatrixMessageAdapter({ @@ -1096,11 +1125,48 @@ describe("deliverOutboundPayloads", () => { expect(hookMocks.runner.runMessageSending).not.toHaveBeenCalled(); }); + it("revalidates conversation authority after queue admission and before the adapter", async () => { + const order: string[] = []; + queueMocks.enqueueDelivery.mockImplementationOnce(async () => { + order.push("queue"); + return "queue-route-authorization"; + }); + const sendMatrix = vi.fn(async () => { + order.push("send"); + return { messageId: "message-1" }; + }); + + await expect( + deliverMatrix({ + payloads: [{ text: "hello" }], + deps: { matrix: sendMatrix }, + queuePolicy: "required", + deliveryCompletion: { + kind: "conversation", + agentId: "main", + operationId: "operation-revoked", + routeFingerprint: "route-revoked", + }, + onDeliveryAttempt: async () => { + order.push("authorize"); + throw new PlatformMessageNotDispatchedError("route was revoked", { + cause: undefined, + retryable: false, + }); + }, + }), + ).rejects.toThrow("route was revoked"); + + expect(order).toEqual(["queue", "authorize"]); + expect(sendMatrix).not.toHaveBeenCalled(); + }); + it("finalizes owner state only after a chunked batch completes", async () => { const sendMatrix = vi .fn() .mockResolvedValueOnce({ messageId: "chunk-1" }) .mockResolvedValueOnce({ messageId: "chunk-2" }); + const onDeliveryAttempt = vi.fn(async () => {}); await deliverMatrix({ cfg: { channels: { matrix: { textChunkLimit: 2 } } } as OpenClawConfig, @@ -1111,9 +1177,12 @@ describe("deliverOutboundPayloads", () => { kind: "conversation", agentId: "main", operationId: "operation-chunked", + routeFingerprint: "route-chunked", }, + onDeliveryAttempt, }); + expect(onDeliveryAttempt).toHaveBeenCalledOnce(); expect(sendMatrix).toHaveBeenCalledTimes(2); expect(completionMocks.completeDurableDelivery).toHaveBeenCalledOnce(); expect(completionMocks.completeDurableDelivery).toHaveBeenCalledWith( @@ -1158,7 +1227,9 @@ describe("deliverOutboundPayloads", () => { kind: "conversation", agentId: "main", operationId: "operation-suppressed", + routeFingerprint: "route-suppressed", }, + onDeliveryAttempt: async () => {}, }); expect(results).toEqual([]); @@ -1634,7 +1705,9 @@ describe("deliverOutboundPayloads", () => { kind: "conversation", agentId: "main", operationId: "suppressed-metadata", + routeFingerprint: "route-suppressed-metadata", }, + onDeliveryAttempt: async () => {}, onPayloadDeliveryOutcome: (outcome) => outcomes.push(outcome), }), ).resolves.toEqual([]); @@ -2254,7 +2327,9 @@ describe("deliverOutboundPayloads", () => { kind: "conversation", agentId: "main", operationId: "operation-rejected", + routeFingerprint: "route-rejected", }, + onDeliveryAttempt: async () => {}, }), ).rejects.toThrow("atomic message limit"); @@ -2293,7 +2368,9 @@ describe("deliverOutboundPayloads", () => { kind: "conversation", agentId: "main", operationId: "operation-empty-rejection", + routeFingerprint: "route-empty-rejection", }, + onDeliveryAttempt: async () => {}, }), ).rejects.toThrow("Platform rejected the message before dispatch"); diff --git a/src/infra/outbound/delivery-completion.ts b/src/infra/outbound/delivery-completion.ts index 9e3a4303b810..8bac329e2841 100644 --- a/src/infra/outbound/delivery-completion.ts +++ b/src/infra/outbound/delivery-completion.ts @@ -18,6 +18,8 @@ export type DurableDeliveryCompletion = agentId: string; operationId: string; storePath?: string; + /** Present on Gateway-owned conversation intents created with route authorization. */ + routeFingerprint?: string; } | { kind: "pending-final"; diff --git a/src/infra/outbound/delivery-queue-recovery.ts b/src/infra/outbound/delivery-queue-recovery.ts index 928dbdcb2b1c..f96481f1683c 100644 --- a/src/infra/outbound/delivery-queue-recovery.ts +++ b/src/infra/outbound/delivery-queue-recovery.ts @@ -284,6 +284,8 @@ function buildRecoveryDeliverParams( stateDir?: string, producerClaimId?: string, ) { + const conversationCompletion = + entry.deliveryCompletion?.kind === "conversation" ? entry.deliveryCompletion : undefined; return { cfg, channel: entry.channel, @@ -310,6 +312,20 @@ function buildRecoveryDeliverParams( preparedMessageId: entry.preparedMessageId, // Recovery owns terminal completion because nested delivery only reports // process-local evidence that cannot survive another restart. + ...(conversationCompletion + ? { + conversationDeliveryAttemptAuthority: { + agentId: conversationCompletion.agentId, + operationId: conversationCompletion.operationId, + ...(conversationCompletion.storePath + ? { storePath: conversationCompletion.storePath } + : {}), + ...(conversationCompletion.routeFingerprint + ? { routeFingerprint: conversationCompletion.routeFingerprint } + : {}), + }, + } + : {}), deliveryQueueId: entry.id, deliveryQueueStateDir: stateDir, ...(producerClaimId ? { deliveryProducerClaimId: producerClaimId } : {}), diff --git a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts index 397006da137c..3a90bc98d7ae 100644 --- a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts +++ b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts @@ -1,10 +1,17 @@ // Covers reconnect-triggered queue drain selection, active claims, backoff // bypass, and concurrent drain suppression. +import path from "node:path"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { controlNextRecoverySleep } from "../../../test/helpers/infra/delivery-recovery.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { beginConversationDeliveryOperation } from "../../config/sessions/conversation-delivery-store.js"; +import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; +import { drainPendingDeliveries as drainPluginPendingDeliveries } from "../../plugin-sdk/delivery-queue-runtime.js"; +import { buildConversationRef } from "../../routing/conversation-ref.js"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; +import { PlatformMessageNotDispatchedError } from "./deliver-types.js"; import { OUTBOUND_DELIVERY_QUEUE_NAME } from "./delivery-queue-media-staging.js"; import { type DeliverFn, @@ -195,6 +202,80 @@ describe("drainPendingDeliveriesCore for reconnect", () => { expect(delivery.skipQueue).toBe(true); }); + it("leaves Gateway conversation records for the authorized recovery owner", async () => { + const operationId = "conversation-reconnect"; + const storePath = path.join(tmpDir, "agent-sessions.json"); + const scope = { agentId: "main", storePath }; + const conversationRef = buildConversationRef({ + channel: "reef", + accountId: "default", + kind: "direct", + peerId: "peer-agent", + }); + await upsertSessionEntryCore( + { ...scope, sessionKey: "agent:main:reef:direct:peer-agent" }, + { + sessionId: "reef-session", + updatedAt: 100, + chatType: "direct", + delivery: normalizeSessionDeliveryState({ + context: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, + origin: { + provider: "reef", + accountId: "default", + nativeDirectUserId: "peer-agent", + }, + }), + }, + ); + beginConversationDeliveryOperation(scope, { + operationId, + operationKind: "send", + conversationRef, + message: "deliver only through the authorized recovery owner", + preparedMessageId: "reef-prepared", + }); + const id = await enqueueDelivery( + { + channel: "reef", + to: "reef:peer-agent", + accountId: "default", + payloads: [{ text: "deliver only through the authorized recovery owner" }], + deliveryCompletion: { + kind: "conversation", + agentId: "main", + operationId, + storePath, + routeFingerprint: "route-reconnect", + }, + }, + tmpDir, + ); + await failDelivery(id, NO_LISTENER_ERROR, tmpDir); + const deliver = vi.fn(async () => { + throw new PlatformMessageNotDispatchedError( + "Conversation delivery is missing its current route authorization", + { cause: undefined, retryable: false }, + ); + }); + + await drainPluginPendingDeliveries({ + drainKey: "reef:default", + logLabel: "Reef reconnect drain", + cfg: stubCfg, + log: createRecoveryLog(), + stateDir: tmpDir, + deliver, + selectEntry: (entry) => ({ + match: entry.channel === "reef" && entry.accountId === "default", + bypassBackoff: true, + }), + }); + + expect(deliver).not.toHaveBeenCalled(); + expect((await loadPendingDeliveries(tmpDir)).map((entry) => entry.id)).toContain(id); + }); + it("skips entries from other accounts", async () => { const log = createRecoveryLog(); const deliver = vi.fn(async () => {}); diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index f1fa2518a279..ac67e3c95f61 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -351,6 +351,7 @@ describe("delivery-queue recovery", () => { agentId: "main", operationId, storePath, + routeFingerprint: "route-recovery", }, }, operationId, @@ -421,7 +422,14 @@ describe("delivery-queue recovery", () => { it("finalizes a persisted conversation operation during queue recovery", async () => { const scope = await createConversationRecoveryFixture("operation-recovery"); const deliveryResult = { channel: "reef" as const, messageId: "reef-platform" }; - const deliver = vi.fn(async (params: { onDeliveryResult?: (result: unknown) => unknown }) => { + const deliver = vi.fn(async (params: Parameters[0]) => { + expect(params.deliveryCompletion).toBeUndefined(); + expect(params.conversationDeliveryAttemptAuthority).toEqual({ + agentId: "main", + operationId: "operation-recovery", + storePath: scope.storePath, + routeFingerprint: "route-recovery", + }); await params.onDeliveryResult?.(deliveryResult); return [deliveryResult]; }); diff --git a/src/infra/outbound/message-action-contracts.ts b/src/infra/outbound/message-action-contracts.ts index 0687c6e5169d..9b487d551f95 100644 --- a/src/infra/outbound/message-action-contracts.ts +++ b/src/infra/outbound/message-action-contracts.ts @@ -94,6 +94,8 @@ export type MessageActionInput = { deliveryCompletion?: DurableDeliveryCompletion; /** @internal Runs after queue persistence and before platform I/O. */ onDeliveryIntent?: (intent: DurableMessageSendIntent) => void; + /** @internal Revalidates caller-owned authority before each durable adapter attempt. */ + onDeliveryAttempt?: () => Promise; /** @internal Runs on identified platform evidence before queue acknowledgement. */ onDeliveryResult?: (result: OutboundDeliveryResult) => Promise | void; /** @internal Revalidates caller authority immediately before recipient-visible I/O. */ diff --git a/src/infra/outbound/message-action-send.ts b/src/infra/outbound/message-action-send.ts index 5579b75e92c1..18e152b06e98 100644 --- a/src/infra/outbound/message-action-send.ts +++ b/src/infra/outbound/message-action-send.ts @@ -601,6 +601,7 @@ export async function executeMessageSend(ctx: ResolvedActionContext): Promise void; + /** @internal Revalidates authority once per durable queue execution, before adapter fanout. */ + onDeliveryAttempt?: () => Promise; /** @internal Runs on identified platform evidence before queue acknowledgement. */ onDeliveryResult?: (result: OutboundDeliveryResult) => Promise | void; /** @internal Revalidates caller authority immediately before recipient-visible I/O. */ @@ -455,6 +457,7 @@ export async function sendMessage(params: MessageSendParams): Promise void; + /** Revalidates authority once per durable queue execution, before adapter fanout. */ + onDeliveryAttempt?: () => Promise; /** Runs on identified platform evidence before queue acknowledgement. */ onDeliveryResult?: (result: OutboundDeliveryResult) => Promise | void; /** Revalidates caller authority immediately before recipient-visible I/O. */ @@ -188,6 +190,7 @@ async function sendCoreMessage(params: { deliveryCompletion: params.ctx.deliveryCompletion, requireUnknownSendReconciliation: params.ctx.requireQueuePersistence ? false : undefined, onDeliveryIntent: params.ctx.onDeliveryIntent, + onDeliveryAttempt: params.ctx.onDeliveryAttempt, onDeliveryResult: params.ctx.onDeliveryResult, onPlatformSendDispatch: params.ctx.onPlatformSendDispatch, skipQueue: params.ctx.skipQueue, diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 7e57cc8f40b5..f21713a00707 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -250,6 +250,8 @@ type OutboundSessionEntryParams = { channel: ChannelId; accountId?: string | null; route: OutboundSessionRoute; + /** Revalidates caller-owned route authority at the final persistence boundary. */ + assertCommitAllowed?: () => void; }; async function persistOutboundSessionEntry( @@ -285,6 +287,7 @@ async function persistOutboundSessionEntry( accountId: params.accountId ?? undefined, threadId: params.route.threadId, ctx, + ...(params.assertCommitAllowed ? { assertCommitAllowed: params.assertCommitAllowed } : {}), }); } diff --git a/src/infra/outbound/session-binding-service.test.ts b/src/infra/outbound/session-binding-service.test.ts index 1f5abb138161..033eaf9e675a 100644 --- a/src/infra/outbound/session-binding-service.test.ts +++ b/src/infra/outbound/session-binding-service.test.ts @@ -9,12 +9,14 @@ import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; import { testing, getSessionBindingService, + inspectSessionBindingByConversation, isSessionBindingError, registerSessionBindingAdapter, unregisterSessionBindingAdapter, type SessionBindingAdapter, type SessionBindingBindInput, type SessionBindingRecord, + type SessionBindingService, } from "./session-binding-service.js"; type SessionBindingServiceModule = typeof import("./session-binding-service.js"); @@ -48,10 +50,60 @@ function setMinimalCurrentConversationRegistry(): void { }, }, }, + { + pluginId: "adapter-chat", + source: "test", + plugin: { + id: "adapter-chat", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + bindingStore: "adapter", + }, + }, + }, + { + pluginId: "legacy-adapter-chat", + source: "test", + plugin: { + id: "legacy-adapter-chat", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: () => ({ stop: () => undefined }), + }, + }, + }, ]), ); } +it("keeps the stable session-binding service shape structurally assignable", () => { + const service: SessionBindingService = { + bind: async () => { + throw new Error("not implemented"); + }, + getCapabilities: () => ({ + adapterAvailable: false, + bindSupported: false, + unbindSupported: false, + placements: [], + }), + listBySession: () => [], + resolveByConversation: () => null, + touch: () => {}, + unbind: async () => [], + }; + + expect( + service.resolveByConversation({ + channel: "demo", + accountId: "default", + conversationId: "room-1", + }), + ).toBeNull(); +}); + async function importSessionBindingServiceModule( cacheBust: string, ): Promise { @@ -207,6 +259,51 @@ describe("session binding service", () => { ); }); + it.each(["adapter-chat", "legacy-adapter-chat"])( + "distinguishes an unavailable %s owner from an empty result", + async (channel) => { + const service = getSessionBindingService(); + const conversation = { + channel, + accountId: "default", + conversationId: "room-1", + }; + + expect(service.getCapabilities(conversation)).toEqual({ + adapterAvailable: false, + bindSupported: false, + unbindSupported: false, + placements: [], + }); + expect(inspectSessionBindingByConversation(conversation)).toEqual({ + status: "unavailable", + }); + await expectSessionBindingError( + service.bind({ + targetSessionKey: "agent:finance:bound", + targetKind: "session", + conversation, + }), + "BINDING_ADAPTER_UNAVAILABLE", + ); + const adapter: SessionBindingAdapter = { + channel, + accountId: "default", + listBySession: () => [], + resolveByConversation: () => null, + }; + registerSessionBindingAdapter(adapter); + expect(inspectSessionBindingByConversation(conversation)).toEqual({ + status: "available", + binding: null, + }); + unregisterSessionBindingAdapter({ channel, accountId: "default", adapter }); + expect(inspectSessionBindingByConversation(conversation)).toEqual({ + status: "unavailable", + }); + }, + ); + it("returns structured errors for unsupported placement", async () => { registerSessionBindingAdapter({ channel: "demo-binding", diff --git a/src/infra/outbound/session-binding-service.ts b/src/infra/outbound/session-binding-service.ts index 3fe7586d7d17..520b83e6fc73 100644 --- a/src/infra/outbound/session-binding-service.ts +++ b/src/infra/outbound/session-binding-service.ts @@ -7,6 +7,7 @@ import { bindGenericCurrentConversation, getGenericCurrentConversationBindingCapabilities, listGenericCurrentConversationBindingsBySession, + requiresRegisteredSessionBindingAdapter, resolveGenericCurrentConversationBinding, touchGenericCurrentConversationBinding, unbindGenericCurrentConversationBindings, @@ -207,6 +208,28 @@ function dedupeBindings(records: SessionBindingRecord[]): SessionBindingRecord[] return [...byId.values()]; } +export function inspectSessionBindingByConversation( + ref: ConversationRef, +): { status: "available"; binding: SessionBindingRecord | null } | { status: "unavailable" } { + const normalized = normalizeConversationRef(ref); + if (!normalized.channel || !normalized.conversationId) { + return { status: "available", binding: null }; + } + const adapter = resolveAdapterForChannelAccount(normalized); + if (adapter) { + return { status: "available", binding: adapter.resolveByConversation(normalized) }; + } + // A channel-owned adapter may disappear briefly during restart. That gap is not an + // authoritative empty result and must not let callers fall through to another owner. + if (requiresRegisteredSessionBindingAdapter(normalized)) { + return { status: "unavailable" }; + } + return { + status: "available", + binding: resolveGenericCurrentConversationBinding(normalized), + }; +} + function createDefaultSessionBindingService(): SessionBindingService { return { bind: async (input) => { diff --git a/src/infra/state-migrations.media-persistence.historical-schema.test-support.ts b/src/infra/state-migrations.media-persistence.historical-schema.test-support.ts index f4595ed60180..f93896c4a04f 100644 --- a/src/infra/state-migrations.media-persistence.historical-schema.test-support.ts +++ b/src/infra/state-migrations.media-persistence.historical-schema.test-support.ts @@ -43,6 +43,7 @@ export function historicalV15AgentSchemaSql(): string { let sql = restoreHistoricalAgentLeaseSchema(OPENCLAW_AGENT_SCHEMA_SQL) .replace(" entry_valid INTEGER NOT NULL DEFAULT 0 CHECK (entry_valid IN (-1, 0, 1)),\n", "") .replace(" project_id TEXT,\n", "") + .replace(" route_context_json TEXT,\n", "") .replace( " owner_actor_type TEXT,\n owner_actor_id TEXT,\n owner_assigned_by_type TEXT,\n owner_assigned_by_id TEXT,\n owner_assigned_at INTEGER,\n", "", @@ -57,6 +58,11 @@ export function historicalV15AgentSchemaSql(): string { "CREATE INDEX IF NOT EXISTS idx_agent_session_nodes_entry_valid_pending", "CREATE TABLE IF NOT EXISTS session_windows (", ); + sql = removeSchemaRange( + sql, + "-- Older same-version writers preserve the envelope while updating the association.", + "CREATE INDEX IF NOT EXISTS idx_agent_session_conversations_conversation", + ); sql = removeSchemaRange( sql, "CREATE TABLE IF NOT EXISTS message_tool_run_outcomes (", diff --git a/src/plugin-sdk/conversation-binding-inspection-runtime.ts b/src/plugin-sdk/conversation-binding-inspection-runtime.ts new file mode 100644 index 000000000000..7d8f01da7c76 --- /dev/null +++ b/src/plugin-sdk/conversation-binding-inspection-runtime.ts @@ -0,0 +1,20 @@ +import { + inspectSessionBindingByConversation, + type ConversationRef, + type SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; + +/** Read-only result from the authoritative current-conversation binding store. */ +export type ConversationBindingInspection = + | { status: "available"; binding: SessionBindingRecord | null } + | { status: "unavailable" }; + +/** + * Inspect current-conversation binding state without refreshing binding liveness. + * `unavailable` is distinct from an authoritative empty binding result. + */ +export function inspectConversationBinding( + conversation: ConversationRef, +): ConversationBindingInspection { + return inspectSessionBindingByConversation(conversation); +} diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts index 59fa8f66bd25..42fdacd81600 100644 --- a/src/plugin-sdk/core.test.ts +++ b/src/plugin-sdk/core.test.ts @@ -226,4 +226,21 @@ describe("createChatChannelPlugin", () => { }), ).toBe(false); }); + + it("exports the conversation route-owner result contract", () => { + const messaging = { + resolveConversationRouteOwner: ({ conversation }) => + conversation.peerId === "retry" + ? ({ kind: "unavailable" } as const) + : ({ kind: "agent", agentId: "main" } as const), + } satisfies NonNullable; + + expect( + messaging.resolveConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "direct", peerId: "retry" }, + }), + ).toEqual({ kind: "unavailable" }); + }); }); diff --git a/src/plugin-sdk/delivery-queue-runtime.ts b/src/plugin-sdk/delivery-queue-runtime.ts index ef66e7e09ad7..a4a169cdcf8d 100644 --- a/src/plugin-sdk/delivery-queue-runtime.ts +++ b/src/plugin-sdk/delivery-queue-runtime.ts @@ -31,6 +31,12 @@ export async function drainPendingDeliveries(opts: DrainPendingDeliveriesOptions await drainPendingDeliveriesCore({ ...opts, deliver, + // Conversation records belong to the Gateway recovery loop, which reconstructs current + // route authority before delivery. Plugin reconnect drains cannot safely consume them. + selectEntry: (entry, now) => + entry.deliveryCompletion?.kind === "conversation" + ? { match: false, bypassBackoff: false } + : opts.selectEntry(entry, now), }); }); } diff --git a/src/plugin-sdk/session-binding-runtime.ts b/src/plugin-sdk/session-binding-runtime.ts index cce4abd67e5f..c98a86ceb85a 100644 --- a/src/plugin-sdk/session-binding-runtime.ts +++ b/src/plugin-sdk/session-binding-runtime.ts @@ -4,6 +4,7 @@ export { testing as __testing, testing, getSessionBindingService, + inspectSessionBindingByConversation, registerSessionBindingAdapter, type SessionBindingRecord, type SessionBindingService, diff --git a/src/plugins/channel-registry-state.types.ts b/src/plugins/channel-registry-state.types.ts index 659ce0bd50d8..9183a8506197 100644 --- a/src/plugins/channel-registry-state.types.ts +++ b/src/plugins/channel-registry-state.types.ts @@ -8,6 +8,7 @@ export type ActiveChannelPluginRuntimeShape = { } | null; messaging?: { targetPrefixes?: readonly string[]; + resolveConversationRouteOwner?: (...args: never[]) => unknown; } | null; capabilities?: { nativeCommands?: boolean; @@ -15,6 +16,8 @@ export type ActiveChannelPluginRuntimeShape = { conversationBindings?: { supportsCurrentConversationBinding?: boolean; isCurrentConversationBindingSupported?: (params: { accountId: string }) => boolean; + bindingStore?: "adapter"; + createManager?: unknown; } | null; }; diff --git a/src/state/openclaw-agent-db-schema-helpers.ts b/src/state/openclaw-agent-db-schema-helpers.ts index 8f3bc49dfe6a..9400b440babe 100644 --- a/src/state/openclaw-agent-db-schema-helpers.ts +++ b/src/state/openclaw-agent-db-schema-helpers.ts @@ -27,8 +27,8 @@ import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "./openclaw-agent-db import { OPENCLAW_AGENT_SCHEMA_VERSION } from "./openclaw-agent-db-contract.js"; import { OpenClawAgentDatabaseMediaMigrationRequiredError } from "./openclaw-agent-db-migration-required.js"; import { + ensureSessionAdditiveColumns, ensureSessionEntryValidityProjection, - ensureSessionProjectColumn, } from "./openclaw-agent-db-session-migrations.js"; import { MESSAGE_TOOL_RUN_OUTCOMES_TABLE } from "./openclaw-agent-message-tool-outcome-schema.js"; import { @@ -71,6 +71,7 @@ const AGENT_SCHEMA_COMPATIBILITY = { ...STANDING_INTENTS_FTS_SHADOW_TABLES, ], allowedMissingColumns: [ + "session_conversations.route_context_json", "session_participants.actor_source", "standing_intents.creator_sender", ...FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS.map( @@ -189,7 +190,7 @@ export function repairAndAssertOpenClawAgentV14SchemaForMigration( ); } - ensureSessionProjectColumn(database); + ensureSessionAdditiveColumns(database); ensureSessionEntryValidityProjection(database); ensureSessionKeyContractSchemaInTransaction(database); diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index a8d6f6e08c7e..acf5fa1cf5ed 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -42,8 +42,9 @@ import { } from "./openclaw-agent-db-schema-helpers.js"; import { backfillSessionConversations, - ensureSessionProjectColumn, + ensureSessionAdditiveColumns, ensureSessionEntryValidityProjection, + hasPendingSessionConversationRouteContextColumn, migrateConversationDeliveryTargetColumn, migrateSessionEntryStatusProjection, readSqliteTableColumns, @@ -569,6 +570,7 @@ export function assertAgentDatabaseIntegrityBeforeMutation( (hasPendingMemoryChunkMetadataMigration(database) || hasPendingSessionKeyContractSchemaMigration(database) || hasRetiredAgentStateLeaseSchema(database) || + hasPendingSessionConversationRouteContextColumn(database) || hasPendingSessionProjectColumn(database)); if (userVersion === OPENCLAW_AGENT_SCHEMA_VERSION && !hasPendingCurrentVersionMigration) { verifyAndRepairCanonicalSqliteIndexes(database, pathname, OPENCLAW_AGENT_SCHEMA_SQL, { @@ -628,7 +630,7 @@ function ensureAgentSchema( } migrateRetiredAgentStateLeaseSchema(db, pathname, targetVersion); if (previousVersion === targetVersion) { - ensureSessionProjectColumn(db); + ensureSessionAdditiveColumns(db); ensureSessionEntryValidityProjection(db); ensureSessionKeyContractSchemaInTransaction(db); if (hasPendingMemoryChunkMetadataMigration(db)) { @@ -663,7 +665,7 @@ function ensureAgentSchema( } backfillSessionEntryProvenance(db, previousVersion); migrateSessionNodesAndWindows(db, previousVersion); - ensureSessionProjectColumn(db); + ensureSessionAdditiveColumns(db); ensureSessionEntryValidityProjection(db); db.exec(OPENCLAW_AGENT_SCHEMA_SQL); migrateMemoryChunkMetadataSchema(db); diff --git a/src/state/openclaw-agent-db-session-migrations.test.ts b/src/state/openclaw-agent-db-session-migrations.test.ts index 8dfc38e9dfa2..78477151048f 100644 --- a/src/state/openclaw-agent-db-session-migrations.test.ts +++ b/src/state/openclaw-agent-db-session-migrations.test.ts @@ -3,6 +3,7 @@ import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { buildConversationRef } from "../routing/conversation-ref.js"; import { backfillSessionConversations, + ensureSessionAdditiveColumns, migrateConversationDeliveryTargetColumn, } from "./openclaw-agent-db-session-migrations.js"; @@ -15,6 +16,72 @@ describe("agent DB conversation migration", () => { } }); + it("adds nullable route context without advancing the schema version", () => { + const sqlite = requireNodeSqlite(); + const database = new sqlite.DatabaseSync(":memory:"); + databases.push(database); + database.exec(` + PRAGMA user_version = 17; + CREATE TABLE session_conversations ( + session_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + PRIMARY KEY (session_id, conversation_id, role) + ) STRICT; + INSERT INTO session_conversations ( + session_id, conversation_id, role, first_seen_at, last_seen_at + ) VALUES ('session-a', 'conversation-a', 'primary', 1, 1); + `); + + ensureSessionAdditiveColumns(database); + ensureSessionAdditiveColumns(database); + database + .prepare( + "UPDATE session_conversations SET route_context_json = ? WHERE session_id = ? AND conversation_id = ?", + ) + .run( + '{"version":1,"writeId":"candidate-write","observedAt":1,"context":{"guildId":"guild-a"}}', + "session-a", + "conversation-a", + ); + database + .prepare( + "UPDATE session_conversations SET route_context_json = ?, last_seen_at = ? WHERE session_id = ? AND conversation_id = ?", + ) + .run( + '{"version":1,"writeId":"current-write","observedAt":1,"context":{"guildId":"guild-a"}}', + 1, + "session-a", + "conversation-a", + ); + expect( + database + .prepare( + "SELECT route_context_json FROM session_conversations WHERE session_id = 'session-a'", + ) + .get(), + ).toEqual({ + route_context_json: + '{"version":1,"writeId":"current-write","observedAt":1,"context":{"guildId":"guild-a"}}', + }); + database + .prepare( + "UPDATE session_conversations SET last_seen_at = ? WHERE session_id = ? AND conversation_id = ?", + ) + .run(1, "session-a", "conversation-a"); + + expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 17 }); + expect( + database + .prepare( + "SELECT route_context_json, last_seen_at FROM session_conversations WHERE session_id = 'session-a'", + ) + .get(), + ).toEqual({ route_context_json: null, last_seen_at: 1 }); + }); + it("backfills direct addresses and keeps shared-main peers as participants", () => { const sqlite = requireNodeSqlite(); const database = new sqlite.DatabaseSync(":memory:"); diff --git a/src/state/openclaw-agent-db-session-migrations.ts b/src/state/openclaw-agent-db-session-migrations.ts index 59401dbab9bc..19415e330a08 100644 --- a/src/state/openclaw-agent-db-session-migrations.ts +++ b/src/state/openclaw-agent-db-session-migrations.ts @@ -127,6 +127,7 @@ export function backfillSessionConversations(db: DatabaseSync): void { session_id TEXT NOT NULL, conversation_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'primary' CHECK (role IN ('primary', 'participant', 'related')), + route_context_json TEXT, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, PRIMARY KEY (session_id, conversation_id, role), @@ -269,13 +270,37 @@ export function readSqliteTableColumns(db: DatabaseSync, tableName: string): Set return new Set(rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : []))); } -/** Installs the same-version project identity projection on first updated-binary open. */ -export function ensureSessionProjectColumn(db: DatabaseSync): void { +/** Installs same-version session projections on first updated-binary open. */ +export function ensureSessionAdditiveColumns(db: DatabaseSync): void { const columns = readSqliteTableColumns(db, "session_nodes"); - if (!columns || columns.has("project_id")) { - return; + if (columns && !columns.has("project_id")) { + db.exec("ALTER TABLE session_nodes ADD COLUMN project_id TEXT;"); } - db.exec("ALTER TABLE session_nodes ADD COLUMN project_id TEXT;"); + const conversationColumns = readSqliteTableColumns(db, "session_conversations"); + if (conversationColumns && !conversationColumns.has("route_context_json")) { + db.exec("ALTER TABLE session_conversations ADD COLUMN route_context_json TEXT"); + } + if (conversationColumns) { + // Same-version older writers leave the envelope byte-identical. Clear it on their update so + // stale owner facts cannot survive a downgrade/re-upgrade cycle with an unchanged timestamp. + db.exec(` + CREATE TRIGGER IF NOT EXISTS session_conversations_route_context_invalidate_after_update + AFTER UPDATE OF role, last_seen_at ON session_conversations + WHEN NEW.route_context_json IS OLD.route_context_json + BEGIN + UPDATE session_conversations + SET route_context_json = NULL + WHERE session_id = NEW.session_id + AND conversation_id = NEW.conversation_id + AND role = NEW.role; + END; + `); + } +} + +export function hasPendingSessionConversationRouteContextColumn(db: DatabaseSync): boolean { + const columns = readSqliteTableColumns(db, "session_conversations"); + return Boolean(columns && !columns.has("route_context_json")); } /** Adds the v11 exact delivery target before the conversation backfill writes canonical rows. */ diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index da9e2882f43c..2d9184033329 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -221,6 +221,7 @@ export interface SessionConversations { first_seen_at: number; last_seen_at: number; role: Generated; + route_context_json: string | null; session_id: string; } diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index 39402ba044a4..d91291164046 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -3490,9 +3490,11 @@ describe("openclaw agent database", () => { DROP TRIGGER session_nodes_entry_valid_after_insert; DROP TRIGGER session_nodes_entry_valid_after_entry_update; DROP TRIGGER session_nodes_entry_valid_after_identity_update; + DROP TRIGGER session_conversations_route_context_invalidate_after_update; DROP INDEX idx_agent_session_nodes_entry_valid_pending; DROP TABLE session_key_contract; ALTER TABLE session_nodes DROP COLUMN entry_valid; + ALTER TABLE session_conversations DROP COLUMN route_context_json; `); expect(readSqliteNumberPragma(shippedSchema, "user_version")).toBe( OPENCLAW_AGENT_SCHEMA_VERSION, @@ -3515,6 +3517,13 @@ describe("openclaw agent database", () => { expect( repaired.db.prepare("SELECT main_key FROM session_key_contract WHERE id = 1").get(), ).toEqual({ main_key: "main" }); + expect( + repaired.db + .prepare( + "SELECT name FROM pragma_table_info('session_conversations') WHERE name = 'route_context_json'", + ) + .get(), + ).toEqual({ name: "route_context_json" }); }); it("installs same-version session additions before maintenance index repair", () => { diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index a87b195f6e30..c41d257bf30d 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -219,6 +219,7 @@ CREATE TABLE IF NOT EXISTS session_conversations ( session_id TEXT NOT NULL, conversation_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'primary' CHECK (role IN ('primary', 'participant', 'related')), + route_context_json TEXT, first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL, PRIMARY KEY (session_id, conversation_id, role), @@ -226,6 +227,18 @@ CREATE TABLE IF NOT EXISTS session_conversations ( FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE ) STRICT; +-- Older same-version writers preserve the envelope while updating the association. +CREATE TRIGGER IF NOT EXISTS session_conversations_route_context_invalidate_after_update +AFTER UPDATE OF role, last_seen_at ON session_conversations +WHEN NEW.route_context_json IS OLD.route_context_json +BEGIN + UPDATE session_conversations + SET route_context_json = NULL + WHERE session_id = NEW.session_id + AND conversation_id = NEW.conversation_id + AND role = NEW.role; +END; + CREATE INDEX IF NOT EXISTS idx_agent_session_conversations_conversation ON session_conversations(conversation_id, last_seen_at DESC, session_id); diff --git a/test/e2e/qa-lab/runtime/conversation-route-ownership.e2e.test.ts b/test/e2e/qa-lab/runtime/conversation-route-ownership.e2e.test.ts new file mode 100644 index 000000000000..a88501a0532d --- /dev/null +++ b/test/e2e/qa-lab/runtime/conversation-route-ownership.e2e.test.ts @@ -0,0 +1,337 @@ +import { randomUUID } from "node:crypto"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createQaBusState, + createQaChannelTransport, + startQaBusServer, +} from "../../../../extensions/qa-lab/api.js"; +import { startQaLiveLaneGateway } from "../../../../extensions/qa-lab/runtime-api.js"; + +const CHANNEL_ID = "qa-channel"; +const PRIMARY_AGENT_ID = "main"; +const SIBLING_AGENT_ID = "finance"; +const PRIMARY_ACCOUNT_ID = "default"; +const SIBLING_ACCOUNT_ID = "finance"; +const PRIMARY_PEER_ID = "primary-peer"; +const SIBLING_PEER_ID = "finance-peer"; + +type GatewayHarness = Awaited>; + +let harness: GatewayHarness | undefined; +let bus: Awaited> | undefined; + +function conversationItems(payload: unknown): Array> { + if (!isRecord(payload) || !Array.isArray(payload.conversations)) { + throw new Error(`conversations.list returned an invalid payload: ${JSON.stringify(payload)}`); + } + const items = payload.conversations.filter(isRecord); + if (items.length !== payload.conversations.length) { + throw new Error(`conversations.list returned an invalid item: ${JSON.stringify(payload)}`); + } + return items; +} + +function findConversation( + items: Array>, + accountId: string, + targetIncludes: string, +) { + return items.find( + (item) => + item.accountId === accountId && + typeof item.target === "string" && + item.target.includes(targetIncludes), + ); +} + +async function listConversations(gateway: GatewayHarness["gateway"], agentId: string) { + return conversationItems( + await gateway.call("conversations.list", { + agentId, + channel: CHANNEL_ID, + limit: 50, + }), + ); +} + +async function waitForConversation(params: { + gateway: GatewayHarness["gateway"]; + agentId: string; + accountId: string; + targetIncludes: string; +}) { + const deadline = Date.now() + 30_000; + let latest: Array> = []; + while (Date.now() < deadline) { + latest = await listConversations(params.gateway, params.agentId); + const match = findConversation(latest, params.accountId, params.targetIncludes); + if (match) { + return match; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error( + `timed out waiting for ${params.agentId} to own ${params.accountId}/${params.targetIncludes}: ${JSON.stringify(latest)}`, + ); +} + +async function waitForAppliedConfig(gateway: GatewayHarness["gateway"], hash: string) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const payload = await gateway.call("config.get", {}); + if ( + isRecord(payload) && + payload.hash === hash && + payload.appliedConfigHash === payload.configRevisionHash + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Gateway did not apply config revision ${hash}`); +} + +function outboundMessagesWithText( + state: ReturnType, + accountId: string, + text: string, +) { + return state + .getSnapshot() + .messages.filter( + (message) => + message.direction === "outbound" && + message.accountId === accountId && + message.text === text, + ); +} + +async function sendConversation(params: { + gateway: GatewayHarness["gateway"]; + agentId: string; + conversationRef: string; + message: string; +}) { + return await params.gateway.call("conversations.send", { + agentId: params.agentId, + operationId: randomUUID(), + conversationRef: params.conversationRef, + message: params.message, + }); +} + +afterEach(async () => { + const cleanupErrors: unknown[] = []; + try { + await harness?.stop(); + } catch (error) { + cleanupErrors.push(error); + } finally { + harness = undefined; + } + try { + await bus?.stop(); + } catch (error) { + cleanupErrors.push(error); + } finally { + bus = undefined; + } + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, "conversation route ownership proof cleanup failed"); + } +}); + +describe("conversation route ownership clean-machine proof", () => { + it( + "keeps sibling accounts isolated and revalidates persisted references after reassignment", + { timeout: 120_000 }, + async () => { + const state = createQaBusState(); + const transport = createQaChannelTransport(state); + bus = await startQaBusServer({ state }); + harness = await startQaLiveLaneGateway({ + repoRoot: process.cwd(), + providerMode: "mock-openai", + primaryModel: "mock-openai/gpt-5.6-luna", + alternateModel: "mock-openai/gpt-5.6-luna-alt", + transport, + transportBaseUrl: bus.baseUrl, + controlUiEnabled: false, + mockAuthAgentIds: [PRIMARY_AGENT_ID, SIBLING_AGENT_ID], + mutateConfig: (config) => ({ + ...config, + agents: { + ...config.agents, + ownership: "explicit", + entries: { + ...config.agents?.entries, + [PRIMARY_AGENT_ID]: {}, + [SIBLING_AGENT_ID]: {}, + }, + }, + bindings: [ + { + type: "route", + agentId: PRIMARY_AGENT_ID, + match: { channel: CHANNEL_ID, accountId: PRIMARY_ACCOUNT_ID }, + }, + { + type: "route", + agentId: SIBLING_AGENT_ID, + match: { channel: CHANNEL_ID, accountId: SIBLING_ACCOUNT_ID }, + }, + ], + channels: { + ...config.channels, + [CHANNEL_ID]: { + ...config.channels?.[CHANNEL_ID], + accounts: { + [SIBLING_ACCOUNT_ID]: { + baseUrl: bus?.baseUrl, + enabled: true, + allowFrom: ["*"], + pollTimeoutMs: 250, + }, + }, + }, + }, + }), + }); + const { gateway } = harness; + + state.addInboundMessage({ + accountId: PRIMARY_ACCOUNT_ID, + conversation: { kind: "direct", id: PRIMARY_PEER_ID }, + senderId: PRIMARY_PEER_ID, + text: "seed primary ownership", + }); + state.addInboundMessage({ + accountId: SIBLING_ACCOUNT_ID, + conversation: { kind: "direct", id: SIBLING_PEER_ID }, + senderId: SIBLING_PEER_ID, + text: "seed sibling ownership", + }); + + const primaryConversation = await waitForConversation({ + gateway, + agentId: PRIMARY_AGENT_ID, + accountId: PRIMARY_ACCOUNT_ID, + targetIncludes: PRIMARY_PEER_ID, + }); + const siblingConversation = await waitForConversation({ + gateway, + agentId: SIBLING_AGENT_ID, + accountId: SIBLING_ACCOUNT_ID, + targetIncludes: SIBLING_PEER_ID, + }); + const primaryList = await listConversations(gateway, PRIMARY_AGENT_ID); + const siblingList = await listConversations(gateway, SIBLING_AGENT_ID); + expect(findConversation(primaryList, SIBLING_ACCOUNT_ID, SIBLING_PEER_ID)).toBeUndefined(); + expect(findConversation(siblingList, PRIMARY_ACCOUNT_ID, PRIMARY_PEER_ID)).toBeUndefined(); + + const primaryConversationRef = String(primaryConversation.conversationRef); + const siblingConversationRef = String(siblingConversation.conversationRef); + const initialAllowedText = `ownership-proof-initial-${randomUUID()}`; + await sendConversation({ + gateway, + agentId: PRIMARY_AGENT_ID, + conversationRef: primaryConversationRef, + message: initialAllowedText, + }); + expect(outboundMessagesWithText(state, PRIMARY_ACCOUNT_ID, initialAllowedText)).toHaveLength( + 1, + ); + + const configBefore = await gateway.call("config.get", {}); + if (!isRecord(configBefore) || typeof configBefore.hash !== "string") { + throw new Error(`config.get returned no hash: ${JSON.stringify(configBefore)}`); + } + const patchResult = await gateway.call("config.patch", { + raw: JSON.stringify({ + bindings: [ + { + type: "route", + agentId: SIBLING_AGENT_ID, + match: { channel: CHANNEL_ID, accountId: PRIMARY_ACCOUNT_ID }, + }, + { + type: "route", + agentId: SIBLING_AGENT_ID, + match: { channel: CHANNEL_ID, accountId: SIBLING_ACCOUNT_ID }, + }, + ], + }), + baseHash: configBefore.hash, + replacePaths: ["bindings"], + restartDelayMs: 0, + }); + if (!isRecord(patchResult) || typeof patchResult.hash !== "string") { + throw new Error(`config.patch returned no hash: ${JSON.stringify(patchResult)}`); + } + await waitForAppliedConfig(gateway, patchResult.hash); + + const staleSendText = `ownership-proof-stale-send-${randomUUID()}`; + let staleSendDenied = false; + try { + await sendConversation({ + gateway, + agentId: PRIMARY_AGENT_ID, + conversationRef: primaryConversationRef, + message: staleSendText, + }); + } catch { + staleSendDenied = true; + } + expect(staleSendDenied).toBe(true); + expect(outboundMessagesWithText(state, PRIMARY_ACCOUNT_ID, staleSendText)).toHaveLength(0); + + const staleTurnText = `ownership-proof-stale-turn-${randomUUID()}`; + let staleTurnDenied = false; + try { + await gateway.call("conversations.turn", { + agentId: PRIMARY_AGENT_ID, + turnId: randomUUID(), + conversationRef: primaryConversationRef, + message: staleTurnText, + timeoutMs: 1_000, + }); + } catch { + staleTurnDenied = true; + } + expect(staleTurnDenied).toBe(true); + expect(outboundMessagesWithText(state, PRIMARY_ACCOUNT_ID, staleTurnText)).toHaveLength(0); + + const siblingAllowedText = `ownership-proof-sibling-${randomUUID()}`; + await sendConversation({ + gateway, + agentId: SIBLING_AGENT_ID, + conversationRef: siblingConversationRef, + message: siblingAllowedText, + }); + expect(outboundMessagesWithText(state, SIBLING_ACCOUNT_ID, siblingAllowedText)).toHaveLength( + 1, + ); + + const verdict = { + ok: true, + siblingAccountHiddenFromPrimaryAgent: + findConversation(primaryList, SIBLING_ACCOUNT_ID, SIBLING_PEER_ID) === undefined, + primaryAccountHiddenFromSiblingAgent: + findConversation(siblingList, PRIMARY_ACCOUNT_ID, PRIMARY_PEER_ID) === undefined, + initialOwnerDeliveredExactlyOnce: + outboundMessagesWithText(state, PRIMARY_ACCOUNT_ID, initialAllowedText).length === 1, + staleSendDeniedWithoutProviderIo: + staleSendDenied && + outboundMessagesWithText(state, PRIMARY_ACCOUNT_ID, staleSendText).length === 0, + staleTurnDeniedWithoutProviderIo: + staleTurnDenied && + outboundMessagesWithText(state, PRIMARY_ACCOUNT_ID, staleTurnText).length === 0, + siblingOwnerDeliveredExactlyOnce: + outboundMessagesWithText(state, SIBLING_ACCOUNT_ID, siblingAllowedText).length === 1, + }; + expect(Object.values(verdict).every(Boolean)).toBe(true); + console.log(`CONVERSATION_ROUTE_OWNERSHIP_PROOF=${JSON.stringify(verdict)}`); + }, + ); +}); diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 3e3804269629..959f80044281 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -885,6 +885,7 @@ describe("createPackedPluginSdkTypescriptSmokeProject", () => { expect(source).toContain('"openclaw/plugin-sdk/channel-entry-contract"'); expect(source).toContain('"openclaw/plugin-sdk/config-contracts"'); expect(source).toContain('"openclaw/plugin-sdk/runtime-env"'); + expect(source).toContain('"openclaw/plugin-sdk/conversation-binding-inspection-runtime"'); expect(source).toContain("type PublicPluginSdkModules = ["); expect(source).not.toContain("TelegramAccountConfig"); expect(source).not.toContain("openclaw/plugin-sdk/channel-contract-testing");