fix(ui): retry first-run model detection once (#125212)

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-17 04:00:28 -07:00
committed by GitHub
parent bbe22082b0
commit 34932dfe24
2 changed files with 126 additions and 8 deletions
@@ -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<ApplicationContext<RouteId>["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<RouteId>;
await startRedirect(context);
listener!(snapshot as Parameters<GatewayListener>[0]);
expect(request).toHaveBeenCalledOnce();
rejectFirst(new Error("temporary gateway failure"));
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
listener!(snapshot as Parameters<GatewayListener>[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<ApplicationContext<RouteId>["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<RouteId>;
await startRedirect(context);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
listener!(snapshot as Parameters<GatewayListener>[0]);
await Promise.resolve();
expect(request).toHaveBeenCalledTimes(2);
});
it("does not redirect after the operator leaves the default landing", async () => {
const result = {
candidates: [],
+33 -8
View File
@@ -64,8 +64,15 @@ function startModelSetupFirstRunRedirect(params: {
context: ApplicationContext<RouteId>;
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<ApplicationContext<RouteId>["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();
};