diff --git a/extensions/lmstudio/src/setup.test.ts b/extensions/lmstudio/src/setup.test.ts index 98296ee0fe22..1bb93d4d912c 100644 --- a/extensions/lmstudio/src/setup.test.ts +++ b/extensions/lmstudio/src/setup.test.ts @@ -182,6 +182,54 @@ function createQueuedWizardPrompterHarness(textValues: string[]): { return { prompter, note, text }; } +function createMethodBoundWizardPrompterHarness(textValues: string[]): { + prompter: WizardPrompter; + note: ReturnType; + text: ReturnType; +} { + const queue = [...textValues]; + const note = vi.fn(async (_message: string, _title?: string) => {}); + const text = vi.fn(async () => queue.shift() ?? ""); + + class MethodBoundWizardPrompter implements WizardPrompter { + async intro() {} + async outro() {} + async note(message: string, title?: string) { + await this.recordNote(message, title); + } + async select(params: { options: Array<{ value: T }> }) { + const firstOption = params.options[0]; + if (!firstOption) { + throw new Error("select called without options"); + } + return firstOption.value; + } + async multiselect() { + return []; + } + async text() { + return await this.readText(); + } + async confirm() { + return false; + } + progress() { + return { + update: () => {}, + stop: () => {}, + }; + } + private async readText() { + return await text(); + } + private async recordNote(message: string, title?: string) { + await note(message, title); + } + } + + return { prompter: new MethodBoundWizardPrompter(), note, text }; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -798,6 +846,43 @@ describe("lmstudio setup", () => { }); }); + it("interactive setup preserves gateway wizard prompter method binding", async () => { + const { prompter, text } = createMethodBoundWizardPrompterHarness([ + "http://localhost:1234/api/v1/", + "lmstudio-test-key", + "4096", + ]); + + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + prompter, + }); + + expect(text).toHaveBeenCalledTimes(3); + expect(result.defaultModel).toBe("lmstudio/qwen3-8b-instruct"); + }); + + it("interactive setup preserves gateway wizard note binding on discovery failure", async () => { + fetchLmstudioModelsMock.mockResolvedValueOnce({ reachable: false, models: [] }); + const { prompter, note, text } = createMethodBoundWizardPrompterHarness([ + "http://localhost:1234/api/v1/", + "lmstudio-test-key", + ]); + + await expect( + promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + prompter, + }), + ).rejects.toThrow("LM Studio not reachable"); + + expect(text).toHaveBeenCalledTimes(2); + expect(note).toHaveBeenCalledWith( + "LM Studio could not be reached at http://localhost:1234/v1.\nStart LM Studio (or run lms server start) and re-run setup.", + "LM Studio", + ); + }); + it("interactive setup accepts a blank API key for unauthenticated local LM Studio", async () => { const { prompter, text } = createQueuedWizardPrompterHarness([ "http://localhost:1234/api/v1/", diff --git a/extensions/lmstudio/src/setup.ts b/extensions/lmstudio/src/setup.ts index 7454244dcca6..96e29c02c7a8 100644 --- a/extensions/lmstudio/src/setup.ts +++ b/extensions/lmstudio/src/setup.ts @@ -390,11 +390,13 @@ export async function promptAndConfigureLmstudioInteractive(params: { promptText?: ProviderPromptText; note?: ProviderPromptNote; }): Promise { - const promptText = params.prompter?.text ?? params.promptText; + const promptText = params.prompter + ? params.prompter.text.bind(params.prompter) + : params.promptText; if (!promptText) { throw new Error("LM Studio interactive setup requires a text prompter."); } - const note = params.prompter?.note ?? params.note; + const note = params.prompter ? params.prompter.note.bind(params.prompter) : params.note; const defaultBaseUrl = resolveLmstudioSetupDefaultBaseUrl(); const baseUrlRaw = await promptText({ message: `${LMSTUDIO_PROVIDER_LABEL} base URL`,