From 2021d817fd297dce19347f66b3a6cae8e85b1cb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Nov 2026 02:29:56 +0000 Subject: [PATCH] fix(xl-ai): reject invokeAI when the menu is closed and the request fails setAIResponseStatus ignores updates while the AI menu is closed, so a programmatic invokeAI that failed resolved like success. Reject with that error when the call starts with the menu closed. Calls that start with the menu open still resolve and store the error for the menu. Co-authored-by: Rahul Kashyap --- packages/xl-ai/src/AIExtension.test.ts | 144 +++++++++++++++++++++++++ packages/xl-ai/src/AIExtension.ts | 30 ++++-- 2 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 packages/xl-ai/src/AIExtension.test.ts diff --git a/packages/xl-ai/src/AIExtension.test.ts b/packages/xl-ai/src/AIExtension.test.ts new file mode 100644 index 0000000000..73e6dc3dff --- /dev/null +++ b/packages/xl-ai/src/AIExtension.test.ts @@ -0,0 +1,144 @@ +/** + * Regression test for https://github.com/TypeCellOS/BlockNote/issues/3275 + * + * `invokeAI` with the AI menu closed used to resolve on failure, because + * `setAIResponseStatus` drops updates while `aiMenuState` is `"closed"`. + */ +import { BlockNoteEditor } from "@blocknote/core"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { AIExtension } from "./AIExtension.js"; + +const sendMessageWithAIRequest = vi.hoisted(() => vi.fn()); + +vi.mock("./api/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sendMessageWithAIRequest, + }; +}); + +const mountedEditors: BlockNoteEditor[] = []; + +function createEditor() { + const editor = BlockNoteEditor.create({ + initialContent: [ + { + id: "paragraph-1", + type: "paragraph", + content: "Hello", + }, + ], + extensions: [AIExtension()], + }); + editor.mount(document.createElement("div")); + mountedEditors.push(editor); + return editor; +} + +// jsdom does not implement scrolling. Opening the menu calls it on the block. +function withScrollIntoView(run: () => void) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView", + ); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: () => {}, + }); + try { + run(); + } finally { + if (descriptor) { + Object.defineProperty( + HTMLElement.prototype, + "scrollIntoView", + descriptor, + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } + } +} + +afterEach(() => { + sendMessageWithAIRequest.mockReset(); + while (mountedEditors.length > 0) { + mountedEditors.pop()?._tiptapEditor.destroy(); + } +}); + +describe("invokeAI errors when the AI menu is closed", () => { + it("rejects with the chat error when the transport fails", async () => { + const error = new Error("HTTP 500"); + sendMessageWithAIRequest.mockImplementation(async (chat: object) => { + Object.defineProperty(chat, "status", { + configurable: true, + get: () => "error", + }); + Object.defineProperty(chat, "error", { + configurable: true, + get: () => error, + }); + return { ok: true, value: undefined }; + }); + const ai = createEditor().getExtension(AIExtension)!; + + await expect(ai.invokeAI({ userPrompt: "edit this" })).rejects.toBe(error); + expect(ai.store.state.aiMenuState).toBe("closed"); + }); + + it("rejects with a tool-execution error", async () => { + const error = new Error("tool failed"); + sendMessageWithAIRequest.mockResolvedValue({ ok: false, error }); + const ai = createEditor().getExtension(AIExtension)!; + + await expect(ai.invokeAI({ userPrompt: "edit this" })).rejects.toBe(error); + expect(ai.store.state.aiMenuState).toBe("closed"); + }); + + it("rejects when the request throws", async () => { + const error = new Error("network down"); + sendMessageWithAIRequest.mockRejectedValue(error); + const ai = createEditor().getExtension(AIExtension)!; + + await expect(ai.invokeAI({ userPrompt: "edit this" })).rejects.toBe(error); + expect(ai.store.state.aiMenuState).toBe("closed"); + }); + + it("records a failed result in the menu instead of rejecting when the menu is open", async () => { + const error = new Error("HTTP 500"); + sendMessageWithAIRequest.mockResolvedValue({ ok: false, error }); + const ai = createEditor().getExtension(AIExtension)!; + withScrollIntoView(() => { + ai.openAIMenuAtBlock("paragraph-1"); + }); + + await expect( + ai.invokeAI({ userPrompt: "edit this" }), + ).resolves.toBeUndefined(); + expect(ai.store.state.aiMenuState).toEqual({ + status: "error", + error, + blockId: "paragraph-1", + }); + }); + + it("records a thrown failure in the menu instead of rejecting when the menu is open", async () => { + const error = new Error("network down"); + sendMessageWithAIRequest.mockRejectedValue(error); + const ai = createEditor().getExtension(AIExtension)!; + withScrollIntoView(() => { + ai.openAIMenuAtBlock("paragraph-1"); + }); + + await expect( + ai.invokeAI({ userPrompt: "edit this" }), + ).resolves.toBeUndefined(); + expect(ai.store.state.aiMenuState).toEqual({ + status: "error", + error, + blockId: "paragraph-1", + }); + }); +}); diff --git a/packages/xl-ai/src/AIExtension.ts b/packages/xl-ai/src/AIExtension.ts index 7853ee8fd7..0b8ae07c7d 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -382,10 +382,26 @@ export const AIExtension = createExtension( * Execute a call to an LLM and apply the result to the editor */ async invokeAI(opts: InvokeAIOptions) { + // Calls that start with the menu open keep today's behavior: the error + // is stored for the menu and the promise resolves. Programmatic calls + // leave the menu closed, and `setAIResponseStatus` drops that update, + // so reject instead — otherwise failure looks like success. + const invokedWhileMenuClosed = store.state.aiMenuState === "closed"; this.setAIResponseStatus("thinking"); // If in collaboration mode, fork the yDoc to allow modifications without affecting the remote editor.getExtension("yForkDoc")?.fork(); + let closedMenuFailure: { error: unknown } | undefined; + const reportError = (error: unknown) => { + this.setAIResponseStatus({ + status: "error", + error, + }); + if (invokedWhileMenuClosed) { + closedMenuFailure = { error }; + } + }; + try { // Create a new AbortController for this request const abortController = new AbortController(); @@ -505,16 +521,10 @@ export const AIExtension = createExtension( chatStatus: chat.status, chatError: chat.error, }); - this.setAIResponseStatus({ - status: "error", - error: result.ok ? chat.error : result.error, - }); + reportError(result.ok ? chat.error : result.error); } } catch (e) { - this.setAIResponseStatus({ - status: "error", - error: e, - }); + reportError(e); // eslint-disable-next-line no-console console.error( "Unexpected error calling LLM", @@ -522,6 +532,10 @@ export const AIExtension = createExtension( chatSession?.chat.messages, ); } + + if (closedMenuFailure) { + throw closedMenuFailure.error; + } }, }; },