test: dedupe plugin bundle and discovery suites

This commit is contained in:
Peter Steinberger
2026-03-28 02:33:23 +00:00
parent c18d315858
commit 0454612083
17 changed files with 1703 additions and 1760 deletions

View File

@@ -197,6 +197,36 @@ function createTelegramCodexBindRequest(
};
}
function createCodexBindRequest(params: {
channel: "discord" | "telegram";
accountId: string;
conversationId: string;
summary: string;
pluginRoot?: string;
pluginId?: string;
parentConversationId?: string;
threadId?: string;
detachHint?: string;
}) {
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 } : {}),
},
} satisfies PluginBindingRequestInput;
}
async function requestPendingBinding(
input: PluginBindingRequestInput,
requestBinding = requestPluginConversationBinding,
@@ -256,6 +286,91 @@ function createDeferredVoid(): { promise: Promise<void>; resolve: () => void } {
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();
@@ -423,20 +538,16 @@ describe("plugin conversation binding approvals", () => {
});
it("does not share persistent approvals across plugin roots even with the same plugin id", async () => {
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
const request = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: "77",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
});
summary: "Bind this conversation to Codex thread abc.",
}),
);
expect(request.status).toBe("pending");
if (request.status !== "pending") {
@@ -449,40 +560,31 @@ describe("plugin conversation binding approvals", () => {
senderId: "user-1",
});
const samePluginNewPath = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-b",
requestedBySenderId: "user-1",
conversation: {
const samePluginNewPath = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:78",
parentConversationId: "-10099",
threadId: "78",
},
binding: { summary: "Bind this conversation to Codex thread def." },
});
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 request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
const request = await requestPluginConversationBinding(
createCodexBindRequest({
channel: "discord",
accountId: "isolated",
conversationId: "channel:detach-hint",
},
binding: {
summary: "Bind this conversation to Codex thread 999.",
detachHint: "/codex_detach",
},
});
}),
);
expect(["pending", "bound"]).toContain(request.status);
@@ -517,220 +619,120 @@ describe("plugin conversation binding approvals", () => {
expect(currentBinding?.detachHint).toBe("/codex_detach");
});
it("notifies the owning plugin when a bind approval is approved", async () => {
const registry = createEmptyPluginRegistry();
const onResolved = vi.fn(async () => undefined);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
it.each([
{
name: "notifies the owning plugin when a bind approval is approved",
pluginRoot: "/plugins/callback-test",
handler: onResolved,
source: "/plugins/callback-test/index.ts",
rootDir: "/plugins/callback-test",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
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." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
const approved = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "allow-once",
senderId: "user-1",
});
expect(approved.status).toBe("approved");
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith({
status: "approved",
binding: expect.objectContaining({
requestInput: {
pluginId: "codex",
pluginName: "Codex App Server",
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",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
},
});
});
it("notifies the owning plugin when a bind approval is denied", async () => {
const registry = createEmptyPluginRegistry();
const onResolved = vi.fn(async () => undefined);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
pluginRoot: "/plugins/callback-deny",
handler: onResolved,
source: "/plugins/callback-deny/index.ts",
rootDir: "/plugins/callback-deny",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
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",
},
},
},
binding: { summary: "Bind this conversation to Codex thread deny." },
});
expect(request.status).toBe("pending");
if (request.status !== "pending") {
throw new Error("expected pending bind request");
}
const denied = await resolvePluginConversationBindingApproval({
approvalId: request.approvalId,
decision: "deny",
senderId: "user-1",
});
expect(denied.status).toBe("denied");
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith({
status: "denied",
binding: undefined,
decision: "deny",
request: {
summary: "Bind this conversation to Codex thread deny.",
detachHint: undefined,
},
{
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("does not wait for an approved bind callback before returning", async () => {
const registry = createEmptyPluginRegistry();
const callbackGate = createDeferredVoid();
const onResolved = vi.fn(async () => callbackGate.promise);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
it.each([
{
name: "does not wait for an approved bind callback before returning",
pluginRoot: "/plugins/callback-slow-approve",
handler: onResolved,
source: "/plugins/callback-slow-approve/index.ts",
rootDir: "/plugins/callback-slow-approve",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-slow-approve",
requestedBySenderId: "user-1",
conversation: {
channel: "discord",
accountId: "isolated",
conversationId: "channel: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." },
},
binding: { summary: "Bind this conversation to Codex thread slow-approve." },
});
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: "allow-once",
senderId: "user-1",
}).then((result) => {
settled = true;
return result;
});
await flushMicrotasks();
expect(settled).toBe(true);
expect(onResolved).toHaveBeenCalledTimes(1);
callbackGate.resolve();
const approved = await resolutionPromise;
expect(approved.status).toBe("approved");
});
it("does not wait for a denied bind callback before returning", async () => {
const registry = createEmptyPluginRegistry();
const callbackGate = createDeferredVoid();
const onResolved = vi.fn(async () => callbackGate.promise);
registry.conversationBindingResolvedHandlers.push({
pluginId: "codex",
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",
handler: onResolved,
source: "/plugins/callback-slow-deny/index.ts",
rootDir: "/plugins/callback-slow-deny",
});
setActivePluginRegistry(registry);
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/callback-slow-deny",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "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." },
},
binding: { summary: "Bind this conversation to Codex thread slow-deny." },
});
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: "deny",
senderId: "user-1",
}).then((result) => {
settled = true;
return result;
});
await flushMicrotasks();
expect(settled).toBe(true);
expect(onResolved).toHaveBeenCalledTimes(1);
callbackGate.resolve();
const denied = await resolutionPromise;
expect(denied.status).toBe("denied");
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 () => {
@@ -842,89 +844,75 @@ describe("plugin conversation binding approvals", () => {
});
});
it("migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs", async () => {
sessionBindingState.setRecord({
bindingId: "binding-legacy",
targetSessionKey: "plugin-binding:old-codex-plugin:legacy123",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
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",
},
},
status: "active",
boundAt: Date.now(),
metadata: {
label: "legacy plugin bind",
},
});
const request = await requestPluginConversationBinding({
pluginId: "codex",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
requestInput: createCodexBindRequest({
channel: "telegram",
accountId: "default",
conversationId: "-10099:topic:77",
parentConversationId: "-10099",
threadId: "77",
},
binding: { summary: "Bind this conversation to Codex thread abc." },
});
const binding = await resolveRequestedBinding(request);
expect(binding).toEqual(
expect.objectContaining({
summary: "Bind this conversation to Codex thread abc.",
}),
expectedBinding: {
pluginId: "codex",
pluginRoot: "/plugins/codex-a",
conversationId: "-10099:topic:77",
}),
);
});
it("migrates a legacy codex thread binding session key through the new approval flow", async () => {
sessionBindingState.setRecord({
bindingId: "binding-legacy-codex-thread",
targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9",
targetKind: "session",
conversation: {
},
},
{
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",
},
status: "active",
boundAt: Date.now(),
metadata: {
label: "legacy codex thread bind",
},
});
const request = await requestPluginConversationBinding({
pluginId: "openclaw-codex-app-server",
pluginName: "Codex App Server",
pluginRoot: "/plugins/codex-a",
requestedBySenderId: "user-1",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "8460800771",
},
binding: {
summary: "Bind this conversation to Codex thread 019ce411-6322-7db2-a821-1a61c530e7d9.",
},
});
const binding = await resolveRequestedBinding(request);
expect(binding).toEqual(
expect.objectContaining({
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));
});
});