fix(lmstudio): preserve wizard prompter binding

Bind LM Studio wizard prompter callbacks before storing them so class-backed gateway setup sessions keep their receiver and no longer crash when selecting LM Studio.

Thanks @christineyan4.

Co-authored-by: Christine Yan <christine.yan4@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Christine Yan
2026-06-07 03:26:39 -04:00
committed by GitHub
parent 697d2d040c
commit 22276e6de0
2 changed files with 89 additions and 2 deletions

View File

@@ -182,6 +182,54 @@ function createQueuedWizardPrompterHarness(textValues: string[]): {
return { prompter, note, text };
}
function createMethodBoundWizardPrompterHarness(textValues: string[]): {
prompter: WizardPrompter;
note: ReturnType<typeof vi.fn>;
text: ReturnType<typeof vi.fn>;
} {
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<T>(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<string, unknown> {
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/",

View File

@@ -390,11 +390,13 @@ export async function promptAndConfigureLmstudioInteractive(params: {
promptText?: ProviderPromptText;
note?: ProviderPromptNote;
}): Promise<ProviderAuthResult> {
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`,