import fs from "node:fs" ;
import path from "node:path" ;
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest" ;
import type {
ConversationRef,
SessionBindingAdapter,
SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js" ;
import { createEmptyPluginRegistry } from "./registry-empty.js" ;
import type { PluginRegistry } from "./registry.js" ;
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js" ;
const tempDirs: string[] = [];
const tempRoot = makeTrackedTempDir("openclaw-plugin-binding" , tempDirs);
const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json" );
const sessionBindingState = vi.hoisted(() => {
const records = new Map<string, SessionBindingRecord>();
let nextId = 1 ;
function normalizeRef(ref: ConversationRef): ConversationRef {
return {
channel: ref.channel.trim().toLowerCase(),
accountId: ref.accountId.trim() || "default" ,
conversationId: ref.conversationId.trim(),
parentConversationId: ref.parentConversationId?.trim() || undefined,
};
}
function toKey(ref: ConversationRef): string {
const normalized = normalizeRef(ref);
return JSON.stringify(normalized);
}
return {
records,
bind: vi.fn(
async (input: {
targetSessionKey: string;
targetKind: "session" | "subagent" ;
conversation: ConversationRef;
metadata?: Record<string, unknown>;
}) => {
const normalized = normalizeRef(input.conversation);
const record: SessionBindingRecord = {
bindingId: `binding-${nextId++}`,
targetSessionKey: input.targetSessionKey,
targetKind: input.targetKind,
conversation: normalized,
status: "active" ,
boundAt: Date.now(),
metadata: input.metadata,
};
records.set(toKey(normalized), record);
return record;
},
),
resolveByConversation: vi.fn((ref: ConversationRef) => {
return records.get(toKey(ref)) ?? null ;
}),
touch: vi.fn(),
unbind: vi.fn(async (input: { bindingId?: string }) => {
const removed: SessionBindingRecord[] = [];
for (const [key, record] of records.entries()) {
if (record.bindingId !== input.bindingId) {
continue ;
}
removed.push(record);
records.delete (key);
}
return removed;
}),
reset() {
records.clear();
nextId = 1 ;
this .bind.mockClear();
this .resolveByConversation.mockClear();
this .touch.mockClear();
this .unbind.mockClear();
},
setRecord(record: SessionBindingRecord) {
records.set(toKey(record.conversation), record);
},
};
});
const pluginRuntimeState = vi.hoisted(
() =>
({
// The runtime mock is initialized before imports; beforeEach installs the real shared stub.
registry: null as unknown as PluginRegistry,
}) satisfies { registry: PluginRegistry },
);
vi.mock("../infra/home-dir.js" , async () => {
const actual =
await vi.importActual<typeof import ("../infra/home-dir.js" )>("../infra/home-dir.js" );
return {
...actual,
expandHomePrefix: (value: string) => {
if (value === "~/.openclaw/plugin-binding-approvals.json" ) {
return approvalsPath;
}
return actual.expandHomePrefix(value);
},
};
});
vi.mock("./runtime.js" , async () => {
const actual = await vi.importActual<typeof import ("./runtime.js" )>("./runtime.js" );
return {
...actual,
getActivePluginRegistry: () => pluginRuntimeState.registry,
getActivePluginChannelRegistry: () => pluginRuntimeState.registry,
setActivePluginRegistry: (registry: PluginRegistry) => {
pluginRuntimeState.registry = registry;
},
};
});
let __testing: typeof import ("./conversation-binding.js" ).__testing;
let buildPluginBindingApprovalCustomId: typeof import ("./conversation-binding.js" ).buildPluginBindingApprovalCustomId;
let detachPluginConversationBinding: typeof import ("./conversation-binding.js" ).detachPluginConversationBinding;
let getCurrentPluginConversationBinding: typeof import ("./conversation-binding.js" ).getCurrentPluginConversationBinding;
let parsePluginBindingApprovalCustomId: typeof import ("./conversation-binding.js" ).parsePluginBindingApprovalCustomId;
let requestPluginConversationBinding: typeof import ("./conversation-binding.js" ).requestPluginConversationBinding;
let resolvePluginConversationBindingApproval: typeof import ("./conversation-binding.js" ).resolvePluginConversationBindingApproval;
let registerSessionBindingAdapter: typeof import ("../infra/outbound/session-binding-service.js" ).registerSessionBindingAdapter;
let unregisterSessionBindingAdapter: typeof import ("../infra/outbound/session-binding-service.js" ).unregisterSessionBindingAdapter;
let setActivePluginRegistry: typeof import ("./runtime.js" ).setActivePluginRegistry;
type PluginBindingRequest = Awaited<ReturnType<typeof requestPluginConversationBinding>>;
type PluginBindingRequestInput = Parameters<typeof requestPluginConversationBinding>[0 ];
type PluginBindingDecision = Parameters<
typeof resolvePluginConversationBindingApproval
>[0 ]["decision" ];
type ConversationBindingModule = typeof import ("./conversation-binding.js" );
const conversationBindingModuleUrl = new URL("./conversation-binding.ts" , import .meta.url).href;
async function importConversationBindingModule(
cacheBust: string,
): Promise<ConversationBindingModule> {
return (await import (
`${conversationBindingModuleUrl}?t=${cacheBust}`
)) as ConversationBindingModule;
}
function createAdapter(channel: string, accountId: string): SessionBindingAdapter {
return {
channel,
accountId,
capabilities: {
bindSupported: true ,
unbindSupported: true ,
placements: ["current" , "child" ],
},
bind: sessionBindingState.bind,
listBySession: () => [],
resolveByConversation: sessionBindingState.resolveByConversation,
touch: sessionBindingState.touch,
unbind: sessionBindingState.unbind,
};
}
afterAll(() => {
cleanupTrackedTempDirs(tempDirs);
});
function createDiscordCodexBindRequest(
conversationId: string,
summary: string,
accountId = "isolated" ,
): PluginBindingRequestInput {
return {
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/codex-a" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "discord" ,
accountId,
conversationId,
},
binding: { summary },
};
}
function createTelegramCodexBindRequest(
conversationId: string,
threadId: string,
summary: string,
pluginRoot = "/plugins/codex-a" ,
): PluginBindingRequestInput {
return {
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot,
requestedBySenderId: "user-1" ,
conversation: {
channel: "telegram" ,
accountId: "default" ,
conversationId,
parentConversationId: "-10099" ,
threadId,
},
binding: { summary },
};
}
function createCodexBindRequest(params: {
channel: "discord" | "telegram" ;
accountId: string;
conversationId: string;
summary: string;
pluginRoot?: string;
pluginId?: string;
parentConversationId?: string;
threadId?: string;
detachHint?: string;
data?: Record<string, unknown>;
}) {
return {
pluginId: params.pluginId ?? "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: params.pluginRoot ?? "/plugins/codex-a" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
...(params.threadId ? { threadId: params.threadId } : {}),
},
binding: {
summary: params.summary,
...(params.detachHint ? { detachHint: params.detachHint } : {}),
...(params.data ? { data: params.data } : {}),
},
} satisfies PluginBindingRequestInput;
}
async function requestPendingBinding(
input: PluginBindingRequestInput,
requestBinding = requestPluginConversationBinding,
) {
const request = await requestBinding(input);
expect(request.status).toBe("pending" );
if (request.status !== "pending" ) {
throw new Error("expected pending bind request" );
}
return request;
}
async function approveBindingRequest(
approvalId: string,
decision: PluginBindingDecision,
resolveApproval = resolvePluginConversationBindingApproval,
) {
return await resolveApproval({
approvalId,
decision,
senderId: "user-1" ,
});
}
async function importDuplicateConversationBindingModules() {
const first = await importConversationBindingModule(`first-${Date.now()}`);
const second = await importConversationBindingModule(`second-${Date.now()}`);
first.__testing.reset();
return { first, second };
}
async function resolveRequestedBinding(request: PluginBindingRequest) {
expect(["pending" , "bound" ]).toContain(request.status);
if (request.status === "pending" ) {
const approved = await approveBindingRequest(request.approvalId, "allow-once" );
expect(approved.status).toBe("approved" );
if (approved.status !== "approved" ) {
throw new Error("expected approved bind result" );
}
return approved.binding;
}
if (request.status === "bound" ) {
return request.binding;
}
throw new Error("expected pending or bound bind result" );
}
async function requestResolvedBinding(input: PluginBindingRequestInput) {
return await resolveRequestedBinding(await requestPluginConversationBinding(input));
}
async function flushMicrotasks(): Promise<void > {
await new Promise<void >((resolve) => setImmediate(resolve));
}
function createDeferredVoid(): { promise: Promise<void >; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void >((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function createResolvedHandlerRegistry(params: {
pluginRoot: string;
handler: (input: unknown) => Promise<void >;
}) {
const registry = createEmptyPluginRegistry();
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex" ,
pluginRoot: params.pluginRoot,
handler: params.handler,
source: `${params.pluginRoot}/index.ts`,
rootDir: params.pluginRoot,
});
setActivePluginRegistry(registry);
return registry;
}
async function expectResolutionCallback(params: {
pluginRoot: string;
requestInput: PluginBindingRequestInput;
decision: PluginBindingDecision;
expectedStatus: "approved" | "denied" ;
expectedCallback: unknown;
}) {
const onResolved = vi.fn(async () => undefined);
createResolvedHandlerRegistry({
pluginRoot: params.pluginRoot,
handler: onResolved,
});
const request = await requestPluginConversationBinding(params.requestInput);
expect(request.status).toBe("pending" );
if (request.status !== "pending" ) {
throw new Error("expected pending bind request" );
}
const result = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: params.decision,
senderId: "user-1" ,
});
expect(result.status).toBe(params.expectedStatus);
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith(params.expectedCallback);
}
async function expectResolutionDoesNotWait(params: {
pluginRoot: string;
requestInput: PluginBindingRequestInput;
decision: PluginBindingDecision;
expectedStatus: "approved" | "denied" ;
}) {
const callbackGate = createDeferredVoid();
const onResolved = vi.fn(async () => callbackGate.promise);
createResolvedHandlerRegistry({
pluginRoot: params.pluginRoot,
handler: onResolved,
});
const request = await requestPluginConversationBinding(params.requestInput);
expect(request.status).toBe("pending" );
if (request.status !== "pending" ) {
throw new Error("expected pending bind request" );
}
let settled = false ;
const resolutionPromise = resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: params.decision,
senderId: "user-1" ,
}).then((result) => {
settled = true ;
return result;
});
await flushMicrotasks();
expect(settled).toBe(true );
expect(onResolved).toHaveBeenCalledTimes(1 );
callbackGate.resolve();
const result = await resolutionPromise;
expect(result.status).toBe(params.expectedStatus);
}
describe("plugin conversation binding approvals" , () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../infra/home-dir.js" , async () => {
const actual =
await vi.importActual<typeof import ("../infra/home-dir.js" )>("../infra/home-dir.js" );
return {
...actual,
expandHomePrefix: (value: string) => {
if (value === "~/.openclaw/plugin-binding-approvals.json" ) {
return approvalsPath;
}
return actual.expandHomePrefix(value);
},
};
});
vi.doMock("./runtime.js" , async () => {
const actual = await vi.importActual<typeof import ("./runtime.js" )>("./runtime.js" );
return {
...actual,
getActivePluginRegistry: () => pluginRuntimeState.registry,
getActivePluginChannelRegistry: () => pluginRuntimeState.registry,
setActivePluginRegistry: (registry: PluginRegistry) => {
pluginRuntimeState.registry = registry;
},
};
});
({
__testing,
buildPluginBindingApprovalCustomId,
detachPluginConversationBinding,
getCurrentPluginConversationBinding,
parsePluginBindingApprovalCustomId,
requestPluginConversationBinding,
resolvePluginConversationBindingApproval,
} = await import ("./conversation-binding.js" ));
({ registerSessionBindingAdapter, unregisterSessionBindingAdapter } =
await import ("../infra/outbound/session-binding-service.js" ));
({ setActivePluginRegistry } = await import ("./runtime.js" ));
sessionBindingState.reset();
__testing.reset();
setActivePluginRegistry(createEmptyPluginRegistry());
fs.rmSync(approvalsPath, { force: true });
unregisterSessionBindingAdapter({ channel: "discord" , accountId: "default" });
unregisterSessionBindingAdapter({ channel: "discord" , accountId: "work" });
unregisterSessionBindingAdapter({ channel: "discord" , accountId: "isolated" });
unregisterSessionBindingAdapter({ channel: "telegram" , accountId: "default" });
registerSessionBindingAdapter(createAdapter("discord" , "default" ));
registerSessionBindingAdapter(createAdapter("discord" , "work" ));
registerSessionBindingAdapter(createAdapter("discord" , "isolated" ));
registerSessionBindingAdapter(createAdapter("telegram" , "default" ));
});
it("keeps Telegram bind approval callback_data within Telegram's limit" , () => {
const allowOnce = buildPluginBindingApprovalCustomId("abcdefghijkl" , "allow-once" );
const allowAlways = buildPluginBindingApprovalCustomId("abcdefghijkl" , "allow-always" );
const deny = buildPluginBindingApprovalCustomId("abcdefghijkl" , "deny" );
expect(Buffer.byteLength(allowOnce, "utf8" )).toBeLessThanOrEqual(64 );
expect(Buffer.byteLength(allowAlways, "utf8" )).toBeLessThanOrEqual(64 );
expect(Buffer.byteLength(deny, "utf8" )).toBeLessThanOrEqual(64 );
expect(parsePluginBindingApprovalCustomId(allowAlways)).toEqual({
approvalId: "abcdefghijkl" ,
decision: "allow-always" ,
});
});
it("requires a fresh approval again after allow-once is consumed" , async () => {
const firstRequest = await requestPendingBinding(
createDiscordCodexBindRequest("channel:1" , "Bind this conversation to Codex thread 123." ),
);
const approved = await approveBindingRequest(firstRequest.approvalId, "allow-once" );
expect(approved.status).toBe("approved" );
const secondRequest = await requestPluginConversationBinding(
createDiscordCodexBindRequest("channel:2" , "Bind this conversation to Codex thread 456." ),
);
expect(secondRequest.status).toBe("pending" );
});
it("persists always-allow by plugin root plus channel/account only" , async () => {
const firstRequest = await requestPendingBinding(
createDiscordCodexBindRequest("channel:1" , "Bind this conversation to Codex thread 123." ),
);
const approved = await approveBindingRequest(firstRequest.approvalId, "allow-always" );
expect(approved.status).toBe("approved" );
const sameScope = await requestPluginConversationBinding(
createDiscordCodexBindRequest("channel:2" , "Bind this conversation to Codex thread 456." ),
);
expect(sameScope.status).toBe("bound" );
const differentAccount = await requestPluginConversationBinding(
createDiscordCodexBindRequest(
"channel:3" ,
"Bind this conversation to Codex thread 789." ,
"work" ,
),
);
expect(differentAccount.status).toBe("pending" );
});
it("shares pending bind approvals across duplicate module instances" , async () => {
const { first, second } = await importDuplicateConversationBindingModules();
const request = await requestPendingBinding(
createTelegramCodexBindRequest(
"-10099:topic:77" ,
"77" ,
"Bind this conversation to Codex thread abc." ,
),
first.requestPluginConversationBinding,
);
await expect(
approveBindingRequest(
request.approvalId,
"allow-once" ,
second.resolvePluginConversationBindingApproval,
),
).resolves.toMatchObject({
status: "approved" ,
binding: expect.objectContaining({
pluginId: "codex" ,
pluginRoot: "/plugins/codex-a" ,
conversationId: "-10099:topic:77" ,
}),
});
second.__testing.reset();
});
it("shares persistent approvals across duplicate module instances" , async () => {
const { first, second } = await importDuplicateConversationBindingModules();
const request = await requestPendingBinding(
createTelegramCodexBindRequest(
"-10099:topic:77" ,
"77" ,
"Bind this conversation to Codex thread abc." ,
),
first.requestPluginConversationBinding,
);
await expect(
approveBindingRequest(
request.approvalId,
"allow-always" ,
second.resolvePluginConversationBindingApproval,
),
).resolves.toMatchObject({
status: "approved" ,
decision: "allow-always" ,
});
const rebound = await first.requestPluginConversationBinding(
createTelegramCodexBindRequest(
"-10099:topic:78" ,
"78" ,
"Bind this conversation to Codex thread def." ,
),
);
expect(rebound.status).toBe("bound" );
first.__testing.reset();
fs.rmSync(approvalsPath, { force: true });
});
it("does not share persistent approvals across plugin roots even with the same plugin id" , async () => {
const request = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "telegram" ,
accountId: "default" ,
conversationId: "-10099:topic:77" ,
parentConversationId: "-10099" ,
threadId: "77" ,
summary: "Bind this conversation to Codex thread abc." ,
}),
);
expect(request.status).toBe("pending" );
if (request.status !== "pending" ) {
throw new Error("expected pending bind request" );
}
await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "allow-always" ,
senderId: "user-1" ,
});
const samePluginNewPath = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "telegram" ,
accountId: "default" ,
conversationId: "-10099:topic:78" ,
parentConversationId: "-10099" ,
threadId: "78" ,
summary: "Bind this conversation to Codex thread def." ,
pluginRoot: "/plugins/codex-b" ,
}),
);
expect(samePluginNewPath.status).toBe("pending" );
});
it("persists detachHint on approved plugin bindings" , async () => {
const binding = await requestResolvedBinding(
createCodexBindRequest({
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:detach-hint" ,
summary: "Bind this conversation to Codex thread 999." ,
detachHint: "/codex_detach" ,
}),
);
expect(binding.detachHint).toBe("/codex_detach" );
const currentBinding = await getCurrentPluginConversationBinding({
pluginRoot: "/plugins/codex-a" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:detach-hint" ,
},
});
expect(currentBinding?.detachHint).toBe("/codex_detach" );
});
it("persists plugin-owned binding data on approved plugin bindings" , async () => {
const data = {
kind: "codex-app-server-session" ,
version: 1 ,
sessionFile: "/tmp/openclaw/session.jsonl" ,
workspaceDir: "/workspace/openclaw" ,
};
const binding = await requestResolvedBinding(
createCodexBindRequest({
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:binding-data" ,
summary: "Bind this conversation to Codex thread 999." ,
data,
}),
);
expect(binding.data).toEqual(data);
const currentBinding = await getCurrentPluginConversationBinding({
pluginRoot: "/plugins/codex-a" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:binding-data" ,
},
});
expect(currentBinding?.data).toEqual(data);
});
it.each([
{
name: "notifies the owning plugin when a bind approval is approved" ,
pluginRoot: "/plugins/callback-test" ,
requestInput: {
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/callback-test" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:callback-test" ,
},
binding: { summary: "Bind this conversation to Codex thread abc." },
},
decision: "allow-once" as const ,
expectedStatus: "approved" as const ,
expectedCallback: {
status: "approved" ,
binding: expect.objectContaining({
pluginId: "codex" ,
pluginRoot: "/plugins/callback-test" ,
conversationId: "channel:callback-test" ,
}),
decision: "allow-once" ,
request: {
summary: "Bind this conversation to Codex thread abc." ,
detachHint: undefined,
requestedBySenderId: "user-1" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:callback-test" ,
},
},
},
},
{
name: "notifies the owning plugin when a bind approval is denied" ,
pluginRoot: "/plugins/callback-deny" ,
requestInput: {
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/callback-deny" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "telegram" ,
accountId: "default" ,
conversationId: "8460800771" ,
},
binding: { summary: "Bind this conversation to Codex thread deny." },
},
decision: "deny" as const ,
expectedStatus: "denied" as const ,
expectedCallback: {
status: "denied" ,
binding: undefined,
decision: "deny" ,
request: {
summary: "Bind this conversation to Codex thread deny." ,
detachHint: undefined,
requestedBySenderId: "user-1" ,
conversation: {
channel: "telegram" ,
accountId: "default" ,
conversationId: "8460800771" ,
},
},
},
},
] as const )("$name" , async (testCase) => {
await expectResolutionCallback(testCase);
});
it.each([
{
name: "does not wait for an approved bind callback before returning" ,
pluginRoot: "/plugins/callback-slow-approve" ,
requestInput: {
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/callback-slow-approve" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:slow-approve" ,
},
binding: { summary: "Bind this conversation to Codex thread slow-approve." },
},
decision: "allow-once" as const ,
expectedStatus: "approved" as const ,
},
{
name: "does not wait for a denied bind callback before returning" ,
pluginRoot: "/plugins/callback-slow-deny" ,
requestInput: {
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/callback-slow-deny" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "telegram" ,
accountId: "default" ,
conversationId: "slow-deny" ,
},
binding: { summary: "Bind this conversation to Codex thread slow-deny." },
},
decision: "deny" as const ,
expectedStatus: "denied" as const ,
},
] as const )("$name" , async (testCase) => {
await expectResolutionDoesNotWait(testCase);
});
it("returns and detaches only bindings owned by the requesting plugin root" , async () => {
await requestResolvedBinding({
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/codex-a" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:1" ,
},
binding: { summary: "Bind this conversation to Codex thread 123." },
});
const current = await getCurrentPluginConversationBinding({
pluginRoot: "/plugins/codex-a" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:1" ,
},
});
expect(current).toEqual(
expect.objectContaining({
pluginId: "codex" ,
pluginRoot: "/plugins/codex-a" ,
conversationId: "channel:1" ,
}),
);
const otherPluginView = await getCurrentPluginConversationBinding({
pluginRoot: "/plugins/codex-b" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:1" ,
},
});
expect(otherPluginView).toBeNull();
expect(
await detachPluginConversationBinding({
pluginRoot: "/plugins/codex-b" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:1" ,
},
}),
).toEqual({ removed: false });
expect(
await detachPluginConversationBinding({
pluginRoot: "/plugins/codex-a" ,
conversation: {
channel: "discord" ,
accountId: "isolated" ,
conversationId: "channel:1" ,
},
}),
).toEqual({ removed: true });
});
it("refuses to claim a conversation already bound by core" , async () => {
sessionBindingState.setRecord({
bindingId: "binding-core" ,
targetSessionKey: "agent:main:discord:channel:1" ,
targetKind: "session" ,
conversation: {
channel: "discord" ,
accountId: "default" ,
conversationId: "channel:1" ,
},
status: "active" ,
boundAt: Date.now(),
metadata: { owner: "core" },
});
const result = await requestPluginConversationBinding({
pluginId: "codex" ,
pluginName: "Codex App Server" ,
pluginRoot: "/plugins/codex-a" ,
requestedBySenderId: "user-1" ,
conversation: {
channel: "discord" ,
accountId: "default" ,
conversationId: "channel:1" ,
},
binding: { summary: "Bind this conversation to Codex thread 123." },
});
expect(result).toEqual({
status: "error" ,
message:
"This conversation is already bound by core routing and cannot be claimed by a plugin." ,
});
});
it.each([
{
name: "migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs" ,
existingRecord: {
bindingId: "binding-legacy" ,
targetSessionKey: "plugin-binding:old-codex-plugin:legacy123" ,
targetKind: "session" as const ,
conversation: {
channel: "telegram" ,
accountId: "default" ,
conversationId: "-10099:topic:77" ,
},
status: "active" as const ,
metadata: {
label: "legacy plugin bind" ,
},
},
requestInput: createCodexBindRequest({
channel: "telegram" ,
accountId: "default" ,
conversationId: "-10099:topic:77" ,
parentConversationId: "-10099" ,
threadId: "77" ,
summary: "Bind this conversation to Codex thread abc." ,
}),
expectedBinding: {
pluginId: "codex" ,
pluginRoot: "/plugins/codex-a" ,
conversationId: "-10099:topic:77" ,
},
},
{
name: "migrates a legacy codex thread binding session key through the new approval flow" ,
existingRecord: {
bindingId: "binding-legacy-codex-thread" ,
targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9" ,
targetKind: "session" as const ,
conversation: {
channel: "telegram" ,
accountId: "default" ,
conversationId: "8460800771" ,
},
status: "active" as const ,
metadata: {
label: "legacy codex thread bind" ,
},
},
requestInput: createCodexBindRequest({
channel: "telegram" ,
accountId: "default" ,
conversationId: "8460800771" ,
summary: "Bind this conversation to Codex thread 019ce411-6322-7db2-a821-1a61c530e7d9." ,
pluginId: "openclaw-codex-app-server" ,
}),
expectedBinding: {
pluginId: "openclaw-codex-app-server" ,
pluginRoot: "/plugins/codex-a" ,
conversationId: "8460800771" ,
},
},
] as const )("$name" , async ({ existingRecord, requestInput, expectedBinding }) => {
sessionBindingState.setRecord({
...existingRecord,
boundAt: Date.now(),
});
const request = await requestPluginConversationBinding(requestInput);
const binding = await resolveRequestedBinding(request);
expect(binding).toEqual(expect.objectContaining(expectedBinding));
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-09)
¤
*© Formatika GbR, Deutschland