diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 4a63a39dc09..ab7bcb51a48 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -386,4 +386,61 @@ describe("plugin conversation binding approvals", () => { "This conversation is already bound by core routing and cannot be claimed by a plugin.", }); }); + + it("migrates a legacy plugin binding record through the new approval flow", async () => { + sessionBindingState.setRecord({ + bindingId: "binding-legacy", + targetSessionKey: "plugin-binding:codex:legacy123", + targetKind: "session", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + }, + 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: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: "77", + }, + binding: { summary: "Bind this conversation to Codex thread abc." }, + }); + + expect(["pending", "bound"]).toContain(request.status); + const binding = + request.status === "pending" + ? await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: "allow-once", + senderId: "user-1", + }).then((approved) => { + expect(approved.status).toBe("approved"); + if (approved.status !== "approved") { + throw new Error("expected approved bind result"); + } + return approved.binding; + }) + : request.binding; + + expect(binding).toEqual( + expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/codex-a", + conversationId: "-10099:topic:77", + }), + ); + }); }); diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index f86942f6c83..e87b7b07568 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -196,6 +196,23 @@ function buildPluginBindingSessionKey(params: { return `${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:${hash}`; } +function isLegacyPluginBindingRecord(params: { + record: + | { + targetSessionKey: string; + metadata?: Record; + } + | null + | undefined; + pluginId: string; +}): boolean { + if (!params.record || isPluginOwnedBindingMetadata(params.record.metadata)) { + return false; + } + const targetSessionKey = params.record.targetSessionKey.trim(); + return targetSessionKey.startsWith(`${PLUGIN_BINDING_SESSION_PREFIX}:${params.pluginId}:`); +} + function buildDiscordButtonRow( approvalId: string, labels?: { once?: string; always?: string; deny?: string }, @@ -525,12 +542,22 @@ export async function requestPluginConversationBinding(params: { const ref = toConversationRef(conversation); const existing = getSessionBindingService().resolveByConversation(ref); const existingPluginBinding = toPluginConversationBinding(existing); + const existingLegacyPluginBinding = isLegacyPluginBindingRecord({ + record: existing, + pluginId: params.pluginId, + }); if (existing && !existingPluginBinding) { - return { - status: "error", - message: - "This conversation is already bound by core routing and cannot be claimed by a plugin.", - }; + if (existingLegacyPluginBinding) { + log.info( + `plugin binding migrating legacy record plugin=${params.pluginId} root=${params.pluginRoot} channel=${ref.channel} account=${ref.accountId} conversation=${ref.conversationId}`, + ); + } else { + return { + status: "error", + message: + "This conversation is already bound by core routing and cannot be claimed by a plugin.", + }; + } } if (existingPluginBinding && existingPluginBinding.pluginRoot !== params.pluginRoot) { return {