1201 lines
36 KiB
TypeScript
1201 lines
36 KiB
TypeScript
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 {
|
|
Model,
|
|
RuntimeSummary,
|
|
UiState,
|
|
Workspace,
|
|
} from "./workspace/types";
|
|
import {
|
|
initialWorkspaceState,
|
|
reconcileWorkspace,
|
|
workspaceReducer,
|
|
} from "./workspace/reducer";
|
|
import { modelIdentityFromValue, modelIdentityValue } from "./model-catalog";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
invoke: vi.fn(),
|
|
loadSnapshot: vi.fn(),
|
|
refresh: vi.fn(),
|
|
dispatch: vi.fn(),
|
|
emitTo: vi.fn(),
|
|
useWorkspace: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
|
vi.mock("@tauri-apps/api/event", () => ({ emitTo: mocks.emitTo }));
|
|
vi.mock("@tauri-apps/api/window", () => ({
|
|
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
|
}));
|
|
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
|
|
vi.mock("./workspace/useWorkspace", () => ({
|
|
useWorkspace: mocks.useWorkspace,
|
|
}));
|
|
|
|
import App from "./App";
|
|
|
|
const summary: RuntimeSummary = {
|
|
runtimeId: "runtime-a",
|
|
agentId: "agent-a",
|
|
worktreePath: "/repo",
|
|
state: "idle",
|
|
label: "Session A",
|
|
attention: false,
|
|
queueCount: 0,
|
|
lastActivity: "2026-08-24T20:00:00.000Z",
|
|
openedAt: "2026-08-24T20:00:00.000Z",
|
|
};
|
|
const otherSummary: RuntimeSummary = {
|
|
...summary,
|
|
runtimeId: "runtime-b",
|
|
agentId: "agent-b",
|
|
label: "Session B",
|
|
};
|
|
const uiState: UiState = {
|
|
version: 1,
|
|
directoryOrder: [],
|
|
runtimeOrderByDirectory: {},
|
|
drafts: {},
|
|
scroll: {},
|
|
lastSeen: {},
|
|
};
|
|
|
|
function workspaceState() {
|
|
const workspace: Workspace = {
|
|
bridgeInstanceId: "epoch",
|
|
latestSeq: 0,
|
|
directories: [
|
|
{
|
|
worktreePath: "/repo",
|
|
isHome: true,
|
|
openCount: 2,
|
|
workingCount: 0,
|
|
attentionCount: 0,
|
|
recoveringCount: 0,
|
|
errorCount: 0,
|
|
runtimes: [summary, otherSummary],
|
|
},
|
|
],
|
|
};
|
|
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
|
state.runtimesById[summary.runtimeId].models = [
|
|
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
|
|
];
|
|
return state;
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe("model catalog identity", () => {
|
|
it("preserves model IDs containing slashes", () => {
|
|
const identity = {
|
|
provider: "openrouter",
|
|
modelId: "anthropic/claude/sonnet",
|
|
};
|
|
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(
|
|
identity,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("App command progress and errors", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.invoke.mockResolvedValue(uiState);
|
|
mocks.emitTo.mockResolvedValue(undefined);
|
|
mocks.refresh.mockResolvedValue(undefined);
|
|
mocks.loadSnapshot.mockResolvedValue(true);
|
|
mocks.useWorkspace.mockReturnValue({
|
|
state: workspaceState(),
|
|
dispatch: mocks.dispatch,
|
|
refresh: mocks.refresh,
|
|
loadSnapshot: mocks.loadSnapshot,
|
|
});
|
|
});
|
|
|
|
it("shows explicit Pi readiness in the compact session status", () => {
|
|
render(<App />);
|
|
const status = screen.getByRole("status", { name: "Session status" });
|
|
expect(status).toHaveTextContent("Pi ready");
|
|
expect(status).toHaveTextContent("Ready for your next prompt");
|
|
});
|
|
|
|
it("starts a session in selected directory with Ctrl+N", async () => {
|
|
mocks.invoke.mockImplementation((command: string) => {
|
|
if (command === "create_session_runtime")
|
|
return Promise.resolve({ runtime: { runtimeId: "runtime-new" } });
|
|
return Promise.resolve(uiState);
|
|
});
|
|
render(<App />);
|
|
const event = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
ctrlKey: true,
|
|
key: "n",
|
|
});
|
|
window.dispatchEvent(event);
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
await waitFor(() =>
|
|
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
|
|
worktreePath: "/repo",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("loads categorized snapshot selectors and derives providers from choices", async () => {
|
|
const user = userEvent.setup();
|
|
const loaded: UiState = {
|
|
...uiState,
|
|
quickPane: {
|
|
workspace: "/repo",
|
|
provider: "openai",
|
|
model: "gpt-5",
|
|
followUpProvider: "openai",
|
|
followUpModel: "gpt-5",
|
|
instructions: "Stay concise.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
mocks.invoke.mockImplementation((command: string) => {
|
|
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("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: "Default thinking Medium" }),
|
|
);
|
|
await user.click(screen.getByRole("option", { name: "High" }));
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(mocks.invoke).toHaveBeenCalledWith("save_ui_state", {
|
|
state: {
|
|
...loaded,
|
|
quickPane: {
|
|
...loaded.quickPane,
|
|
provider: "openrouter",
|
|
model: "anthropic/claude/sonnet",
|
|
defaultThinkingLevel: "high",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses and always closes a fresh temporary runtime whenever Settings opens", async () => {
|
|
const user = userEvent.setup();
|
|
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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
});
|
|
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",
|
|
provider: "openai",
|
|
model: "old-model",
|
|
followUpProvider: "openai",
|
|
followUpModel: "old-model",
|
|
instructions: "Stay concise.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
const snapshot = deferred<unknown>();
|
|
mocks.invoke.mockImplementation((command: string) => {
|
|
if (command === "load_ui_state") return Promise.resolve(loaded);
|
|
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);
|
|
});
|
|
const app = render(<App />);
|
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
|
await waitFor(() =>
|
|
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",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("closes temporary runtime and blocks save for snapshot failure or empty catalog", async () => {
|
|
const user = userEvent.setup();
|
|
const loaded = {
|
|
...uiState,
|
|
quickPane: {
|
|
workspace: "/repo",
|
|
provider: "openai",
|
|
model: "old-model",
|
|
followUpProvider: "openai",
|
|
followUpModel: "old-model",
|
|
instructions: "Stay concise.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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" }));
|
|
expect(
|
|
await screen.findByText("Could not load model catalog: Error: offline"),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
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("invalidates selections and refreshes catalog for an edited workspace", async () => {
|
|
const user = userEvent.setup();
|
|
const loaded = {
|
|
...uiState,
|
|
quickPane: {
|
|
workspace: "/repo",
|
|
provider: "openai",
|
|
model: "old-model",
|
|
followUpProvider: "openai",
|
|
followUpModel: "old-model",
|
|
instructions: "Stay concise.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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: 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(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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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.",
|
|
maximumToolCalls: 6,
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
};
|
|
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.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(),
|
|
);
|
|
});
|
|
|
|
it("shows restart progress until the replacement snapshot is restored", async () => {
|
|
const user = userEvent.setup();
|
|
const restart = deferred<unknown>();
|
|
const snapshot = deferred<boolean>();
|
|
mocks.invoke.mockImplementation((command: string) => {
|
|
if (command === "restart") return restart.promise;
|
|
if (command === "load_ui_state") return Promise.resolve(uiState);
|
|
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);
|
|
render(<App />);
|
|
await user.click(screen.getByRole("button", { name: "Controls" }));
|
|
await user.click(screen.getByRole("button", { name: "Restart Pi" }));
|
|
|
|
const restarting = screen.getByRole("button", { name: "Restarting Pi…" });
|
|
expect(restarting).toBeDisabled();
|
|
expect(
|
|
screen.getByText("Restoring this session and its extensions…"),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
|
await user.click(restarting);
|
|
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();
|
|
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
|
|
throwOnError: true,
|
|
});
|
|
|
|
await act(async () => snapshot.resolve(true));
|
|
await waitFor(() =>
|
|
expect(screen.getByRole("button", { name: "Restart Pi" })).toBeEnabled(),
|
|
);
|
|
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "false");
|
|
});
|
|
|
|
it("renews a background session from its tab without changing selection", async () => {
|
|
const user = userEvent.setup();
|
|
const renewal = deferred<unknown>();
|
|
const snapshot = deferred<boolean>();
|
|
mocks.invoke.mockImplementation((command: string) =>
|
|
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
|
|
);
|
|
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
|
render(<App />);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Renew Session B" }));
|
|
expect(mocks.invoke).toHaveBeenCalledWith("renew_session", {
|
|
agentId: "agent-b",
|
|
});
|
|
expect(
|
|
screen.getByRole("button", { name: "Renewing Session B" }),
|
|
).toBeDisabled();
|
|
expect(screen.getByRole("tab", { name: /Session A/ })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
|
|
await act(async () => renewal.resolve(undefined));
|
|
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-b", {
|
|
throwOnError: true,
|
|
});
|
|
await act(async () => snapshot.resolve(true));
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("button", { name: "Renew Session B" }),
|
|
).toBeEnabled(),
|
|
);
|
|
});
|
|
|
|
it("keeps parallel prompt submission state attached to its session", async () => {
|
|
const user = userEvent.setup();
|
|
const submissionA = deferred<{ delivery: "prompt" }>();
|
|
const submissionB = deferred<{ delivery: "prompt" }>();
|
|
let state = workspaceState();
|
|
state = workspaceReducer(state, {
|
|
type: "draftChanged",
|
|
runtimeId: "runtime-a",
|
|
draft: "Prompt A",
|
|
});
|
|
mocks.dispatch.mockImplementation((action) => {
|
|
state = workspaceReducer(state, action);
|
|
});
|
|
mocks.useWorkspace.mockImplementation(() => ({
|
|
state,
|
|
dispatch: mocks.dispatch,
|
|
refresh: mocks.refresh,
|
|
loadSnapshot: mocks.loadSnapshot,
|
|
}));
|
|
mocks.invoke.mockImplementation(
|
|
(command: string, args?: { agentId?: string }) => {
|
|
if (command !== "submit_prompt") return Promise.resolve(undefined);
|
|
return args?.agentId === "agent-a"
|
|
? submissionA.promise
|
|
: submissionB.promise;
|
|
},
|
|
);
|
|
const rendered = render(<App />);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
state = workspaceReducer(state, {
|
|
type: "selectRuntime",
|
|
runtimeId: "runtime-b",
|
|
});
|
|
state = workspaceReducer(state, {
|
|
type: "draftChanged",
|
|
runtimeId: "runtime-b",
|
|
draft: "Prompt B",
|
|
});
|
|
rendered.rerender(<App />);
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
state = workspaceReducer(state, {
|
|
type: "selectRuntime",
|
|
runtimeId: "runtime-a",
|
|
});
|
|
rendered.rerender(<App />);
|
|
|
|
expect(screen.getByLabelText("Prompt")).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
|
|
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
|
|
const sessionStatus = screen.getByRole("status", { name: "Session status" });
|
|
expect(sessionStatus).toHaveTextContent("Working");
|
|
expect(sessionStatus).toHaveTextContent("Sending prompt to Pi…");
|
|
expect(sessionStatus).toHaveTextContent("Bridge");
|
|
expect(sessionStatus).toHaveTextContent("ready");
|
|
expect(sessionStatus).toHaveClass("working");
|
|
expect(rendered.container.querySelector(".status-row")).toBeNull();
|
|
|
|
await act(async () => {
|
|
submissionA.resolve({ delivery: "prompt" });
|
|
submissionB.resolve({ delivery: "prompt" });
|
|
});
|
|
});
|
|
|
|
it("keeps prompt failures attached to the session that submitted them", async () => {
|
|
const user = userEvent.setup();
|
|
const submission = deferred<{ delivery: "prompt" }>();
|
|
let state = workspaceState();
|
|
state = workspaceReducer(state, {
|
|
type: "draftChanged",
|
|
runtimeId: "runtime-a",
|
|
draft: "Fail in A",
|
|
});
|
|
mocks.dispatch.mockImplementation((action) => {
|
|
state = workspaceReducer(state, action);
|
|
});
|
|
mocks.useWorkspace.mockImplementation(() => ({
|
|
state,
|
|
dispatch: mocks.dispatch,
|
|
refresh: mocks.refresh,
|
|
loadSnapshot: mocks.loadSnapshot,
|
|
}));
|
|
mocks.invoke.mockReturnValue(submission.promise);
|
|
const rendered = render(<App />);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
state = workspaceReducer(state, {
|
|
type: "selectRuntime",
|
|
runtimeId: "runtime-b",
|
|
});
|
|
rendered.rerender(<App />);
|
|
await act(async () => submission.reject(new Error("session A failed")));
|
|
|
|
expect(
|
|
screen.queryByText("Pi command failed: Error: session A failed"),
|
|
).toBeNull();
|
|
state = workspaceReducer(state, {
|
|
type: "selectRuntime",
|
|
runtimeId: "runtime-a",
|
|
});
|
|
rendered.rerender(<App />);
|
|
expect(
|
|
screen.getByText("Pi command failed: Error: session A failed"),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps command forms bound to the session that opened them", async () => {
|
|
const user = userEvent.setup();
|
|
let state = workspaceState();
|
|
state = workspaceReducer(state, {
|
|
type: "draftChanged",
|
|
runtimeId: "runtime-a",
|
|
draft: "/bash",
|
|
});
|
|
mocks.dispatch.mockImplementation((action) => {
|
|
state = workspaceReducer(state, action);
|
|
});
|
|
mocks.useWorkspace.mockImplementation(() => ({
|
|
state,
|
|
dispatch: mocks.dispatch,
|
|
refresh: mocks.refresh,
|
|
loadSnapshot: mocks.loadSnapshot,
|
|
}));
|
|
mocks.invoke.mockResolvedValue(undefined);
|
|
const rendered = render(<App />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /\/bash/ }));
|
|
expect(screen.getByRole("dialog", { name: "/bash" })).toBeInTheDocument();
|
|
state = workspaceReducer(state, {
|
|
type: "selectRuntime",
|
|
runtimeId: "runtime-b",
|
|
});
|
|
rendered.rerender(<App />);
|
|
expect(screen.queryByRole("dialog", { name: "/bash" })).toBeNull();
|
|
|
|
state = workspaceReducer(state, {
|
|
type: "selectRuntime",
|
|
runtimeId: "runtime-a",
|
|
});
|
|
rendered.rerender(<App />);
|
|
await user.type(screen.getByLabelText("command"), "echo session-a");
|
|
await user.click(screen.getByRole("button", { name: "Run command" }));
|
|
|
|
expect(mocks.invoke).toHaveBeenCalledWith("pi_rpc_command", {
|
|
agentId: "agent-a",
|
|
command: "bash",
|
|
input: { command: "echo session-a" },
|
|
});
|
|
});
|
|
|
|
it("locks selected-session input and output throughout renewal", async () => {
|
|
const user = userEvent.setup();
|
|
const renewal = deferred<unknown>();
|
|
const snapshot = deferred<boolean>();
|
|
mocks.invoke.mockImplementation((command: string) =>
|
|
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
|
|
);
|
|
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
|
render(<App />);
|
|
const prompt = screen.getByLabelText("Prompt");
|
|
expect(prompt).toBeEnabled();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Renew Session A" }));
|
|
expect(prompt).toBeDisabled();
|
|
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-busy", "true");
|
|
expect(
|
|
screen.getByText("Input and output are temporarily locked."),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen
|
|
.getByRole("region", { name: "Conversation" })
|
|
.closest(".workflow-main"),
|
|
).toHaveAttribute("inert");
|
|
expect(
|
|
screen.getByRole("button", { name: /Model Current model/i }),
|
|
).toBeDisabled();
|
|
|
|
await act(async () => renewal.resolve(undefined));
|
|
expect(prompt).toBeDisabled();
|
|
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
|
|
throwOnError: true,
|
|
});
|
|
await act(async () => snapshot.resolve(true));
|
|
await waitFor(() => expect(prompt).toBeEnabled());
|
|
expect(
|
|
screen.queryByText("Input and output are temporarily locked."),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("keeps the model picker open and shows a persistent alert on RPC failure", async () => {
|
|
const user = userEvent.setup();
|
|
mocks.invoke.mockRejectedValue(new Error("provider rejected the model"));
|
|
render(<App />);
|
|
await user.click(
|
|
screen.getByRole("button", { name: /Model Current model/i }),
|
|
);
|
|
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
|
|
|
|
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
|
|
agentId: "agent-a",
|
|
provider: "openai",
|
|
modelId: "gpt-5",
|
|
});
|
|
const modelError = await screen.findByText(
|
|
"Pi model command failed: Error: provider rejected the model",
|
|
);
|
|
expect(modelError.closest('[role="alert"]')).not.toBeNull();
|
|
expect(
|
|
screen.getByRole("dialog", { name: "Choose model" }),
|
|
).toBeInTheDocument();
|
|
expect(mocks.loadSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
});
|