mirror of https://github.com/openclaw/openclaw.git
1024 lines
31 KiB
TypeScript
1024 lines
31 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js";
|
|
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
|
import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import { parseTelegramTopicConversation } from "./conversation-id.js";
|
|
import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js";
|
|
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
|
|
const managerMocks = vi.hoisted(() => ({
|
|
resolveSession: vi.fn(),
|
|
closeSession: vi.fn(),
|
|
initializeSession: vi.fn(),
|
|
updateSessionRuntimeOptions: vi.fn(),
|
|
}));
|
|
const sessionMetaMocks = vi.hoisted(() => ({
|
|
readAcpSessionEntry: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./control-plane/manager.js", () => ({
|
|
getAcpSessionManager: () => ({
|
|
resolveSession: managerMocks.resolveSession,
|
|
closeSession: managerMocks.closeSession,
|
|
initializeSession: managerMocks.initializeSession,
|
|
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
|
|
}),
|
|
}));
|
|
vi.mock("./runtime/session-meta.js", () => ({
|
|
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
|
}));
|
|
|
|
type PersistentBindingsModule = Pick<
|
|
typeof import("./persistent-bindings.resolve.js"),
|
|
"resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey"
|
|
> &
|
|
Pick<
|
|
typeof import("./persistent-bindings.lifecycle.js"),
|
|
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
|
|
>;
|
|
let persistentBindings: PersistentBindingsModule;
|
|
let lifecycleBindingsModule: Pick<
|
|
typeof import("./persistent-bindings.lifecycle.js"),
|
|
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
|
|
>;
|
|
|
|
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
|
type BindingRecordInput = Parameters<
|
|
PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]
|
|
>[0];
|
|
type BindingSpec = Parameters<
|
|
PersistentBindingsModule["ensureConfiguredAcpBindingSession"]
|
|
>[0]["spec"];
|
|
|
|
const baseCfg = {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
agents: {
|
|
list: [{ id: "codex" }, { id: "claude" }],
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const defaultDiscordConversationId = "1478836151241412759";
|
|
const defaultDiscordAccountId = "default";
|
|
|
|
const discordBindings: ChannelConfiguredBindingProvider = {
|
|
compileConfiguredBinding: ({ conversationId }) => {
|
|
const normalized = conversationId.trim();
|
|
return normalized ? { conversationId: normalized } : null;
|
|
},
|
|
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
|
|
if (compiledBinding.conversationId === conversationId) {
|
|
return { conversationId, matchPriority: 2 };
|
|
}
|
|
if (
|
|
parentConversationId &&
|
|
parentConversationId !== conversationId &&
|
|
compiledBinding.conversationId === parentConversationId
|
|
) {
|
|
return { conversationId: parentConversationId, matchPriority: 1 };
|
|
}
|
|
return null;
|
|
},
|
|
};
|
|
|
|
const telegramBindings: ChannelConfiguredBindingProvider = {
|
|
compileConfiguredBinding: ({ conversationId }) => {
|
|
const parsed = parseTelegramTopicConversation({ conversationId });
|
|
if (!parsed || !parsed.chatId.startsWith("-")) {
|
|
return null;
|
|
}
|
|
return {
|
|
conversationId: parsed.canonicalConversationId,
|
|
parentConversationId: parsed.chatId,
|
|
};
|
|
},
|
|
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
|
|
const incoming = parseTelegramTopicConversation({
|
|
conversationId,
|
|
parentConversationId,
|
|
});
|
|
if (!incoming || !incoming.chatId.startsWith("-")) {
|
|
return null;
|
|
}
|
|
if (compiledBinding.conversationId !== incoming.canonicalConversationId) {
|
|
return null;
|
|
}
|
|
return {
|
|
conversationId: incoming.canonicalConversationId,
|
|
parentConversationId: incoming.chatId,
|
|
matchPriority: 2,
|
|
};
|
|
},
|
|
};
|
|
|
|
function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
|
|
const trimmed = conversationId.trim();
|
|
if (!trimmed || trimmed.includes(":")) {
|
|
return false;
|
|
}
|
|
if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const feishuBindings: ChannelConfiguredBindingProvider = {
|
|
compileConfiguredBinding: ({ conversationId }) => {
|
|
const parsed = parseFeishuConversationId({ conversationId });
|
|
if (
|
|
!parsed ||
|
|
(parsed.scope !== "group_topic" &&
|
|
parsed.scope !== "group_topic_sender" &&
|
|
!isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
conversationId: parsed.canonicalConversationId,
|
|
parentConversationId:
|
|
parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
|
|
? parsed.chatId
|
|
: undefined,
|
|
};
|
|
},
|
|
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
|
|
const incoming = parseFeishuConversationId({
|
|
conversationId,
|
|
parentConversationId,
|
|
});
|
|
if (
|
|
!incoming ||
|
|
(incoming.scope !== "group_topic" &&
|
|
incoming.scope !== "group_topic_sender" &&
|
|
!isSupportedFeishuDirectConversationId(incoming.canonicalConversationId))
|
|
) {
|
|
return null;
|
|
}
|
|
const matchesCanonicalConversation =
|
|
compiledBinding.conversationId === incoming.canonicalConversationId;
|
|
const matchesParentTopicForSenderScopedConversation =
|
|
incoming.scope === "group_topic_sender" &&
|
|
compiledBinding.parentConversationId === incoming.chatId &&
|
|
compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`;
|
|
if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
|
|
return null;
|
|
}
|
|
return {
|
|
conversationId: matchesParentTopicForSenderScopedConversation
|
|
? compiledBinding.conversationId
|
|
: incoming.canonicalConversationId,
|
|
parentConversationId:
|
|
incoming.scope === "group_topic" || incoming.scope === "group_topic_sender"
|
|
? incoming.chatId
|
|
: undefined,
|
|
matchPriority: matchesCanonicalConversation ? 2 : 1,
|
|
};
|
|
},
|
|
};
|
|
|
|
function createConfiguredBindingTestPlugin(
|
|
id: ChannelPlugin["id"],
|
|
bindings: ChannelConfiguredBindingProvider,
|
|
): Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config" | "bindings"> {
|
|
return {
|
|
...createChannelTestPluginBase({ id }),
|
|
bindings,
|
|
};
|
|
}
|
|
|
|
function createCfgWithBindings(
|
|
bindings: ConfiguredBinding[],
|
|
overrides?: Partial<OpenClawConfig>,
|
|
): OpenClawConfig {
|
|
return {
|
|
...baseCfg,
|
|
...overrides,
|
|
bindings,
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function createDiscordBinding(params: {
|
|
agentId: string;
|
|
conversationId: string;
|
|
accountId?: string;
|
|
acp?: Record<string, unknown>;
|
|
}): ConfiguredBinding {
|
|
return {
|
|
type: "acp",
|
|
agentId: params.agentId,
|
|
match: {
|
|
channel: "discord",
|
|
accountId: params.accountId ?? defaultDiscordAccountId,
|
|
peer: { kind: "channel", id: params.conversationId },
|
|
},
|
|
...(params.acp ? { acp: params.acp } : {}),
|
|
} as ConfiguredBinding;
|
|
}
|
|
|
|
function createTelegramGroupBinding(params: {
|
|
agentId: string;
|
|
conversationId: string;
|
|
acp?: Record<string, unknown>;
|
|
}): ConfiguredBinding {
|
|
return {
|
|
type: "acp",
|
|
agentId: params.agentId,
|
|
match: {
|
|
channel: "telegram",
|
|
accountId: defaultDiscordAccountId,
|
|
peer: { kind: "group", id: params.conversationId },
|
|
},
|
|
...(params.acp ? { acp: params.acp } : {}),
|
|
} as ConfiguredBinding;
|
|
}
|
|
|
|
function createFeishuBinding(params: {
|
|
agentId: string;
|
|
conversationId: string;
|
|
accountId?: string;
|
|
acp?: Record<string, unknown>;
|
|
}): ConfiguredBinding {
|
|
return {
|
|
type: "acp",
|
|
agentId: params.agentId,
|
|
match: {
|
|
channel: "feishu",
|
|
accountId: params.accountId ?? defaultDiscordAccountId,
|
|
peer: {
|
|
kind: params.conversationId.includes(":topic:") ? "group" : "direct",
|
|
id: params.conversationId,
|
|
},
|
|
},
|
|
...(params.acp ? { acp: params.acp } : {}),
|
|
} as ConfiguredBinding;
|
|
}
|
|
|
|
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
|
return persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "discord",
|
|
accountId: defaultDiscordAccountId,
|
|
conversationId: defaultDiscordConversationId,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function resolveDiscordBindingSpecBySession(
|
|
cfg: OpenClawConfig,
|
|
conversationId = defaultDiscordConversationId,
|
|
) {
|
|
const resolved = resolveBindingRecord(cfg, { conversationId });
|
|
return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
|
cfg,
|
|
sessionKey: resolved?.record.targetSessionKey ?? "",
|
|
});
|
|
}
|
|
|
|
function createDiscordPersistentSpec(overrides: Partial<BindingSpec> = {}): BindingSpec {
|
|
return {
|
|
channel: "discord",
|
|
accountId: defaultDiscordAccountId,
|
|
conversationId: defaultDiscordConversationId,
|
|
agentId: "codex",
|
|
mode: "persistent",
|
|
...overrides,
|
|
} as BindingSpec;
|
|
}
|
|
|
|
function mockReadySession(params: {
|
|
spec: BindingSpec;
|
|
cwd: string;
|
|
state?: "idle" | "running" | "error";
|
|
}) {
|
|
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "ready",
|
|
sessionKey,
|
|
meta: {
|
|
backend: "acpx",
|
|
agent: params.spec.acpAgentId ?? params.spec.agentId,
|
|
runtimeSessionName: "existing",
|
|
mode: params.spec.mode,
|
|
runtimeOptions: { cwd: params.cwd },
|
|
state: params.state ?? "idle",
|
|
lastActivityAt: Date.now(),
|
|
},
|
|
});
|
|
return sessionKey;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
persistentBindings = {
|
|
resolveConfiguredAcpBindingRecord:
|
|
persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord,
|
|
resolveConfiguredAcpBindingSpecBySessionKey:
|
|
persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey,
|
|
ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession,
|
|
resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace,
|
|
};
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "discord",
|
|
plugin: createConfiguredBindingTestPlugin("discord", discordBindings),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "telegram",
|
|
plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "feishu",
|
|
plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings),
|
|
source: "test",
|
|
},
|
|
]),
|
|
);
|
|
managerMocks.resolveSession.mockReset();
|
|
managerMocks.closeSession.mockReset().mockResolvedValue({
|
|
runtimeClosed: true,
|
|
metaCleared: true,
|
|
});
|
|
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
|
|
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
|
|
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js");
|
|
});
|
|
|
|
describe("resolveConfiguredAcpBindingRecord", () => {
|
|
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: defaultDiscordConversationId,
|
|
acp: { cwd: "/repo/openclaw" },
|
|
}),
|
|
]);
|
|
const resolved = resolveBindingRecord(cfg);
|
|
|
|
expect(resolved?.spec.channel).toBe("discord");
|
|
expect(resolved?.spec.conversationId).toBe(defaultDiscordConversationId);
|
|
expect(resolved?.spec.agentId).toBe("codex");
|
|
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:");
|
|
expect(resolved?.record.metadata?.source).toBe("config");
|
|
});
|
|
|
|
it("falls back to parent discord channel when conversation is a thread id", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: "channel-parent-1",
|
|
}),
|
|
]);
|
|
const resolved = resolveBindingRecord(cfg, {
|
|
conversationId: "thread-123",
|
|
parentConversationId: "channel-parent-1",
|
|
});
|
|
|
|
expect(resolved?.spec.conversationId).toBe("channel-parent-1");
|
|
expect(resolved?.record.conversation.conversationId).toBe("channel-parent-1");
|
|
});
|
|
|
|
it("prefers direct discord thread binding over parent channel fallback", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: "channel-parent-1",
|
|
}),
|
|
createDiscordBinding({
|
|
agentId: "claude",
|
|
conversationId: "thread-123",
|
|
}),
|
|
]);
|
|
const resolved = resolveBindingRecord(cfg, {
|
|
conversationId: "thread-123",
|
|
parentConversationId: "channel-parent-1",
|
|
});
|
|
|
|
expect(resolved?.spec.conversationId).toBe("thread-123");
|
|
expect(resolved?.spec.agentId).toBe("claude");
|
|
});
|
|
|
|
it("prefers sender-scoped Feishu bindings over topic inheritance", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "codex",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
accountId: "work",
|
|
}),
|
|
createFeishuBinding({
|
|
agentId: "claude",
|
|
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
|
accountId: "work",
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "work",
|
|
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
|
parentConversationId: "oc_group_chat",
|
|
});
|
|
|
|
expect(resolved?.spec.conversationId).toBe(
|
|
"oc_group_chat:topic:om_topic_root:sender:ou_sender_1",
|
|
);
|
|
expect(resolved?.spec.agentId).toBe("claude");
|
|
});
|
|
|
|
it("prefers exact account binding over wildcard for the same discord conversation", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: defaultDiscordConversationId,
|
|
accountId: "*",
|
|
}),
|
|
createDiscordBinding({
|
|
agentId: "claude",
|
|
conversationId: defaultDiscordConversationId,
|
|
}),
|
|
]);
|
|
const resolved = resolveBindingRecord(cfg);
|
|
|
|
expect(resolved?.spec.agentId).toBe("claude");
|
|
});
|
|
|
|
it("returns null when no top-level ACP binding matches the conversation", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: "different-channel",
|
|
}),
|
|
]);
|
|
const resolved = resolveBindingRecord(cfg, {
|
|
conversationId: "thread-123",
|
|
parentConversationId: "channel-parent-1",
|
|
});
|
|
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("resolves telegram forum topic bindings using canonical conversation ids", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createTelegramGroupBinding({
|
|
agentId: "claude",
|
|
conversationId: "-1001234567890:topic:42",
|
|
acp: { backend: "acpx" },
|
|
}),
|
|
]);
|
|
|
|
const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:42",
|
|
});
|
|
const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "42",
|
|
parentConversationId: "-1001234567890",
|
|
});
|
|
|
|
expect(canonical?.spec.conversationId).toBe("-1001234567890:topic:42");
|
|
expect(splitIds?.spec.conversationId).toBe("-1001234567890:topic:42");
|
|
expect(canonical?.spec.agentId).toBe("claude");
|
|
expect(canonical?.spec.backend).toBe("acpx");
|
|
expect(splitIds?.record.targetSessionKey).toBe(canonical?.record.targetSessionKey);
|
|
});
|
|
|
|
it("skips telegram non-group topic configs", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createTelegramGroupBinding({
|
|
agentId: "claude",
|
|
conversationId: "123456789:topic:42",
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "123456789:topic:42",
|
|
});
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("resolves Feishu DM bindings using direct peer ids", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "codex",
|
|
conversationId: "ou_user_1",
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "ou_user_1",
|
|
});
|
|
|
|
expect(resolved?.spec.channel).toBe("feishu");
|
|
expect(resolved?.spec.conversationId).toBe("ou_user_1");
|
|
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
|
|
});
|
|
|
|
it("resolves Feishu DM bindings using user_id fallback peer ids", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "codex",
|
|
conversationId: "user_123",
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "user_123",
|
|
});
|
|
|
|
expect(resolved?.spec.channel).toBe("feishu");
|
|
expect(resolved?.spec.conversationId).toBe("user_123");
|
|
expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:");
|
|
});
|
|
|
|
it("resolves Feishu topic bindings with parent chat ids", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "claude",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
acp: { backend: "acpx" },
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
parentConversationId: "oc_group_chat",
|
|
});
|
|
|
|
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
|
expect(resolved?.spec.agentId).toBe("claude");
|
|
expect(resolved?.record.conversation.parentConversationId).toBe("oc_group_chat");
|
|
});
|
|
|
|
it("inherits configured Feishu topic bindings for sender-scoped topic conversations", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "claude",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
acp: { backend: "acpx" },
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
|
|
parentConversationId: "oc_group_chat",
|
|
});
|
|
|
|
expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
|
expect(resolved?.spec.agentId).toBe("claude");
|
|
expect(resolved?.spec.backend).toBe("acpx");
|
|
expect(resolved?.record.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root");
|
|
});
|
|
|
|
it("rejects non-matching Feishu topic roots", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "claude",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_other_root",
|
|
parentConversationId: "oc_group_chat",
|
|
});
|
|
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("rejects Feishu non-topic group ACP bindings", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "claude",
|
|
conversationId: "oc_group_chat",
|
|
}),
|
|
]);
|
|
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat",
|
|
});
|
|
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("applies agent runtime ACP defaults for bound conversations", () => {
|
|
const cfg = createCfgWithBindings(
|
|
[
|
|
createDiscordBinding({
|
|
agentId: "coding",
|
|
conversationId: defaultDiscordConversationId,
|
|
}),
|
|
],
|
|
{
|
|
agents: {
|
|
list: [
|
|
{ id: "main" },
|
|
{
|
|
id: "coding",
|
|
runtime: {
|
|
type: "acp",
|
|
acp: {
|
|
agent: "codex",
|
|
backend: "acpx",
|
|
mode: "oneshot",
|
|
cwd: "/workspace/repo-a",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
const resolved = resolveBindingRecord(cfg);
|
|
|
|
expect(resolved?.spec.agentId).toBe("coding");
|
|
expect(resolved?.spec.acpAgentId).toBe("codex");
|
|
expect(resolved?.spec.mode).toBe("oneshot");
|
|
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
|
|
expect(resolved?.spec.backend).toBe("acpx");
|
|
});
|
|
|
|
it("derives configured binding cwd from an explicit agent workspace", () => {
|
|
const cfg = createCfgWithBindings(
|
|
[
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: defaultDiscordConversationId,
|
|
}),
|
|
],
|
|
{
|
|
agents: {
|
|
list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }],
|
|
},
|
|
},
|
|
);
|
|
const resolved = resolveBindingRecord(cfg);
|
|
|
|
expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex"));
|
|
});
|
|
});
|
|
|
|
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
|
it("maps a configured discord binding session key back to its spec", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: defaultDiscordConversationId,
|
|
acp: { backend: "acpx" },
|
|
}),
|
|
]);
|
|
const spec = resolveDiscordBindingSpecBySession(cfg);
|
|
|
|
expect(spec?.channel).toBe("discord");
|
|
expect(spec?.conversationId).toBe(defaultDiscordConversationId);
|
|
expect(spec?.agentId).toBe("codex");
|
|
expect(spec?.backend).toBe("acpx");
|
|
});
|
|
|
|
it("returns null for unknown session keys", () => {
|
|
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
|
cfg: baseCfg,
|
|
sessionKey: "agent:main:acp:binding:discord:default:notfound",
|
|
});
|
|
expect(spec).toBeNull();
|
|
});
|
|
|
|
it("prefers exact account ACP settings over wildcard when session keys collide", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: defaultDiscordConversationId,
|
|
accountId: "*",
|
|
acp: { backend: "wild" },
|
|
}),
|
|
createDiscordBinding({
|
|
agentId: "codex",
|
|
conversationId: defaultDiscordConversationId,
|
|
acp: { backend: "exact" },
|
|
}),
|
|
]);
|
|
const spec = resolveDiscordBindingSpecBySession(cfg);
|
|
|
|
expect(spec?.backend).toBe("exact");
|
|
});
|
|
|
|
it("maps a configured Feishu user_id DM binding session key back to its spec", () => {
|
|
const cfg = createCfgWithBindings([
|
|
createFeishuBinding({
|
|
agentId: "codex",
|
|
conversationId: "user_123",
|
|
acp: { backend: "acpx" },
|
|
}),
|
|
]);
|
|
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "user_123",
|
|
});
|
|
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
|
cfg,
|
|
sessionKey: resolved?.record.targetSessionKey ?? "",
|
|
});
|
|
|
|
expect(spec?.channel).toBe("feishu");
|
|
expect(spec?.conversationId).toBe("user_123");
|
|
expect(spec?.agentId).toBe("codex");
|
|
expect(spec?.backend).toBe("acpx");
|
|
});
|
|
});
|
|
|
|
describe("buildConfiguredAcpSessionKey", () => {
|
|
it("is deterministic for the same conversation binding", () => {
|
|
const sessionKeyA = buildConfiguredAcpSessionKey({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "1478836151241412759",
|
|
agentId: "codex",
|
|
mode: "persistent",
|
|
});
|
|
const sessionKeyB = buildConfiguredAcpSessionKey({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "1478836151241412759",
|
|
agentId: "codex",
|
|
mode: "persistent",
|
|
});
|
|
expect(sessionKeyA).toBe(sessionKeyB);
|
|
});
|
|
});
|
|
|
|
describe("ensureConfiguredAcpBindingSession", () => {
|
|
it("keeps an existing ready session when configured binding omits cwd", async () => {
|
|
const spec = createDiscordPersistentSpec();
|
|
const sessionKey = mockReadySession({
|
|
spec,
|
|
cwd: "/workspace/openclaw",
|
|
});
|
|
|
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
|
cfg: baseCfg,
|
|
spec,
|
|
});
|
|
|
|
expect(ensured).toEqual({ ok: true, sessionKey });
|
|
expect(managerMocks.closeSession).not.toHaveBeenCalled();
|
|
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => {
|
|
const spec = createDiscordPersistentSpec({
|
|
cwd: "/workspace/repo-a",
|
|
});
|
|
const sessionKey = mockReadySession({
|
|
spec,
|
|
cwd: "/workspace/other-repo",
|
|
});
|
|
|
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
|
cfg: baseCfg,
|
|
spec,
|
|
});
|
|
|
|
expect(ensured).toEqual({ ok: true, sessionKey });
|
|
expect(managerMocks.closeSession).toHaveBeenCalledTimes(1);
|
|
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey,
|
|
clearMeta: false,
|
|
}),
|
|
);
|
|
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps a matching ready session even when the stored ACP session is in error state", async () => {
|
|
const spec = createDiscordPersistentSpec({
|
|
cwd: "/home/bob/clawd",
|
|
});
|
|
const sessionKey = mockReadySession({
|
|
spec,
|
|
cwd: "/home/bob/clawd",
|
|
state: "error",
|
|
});
|
|
|
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
|
cfg: baseCfg,
|
|
spec,
|
|
});
|
|
|
|
expect(ensured).toEqual({ ok: true, sessionKey });
|
|
expect(managerMocks.closeSession).not.toHaveBeenCalled();
|
|
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("initializes ACP session with runtime agent override when provided", async () => {
|
|
const spec = createDiscordPersistentSpec({
|
|
agentId: "coding",
|
|
acpAgentId: "codex",
|
|
});
|
|
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
|
|
|
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
|
cfg: baseCfg,
|
|
spec,
|
|
});
|
|
|
|
expect(ensured.ok).toBe(true);
|
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agent: "codex",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resetAcpSessionInPlace", () => {
|
|
it("reinitializes from configured binding when ACP metadata is missing", async () => {
|
|
const cfg = createCfgWithBindings([
|
|
createDiscordBinding({
|
|
agentId: "claude",
|
|
conversationId: "1478844424791396446",
|
|
acp: {
|
|
mode: "persistent",
|
|
backend: "acpx",
|
|
},
|
|
}),
|
|
]);
|
|
const sessionKey = buildConfiguredAcpSessionKey({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "1478844424791396446",
|
|
agentId: "claude",
|
|
mode: "persistent",
|
|
backend: "acpx",
|
|
});
|
|
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
|
|
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
|
cfg,
|
|
sessionKey,
|
|
reason: "new",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey,
|
|
agent: "claude",
|
|
mode: "persistent",
|
|
backendId: "acpx",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not clear ACP metadata before reinitialize succeeds", async () => {
|
|
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
|
|
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
|
acp: {
|
|
agent: "claude",
|
|
mode: "persistent",
|
|
backend: "acpx",
|
|
runtimeOptions: { cwd: "/home/bob/clawd" },
|
|
},
|
|
});
|
|
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
|
|
|
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
|
cfg: baseCfg,
|
|
sessionKey,
|
|
reason: "reset",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: false, error: "backend unavailable" });
|
|
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey,
|
|
clearMeta: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves harness agent ids during in-place reset even when not in agents.list", async () => {
|
|
const cfg = {
|
|
...baseCfg,
|
|
agents: {
|
|
list: [{ id: "main" }, { id: "coding" }],
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const sessionKey = "agent:coding:acp:binding:discord:default:9373ab192b2317f4";
|
|
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
|
acp: {
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
backend: "acpx",
|
|
},
|
|
});
|
|
|
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
|
cfg,
|
|
sessionKey,
|
|
reason: "reset",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey,
|
|
agent: "codex",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => {
|
|
const cfg = createCfgWithBindings(
|
|
[
|
|
createDiscordBinding({
|
|
agentId: "coding",
|
|
conversationId: "1478844424791396446",
|
|
}),
|
|
],
|
|
{
|
|
agents: {
|
|
list: [
|
|
{ id: "main" },
|
|
{
|
|
id: "coding",
|
|
runtime: {
|
|
type: "acp",
|
|
acp: {
|
|
agent: "codex",
|
|
backend: "acpx",
|
|
mode: "persistent",
|
|
},
|
|
},
|
|
},
|
|
{ id: "claude" },
|
|
],
|
|
},
|
|
},
|
|
);
|
|
const sessionKey = buildConfiguredAcpSessionKey({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "1478844424791396446",
|
|
agentId: "coding",
|
|
acpAgentId: "codex",
|
|
mode: "persistent",
|
|
backend: "acpx",
|
|
});
|
|
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
|
acp: {
|
|
mode: "persistent",
|
|
backend: "acpx",
|
|
},
|
|
});
|
|
|
|
const result = await persistentBindings.resetAcpSessionInPlace({
|
|
cfg,
|
|
sessionKey,
|
|
reason: "reset",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey,
|
|
agent: "codex",
|
|
backendId: "acpx",
|
|
}),
|
|
);
|
|
});
|
|
});
|