mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 13:00:48 +00:00
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.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,
|
|
}));
|
|
|
|
import {
|
|
buildConfiguredAcpSessionKey,
|
|
ensureConfiguredAcpBindingSession,
|
|
resetAcpSessionInPlace,
|
|
resolveConfiguredAcpBindingRecord,
|
|
resolveConfiguredAcpBindingSpecBySessionKey,
|
|
} from "./persistent-bindings.js";
|
|
|
|
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
|
type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0];
|
|
type BindingSpec = Parameters<typeof 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";
|
|
|
|
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 resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
|
return resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "discord",
|
|
accountId: defaultDiscordAccountId,
|
|
conversationId: defaultDiscordConversationId,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function resolveDiscordBindingSpecBySession(
|
|
cfg: OpenClawConfig,
|
|
conversationId = defaultDiscordConversationId,
|
|
) {
|
|
const resolved = resolveBindingRecord(cfg, { conversationId });
|
|
return 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 }) {
|
|
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: "idle",
|
|
lastActivityAt: Date.now(),
|
|
},
|
|
});
|
|
return sessionKey;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
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);
|
|
});
|
|
|
|
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 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 = resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:42",
|
|
});
|
|
const splitIds = 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 = resolveConfiguredAcpBindingRecord({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "123456789:topic:42",
|
|
});
|
|
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");
|
|
});
|
|
});
|
|
|
|
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 = 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");
|
|
});
|
|
});
|
|
|
|
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 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 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("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 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 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 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 resetAcpSessionInPlace({
|
|
cfg,
|
|
sessionKey,
|
|
reason: "reset",
|
|
});
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionKey,
|
|
agent: "codex",
|
|
}),
|
|
);
|
|
});
|
|
});
|