From 69f10e72f2b6cc00a55dddf62864a71d39458c87 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 12:23:56 +0800 Subject: [PATCH] fix(ollama): retry unreachable setup in place (#116210) --- extensions/ollama/src/setup.test.ts | 52 ++++++-- extensions/ollama/src/setup.ts | 49 +++++--- ui/src/e2e/model-setup.e2e.test.ts | 178 ++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 28 deletions(-) diff --git a/extensions/ollama/src/setup.test.ts b/extensions/ollama/src/setup.test.ts index a49616a5bbcd..103cd3e95e94 100644 --- a/extensions/ollama/src/setup.test.ts +++ b/extensions/ollama/src/setup.test.ts @@ -361,8 +361,43 @@ describe("ollama setup", () => { expect(events).toEqual(["select", "text"]); }); - it("shows cloud-mode unreachable guidance when the host is down", async () => { + it("retries the configured host after showing unreachable guidance", async () => { const prompter = createLocalPrompter(); + prompter.confirm = vi.fn().mockResolvedValueOnce(true); + const reachableFetch = createOllamaFetchMock({ tags: ["qwen3:0.6b"] }); + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("down")) + .mockImplementation(reachableFetch); + vi.stubGlobal("fetch", fetchMock); + + const result = await promptAndConfigureOllama({ + cfg: {}, + prompter, + }); + + expect(prompter.note).toHaveBeenCalledWith( + [ + "Ollama could not be reached at http://127.0.0.1:11434.", + "Start or restart the Ollama server for this address.", + "If Ollama is not installed on that machine, download it at https://ollama.com/download", + "", + "Continue when it is running. OpenClaw will retry this address.", + ].join("\n"), + "Ollama", + ); + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "Retry this Ollama address now?", + initialValue: true, + }); + expect(result.config.models?.providers?.ollama?.models).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "qwen3:0.6b" })]), + ); + }); + + it("reports the configured host when the retry is still unreachable", async () => { + const prompter = createLocalPrompter(); + prompter.confirm = vi.fn().mockResolvedValueOnce(true); const fetchMock = createOllamaFetchMock({ tagsError: new Error("down") }); vi.stubGlobal("fetch", fetchMock); @@ -371,17 +406,9 @@ describe("ollama setup", () => { cfg: {}, prompter, }), - ).rejects.toThrow("Ollama not reachable"); + ).rejects.toThrow("Ollama is still not reachable at http://127.0.0.1:11434"); - expect(prompter.note).toHaveBeenCalledWith( - [ - "Ollama could not be reached at http://127.0.0.1:11434.", - "Download it at https://ollama.com/download", - "", - "Start Ollama and re-run setup.", - ].join("\n"), - "Ollama", - ); + expect(fetchMock).toHaveBeenCalledTimes(2); }); it("cloud + local mode falls back to local models when ollama signin is missing", async () => { @@ -1048,7 +1075,8 @@ describe("ollama setup", () => { expect(runtime.error).toHaveBeenCalledWith( [ "Ollama could not be reached at http://127.0.0.1:11435.", - "Download it at https://ollama.com/download", + "Start or restart the Ollama server for this address.", + "If Ollama is not installed on that machine, download it at https://ollama.com/download", ].join("\n"), ); expect(runtime.exit).toHaveBeenCalledWith(1); diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index 78236ad17939..9ca354eb7ee1 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -96,12 +96,12 @@ const HOST_BACKED_OLLAMA_MODE_CONFIG: Record< }, }; -function buildOllamaUnreachableLines(baseUrl: string): string[] { +function buildOllamaUnreachableLines(baseUrl: string, retry: boolean): string[] { return [ `Ollama could not be reached at ${baseUrl}.`, - "Download it at https://ollama.com/download", - "", - "Start Ollama and re-run setup.", + "Start or restart the Ollama server for this address.", + "If Ollama is not installed on that machine, download it at https://ollama.com/download", + ...(retry ? ["", "Continue when it is running. OpenClaw will retry this address."] : []), ]; } @@ -283,24 +283,41 @@ async function promptAndConfigureHostBackedOllama(params: { signal?: AbortSignal; }): Promise { const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env); - const { - reachable, - models, - inspectedModels, - discoveredModelsByName, - inspectionFailures, - hasToolsCapableModel, - } = await discoverOllamaModelsForSetup({ + let discovery = await discoverOllamaModelsForSetup({ baseUrl, inspectTools: true, ...(params.signal ? { signal: params.signal } : {}), }); - if (!reachable) { - await params.prompter.note(buildOllamaUnreachableLines(baseUrl).join("\n"), "Ollama"); - throw new WizardCancelledError("Ollama not reachable"); + if (!discovery.reachable) { + await params.prompter.note(buildOllamaUnreachableLines(baseUrl, true).join("\n"), "Ollama"); + const shouldRetry = await params.prompter.confirm({ + message: "Retry this Ollama address now?", + initialValue: true, + }); + if (!shouldRetry) { + throw new WizardCancelledError("Ollama setup cancelled"); + } + params.signal?.throwIfAborted(); + discovery = await discoverOllamaModelsForSetup({ + baseUrl, + inspectTools: true, + ...(params.signal ? { signal: params.signal } : {}), + }); } + if (!discovery.reachable) { + throw new WizardCancelledError(`Ollama is still not reachable at ${baseUrl}`); + } + + const { + models, + inspectedModels, + discoveredModelsByName, + inspectionFailures, + hasToolsCapableModel, + } = discovery; + if (inspectionFailures.length > 0) { await params.prompter.note( [ @@ -449,7 +466,7 @@ export async function configureOllamaNonInteractive(params: { const explicitModel = normalizeOllamaModelName(params.opts.customModelId); if (!reachable) { - params.runtime.error(buildOllamaUnreachableLines(baseUrl).slice(0, 2).join("\n")); + params.runtime.error(buildOllamaUnreachableLines(baseUrl, false).join("\n")); params.runtime.exit(1); return params.nextConfig; } diff --git a/ui/src/e2e/model-setup.e2e.test.ts b/ui/src/e2e/model-setup.e2e.test.ts index 9e719aa9ba11..2a57414dcc8e 100644 --- a/ui/src/e2e/model-setup.e2e.test.ts +++ b/ui/src/e2e/model-setup.e2e.test.ts @@ -235,6 +235,184 @@ describeControlUiE2e("Control UI Model Setup mocked Gateway E2E", () => { } }); + it("recovers Ollama setup in place after the local server starts", async () => { + const context = await browser.newContext({ + colorScheme: "dark", + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const initialDetection = { + candidates: [], + manualProviders: [], + recommendedInstalls: [ + { + id: "ollama", + brandId: "ollama", + label: "Ollama", + hint: "Run open models locally", + website: "https://ollama.com/download", + }, + ], + workspace: "/tmp/openclaw-e2e", + setupComplete: false, + }; + const gateway = await installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.startup", + "openclaw.setup.detect", + "openclaw.setup.prepare.start", + "wizard.next", + ], + methodResponses: { + "openclaw.setup.detect": initialDetection, + "openclaw.setup.prepare.start": { + sessionId: "ollama-prepare-session", + done: false, + status: "running", + }, + "wizard.next": { + sequence: [ + { + done: false, + status: "running", + step: { + id: "ollama-mode", + type: "select", + message: "Ollama mode", + options: [ + { + value: "cloud-local", + label: "Cloud + Local", + hint: "Route cloud and local models through your Ollama host", + }, + { value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models" }, + { value: "local-only", label: "Local only", hint: "Local models only" }, + ], + }, + }, + { + done: false, + status: "running", + step: { + id: "ollama-base-url", + type: "text", + message: "Ollama base URL", + initialValue: "http://127.0.0.1:11434", + }, + }, + { + done: false, + status: "running", + step: { + id: "ollama-retry", + type: "note", + title: "Ollama", + message: [ + "Ollama could not be reached at http://127.0.0.1:11434.", + "Start or restart the Ollama server for this address.", + "If Ollama is not installed on that machine, download it at https://ollama.com/download", + "", + "Continue when it is running. OpenClaw will retry this address.", + ].join("\n"), + }, + }, + { + done: false, + status: "running", + step: { + id: "ollama-retry-confirm", + type: "confirm", + message: "Retry this Ollama address now?", + initialValue: true, + }, + }, + { done: true, status: "done" }, + ], + }, + }, + }); + + try { + const response = await page.goto(`${server.baseUrl}settings/model-setup`); + expect(response?.status()).toBe(200); + await page + .locator('[data-prepare-choice="ollama"]') + .getByRole("button", { name: "Check & set up" }) + .click(); + + const start = await gateway.waitForRequest("openclaw.setup.prepare.start"); + expect(start.params).toMatchObject({ authChoice: "ollama" }); + await page.getByRole("radio", { name: /Local only/u }).check(); + await page.getByRole("button", { name: "Continue" }).click(); + const baseUrl = page.getByLabel("Ollama base URL"); + await expect.poll(() => baseUrl.inputValue()).toBe("http://127.0.0.1:11434"); + await page.getByRole("button", { name: "Submit" }).click(); + await page.getByText("Ollama could not be reached at http://127.0.0.1:11434.").waitFor(); + await page + .getByText("Continue when it is running. OpenClaw will retry this address.") + .waitFor(); + + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, "ollama-recovery-desktop.png"), + }); + } + + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByText("Retry this Ollama address now?").waitFor(); + + await gateway.setMethodResponse("openclaw.setup.detect", { + ...initialDetection, + candidates: [ + { + kind: "provider-auto:ollama", + brandId: "ollama", + label: "Ollama", + detail: "qwen3:0.6b at http://127.0.0.1:11434", + modelRef: "ollama/qwen3:0.6b", + recommended: true, + credentials: true, + }, + ], + recommendedInstalls: [], + }); + await page.getByRole("button", { name: "Yes" }).click(); + await page.getByText("qwen3:0.6b at http://127.0.0.1:11434").waitFor(); + await expect.poll(() => page.locator('[data-prepare-choice="ollama"]').count()).toBe(0); + + if (artifactDir) { + await page.screenshot({ + animations: "disabled", + fullPage: true, + path: path.join(artifactDir, "ollama-ready-desktop.png"), + }); + } + + const wizardRequests = await gateway.getRequests("wizard.next"); + expect(wizardRequests).toHaveLength(5); + expect(wizardRequests[2]?.params).toMatchObject({ + answer: { + stepId: "ollama-base-url", + value: "http://127.0.0.1:11434", + }, + }); + expect(wizardRequests[3]?.params).toMatchObject({ + answer: { stepId: "ollama-retry" }, + }); + expect(wizardRequests[4]?.params).toMatchObject({ + answer: { stepId: "ollama-retry-confirm", value: true }, + }); + } finally { + await context.close(); + } + }); + it("turns an unverifiable Gemini CLI login into direct recovery actions", async () => { const context = await browser.newContext({ colorScheme: "dark",