fix gateway conversation route ownership (#126424)

This commit is contained in:
Josh Avant
2026-08-21 12:20:21 -07:00
committed by GitHub
parent 90d1c43f5a
commit 0b85966072
128 changed files with 4406 additions and 331 deletions
-1
View File
@@ -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
@@ -1 +1 @@
73cfbac3e2ef8a75d561165d9798c805e0a8c726c1bcd1c814c7cae777194b2b sqlite-session-transcript-schema-baseline.sql
fecbb8adccfa0be0b452f646d3bee8d5a17faeb2c2d8a2300fb75ef173c709cd sqlite-session-transcript-schema-baseline.sql
+31 -1
View File
@@ -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 })`.
+6
View File
@@ -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
+3
View File
@@ -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<ResolvedDiscordAccount, DiscordProbe>
],
},
messaging: {
resolveConversationRouteOwner: inspectDiscordConversationRouteOwner,
targetPrefixes: ["discord"],
directTargetStyle: "user-prefixed",
targetIdComparison: "lowercase",
@@ -424,6 +426,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
},
conversationBindings: {
supportsCurrentConversationBinding: true,
bindingStore: "adapter",
defaultTopLevelPlacement,
createManager: async ({ cfg, accountId }) =>
(await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({
@@ -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;
@@ -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" });
});
});
@@ -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,
};
}
@@ -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,
@@ -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 };
}
@@ -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 },
@@ -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: {
@@ -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;
@@ -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");
@@ -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,
});
}
@@ -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",
@@ -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,
};
}
+9
View File
@@ -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 () => {
+2
View File
@@ -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,
},
+1
View File
@@ -993,6 +993,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
conversationBindings: {
bindingStore: "adapter",
defaultTopLevelPlacement: "current",
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
buildFeishuModelOverrideParentCandidates(parentConversationId),
+1
View File
@@ -318,6 +318,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
doctor: imessageDoctor,
conversationBindings: {
supportsCurrentConversationBinding: true,
bindingStore: "adapter",
createManager: ({ cfg, accountId }) =>
createIMessageConversationBindingManager({
cfg,
@@ -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",
@@ -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: {
+3
View File
@@ -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<ResolvedMatrixAccount, MatrixProbe> =
},
conversationBindings: {
supportsCurrentConversationBinding: true,
bindingStore: "adapter",
defaultTopLevelPlacement,
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
setMatrixThreadBindingIdleTimeoutBySessionKey({
@@ -463,6 +465,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
resolveDeliveryTarget: ({ conversationId, parentConversationId }) =>
resolveMatrixDeliveryTarget({ conversationId, parentConversationId }),
resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params),
resolveConversationRouteOwner: resolveMatrixConversationRouteOwner,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
@@ -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" });
});
});
@@ -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 };
}
+12 -6
View File
@@ -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<typeof resolveConfiguredAcpBindingRecord>;
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,
};
}
@@ -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",
@@ -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<string>();
const clients: MattermostClient[] = [];
for (const id of accountIds) {
@@ -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,
}),
@@ -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");
});
@@ -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",
@@ -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,
+2
View File
@@ -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<ResolvedSlackAccount, SlackProbe> = crea
isSlackWorkspaceInstallation(accountId),
},
messaging: {
resolveConversationRouteOwner: inspectSlackConversationRouteOwner,
targetPrefixes: ["slack"],
directTargetStyle: "user-prefixed",
targetIdComparison: "lowercase",
@@ -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<OpenClawConfig["bindings"]>[number];
type SlackRouteBindingPeer = NonNullable<SlackRouteBinding["match"]["peer"]>;
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<OpenClawConfig["bindings"]> = 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<typeof resolveAgentRoute>;
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),
};
}
@@ -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" });
});
});
@@ -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,
};
}
@@ -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<OpenClawConfig["bindings"]>[number];
type SlackRouteBindingPeer = NonNullable<SlackRouteBinding["match"]["peer"]>;
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 }
@@ -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");
@@ -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,
@@ -14,7 +14,7 @@ export function resolveSlackEnterpriseMainDmSessionKey(params: {
export function qualifySlackRoutePeerId(params: {
id: string;
kind: "user" | "channel";
eventScope?: SlackEventScope;
eventScope?: Pick<SlackEventScope, "teamId">;
}): string {
if (!params.eventScope) {
return params.id;
@@ -24,7 +24,7 @@ export function qualifySlackRoutePeerId(params: {
export function qualifySlackConversationId(
conversationId: string,
eventScope?: SlackEventScope,
eventScope?: Pick<SlackEventScope, "teamId">,
): string {
return eventScope
? `team:${encodeURIComponent(eventScope.teamId)}:${conversationId}`
@@ -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");
@@ -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<BuildChannelInboundEventContextParams["access"]>["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: {
@@ -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",
},
}),
);
@@ -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 () => {
@@ -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",
);
@@ -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({
+3
View File
@@ -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 }) =>
@@ -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" }),
);
});
});
@@ -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;
}
@@ -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");
+41 -4
View File
@@ -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<typeof resolveTelegramNamedAccountBaseSessionKey>[1],
): string {
+4
View File
@@ -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"
},
@@ -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<ChannelMessagingAdapter["resolveConversationRouteOwner"]> = ({
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;
+3
View File
@@ -90,6 +90,9 @@ export const pluginSdkDocMetadata = {
"session-store-runtime": {
category: "runtime",
},
"conversation-binding-inspection-runtime": {
category: "runtime",
},
"agent-scope-runtime": {
category: "runtime",
},
+1
View File
@@ -86,6 +86,7 @@
"media-mime",
"embedding-providers",
"media-generation-runtime",
"conversation-binding-inspection-runtime",
"conversation-binding-runtime",
"conversation-runtime",
"thread-bindings-runtime",
+6 -3
View File
@@ -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(
@@ -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),
+6
View File
@@ -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,
+4
View File
@@ -413,6 +413,10 @@ export type MsgContext = Partial<CanonicalInboundText> & {
* 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.
+1
View File
@@ -205,5 +205,6 @@ describe("dispatchInboundDirectDm", () => {
to: "reef:bot-1",
originatingTo: "reef:peer-1",
});
expect(contextParams?.conversation.routePeer).toEqual({ kind: "direct", id: "peer-1" });
});
});
+10 -1
View File
@@ -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,
+4 -1
View File
@@ -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" });
+1
View File
@@ -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({
@@ -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",
+2
View File
@@ -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 = {
@@ -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({
+34 -9
View File
@@ -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,
+2
View File
@@ -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.
+26
View File
@@ -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. */
+8 -2
View File
@@ -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)
@@ -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 },
+81 -32
View File
@@ -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<string, ConversationRecord>();
const unique = new Map<string, MappedConversationRow>();
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);
}
@@ -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();
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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");
}
@@ -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;
@@ -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> | 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({
@@ -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<string, unknown> | 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,
}),
),
@@ -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,
@@ -704,6 +704,7 @@ export async function updateSessionLastRoute(params: {
ctx?: MsgContext;
groupResolution?: GroupKeyResolution | null;
createIfMissing?: boolean;
assertCommitAllowed?: () => void;
}): Promise<SessionEntry | null> {
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, {}) } : {}),
},
);
@@ -405,6 +405,7 @@ export async function projectSessionEntryLifecycleMutation(
expectedEntry,
sessionKey,
entry: cloned,
...(upsert.routeContext !== undefined ? { routeContext: upsert.routeContext } : {}),
...(resetBoundaryPlan ? { resetBoundaryPlan } : {}),
});
}
@@ -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;
}>;
@@ -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;
@@ -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({
+122
View File
@@ -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,
+48 -15
View File
@@ -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<string> }> {
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) };
}
@@ -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["bindings"]>): 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");
});
});
+354
View File
@@ -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<typeof listRouteBindings>[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<ConversationRecord, "conversationRef">;
}): 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,
});
}
+123 -4
View File
@@ -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<string, unknown>) => {
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<void>)();
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",
+31 -5
View File
@@ -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 } : {}),
});
+175 -4
View File
@@ -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<string, unknown>): 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<string, unknown>) => {
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<void> | 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<string, unknown>) => {
persistIntent(input);
current = {
...conversation,
sessionId: "replacement-session",
sessionKey: "agent:main:reef:direct:replacement",
};
try {
await (input.onDeliveryAttempt as () => Promise<void>)();
} 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 {
+60 -8
View File
@@ -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<typeof resolveOutboundChannelPlugin>;
expectedRouteFingerprint: string;
readCurrentConfig: () => OpenClawConfig;
}): Promise<BoundConversationRecord> {
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();
+12 -3
View File
@@ -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 } : {}),
+52 -2
View File
@@ -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");
+33 -2
View File
@@ -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)}`));
@@ -147,6 +147,8 @@ export async function sendGatewayConversationMessage(params: {
operationKind: ConversationDeliveryRecord["operationKind"];
operation?: ConversationDeliveryRecord;
preparedMessageId?: string;
routeFingerprint: string;
onDeliveryAttempt: () => Promise<void>;
signal?: AbortSignal;
}): Promise<ConversationMessageDeliveryResult> {
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 }
: {}),
@@ -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;
+9
View File
@@ -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<DurableDeliveryCompletion, { kind: "conversation" }>,
"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<void>;
/** @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. */

Some files were not shown because too many files have changed in this diff Show More