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; + } }, }; },