diff --git a/apps/linux/README.md b/apps/linux/README.md index 5680c8dedb78..da7be020320e 100644 --- a/apps/linux/README.md +++ b/apps/linux/README.md @@ -47,7 +47,7 @@ The app uses `OPENCLAW_DESKTOP_CLI` when set. Otherwise it checks `~/.openclaw/b Desktop notifications use each platform's system notification service. macOS 13+ uses Apple's User Notifications framework; Windows uses native system toasts and Linux uses the desktop notification service through `notify-rust`. On macOS, test notifications from a signed `.app` bundle: a direct `cargo run` stays unbundled, so the app disables notifications instead of initializing Apple's framework with no bundle identity. -On first run, release builds automatically install the stable CLI channel, while development builds ask for a release channel and preselect Development. After the CLI install, the app opens Model Setup, automatically checks existing credentials, and verifies a real model response before continuing. A working existing model opens the normal dashboard; newly configured AI access continues into guided onboarding. If no existing credentials work, choose a provider, sign in, or enter an API key in Model Setup. Reconnects and later app launches use the normal dashboard URL. +On first run, release builds automatically install the stable CLI channel, while development builds ask for a release channel and preselect Development. After the CLI install, the app opens Model Setup, automatically checks existing credentials, and verifies a real model response before continuing. A working existing model opens the normal dashboard; newly configured AI access continues into guided onboarding. If no existing credentials work, choose a provider, sign in, or enter an API key in Model Setup. In-progress model setup and guided onboarding survive Gateway restarts. After setup, reconnects and later app launches use the normal dashboard URL. ## Updates diff --git a/apps/linux/src-tauri/src/main.rs b/apps/linux/src-tauri/src/main.rs index 5c2caad904c3..b4dcf38e815e 100644 --- a/apps/linux/src-tauri/src/main.rs +++ b/apps/linux/src-tauri/src/main.rs @@ -35,6 +35,28 @@ use tauri_plugin_global_shortcut::{Code, Modifiers}; const CONNECTED_WATCH_INTERVAL: Duration = Duration::from_secs(15); const RECONNECT_INTERVAL: Duration = Duration::from_secs(3); +fn is_active_onboarding_url(url: &Url) -> bool { + let path = url.path().trim_end_matches('/'); + let query_key = if path.ends_with("/settings/model-setup") { + "firstRun" + } else if path.ends_with("/custodian") { + "onboarding" + } else { + return false; + }; + url.query_pairs() + .find(|(key, _)| key == query_key) + .is_some_and(|(_, value)| { + if query_key == "firstRun" { + return value == "1"; + } + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct BuildInfo { @@ -538,11 +560,19 @@ impl DesktopState { continue; } + // Onboarding keeps verification and guided-session state in its live page. Latch it + // for this outage so neither recovery screen nor dashboard reload erases that state. + let preserve_dashboard = main_window(&app) + .ok() + .and_then(|window| window.url().ok()) + .is_some_and(|url| is_active_onboarding_url(&url)); let mut displayed_phase = snapshot.phase; - if matches!( - state.show_local(&app, local_mode(&snapshot), false, Some(generation)), - Ok(false) - ) { + if !preserve_dashboard + && matches!( + state.show_local(&app, local_mode(&snapshot), false, Some(generation)), + Ok(false) + ) + { return; } state.update_tray(&snapshot); @@ -561,6 +591,10 @@ impl DesktopState { if let Ok(ready) = gateway::dashboard(&cli, snapshot) { app.state::() .configure(&app, ready.gateway_ws.clone()); + if preserve_dashboard { + state.update_tray(&ready.snapshot); + break; + } match state.navigate_local( &app, &ready.dashboard_url, @@ -577,7 +611,7 @@ impl DesktopState { Err(_) => {} } } - } else if snapshot.phase != displayed_phase { + } else if !preserve_dashboard && snapshot.phase != displayed_phase { displayed_phase = snapshot.phase; if matches!( state.show_local(&app, local_mode(&snapshot), false, Some(generation),), @@ -603,7 +637,43 @@ fn local_mode(snapshot: &GatewaySnapshot) -> &'static str { #[cfg(test)] mod navigation_tests { - use super::{is_release_version, NavigationState}; + use super::{is_active_onboarding_url, is_release_version, NavigationState, Url}; + + #[test] + fn only_active_onboarding_preserves_the_dashboard_during_reconnect() { + for (url, preserve) in [ + ("http://127.0.0.1/settings/model-setup?firstRun=1", true), + ( + "http://127.0.0.1/openclaw/settings/model-setup/?tab=ai&firstRun=1#token=redacted", + true, + ), + ("http://127.0.0.1/settings/model-setup", false), + ("http://127.0.0.1/settings/model-setup?firstRun=0", false), + ( + "http://127.0.0.1/settings/model-setup?firstRun=0&firstRun=1", + false, + ), + ("http://127.0.0.1/settings/providers?firstRun=1", false), + ("http://127.0.0.1/custodian?onboarding=1", true), + ( + "http://127.0.0.1/openclaw/custodian/?tab=chat&onboarding=YES", + true, + ), + ("http://127.0.0.1/custodian", false), + ("http://127.0.0.1/custodian?onboarding=0", false), + ( + "http://127.0.0.1/custodian?onboarding=0&onboarding=1", + false, + ), + ("http://127.0.0.1/chat?onboarding=1", false), + ] { + assert_eq!( + is_active_onboarding_url(&Url::parse(url).expect("dashboard URL")), + preserve, + "unexpected reconnect policy for {url}" + ); + } + } #[test] fn committed_package_version_is_a_development_build() { @@ -679,8 +749,10 @@ mod navigation_tests { assert_eq!(first.path(), "/settings/model-setup"); assert_eq!(first.query(), Some("firstRun=1")); + assert!(is_active_onboarding_url(&first)); assert_eq!(second.path(), "/"); assert_eq!(second.query(), None); + assert!(!is_active_onboarding_url(&second)); } #[test] diff --git a/test/non-isolated-runner.test.ts b/test/non-isolated-runner.test.ts index 98e2959a1d90..3b3a1d96bbb5 100644 --- a/test/non-isolated-runner.test.ts +++ b/test/non-isolated-runner.test.ts @@ -50,6 +50,15 @@ function fixtureFiles(): Record { const agentEventsPath = JSON.stringify(path.join(repoRoot, "src", "infra", "agent-events.ts")); const loggingConsolePath = JSON.stringify(path.join(repoRoot, "src", "logging", "console.ts")); const loggingStatePath = JSON.stringify(path.join(repoRoot, "src", "logging", "state.ts")); + const pluginMetadataStatePath = JSON.stringify( + path.join(repoRoot, "src", "plugins", "current-plugin-metadata-state.ts"), + ); + const pluginMetadataRuntimePath = JSON.stringify( + path.join(repoRoot, "src", "plugins", "plugin-metadata-snapshot.runtime.ts"), + ); + const pluginMetadataLifecyclePath = JSON.stringify( + path.join(repoRoot, "src", "plugins", "plugin-metadata-lifecycle.ts"), + ); return { "01-dep.ts": 'export function flavor(): string {\n return "real";\n}\n', @@ -196,6 +205,56 @@ function fixtureFiles(): Record { "});", "", ].join("\n"), + "07-a-plugin-metadata.test.ts": [ + `import { setCurrentPluginMetadataSnapshotState } from ${pluginMetadataStatePath};`, + `import { getCurrentPluginMetadataSnapshotRuntime, registerPluginMetadataSnapshotReaders } from ${pluginMetadataRuntimePath};`, + `import { registerPluginMetadataProcessMemoLifecycleClear } from ${pluginMetadataLifecyclePath};`, + 'import { expect, it } from "vitest";', + 'it("seeds process-global plugin metadata readers and their snapshot", () => {', + ' const snapshot = { source: "first-file" };', + ' const readerKey = Symbol.for("openclaw.pluginMetadataSnapshotReaders");', + ' const probeKey = Symbol.for("openclaw.test.pluginMetadataLifecycle");', + " const store = globalThis as Record;", + " const probe = { clears: 0, readers: store[readerKey] };", + " store[probeKey] = probe;", + " registerPluginMetadataProcessMemoLifecycleClear(() => { probe.clears += 1; });", + ' setCurrentPluginMetadataSnapshotState(snapshot, "first-file");', + " registerPluginMetadataSnapshotReaders({", + " getCurrentPluginMetadataSnapshot: () => snapshot,", + " resolvePluginMetadataSnapshot: () => snapshot,", + " });", + " expect(getCurrentPluginMetadataSnapshotRuntime({ config: {} })).toBe(snapshot);", + "});", + "", + ].join("\n"), + "07-b-plugin-metadata.test.ts": [ + `import { getCurrentPluginMetadataSnapshotState } from ${pluginMetadataStatePath};`, + `import { getCurrentPluginMetadataSnapshotRuntime } from ${pluginMetadataRuntimePath};`, + 'import { expect, it } from "vitest";', + 'it("clears process-global plugin metadata snapshots and reader closures", () => {', + ' const readerKey = Symbol.for("openclaw.pluginMetadataSnapshotReaders");', + ' const probeKey = Symbol.for("openclaw.test.pluginMetadataLifecycle");', + " const store = globalThis as Record;", + " const probe = store[probeKey] as { clears: number; readers: object };", + " const readers = store[readerKey] as Record;", + " expect({", + " clears: probe.clears,", + " getterPresent: typeof readers.getCurrentPluginMetadataSnapshot === 'function',", + " resolverPresent: typeof readers.resolvePluginMetadataSnapshot === 'function',", + " readerIdentityRetained: readers === probe.readers,", + " snapshot: getCurrentPluginMetadataSnapshotState().snapshot,", + " readerSnapshot: getCurrentPluginMetadataSnapshotRuntime({ config: {} }),", + " }).toEqual({", + " clears: 1,", + " getterPresent: false,", + " resolverPresent: false,", + " readerIdentityRetained: true,", + " snapshot: undefined,", + " readerSnapshot: undefined,", + " });", + "});", + "", + ].join("\n"), }; } @@ -251,7 +310,7 @@ it("cleans every shared runner surface between files", async () => { // The collection failure is intentional. Every behavior test after it must // pass; any leaked surface turns the summary into a second failure. expect(output).toContain("synthetic collect failure"); - expect(output).toContain("1 failed | 11 passed"); + expect(output).toContain("1 failed | 13 passed"); expect(output).not.toContain("first-file"); } finally { await fs.rm(root, { recursive: true, force: true }); diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts index 024f64969799..789b9ad64760 100644 --- a/test/non-isolated-runner.ts +++ b/test/non-isolated-runner.ts @@ -4,6 +4,8 @@ import { TestRunner, type RunnerTask, type RunnerTestFile, vi } from "vitest"; import { resetAgentEventsForTest } from "../src/infra/agent-events.js"; import { loggingState } from "../src/logging/state.js"; import { clearNamedPluginRuntimeStoresForTest } from "../src/plugin-sdk/runtime-store-registry.js"; +import { clearCurrentPluginMetadataSnapshot } from "../src/plugins/current-plugin-metadata-state.js"; +import { registerPluginMetadataSnapshotReaders } from "../src/plugins/plugin-metadata-snapshot.runtime.js"; import { type CustomElementTracking, dropRepoOwnedCustomElements, @@ -332,6 +334,31 @@ function resetOpenClawSessionSuspensionState(): void { api?.resetSessionSuspensionStateForTest?.(); } +function resetOpenClawPluginMetadataState(modules: EvaluatedModules): void { + let clearLifecycle: (() => void) | undefined; + for (const [modulePath, module] of modules.idToModuleMap) { + if ( + modulePath.startsWith("mock:") || + !/\/src\/plugins\/plugin-metadata-lifecycle\.(?:ts|js)(?:\?.*)?$/u.test(modulePath) || + !module.exports || + typeof module.exports !== "object" + ) { + continue; + } + const clear = Reflect.get(module.exports, "clearPluginMetadataLifecycleCaches"); + if (typeof clear === "function") { + clearLifecycle = clear; + } + } + (clearLifecycle ?? clearCurrentPluginMetadataSnapshot)(); + + // Existing lightweight bridges retain the slot, so reset reader closures in place. + registerPluginMetadataSnapshotReaders({ + getCurrentPluginMetadataSnapshot: undefined, + resolvePluginMetadataSnapshot: undefined, + }); +} + const SERIALIZED_RESOLVE_MOCKS = Symbol.for("openclaw.serializedResolveMocks"); type SerializedResolveMocksState = { @@ -473,10 +500,12 @@ export default class OpenClawNonIsolatedRunner extends TestRunner { // Named plugin runtimes intentionally survive duplicate module evaluation in production. // Clear their shared slots here so one test file cannot lend a partial runtime to the next. clearNamedPluginRuntimeStoresForTest(); + const evaluatedModules = internals.workerState.evaluatedModules as EvaluatedModules; + resetOpenClawPluginMetadataState(evaluatedModules); dropTrackedRepoOwnedCustomElements(); resetSharedDocumentBody(); vi.resetModules(); internals.moduleRunner?.mocker?.reset?.(); - resetEvaluatedModules(internals.workerState.evaluatedModules as EvaluatedModules, true); + resetEvaluatedModules(evaluatedModules, true); } } diff --git a/ui/src/pages/model-setup/first-run-auto-setup.ts b/ui/src/pages/model-setup/first-run-auto-setup.ts index 2f917f475507..0d0b84d5c2e4 100644 --- a/ui/src/pages/model-setup/first-run-auto-setup.ts +++ b/ui/src/pages/model-setup/first-run-auto-setup.ts @@ -88,6 +88,14 @@ export class FirstRunAutoSetup { } } + retryDetection(): void { + if (this.host.routeData()?.firstRun && !this.host.actionsDisabled()) { + this.pendingRestart = null; + this.host.setRefreshWarning(null); + this.reset(); + } + } + dispose(): void { this.routeChanged(); } @@ -123,6 +131,13 @@ export class FirstRunAutoSetup { return; } const configured = pageState.result.setupComplete && pageState.result.configuredModel; + if (this.pendingRestart && !configured) { + this.started = true; + this.host.setRefreshWarning( + `${t("modelSetup.errors.activationFailed")} ${this.pendingRestart.modelRef}. ${t("modelSetup.checkAgain")}.`, + ); + return; + } if (configured && !this.host.canVerify(snapshot.client)) { this.started = true; this.host.setVerifyState({ @@ -172,6 +187,10 @@ export class FirstRunAutoSetup { private async run(owner: FirstRunOwner, detection: SystemAgentSetupDetectResult): Promise { if (detection.setupComplete && detection.configuredModel) { + if (this.pendingRestart && detection.configuredModel !== this.pendingRestart.modelRef) { + this.failPendingActivation(this.pendingRestart); + return; + } const outcome = await this.host.verify(); if (!this.owns(owner) || !outcome || "error" in outcome) { return; @@ -200,6 +219,13 @@ export class FirstRunAutoSetup { return; } this.attempts.add(targetId); + // Activation can commit before its response; retain exact intent across + // the same Gateway's reconnect without repeating an ambiguous mutation. + this.pendingRestart = { + routeData: owner.routeData, + connection: owner.connection, + modelRef: candidate.modelRef, + }; const outcome = await this.host.activate(candidate, targetId); if (!this.owns(owner) || !outcome || "error" in outcome) { return; @@ -207,14 +233,11 @@ export class FirstRunAutoSetup { if (!outcome.value.result.ok) { // A rejected transport may still commit; only a definitive Gateway // failure permits dispatching another provider activation. + this.pendingRestart = null; continue; } if (outcome.value.result.gatewayRestartRequired && outcome.value.result.modelRef) { - this.pendingRestart = { - routeData: owner.routeData, - connection: owner.connection, - modelRef: outcome.value.result.modelRef, - }; + this.pendingRestart.modelRef = outcome.value.result.modelRef; // Keep the completed attempt closed until a replacement hello owns // detection and exact-model verification; the old socket is unsafe. this.host.setActivationState({ @@ -225,6 +248,7 @@ export class FirstRunAutoSetup { this.host.setRefreshWarning(outcome.value.refreshError ?? t("updates.dialog.restarting")); return; } + this.pendingRestart = null; if (this.host.activationSuccessful() && !outcome.value.refreshError) { context.navigate("custodian", { search: "?onboarding=1" }); } @@ -232,6 +256,16 @@ export class FirstRunAutoSetup { } } + private failPendingActivation(pending: PendingRestart): void { + this.pendingRestart = null; + this.host.setRefreshWarning(null); + this.host.setVerifyState({ + phase: "failed", + status: "unknown", + error: `${t("modelSetup.errors.activationFailed")} ${pending.modelRef}`, + }); + } + private finishVerified(owner: FirstRunOwner, modelRef: string): void { const pending = this.pendingRestart; if (!pending) { @@ -245,13 +279,7 @@ export class FirstRunAutoSetup { pending.connection.hello === owner.connection.hello || pending.modelRef !== modelRef ) { - this.pendingRestart = null; - this.host.setRefreshWarning(null); - this.host.setVerifyState({ - phase: "failed", - status: "unknown", - error: `${t("modelSetup.errors.activationFailed")} ${pending.modelRef}`, - }); + this.failPendingActivation(pending); return; } this.pendingRestart = null; diff --git a/ui/src/pages/model-setup/model-setup-first-run.test.ts b/ui/src/pages/model-setup/model-setup-first-run.test.ts index 4047718247d5..08fc13c74d78 100644 --- a/ui/src/pages/model-setup/model-setup-first-run.test.ts +++ b/ui/src/pages/model-setup/model-setup-first-run.test.ts @@ -224,6 +224,42 @@ describe("ModelSetupPage first-run inference", () => { expect(context.navigate).toHaveBeenCalledOnce(); }); + it("automatically activates newly discovered credentials when first-run setup is checked again", async () => { + const { context, client, request } = createFirstRunContext(); + request.mockImplementation(async (method) => { + if (method === "openclaw.setup.detect") { + return { + ...detection, + candidates: [candidate("openai-api-key", "openai/newly-available", true)], + }; + } + if (method === "openclaw.setup.activate") { + return { ok: true, modelRef: "openai/newly-available", latencyMs: 42, lines: [] }; + } + throw new Error(`Unexpected method ${method}`); + }); + + const { page } = await mountPage(context, { + state: { phase: "ready", result: detection }, + client, + firstRun: true, + }); + const checkAgain = page.querySelector(".model-setup__intro .btn"); + expect(checkAgain?.textContent).toContain("Check again"); + checkAgain?.click(); + + await waitForFast(() => { + expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([ + ["openclaw.setup.detect", { agentId: "main" }], + [ + "openclaw.setup.activate", + { agentId: "main", kind: "openai-api-key", modelRef: "openai/newly-available" }, + ], + ]); + expect(context.navigate).toHaveBeenCalledWith("custodian", { search: "?onboarding=1" }); + }); + }); + it("stops first-run activation after an ambiguous transport failure", async () => { const { context, client, request } = createFirstRunContext(); request.mockRejectedValue(new Error("Activation connection dropped after dispatch")); @@ -461,6 +497,214 @@ describe("ModelSetupPage first-run inference", () => { }); }); + it("finishes onboarding when the Gateway reconnects before its activation response", async () => { + const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext(); + let resolveActivation: + | ((result: { ok: true; modelRef: string; gatewayRestartRequired: true }) => void) + | undefined; + request.mockImplementation(async (method) => { + if (method === "openclaw.setup.activate") { + return await new Promise<{ + ok: true; + modelRef: string; + gatewayRestartRequired: true; + }>((resolve) => { + resolveActivation = resolve; + }); + } + if (method === "openclaw.setup.detect") { + return { ...detection, configuredModel: "openai/new", setupComplete: true }; + } + if (method === "openclaw.setup.verify") { + return { ok: true, modelRef: "openai/new", latencyMs: 31 }; + } + throw new Error(`Unexpected method ${method}`); + }); + + const { page } = await mountPage(context, { + state: { + phase: "ready", + result: { + ...detection, + candidates: [candidate("openai-api-key", "openai/new", true)], + }, + }, + client, + firstRun: true, + }); + await waitForFast(() => expect(resolveActivation).toBeTypeOf("function")); + + publishGatewaySnapshot({ + ...context.gateway.snapshot, + phase: "reconnecting", + hello: null, + }); + await page.updateComplete; + publishGatewaySnapshot({ + ...snapshot, + phase: "connected", + hello: { ...snapshot.hello }, + }); + + await waitForFast(() => { + expect( + request.mock.calls + .map(([method]) => method) + .filter((method) => method.startsWith("openclaw.setup.")), + ).toEqual(["openclaw.setup.activate", "openclaw.setup.detect", "openclaw.setup.verify"]); + expect(context.navigate).toHaveBeenCalledWith("custodian", { search: "?onboarding=1" }); + }); + expect(context.navigate).not.toHaveBeenCalledWith("chat"); + resolveActivation?.({ ok: true, modelRef: "openai/new", gatewayRestartRequired: true }); + }); + + it("does not repeat an unconfirmed activation after reconnect without an explicit retry", async () => { + const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext(); + let resolveFirstActivation: + | ((result: { ok: true; modelRef: string; gatewayRestartRequired: true }) => void) + | undefined; + let activationCount = 0; + request.mockImplementation(async (method) => { + if (method === "openclaw.setup.activate") { + activationCount += 1; + if (activationCount === 1) { + return await new Promise<{ + ok: true; + modelRef: string; + gatewayRestartRequired: true; + }>((resolve) => { + resolveFirstActivation = resolve; + }); + } + return { ok: true, modelRef: "openai/new", latencyMs: 31, lines: [] }; + } + if (method === "openclaw.setup.detect") { + return { + ...detection, + candidates: [candidate("openai-api-key", "openai/new", true)], + }; + } + throw new Error(`Unexpected method ${method}`); + }); + + const { page } = await mountPage(context, { + state: { + phase: "ready", + result: { + ...detection, + candidates: [candidate("openai-api-key", "openai/new", true)], + }, + }, + client, + firstRun: true, + }); + await waitForFast(() => expect(resolveFirstActivation).toBeTypeOf("function")); + + publishGatewaySnapshot({ + ...context.gateway.snapshot, + phase: "reconnecting", + hello: null, + }); + await page.updateComplete; + publishGatewaySnapshot({ + ...snapshot, + phase: "connected", + hello: { ...snapshot.hello }, + }); + + await waitForFast(() => { + expect(page.textContent).toContain("The model could not be activated"); + expect(page.textContent).toContain("Check again"); + }); + expect(activationCount).toBe(1); + expect(context.navigate).not.toHaveBeenCalled(); + + page.querySelector(".model-setup__intro .btn")?.click(); + + await waitForFast(() => { + expect(activationCount).toBe(2); + expect(context.navigate).toHaveBeenCalledWith("custodian", { search: "?onboarding=1" }); + }); + resolveFirstActivation?.({ ok: true, modelRef: "openai/new", gatewayRestartRequired: true }); + }); + + it("rejects a different committed model before verification or another activation", async () => { + const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext(); + let resolveFirstActivation: + | ((result: { ok: true; modelRef: string; gatewayRestartRequired: true }) => void) + | undefined; + let activationCount = 0; + request.mockImplementation(async (method) => { + if (method === "openclaw.setup.activate") { + activationCount += 1; + if (activationCount === 1) { + return await new Promise<{ + ok: true; + modelRef: string; + gatewayRestartRequired: true; + }>((resolve) => { + resolveFirstActivation = resolve; + }); + } + return { ok: true, modelRef: "openai/expected", latencyMs: 31, lines: [] }; + } + if (method === "openclaw.setup.detect") { + return { + ...detection, + configuredModel: "anthropic/different", + setupComplete: true, + candidates: [candidate("openai-api-key", "openai/expected", true)], + }; + } + if (method === "openclaw.setup.verify") { + return { ok: false, status: "auth", error: "The different model could not be verified" }; + } + throw new Error(`Unexpected method ${method}`); + }); + + const { page } = await mountPage(context, { + state: { + phase: "ready", + result: { + ...detection, + candidates: [candidate("openai-api-key", "openai/expected", true)], + }, + }, + client, + firstRun: true, + }); + await waitForFast(() => expect(resolveFirstActivation).toBeTypeOf("function")); + + publishGatewaySnapshot({ + ...context.gateway.snapshot, + phase: "reconnecting", + hello: null, + }); + await page.updateComplete; + publishGatewaySnapshot({ + ...snapshot, + phase: "connected", + hello: { ...snapshot.hello }, + }); + + await waitForFast(() => { + expect(page.textContent).toContain("The model could not be activated"); + expect(page.textContent).toContain("openai/expected"); + }); + expect( + request.mock.calls + .map(([method]) => method) + .filter((method) => method.startsWith("openclaw.setup.")), + ).toEqual(["openclaw.setup.activate", "openclaw.setup.detect"]); + expect(activationCount).toBe(1); + expect(context.navigate).not.toHaveBeenCalled(); + resolveFirstActivation?.({ + ok: true, + modelRef: "openai/expected", + gatewayRestartRequired: true, + }); + }); + it("does not accept a different verified model after a required Gateway restart", async () => { const { context, client, request, snapshot, publishGatewaySnapshot } = createFirstRunContext(); request.mockImplementation(async (method) => { diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index e3ea94e54574..a42907ca343c 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -393,10 +393,9 @@ export class ModelSetupPage extends OpenClawLightDomElement { if (pageState.phase !== "ready") { return; } - const available = pageState.result.manualProviders.some( - (provider) => provider.id === this.manualProviderId, - ); - if (!available) { + if ( + !pageState.result.manualProviders.some((provider) => provider.id === this.manualProviderId) + ) { this.manualProviderId = pageState.result.manualProviders[0]?.id ?? ""; } } @@ -683,7 +682,10 @@ export class ModelSetupPage extends OpenClawLightDomElement { moreSignInOpen: this.moreSignInOpen, firstRun: this.routeData?.firstRun === true, iconUrls: this.iconUrls, - onDetect: () => void this.detect(), + onDetect: () => { + this.firstRun.retryDetection(); + void this.detect(); + }, onVerify: () => void this.verifyConnection(), onActivateCandidate: (candidate) => this.activateCandidate(candidate), onStartAuth: (option: AuthOption) => {