feat(quick-pane): add follow-up chat renewal
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
Message,
|
||||
Model,
|
||||
QuickPaneSettings,
|
||||
RuntimeSnapshot,
|
||||
@@ -39,7 +40,6 @@ const settings: QuickPaneSettings = {
|
||||
followUpProvider: "provider",
|
||||
followUpModel: "full/model",
|
||||
instructions: "Stay read-only.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
};
|
||||
const uiState: UiState = {
|
||||
@@ -83,6 +83,16 @@ function runtimeSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotWithTranscript(
|
||||
messages: Message[],
|
||||
isStreaming = false,
|
||||
runtime: RuntimeSnapshot["runtime"] = runtimeSnapshot(defaultModels).runtime,
|
||||
) {
|
||||
const snapshot = runtimeSnapshot(defaultModels, "", isStreaming, runtime);
|
||||
snapshot.transcript = { data: { messages } };
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -101,8 +111,12 @@ function bridgeMock(
|
||||
) {
|
||||
let runtimeCount = 0;
|
||||
const submitted = new Set<string>();
|
||||
const submittedPrompts = new Map<string, string>();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string; agentId?: string }) => {
|
||||
(
|
||||
command: string,
|
||||
payload?: { runtimeId?: string; agentId?: string; message?: string },
|
||||
) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
@@ -114,14 +128,27 @@ function bridgeMock(
|
||||
});
|
||||
}
|
||||
if (command === "submit_prompt") {
|
||||
if (payload?.agentId) submitted.add(payload.agentId);
|
||||
if (payload?.agentId) {
|
||||
submitted.add(payload.agentId);
|
||||
if (payload.message)
|
||||
submittedPrompts.set(payload.agentId, payload.message);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
const segments = payload?.runtimeId?.split("-") ?? [];
|
||||
const index = Number(segments[segments.length - 1] ?? 1) - 1;
|
||||
if (submitted.has(`agent-${index + 1}`) && activeSnapshot)
|
||||
return Promise.resolve(activeSnapshot(index));
|
||||
return Promise.resolve(activeSnapshot(index)).then((snapshot) => {
|
||||
const messages = snapshot.transcript?.data?.messages;
|
||||
const prompt = submittedPrompts.get(`agent-${index + 1}`);
|
||||
if (
|
||||
prompt &&
|
||||
messages?.every((message) => message.role === "assistant")
|
||||
)
|
||||
messages.unshift({ role: "user", content: prompt });
|
||||
return snapshot;
|
||||
});
|
||||
return Promise.resolve(
|
||||
runtimeSnapshot(
|
||||
catalogs[index] ?? catalogs[catalogs.length - 1] ?? [],
|
||||
@@ -182,6 +209,7 @@ async function ask(text = "Inspect") {
|
||||
|
||||
describe("QuickPane", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mocks.focusHandler = undefined;
|
||||
mocks.listen.mockResolvedValue(vi.fn());
|
||||
@@ -205,24 +233,23 @@ describe("QuickPane", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps downward model menu unclipped while trigger text stays truncatable", async () => {
|
||||
it("keeps downward model menu inside the scroll-contained pane", async () => {
|
||||
const user = userEvent.setup();
|
||||
bridgeMock([defaultModels]);
|
||||
const { container } = render(<QuickPane />);
|
||||
const trigger = await preparedTrigger();
|
||||
await user.click(trigger);
|
||||
const menu = screen.getByRole("dialog", { name: "Choose model" });
|
||||
let ancestor = menu.parentElement;
|
||||
while (ancestor && ancestor !== document.body) {
|
||||
expect(getComputedStyle(ancestor).overflow).not.toMatch(/hidden|clip/);
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
const value = trigger.querySelector("span");
|
||||
expect(value).toHaveTextContent("provider · Quick Model");
|
||||
expect(trigger).toHaveClass("model-trigger");
|
||||
expect(container.querySelector(".model-control-down .model-menu")).toBe(
|
||||
menu,
|
||||
);
|
||||
expect(menu).toHaveClass("model-menu");
|
||||
expect(
|
||||
screen.getByRole("listbox", { name: "Available models" }),
|
||||
).toHaveClass("model-results");
|
||||
});
|
||||
|
||||
it("closes an open selector on first Escape and dismisses pane on second", async () => {
|
||||
@@ -320,48 +347,239 @@ describe("QuickPane", () => {
|
||||
expect(input).toHaveValue("Next draft");
|
||||
});
|
||||
|
||||
it("keeps five answers and previews history without changing composer draft", async () => {
|
||||
const tracker = bridgeMock(
|
||||
Array.from({ length: 6 }, () => defaultModels),
|
||||
(index) => runtimeSnapshot(defaultModels, `Answer ${index + 1}`, false),
|
||||
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
|
||||
bridgeMock([defaultModels], () =>
|
||||
snapshotWithTranscript([
|
||||
{ role: "user", content: "Inspect" },
|
||||
{ role: "assistant", content: "Completed answer" },
|
||||
]),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "create_session_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
|
||||
});
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Inspect");
|
||||
const continueButton = await screen.findByRole("button", {
|
||||
name: "Continue in full session",
|
||||
});
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"create_session_runtime",
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await userEvent.click(continueButton);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
||||
agentId: "agent-full",
|
||||
message:
|
||||
"Inspect\n\nQuick Pane handoff: Continue this chat in a full session.\n\nQuick Pane transcript:\nUser: Inspect\n\nAssistant: Completed answer",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unlimited follow-ups on one runtime and renders snapshot messages in order", async () => {
|
||||
let turn = 0;
|
||||
const tracker = bridgeMock([defaultModels], () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "**First request**" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "First answer" },
|
||||
{ type: "toolCall", name: "read" },
|
||||
],
|
||||
},
|
||||
{ role: "toolResult", content: "private tool output" },
|
||||
];
|
||||
if (turn > 1)
|
||||
messages.push(
|
||||
{ role: "user", content: "Second request" },
|
||||
{ role: "assistant", content: "## Second answer" },
|
||||
);
|
||||
return snapshotWithTranscript(messages);
|
||||
});
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "submit_prompt") turn += 1;
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", { name: "Request" });
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
await ask(`Request ${index}`);
|
||||
expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
}
|
||||
expect(tracker.runtimeCount()).toBe(6);
|
||||
const history = screen.getByRole("combobox", { name: "Answer history" });
|
||||
expect(within(history).getAllByRole("option")).toHaveLength(6);
|
||||
await ask("First");
|
||||
expect(await screen.findByText("First answer")).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
await ask("Second");
|
||||
expect(
|
||||
within(history).queryByRole("option", { name: "Request 1" }),
|
||||
).toBeNull();
|
||||
await screen.findByRole("heading", { name: "Second answer" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "Composer draft");
|
||||
await userEvent.selectOptions(history, "runtime-2");
|
||||
const exchange = screen.getByRole("region", { name: "Quick exchange" });
|
||||
expect(within(exchange).getByText("Request 2")).toBeInTheDocument();
|
||||
expect(within(exchange).getByText("Answer 2")).toBeInTheDocument();
|
||||
expect(input).toHaveValue("Composer draft");
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
expect(
|
||||
within(exchange).getByRole("region", { name: "Assistant response" }),
|
||||
).toHaveAttribute("aria-live", "off");
|
||||
mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "submit_prompt",
|
||||
),
|
||||
).toEqual([
|
||||
["submit_prompt", expect.objectContaining({ agentId: "agent-1" })],
|
||||
["submit_prompt", expect.objectContaining({ agentId: "agent-1" })],
|
||||
]);
|
||||
expect(screen.queryByText("private reasoning")).toBeNull();
|
||||
expect(screen.queryByText("private tool output")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: "Answer history" }),
|
||||
).toBeNull();
|
||||
const cards = screen
|
||||
.getByRole("region", { name: "Assistant response" })
|
||||
.querySelectorAll("article");
|
||||
expect(Array.from(cards).map((card) => card.textContent)).toEqual([
|
||||
"YouFirst request",
|
||||
"AssistantFirst answer",
|
||||
"YouSecond request",
|
||||
"AssistantSecond answer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps runtime error and extension request visible in history preview", async () => {
|
||||
it("does not use prior-turn answer after follow-up runtime failure", async () => {
|
||||
const submittedPrompts: string[] = [];
|
||||
bridgeMock([defaultModels], () => {
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: submittedPrompts[0] ?? "" },
|
||||
{ role: "assistant", content: "First answer" },
|
||||
];
|
||||
if (submittedPrompts[1])
|
||||
messages.push({ role: "user", content: submittedPrompts[1] });
|
||||
return snapshotWithTranscript(
|
||||
messages,
|
||||
false,
|
||||
submittedPrompts[1]
|
||||
? {
|
||||
...runtimeSnapshot(defaultModels).runtime,
|
||||
error: { message: "Second runtime failed." },
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
});
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "create_session_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
|
||||
});
|
||||
if (command === "submit_prompt" && payload?.agentId === "agent-1")
|
||||
submittedPrompts.push(String(payload.message));
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("First");
|
||||
expect(await screen.findByText("First answer")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("textbox", { name: "Request" })).toHaveValue(""),
|
||||
);
|
||||
|
||||
await ask("Second");
|
||||
expect(
|
||||
await screen.findByText("Second runtime failed."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("First answer")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
|
||||
worktreePath: "/repo",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
||||
agentId: "agent-full",
|
||||
message:
|
||||
"Second\n\nQuick Pane handoff: Quick runtime failed. Continue in a full session if needed.\n\nQuick Pane transcript:\nUser: First\n\nAssistant: First answer\n\nUser: Second",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the exact submitted payload to the entered multiline request despite delimiter collisions", async () => {
|
||||
const supplementalInstructions =
|
||||
"Stay read-only.\n\nUser request: supplemental collision.";
|
||||
const enteredRequest =
|
||||
"First line\n\nUser request: request collision.\nThird **bold** line";
|
||||
const collisionState: UiState = {
|
||||
...uiState,
|
||||
quickPane: { ...settings, instructions: supplementalInstructions },
|
||||
};
|
||||
let reflectedPrompt = "";
|
||||
bridgeMock([defaultModels], () =>
|
||||
snapshotWithTranscript([
|
||||
{ role: "user", content: "Normal earlier user text" },
|
||||
{
|
||||
role: "user",
|
||||
content: "[Quick Pane advisory mode: unknown reflected prompt]",
|
||||
},
|
||||
{ role: "user", content: reflectedPrompt },
|
||||
{ role: "assistant", content: "Done" },
|
||||
]),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(collisionState);
|
||||
if (command === "submit_prompt")
|
||||
reflectedPrompt = String(payload?.message ?? "");
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Request" }), {
|
||||
target: { value: enteredRequest },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send request" }));
|
||||
|
||||
const expectedPayload = `[Quick Pane advisory mode: use at most 6 read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more tool calls, start final response with [ESCALATE] and explain why.]\n${supplementalInstructions}\n\nUser request: ${enteredRequest}`;
|
||||
await waitFor(() => expect(reflectedPrompt).toBe(expectedPayload));
|
||||
await screen.findByText("Done");
|
||||
const userArticles = document.querySelectorAll(".quick-pane-user-message");
|
||||
expect(userArticles).toHaveLength(2);
|
||||
expect(userArticles[0]).toHaveTextContent("YouNormal earlier user text");
|
||||
expect(userArticles[1]).toHaveTextContent(
|
||||
/YouFirst line\s+User request: request collision\.\s+Third bold line/,
|
||||
);
|
||||
expect(screen.queryByText(/unknown reflected prompt/)).toBeNull();
|
||||
expect(userArticles[1]).not.toHaveTextContent("supplemental collision");
|
||||
expect(userArticles[1]).not.toHaveTextContent("Quick Pane advisory mode");
|
||||
});
|
||||
|
||||
it("keeps runtime errors and extension requests visible", async () => {
|
||||
let failed = false;
|
||||
bridgeMock([defaultModels, defaultModels], (index) => {
|
||||
if (index === 0)
|
||||
return runtimeSnapshot(defaultModels, "First answer", false);
|
||||
const result = runtimeSnapshot(defaultModels, "", true, {
|
||||
...runtimeSnapshot(defaultModels).runtime,
|
||||
activeTool: "functions.read",
|
||||
error: failed ? { message: "Runtime disconnected." } : undefined,
|
||||
});
|
||||
result.extensions = [
|
||||
bridgeMock([defaultModels], () => {
|
||||
const snapshot = snapshotWithTranscript(
|
||||
[
|
||||
{ role: "user", content: "Inspect" },
|
||||
{ role: "assistant", content: "Partial answer" },
|
||||
],
|
||||
true,
|
||||
{
|
||||
...runtimeSnapshot(defaultModels).runtime,
|
||||
activeTool: "functions.read",
|
||||
error: failed ? { message: "Runtime disconnected." } : undefined,
|
||||
},
|
||||
);
|
||||
snapshot.extensions = [
|
||||
{
|
||||
id: "permission-1",
|
||||
method: "confirm",
|
||||
@@ -369,34 +587,25 @@ describe("QuickPane", () => {
|
||||
message: "Approval needed",
|
||||
},
|
||||
];
|
||||
return result;
|
||||
return snapshot;
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", { name: "Request" });
|
||||
await ask("First request");
|
||||
expect(await screen.findByText("First answer")).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
await ask("Active request");
|
||||
await ask("Inspect");
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Review request" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Partial answer")).toBeInTheDocument();
|
||||
|
||||
failed = true;
|
||||
expect(
|
||||
await screen.findByText("Runtime disconnected.", {}, { timeout: 2_000 }),
|
||||
).toBeInTheDocument();
|
||||
const history = screen.getByRole("combobox", { name: "Answer history" });
|
||||
await waitFor(() => expect(history).toBeEnabled());
|
||||
await userEvent.selectOptions(history, "runtime-1");
|
||||
const exchange = screen.getByRole("region", { name: "Quick exchange" });
|
||||
expect(within(exchange).getByText("First answer")).toBeInTheDocument();
|
||||
expect(within(exchange).getByRole("alert")).toHaveTextContent(
|
||||
"Runtime disconnected.",
|
||||
);
|
||||
expect(
|
||||
within(exchange).getByRole("dialog", { name: "Review request" }),
|
||||
screen.getByRole("dialog", { name: "Review request" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Partial answer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders sanitized GFM Markdown and reports active tools", async () => {
|
||||
@@ -482,20 +691,17 @@ describe("QuickPane", () => {
|
||||
await ask("Change files");
|
||||
|
||||
current = runtimeSnapshot(defaultModels, "[ESCAL", false);
|
||||
const history = await screen.findByRole(
|
||||
"combobox",
|
||||
{ name: "Answer history" },
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Request" }),
|
||||
).not.toHaveAttribute("readonly"),
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
const response = screen.getByRole("region", { name: "Assistant response" });
|
||||
expect(response).not.toHaveTextContent("[ESCAL");
|
||||
|
||||
await userEvent.selectOptions(history, "runtime-1");
|
||||
expect(response).not.toHaveTextContent("[ESCAL");
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Quick exchange" }),
|
||||
).queryByText("[ESCAL"),
|
||||
screen.queryByRole("combobox", { name: "Answer history" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -522,7 +728,7 @@ describe("QuickPane", () => {
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("routes Close and pane-level Escape through native dismissal", async () => {
|
||||
it("routes Close and pane-level Escape only through native dismissal", async () => {
|
||||
bridgeMock([[]]);
|
||||
render(<QuickPane />);
|
||||
await screen.findByText("No models are currently available.");
|
||||
@@ -537,39 +743,94 @@ describe("QuickPane", () => {
|
||||
),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries catalog preparation after an error", async () => {
|
||||
let attempts = 0;
|
||||
it("retries startup settings loading from the visible error action", async () => {
|
||||
let loadAttempts = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "create_quick_runtime") {
|
||||
attempts += 1;
|
||||
if (command === "load_ui_state") {
|
||||
loadAttempts += 1;
|
||||
return loadAttempts === 1
|
||||
? Promise.reject(new Error("state unavailable"))
|
||||
: Promise.resolve(uiState);
|
||||
}
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: {
|
||||
agentId: `agent-${attempts}`,
|
||||
runtimeId: `runtime-${attempts}`,
|
||||
},
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (attempts === 1) return Promise.reject(new Error("offline"));
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve(runtimeSnapshot(defaultModels));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent(
|
||||
"Could not load settings: Error: state unavailable",
|
||||
);
|
||||
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await preparedTrigger()).toBeInTheDocument();
|
||||
expect(loadAttempts).toBe(2);
|
||||
expect(screen.queryByText(/Could not load settings/)).toBeNull();
|
||||
});
|
||||
|
||||
it("retries catalog preparation visibly after failed-runtime cleanup", async () => {
|
||||
let attempts = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "create_quick_runtime") {
|
||||
attempts += 1;
|
||||
return Promise.resolve({
|
||||
runtime: {
|
||||
agentId: `agent-${attempts}`,
|
||||
runtimeId: `runtime-${attempts}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "runtime-1")
|
||||
return Promise.reject(new Error("offline"));
|
||||
return Promise.resolve(runtimeSnapshot(defaultModels));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<QuickPane />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent(
|
||||
"Could not prepare model catalog: Error: offline",
|
||||
);
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(within(alert).getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /model/i })).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Request" }), {
|
||||
target: { value: "Blocked until ready" },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled();
|
||||
|
||||
await userEvent.click(within(alert).getByRole("button", { name: "Retry" }));
|
||||
expect(await preparedTrigger()).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Could not prepare model catalog/)).toBeNull();
|
||||
const closeIndex = mocks.invoke.mock.calls.findIndex(
|
||||
([command, payload]) =>
|
||||
command === "close_quick_runtime" && payload?.runtimeId === "runtime-1",
|
||||
);
|
||||
const retryCreateIndex = mocks.invoke.mock.calls.findIndex(
|
||||
([command], index) =>
|
||||
command === "create_quick_runtime" && index > closeIndex,
|
||||
);
|
||||
expect(closeIndex).toBeGreaterThan(-1);
|
||||
expect(retryCreateIndex).toBeGreaterThan(closeIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuickPane fresh runtime catalog", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mocks.listen.mockResolvedValue(vi.fn());
|
||||
mocks.onFocusChanged.mockImplementation((handler) => {
|
||||
@@ -613,6 +874,40 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves idle runtime, transcript, and catalog when hidden and reopened within three minutes", async () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const tracker = bridgeMock([defaultModels], () =>
|
||||
snapshotWithTranscript([
|
||||
{ role: "user", content: "Question" },
|
||||
{ role: "assistant", content: "Remembered answer" },
|
||||
]),
|
||||
);
|
||||
render(<QuickPane />);
|
||||
const trigger = await preparedTrigger();
|
||||
await ask("Question");
|
||||
expect(await screen.findByText("Remembered answer")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Request" }),
|
||||
).not.toHaveAttribute("readonly"),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||
);
|
||||
await act(async () => mocks.focusHandler?.({ payload: false }));
|
||||
now += 180_000;
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
expect(trigger).toBeInTheDocument();
|
||||
expect(screen.getByText("Remembered answer")).toBeInTheDocument();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
action: "Close",
|
||||
@@ -629,7 +924,7 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
},
|
||||
},
|
||||
])(
|
||||
"discards deferred catalog preparation after $action dismissal",
|
||||
"retains deferred catalog preparation across $action dismissal",
|
||||
async ({ dismiss }) => {
|
||||
const tracker = deferredCatalogBridge();
|
||||
render(<QuickPane />);
|
||||
@@ -644,39 +939,23 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"),
|
||||
);
|
||||
await act(async () => mocks.focusHandler?.({ payload: false }));
|
||||
await act(async () =>
|
||||
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /provider · Stale/i }),
|
||||
).toBeNull();
|
||||
await screen.findByRole("button", { name: /provider · Stale/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /provider · Fresh/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(tracker.runtimeCount()).toBe(2);
|
||||
await ask();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
|
||||
agentId: "agent-2",
|
||||
provider: "provider",
|
||||
modelId: "fresh/model",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"set_model",
|
||||
expect.objectContaining({ agentId: "agent-1" }),
|
||||
);
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("discards deferred catalog preparation after idle focus loss", async () => {
|
||||
it("retains deferred catalog preparation across idle focus loss", async () => {
|
||||
const tracker = deferredCatalogBridge();
|
||||
render(<QuickPane />);
|
||||
await waitFor(() =>
|
||||
@@ -687,86 +966,22 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
);
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: false }));
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
|
||||
await act(async () =>
|
||||
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /provider · Stale/i }),
|
||||
).toBeNull();
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /provider · Fresh/i }),
|
||||
await screen.findByRole("button", { name: /provider · Stale/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(tracker.runtimeCount()).toBe(2);
|
||||
await ask();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
|
||||
agentId: "agent-2",
|
||||
provider: "provider",
|
||||
modelId: "fresh/model",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"set_model",
|
||||
expect.objectContaining({ agentId: "agent-1" }),
|
||||
);
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a prior runtime when close fails and retries before replacement", async () => {
|
||||
let runtimeCount = 0;
|
||||
let closeAttempts = 0;
|
||||
const activeRuntimeIds = new Set<string>();
|
||||
let maximumActive = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
const runtimeId = `runtime-${runtimeCount}`;
|
||||
activeRuntimeIds.add(runtimeId);
|
||||
maximumActive = Math.max(maximumActive, activeRuntimeIds.size);
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: `agent-${runtimeCount}`, runtimeId },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve(runtimeSnapshot(defaultModels));
|
||||
if (command === "close_quick_runtime") {
|
||||
closeAttempts += 1;
|
||||
if (closeAttempts === 1)
|
||||
return Promise.reject(new Error("close unavailable"));
|
||||
activeRuntimeIds.delete(payload?.runtimeId ?? "");
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not prepare model catalog: Error: close unavailable",
|
||||
);
|
||||
expect(runtimeCount).toBe(1);
|
||||
expect(activeRuntimeIds).toEqual(new Set(["runtime-1"]));
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(2));
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(activeRuntimeIds).toEqual(new Set(["runtime-2"]));
|
||||
expect(maximumActive).toBe(1);
|
||||
expect(screen.queryByText(/close unavailable/)).toBeNull();
|
||||
});
|
||||
|
||||
it("queues a created stale runtime after close failure and drains it before preparing again", async () => {
|
||||
it("retries stale-created-runtime cleanup before stale chat renewal", async () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const firstSnapshot = deferred<RuntimeSnapshot>();
|
||||
let runtimeCount = 0;
|
||||
let firstCloseAttempts = 0;
|
||||
@@ -816,6 +1031,7 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
);
|
||||
expect(activeRuntimeIds).toEqual(new Set(["runtime-1", "runtime-2"]));
|
||||
|
||||
now += 180_001;
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(3));
|
||||
expect(firstCloseAttempts).toBe(2);
|
||||
@@ -830,46 +1046,132 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recycles idle prepared runtime on focus and exposes new catalog", async () => {
|
||||
const tracker = bridgeMock([
|
||||
defaultModels,
|
||||
[{ provider: "provider", id: "new/model", name: "Fresh" }],
|
||||
]);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /provider · Fresh/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prepares a fresh runtime before a repeated request without reopen", async () => {
|
||||
const tracker = bridgeMock(
|
||||
[defaultModels, [{ provider: "provider", id: "second/model" }]],
|
||||
(index) => runtimeSnapshot(defaultModels, `Done ${index + 1}`, false),
|
||||
it("Ctrl+N resets an idle chat and retries cleanup before replacement", async () => {
|
||||
const tracker = bridgeMock([defaultModels, defaultModels], () =>
|
||||
runtimeSnapshot(defaultModels, "Old answer", false),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
let closeAttempts = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "close_quick_runtime") {
|
||||
closeAttempts += 1;
|
||||
if (closeAttempts === 1)
|
||||
return Promise.reject(new Error("close unavailable"));
|
||||
}
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("First");
|
||||
expect(await screen.findByText("Done 1")).toBeInTheDocument();
|
||||
await ask("Old question");
|
||||
expect(await screen.findByText("Old answer")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Request" }),
|
||||
).not.toHaveAttribute("readonly"),
|
||||
);
|
||||
await ask("Second");
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Request" }), {
|
||||
target: { value: "draft" },
|
||||
});
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "n",
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(screen.queryByText("Old answer")).toBeNull();
|
||||
expect(screen.getByRole("textbox", { name: "Request" })).toHaveValue("");
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("abort", expect.anything());
|
||||
});
|
||||
|
||||
it("Ctrl+N aborts active work before closing and preparing a fresh runtime", async () => {
|
||||
const tracker = bridgeMock([defaultModels, defaultModels], () =>
|
||||
runtimeSnapshot(defaultModels, "Partial", true),
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Active question");
|
||||
expect(await screen.findByText("Partial")).toBeInTheDocument();
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "N",
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
||||
const abortIndex = mocks.invoke.mock.calls.findIndex(
|
||||
([command]) => command === "abort",
|
||||
);
|
||||
const closeIndex = mocks.invoke.mock.calls.findIndex(
|
||||
([command]) => command === "close_quick_runtime",
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" });
|
||||
expect(abortIndex).toBeLessThan(closeIndex);
|
||||
expect(screen.queryByText("Partial")).toBeNull();
|
||||
});
|
||||
|
||||
it("renews a stale idle chat when reopened after three minutes", async () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const freshModels: Model[] = [
|
||||
{ provider: "provider", id: "fresh/model", name: "Fresh" },
|
||||
];
|
||||
const tracker = bridgeMock([defaultModels, freshModels], () =>
|
||||
snapshotWithTranscript([
|
||||
{ role: "user", content: "Question" },
|
||||
{ role: "assistant", content: "Expired answer" },
|
||||
]),
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Question");
|
||||
expect(await screen.findByText("Expired answer")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Request" }),
|
||||
).not.toHaveAttribute("readonly"),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||
);
|
||||
await act(async () => mocks.focusHandler?.({ payload: false }));
|
||||
now += 180_001;
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
|
||||
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
|
||||
agentId: "agent-2",
|
||||
provider: "provider",
|
||||
modelId: "second/model",
|
||||
expect(screen.queryByText("Expired answer")).toBeNull();
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /provider · Fresh/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not renew or queue renewal while request is active", async () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const tracker = bridgeMock([defaultModels], () =>
|
||||
runtimeSnapshot(defaultModels, "Still working", true),
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Long request");
|
||||
expect(await screen.findByText("Still working")).toBeInTheDocument();
|
||||
|
||||
now += 180_001;
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user