diff --git a/ui/src/pages/model-setup/first-run.test.ts b/ui/src/pages/model-setup/first-run.test.ts index a162aea214d6..ba5bd9e2d839 100644 --- a/ui/src/pages/model-setup/first-run.test.ts +++ b/ui/src/pages/model-setup/first-run.test.ts @@ -164,6 +164,99 @@ describe("model setup first-run redirect", () => { ).toEqual(result); }); + it("retries one transient detection failure without duplicating either attempt", async () => { + const result = { + candidates: [], + manualProviders: [], + workspace: "/tmp/workspace", + setupComplete: false, + }; + let rejectFirst!: (error: Error) => void; + const request = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectFirst = reject; + }), + ) + .mockResolvedValueOnce(result); + const client = { request } as unknown as GatewayBrowserClient; + type GatewayListener = Parameters["gateway"]["subscribe"]>[0]; + let listener: GatewayListener | null = null; + const snapshot = { + phase: "connected" as const, + client, + hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, + features: { methods: ["openclaw.setup.detect"] }, + }, + }; + const replace = vi.fn(); + const context = { + gateway: { + snapshot, + subscribe: (next: GatewayListener) => { + listener = next; + return () => undefined; + }, + }, + agentSelection: { + state: { selectedId: "main" }, + subscribe: () => () => undefined, + }, + replace, + } as unknown as ApplicationContext; + + await startRedirect(context); + listener!(snapshot as Parameters[0]); + expect(request).toHaveBeenCalledOnce(); + + rejectFirst(new Error("temporary gateway failure")); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + listener!(snapshot as Parameters[0]); + await vi.waitFor(() => expect(replace).toHaveBeenCalledOnce()); + + expect(request).toHaveBeenCalledTimes(2); + expect(replace).toHaveBeenCalledWith("model-setup", { search: "?firstRun=1" }); + }); + + it("stops after the same connection rejects detection twice", async () => { + const request = vi.fn().mockRejectedValue(new Error("gateway unavailable")); + const client = { request } as unknown as GatewayBrowserClient; + type GatewayListener = Parameters["gateway"]["subscribe"]>[0]; + let listener: GatewayListener | null = null; + const snapshot = { + phase: "connected" as const, + client, + hello: { + auth: { role: "operator", scopes: ["operator.admin"] }, + features: { methods: ["openclaw.setup.detect"] }, + }, + }; + const context = { + gateway: { + snapshot, + subscribe: (next: GatewayListener) => { + listener = next; + return () => undefined; + }, + }, + agentSelection: { + state: { selectedId: "main" }, + subscribe: () => () => undefined, + }, + replace: vi.fn(), + } as unknown as ApplicationContext; + + await startRedirect(context); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + listener!(snapshot as Parameters[0]); + await Promise.resolve(); + + expect(request).toHaveBeenCalledTimes(2); + }); + it("does not redirect after the operator leaves the default landing", async () => { const result = { candidates: [], diff --git a/ui/src/pages/model-setup/first-run.ts b/ui/src/pages/model-setup/first-run.ts index 96809e3c3cac..05f8db76a8f1 100644 --- a/ui/src/pages/model-setup/first-run.ts +++ b/ui/src/pages/model-setup/first-run.ts @@ -64,8 +64,15 @@ function startModelSetupFirstRunRedirect(params: { context: ApplicationContext; isStillDefaultLanding: () => boolean; }): () => void { - let attemptedConnection: ModelSetupDetectionConnection | null = null; + let detection: + | { + connection: ModelSetupDetectionConnection; + attempts: number; + phase: "in-flight" | "retry-ready" | "settled"; + } + | undefined; let redirected = false; + let disposed = false; const handleSnapshot: Parameters["gateway"]["subscribe"]>[0] = ( snapshot, ) => { @@ -80,16 +87,24 @@ function startModelSetupFirstRunRedirect(params: { } const agentId = params.context.agentSelection.state.selectedId; const connection = { client: snapshot.client, hello: snapshot.hello, agentId }; - if ( - connection.client === attemptedConnection?.client && - connection.hello === attemptedConnection?.hello && - connection.agentId === attemptedConnection?.agentId - ) { + const previous = detection; + const sameGeneration = + connection.client === previous?.connection.client && + connection.hello === previous?.connection.hello && + connection.agentId === previous?.connection.agentId; + if (sameGeneration && previous?.phase !== "retry-ready") { return; } - attemptedConnection = connection; + detection = + sameGeneration && previous + ? { connection, attempts: previous.attempts + 1, phase: "in-flight" } + : { connection, attempts: 1, phase: "in-flight" }; + const attempt = detection; void detectModelSetup(snapshot.client, agentId ?? undefined) .then((result) => { + if (disposed || detection !== attempt) { + return; + } const current = params.context.gateway.snapshot; if ( current.phase !== "connected" || @@ -99,6 +114,7 @@ function startModelSetupFirstRunRedirect(params: { ) { return; } + detection = { ...attempt, phase: "settled" }; cacheModelSetupDetection(connection, result); if (!result.setupComplete && !redirected && params.isStillDefaultLanding()) { redirected = true; @@ -106,7 +122,15 @@ function startModelSetupFirstRunRedirect(params: { } }) .catch(() => { - // First-run guidance is best effort. The page offers an explicit retry. + if (disposed || detection !== attempt) { + return; + } + // One same-generation retry absorbs a transient startup race without + // turning first-run guidance into a background retry loop. + detection = { ...attempt, phase: attempt.attempts < 2 ? "retry-ready" : "settled" }; + if (detection.phase === "retry-ready" && params.isStillDefaultLanding()) { + handleSnapshot(params.context.gateway.snapshot); + } }); }; const unsubscribe = params.context.gateway.subscribe(handleSnapshot); @@ -115,6 +139,7 @@ function startModelSetupFirstRunRedirect(params: { ); handleSnapshot(params.context.gateway.snapshot); return () => { + disposed = true; unsubscribe(); unsubscribeSelection(); };