mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(ui): retry first-run model detection once (#125212)
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
bbe22082b0
commit
34932dfe24
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user