Plugins: add binding resolution callbacks (#48678)

Merged via squash.

Prepared head SHA: 6d7b32b184
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-17 13:11:08 -04:00
committed by GitHub
parent ccf16cd889
commit 272d6ed24b
4 changed files with 169 additions and 2 deletions

View File

@@ -143,6 +143,18 @@ async function resolveRequestedBinding(request: PluginBindingRequest) {
throw new Error("expected pending or bound bind result");
}
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 };
}
describe("plugin conversation binding approvals", () => {
beforeEach(() => {
sessionBindingState.reset();
@@ -406,6 +418,7 @@ describe("plugin conversation binding approvals", () => {
});
expect(approved.status).toBe("approved");
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith({
status: "approved",
binding: expect.objectContaining({
@@ -464,6 +477,7 @@ describe("plugin conversation binding approvals", () => {
});
expect(denied.status).toBe("denied");
await flushMicrotasks();
expect(onResolved).toHaveBeenCalledWith({
status: "denied",
binding: undefined,
@@ -481,6 +495,108 @@ describe("plugin conversation binding approvals", () => {
});
});
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",
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",
},
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",
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",
},
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");
});
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
const request = await requestPluginConversationBinding({
pluginId: "codex",

View File

@@ -722,7 +722,7 @@ export async function resolvePluginConversationBindingApproval(params: {
}
pendingRequests.delete(params.approvalId);
if (params.decision === "deny") {
await notifyPluginConversationBindingResolved({
dispatchPluginConversationBindingResolved({
status: "denied",
decision: "deny",
request,
@@ -755,7 +755,7 @@ export async function resolvePluginConversationBindingApproval(params: {
log.info(
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
);
await notifyPluginConversationBindingResolved({
dispatchPluginConversationBindingResolved({
status: "approved",
binding,
decision: params.decision,
@@ -769,6 +769,20 @@ export async function resolvePluginConversationBindingApproval(params: {
};
}
function dispatchPluginConversationBindingResolved(params: {
status: "approved" | "denied";
binding?: PluginConversationBinding;
decision: PluginConversationBindingResolutionDecision;
request: PendingPluginBindingRequest;
}): void {
// Keep platform interaction acks fast even if the plugin does slow post-bind work.
queueMicrotask(() => {
void notifyPluginConversationBindingResolved(params).catch((error) => {
log.warn(`plugin binding resolved dispatch failed: ${String(error)}`);
});
});
}
async function notifyPluginConversationBindingResolved(params: {
status: "approved" | "denied";
binding?: PluginConversationBinding;