mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(linux): keep first-run onboarding alive when the gateway restarts (#129502)
* fix(linux): preserve onboarding through gateway restarts * refactor(ui): keep model setup below file-size limit * test: isolate plugin metadata and register startup retry coverage
This commit is contained in:
committed by
GitHub
parent
34d5309785
commit
18fba50e84
@@ -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
|
||||
|
||||
|
||||
@@ -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::<gateway_ws::GatewayClient>()
|
||||
.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]
|
||||
|
||||
@@ -50,6 +50,15 @@ function fixtureFiles(): Record<string, string> {
|
||||
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<string, string> {
|
||||
"});",
|
||||
"",
|
||||
].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<PropertyKey, unknown>;",
|
||||
" 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<PropertyKey, unknown>;",
|
||||
" const probe = store[probeKey] as { clears: number; readers: object };",
|
||||
" const readers = store[readerKey] as Record<string, unknown>;",
|
||||
" 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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(".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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user