mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix gateway conversation route ownership (#126424)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 })`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -993,6 +993,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
resolveToolPolicy: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
conversationBindings: {
|
||||
bindingStore: "adapter",
|
||||
defaultTopLevelPlacement: "current",
|
||||
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
|
||||
buildFeishuModelOverrideParentCandidates(parentConversationId),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,6 +90,9 @@ export const pluginSdkDocMetadata = {
|
||||
"session-store-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
"conversation-binding-inspection-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
"agent-scope-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"media-mime",
|
||||
"embedding-providers",
|
||||
"media-generation-runtime",
|
||||
"conversation-binding-inspection-runtime",
|
||||
"conversation-binding-runtime",
|
||||
"conversation-runtime",
|
||||
"thread-bindings-runtime",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -205,5 +205,6 @@ describe("dispatchInboundDirectDm", () => {
|
||||
to: "reef:bot-1",
|
||||
originatingTo: "reef:peer-1",
|
||||
});
|
||||
expect(contextParams?.conversation.routePeer).toEqual({ kind: "direct", id: "peer-1" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user