fix(quick-pane): refresh runtime model catalog
This commit is contained in:
+681
-142
@@ -1,7 +1,18 @@
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
|
||||
import type {
|
||||
Model,
|
||||
RuntimeSummary,
|
||||
UiState,
|
||||
Workspace,
|
||||
} from "./workspace/types";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
@@ -96,7 +107,9 @@ describe("model catalog identity", () => {
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude/sonnet",
|
||||
};
|
||||
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(identity);
|
||||
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(
|
||||
identity,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +135,7 @@ describe("App command progress and errors", () => {
|
||||
expect(status).toHaveTextContent("Ready for your next prompt");
|
||||
});
|
||||
|
||||
it("loads shared catalog selectors and derives providers from model choices", async () => {
|
||||
it("loads categorized snapshot selectors and derives providers from choices", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded: UiState = {
|
||||
...uiState,
|
||||
@@ -138,36 +151,45 @@ describe("App command progress and errors", () => {
|
||||
},
|
||||
};
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({
|
||||
models: [
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
{ provider: "openrouter", id: "anthropic/claude/sonnet" },
|
||||
],
|
||||
});
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({ runtime: { runtimeId: "settings-runtime" } });
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [
|
||||
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "anthropic/claude/sonnet",
|
||||
name: "Claude",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"),
|
||||
);
|
||||
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
|
||||
expect(quickModel).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /Follow-up model/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Quick provider/)).toBeNull();
|
||||
expect(screen.queryByLabelText(/Follow-up provider/)).toBeNull();
|
||||
|
||||
await user.selectOptions(
|
||||
quickModel,
|
||||
modelIdentityValue({
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude/sonnet",
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-runtime",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||
|
||||
const quickModel = screen.getByRole("button", {
|
||||
name: /Quick model openai · GPT 5/i,
|
||||
});
|
||||
await user.click(quickModel);
|
||||
expect(screen.getByRole("group", { name: "openrouter" })).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
);
|
||||
@@ -185,10 +207,75 @@ describe("App command progress and errors", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes available models whenever Settings opens", async () => {
|
||||
it("uses and always closes a fresh temporary runtime whenever Settings opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
let catalogCalls = 0;
|
||||
const loaded: UiState = {
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state")
|
||||
return Promise.resolve({
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
});
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
const id = payload?.runtimeId === "settings-1" ? "old-model" : "new-model";
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · old-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-1",
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores older overlapping Settings snapshot while closing both runtimes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
@@ -201,143 +288,593 @@ describe("App command progress and errors", () => {
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const first = deferred<{
|
||||
runtime: Record<string, never>;
|
||||
extensions: never[];
|
||||
models: { data: { models: { provider: string; id: string }[] } };
|
||||
}>();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "settings-1") return first.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "new-model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await act(async () =>
|
||||
first.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-1",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a stale-generation probe when its close fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const staleSnapshot = deferred<{
|
||||
runtime: Record<string, never>;
|
||||
extensions: never[];
|
||||
models: { data: { models: { provider: string; id: string }[] } };
|
||||
}>();
|
||||
let runtimeCount = 0;
|
||||
let staleCloseCount = 0;
|
||||
const lifecycle: string[] = [];
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
lifecycle.push(`create:settings-${runtimeCount}`);
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "settings-1") return staleSnapshot.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: { models: [{ provider: "openai", id: "new-model" }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (command === "close_quick_runtime") {
|
||||
if (payload?.runtimeId === "settings-1") {
|
||||
staleCloseCount += 1;
|
||||
lifecycle.push(`close:settings-1:${staleCloseCount}`);
|
||||
if (staleCloseCount === 1)
|
||||
return Promise.reject(new Error("stale close failed"));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await act(async () =>
|
||||
staleSnapshot.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not load model catalog: Error: stale close failed",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(3));
|
||||
expect(lifecycle.indexOf("close:settings-1:2")).toBeLessThan(
|
||||
lifecycle.indexOf("create:settings-3"),
|
||||
);
|
||||
});
|
||||
|
||||
it("best-effort closes an in-flight Settings probe on unmount", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const snapshot = deferred<unknown>();
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
return Promise.resolve({
|
||||
models: [
|
||||
{
|
||||
provider: "openai",
|
||||
id: catalogCalls === 1 ? "old-model" : "new-model",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({ runtime: { runtimeId: "in-flight-probe" } });
|
||||
if (command === "get_session_runtime_snapshot") return snapshot.promise;
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
const app = render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
const quickModel = await screen.findByRole("combobox", {
|
||||
name: /Quick model/,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/old-model" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
const refreshedQuickModel = screen.getByRole("combobox", {
|
||||
name: /Quick model/,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(refreshedQuickModel).getByRole("option", {
|
||||
name: "openai/new-model",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(refreshedQuickModel).queryByRole("option", {
|
||||
name: "openai/old-model",
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("get_session_runtime_snapshot", {
|
||||
runtimeId: "in-flight-probe",
|
||||
}),
|
||||
);
|
||||
app.unmount();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "in-flight-probe",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(refreshedQuickModel).toHaveValue(
|
||||
modelIdentityValue({ provider: "openai", modelId: "new-model" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an older catalog response that resolves after a newer refresh", async () => {
|
||||
it("closes temporary runtime and blocks save for snapshot failure or empty catalog", async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstCatalog = deferred<{ models: { provider: string; id: string }[] }>();
|
||||
const secondCatalog = deferred<{ models: { provider: string; id: string }[] }>();
|
||||
let catalogCalls = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
return catalogCalls === 1 ? firstCatalog.promise : secondCatalog.promise;
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let openCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
openCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${openCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "settings-1")
|
||||
return Promise.reject(new Error("offline"));
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: { data: { models: [] } },
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
|
||||
await act(async () =>
|
||||
secondCatalog.resolve({
|
||||
models: [{ provider: "openai", id: "new-model" }],
|
||||
}),
|
||||
);
|
||||
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/new-model" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
firstCatalog.resolve({
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/new-model" }),
|
||||
await screen.findByText("Could not load model catalog: Error: offline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(quickModel).queryByRole("option", { name: "openai/stale-model" }),
|
||||
).toBeNull();
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-1",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-2",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clears catalog refresh error after a successful retry", async () => {
|
||||
it("invalidates selections and refreshes catalog for an edited workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
let catalogCalls = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
if (catalogCalls === 1)
|
||||
return Promise.reject(new Error("catalog offline"));
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "openai", id: "recovered-model" }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
payload?: { runtimeId?: string; worktreePath?: string },
|
||||
) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
const id = payload?.runtimeId === "probe-1" ? "old-model" : "new-model";
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · old-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||
target: { value: "/other" },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: /old-model/i })).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("create_quick_runtime", {
|
||||
worktreePath: "/other",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reconcile current settings from an out-of-order old-workspace response", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const oldSnapshot = deferred<{
|
||||
runtime: Record<string, never>;
|
||||
extensions: never[];
|
||||
models: { data: { models: Model[] } };
|
||||
}>();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "probe-1") return oldSnapshot.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "new-provider", id: "new-model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||
target: { value: "/other" },
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model new-provider · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await act(async () =>
|
||||
oldSnapshot.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "old-provider", id: "stale-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||
expect(screen.getByLabelText("Workspace")).toHaveValue("/other");
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "probe-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a retained probe close before creating the next Settings probe", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let runtimeCount = 0;
|
||||
let firstProbeCloseCount = 0;
|
||||
const lifecycle: string[] = [];
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
lifecycle.push(`create:probe-${runtimeCount}`);
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: { models: [{ provider: "openai", id: "model" }] },
|
||||
},
|
||||
});
|
||||
if (command === "close_quick_runtime") {
|
||||
if (payload?.runtimeId === "probe-1") {
|
||||
firstProbeCloseCount += 1;
|
||||
lifecycle.push(`close:probe-1:${firstProbeCloseCount}`);
|
||||
if (firstProbeCloseCount === 1)
|
||||
return Promise.reject(new Error("transient close failure"));
|
||||
} else {
|
||||
lifecycle.push(`close:${payload?.runtimeId}`);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not load model catalog: Error: catalog offline",
|
||||
"Could not load model catalog: Error: transient close failure",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(runtimeCount).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
await waitFor(() => expect(runtimeCount).toBe(2));
|
||||
await waitFor(() => expect(lifecycle).toContain("close:probe-2"));
|
||||
expect(lifecycle.indexOf("close:probe-1:2")).toBeLessThan(
|
||||
lifecycle.indexOf("create:probe-2"),
|
||||
);
|
||||
expect(firstProbeCloseCount).toBe(2);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("blocks new probes and save while retained probe cleanup keeps failing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let createCount = 0;
|
||||
let closeCount = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
createCount += 1;
|
||||
return Promise.resolve({ runtime: { runtimeId: "probe" } });
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: { data: { models: [{ provider: "openai", id: "model" }] } },
|
||||
});
|
||||
if (command === "close_quick_runtime") {
|
||||
closeCount += 1;
|
||||
return Promise.reject(new Error("close failed"));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByText("Could not load model catalog: Error: close failed"),
|
||||
).toBeInTheDocument();
|
||||
expect(createCount).toBe(1);
|
||||
expect(closeCount).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(closeCount).toBe(2));
|
||||
expect(createCount).toBe(1);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves only when ready catalog workspace exactly matches settings workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const nextSnapshot = deferred<unknown>();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "probe-2") return nextSnapshot.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Could not load model catalog: Error: catalog offline",
|
||||
),
|
||||
).toBeNull(),
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||
target: { value: "/other" },
|
||||
});
|
||||
const save = screen.getByRole("button", { name: "Save Quick Pane settings" });
|
||||
expect(save).toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("combobox", { name: /Quick model/ })).getByRole(
|
||||
"option",
|
||||
{ name: "openai/recovered-model" },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||
@@ -347,8 +884,14 @@ describe("App command progress and errors", () => {
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "restart") return restart.promise;
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({ models: [] });
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({ runtime: { runtimeId: "settings-runtime" } });
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: { data: { models: [] } },
|
||||
});
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||
@@ -363,12 +906,8 @@ describe("App command progress and errors", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
||||
await user.click(restarting);
|
||||
expect(mocks.invoke).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "get_model_catalog");
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(3, "restart", {
|
||||
agentId: "agent-a",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("restart", { agentId: "agent-a" });
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||
|
||||
await act(async () => restart.resolve(undefined));
|
||||
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
|
||||
|
||||
Reference in New Issue
Block a user