Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 8 additions & 64 deletions frontend/src/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest";
import type { SSEEvent } from "../../../contracts/cortex-api";
import { ApiError, CortexApi, describeApiError, isAbortedError, isDefinitiveRejection } from "./client";

describe("CortexApi", () => {
Expand Down Expand Up @@ -304,6 +305,12 @@ describe("CortexApi", () => {
expect(terminal).toMatchObject({ kind: "completed", status: "succeeded" });
});

it("types a job stream's result as a terminal event or null, never void", () => {
// A `void` in the union let a caller "use" a result that was never there.
// Compile-time only: tsc rejects this file if the return type drifts.
expectTypeOf<ReturnType<CortexApi["streamJob"]>>().toEqualTypeOf<Promise<SSEEvent | null>>();
});

it("returns no terminal event when a job stream closes while still active", async () => {
const sse = 'id: 1\ndata: {"id":1,"job_id":"job-1","kind":"progress","status":"running"}\n\n';
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(sse, { status: 200 }));
Expand Down Expand Up @@ -347,69 +354,6 @@ describe("CortexApi", () => {
const request = fetcher.mock.calls[0]?.[1] as RequestInit;
expect(new Headers(request.headers).get("Authorization")).toBe("Bearer session-1");
});

it("starts a typed recipe request on the recipe route", async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
job_id: "recipe-job",
request_id: "recipe-request",
profile: "recipe.image.v1",
status: "queued",
sequence: 1,
}), { status: 202, headers: { "Content-Type": "application/json" } }));
window.sessionStorage.setItem("cortex.session.token", "session-1");
const api = new CortexApi("/api/v1", fetcher);

await api.startRecipeImageTransform({
request_id: "recipe-request",
source_artifact_id: "artifact-1",
plan: {
schema_version: "artifact.transform.v1",
input_artifact_id: "artifact-1",
steps: [{ op: "grayscale" }],
output_format: "png",
},
});

expect(fetcher).toHaveBeenCalledWith(
"/api/v1/execution/recipe/image",
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"source_artifact_id":"artifact-1"'),
}),
);
const request = fetcher.mock.calls[0]?.[1] as RequestInit;
expect(new Headers(request.headers).get("Authorization")).toBe("Bearer session-1");
});

it("stages a bounded attachment through the attachment route", async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
job_id: "attachment-job",
request_id: "attachment-request",
profile: "attachment.stage.v1",
status: "succeeded",
sequence: 1,
artifact_id: "artifact-1",
mime_type: "image/png",
size: 4,
sha256: "a".repeat(64),
expires_at: "2026-07-20T00:00:00Z",
}), { status: 201, headers: { "Content-Type": "application/json" } }));
window.sessionStorage.setItem("cortex.session.token", "session-1");
const api = new CortexApi("/api/v1", fetcher);

await api.stageAttachment({
request_id: "attachment-request",
content_base64: "iVBORw==",
});

expect(fetcher).toHaveBeenCalledWith(
"/api/v1/execution/attachments",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ request_id: "attachment-request", content_base64: "iVBORw==" }),
}),
);
});
});

describe("session invalidation guards", () => {
Expand Down
63 changes: 1 addition & 62 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,21 @@
import type {
AddMemoryRequest,
AttachmentStageAccepted,
AttachmentStageRequest,
ChatAttachment,
ChatAttachmentStageRequest,
ChatGroup,
ChatResponse,
ChatSummary,
CodeExecutionAccepted,
CodeExecutionRequest,
CodeExecutionSourceResponse,
CreateChatGroupRequest,
CreateChatRequest,
DiagnosticsResponse,
MoveChatToGroupRequest,
UpdateChatGroupRequest,
ExecutionSSEEvent,
ExecutionApprovalDecisionRequest,
ExecutionStatusResponse,
ExecutionTaskListResponse,
RecipeImageTransformAccepted,
RecipeImageTransformRequest,
ScratchComputeAccepted,
ScratchComputeRequest,
ForkRequest,
GenerationEvent,
GenerationRequest,
ShutdownResponse,
HuggingFaceFileListResponse,
JobAccepted,
JobStatusResponse,
Expand Down Expand Up @@ -523,10 +512,6 @@ export class CortexApi {
return this.request<JobStatusResponse>(`/jobs/${encodeURIComponent(jobId)}`);
}

diagnostics(): Promise<DiagnosticsResponse> {
return this.request<DiagnosticsResponse>("/diagnostics");
}

checkModels(): Promise<JobAccepted> {
return this.request<JobAccepted>("/jobs/models", { method: "POST" });
}
Expand Down Expand Up @@ -569,46 +554,12 @@ export class CortexApi {
return this.request<ExecutionTaskListResponse>(`/execution/tasks${query ? `?${query}` : ""}`);
}

executionStatus(jobId: string): Promise<ExecutionStatusResponse> {
return this.request<ExecutionStatusResponse>(`/execution/${encodeURIComponent(jobId)}`);
}

startCodeExecution(payload: CodeExecutionRequest): Promise<CodeExecutionAccepted> {
return this.request<CodeExecutionAccepted>("/execution/code", {
method: "POST",
body: JSON.stringify(payload),
});
}

executionSource(jobId: string): Promise<CodeExecutionSourceResponse> {
return this.request<CodeExecutionSourceResponse>(
`/execution/${encodeURIComponent(jobId)}/source`,
);
}

startScratchCompute(payload: ScratchComputeRequest): Promise<ScratchComputeAccepted> {
return this.request<ScratchComputeAccepted>("/execution/scratch", {
method: "POST",
body: JSON.stringify(payload),
});
}

startRecipeImageTransform(
payload: RecipeImageTransformRequest,
): Promise<RecipeImageTransformAccepted> {
return this.request<RecipeImageTransformAccepted>("/execution/recipe/image", {
method: "POST",
body: JSON.stringify(payload),
});
}

stageAttachment(payload: AttachmentStageRequest): Promise<AttachmentStageAccepted> {
return this.request<AttachmentStageAccepted>("/execution/attachments", {
method: "POST",
body: JSON.stringify(payload),
});
}

stageChatAttachment(payload: ChatAttachmentStageRequest): Promise<ChatAttachment> {
return this.request<ChatAttachment>("/attachments", {
method: "POST",
Expand Down Expand Up @@ -645,19 +596,11 @@ export class CortexApi {
);
}

streamExecution(
jobId: string,
onEvent: (event: ExecutionSSEEvent) => void,
options: { signal?: AbortSignal; afterEventId?: number } = {},
): Promise<void> {
return this.streamEvents(`/execution/${encodeURIComponent(jobId)}/events`, onEvent, options).then(() => undefined);
}

streamJob(
jobId: string,
onEvent: (event: SSEEvent) => void,
options: { signal?: AbortSignal; afterEventId?: number } = {},
): Promise<SSEEvent | null | void> {
): Promise<SSEEvent | null> {
return this.streamEvents(`/jobs/${encodeURIComponent(jobId)}/events`, onEvent, options);
}

Expand Down Expand Up @@ -687,10 +630,6 @@ export class CortexApi {
});
}

shutdown(): Promise<ShutdownResponse> {
return this.request<ShutdownResponse>("/system/shutdown", { method: "POST" });
}

private async request<T>(
path: string,
options: RequestInit & { authenticated?: boolean } = {},
Expand Down
147 changes: 147 additions & 0 deletions frontend/src/app/App.paletteDelete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { App } from "./App";
import { CortexApi } from "../api/client";
import { useUiStore } from "../stores/useUiStore";
import { ToastProvider } from "./ToastProvider";

/**
* The command palette searches every chat, and a chat that was just deleted is
* hidden for the length of its Undo window without having been removed from the
* backend yet. The palette must agree with the sidebar about which chats exist.
*/

const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});

const CHATS = [
{ id: "chat-a", title: "Alpha plan", timestamp: "2026-01-02T00:00:00Z" },
{ id: "chat-b", title: "Beta notes", timestamp: "2026-01-01T00:00:00Z" },
];

/** The undo window in App.tsx. */
const UNDO_WINDOW_MS = 6000;

describe("command palette with a chat pending deletion", () => {
beforeAll(async () => {
await Promise.all([
import("../features/chat/ChatPage"),
import("../features/settings/SettingsPanel"),
]);
}, 120_000);

afterEach(() => {
vi.useRealTimers();
act(() => { useUiStore.setState({ toasts: [], commandPaletteOpen: false }); });
window.sessionStorage.clear();
window.history.replaceState({}, "", "/");
});

function workspace() {
const deletes: string[] = [];
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
const method = init?.method ?? "GET";
const chatMatch = /\/chats\/([^/?]+)$/.exec(url);
if (chatMatch && method === "DELETE") {
deletes.push(decodeURIComponent(chatMatch[1]));
return new Response(null, { status: 204 });
}
if (chatMatch && method === "GET") {
const chat = CHATS.find((candidate) => candidate.id === chatMatch[1]);
return chat ? json({ ...chat, revision: 1, messages: [] }) : json({ detail: "Not found." }, 404);
}
if (url.endsWith("/system")) return json({ status: "ok", preview: true, session_required: true, started_at: "2026-07-21T18:00:00Z" });
if (url.endsWith("/chat-groups")) return json([]);
if (url.endsWith("/chats")) return json(CHATS);
if (url.endsWith("/settings")) return json({ settings: { models: { chat: null, title: null }, appearance: { theme: "dark" } } });
if (url.endsWith("/models")) return json({ required_models: [], optional_models: [], installed_models: [], connection: { success: true, status: "connected", message: "Ready" } });
return json({ detail: "Unexpected test route." }, 404);
});
return { fetcher, deletes };
}

async function openWorkspace(fetcher: typeof fetch) {
window.sessionStorage.setItem("cortex.session.token", "local-session");
window.history.replaceState({}, "", "/chat/new");
render(<ToastProvider><App api={new CortexApi("/api/v1", fetcher)} /></ToastProvider>);
expect(await screen.findByRole("button", { name: "Delete Alpha plan" }, { timeout: 10_000 })).toBeInTheDocument();
}

/** Confirm the dialog, then switch to fake timers so the undo window can be stepped through. */
async function deleteAlphaAndControlTime() {
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Delete Alpha plan" }));
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete chat" }));
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
}

const passTime = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
const toastRegion = () => within(document.querySelector(".toast-region") as HTMLElement);

/** The palette's own rows: the sidebar lists the same titles outside the dialog. */
const palette = () => within(screen.getByRole("dialog", { name: "Command palette" }));

async function openPalette() {
act(() => useUiStore.getState().setCommandPaletteOpen(true));
await passTime(0);
return palette();
}

async function searchPalette(text: string) {
fireEvent.change(palette().getByPlaceholderText(/search chats/i), { target: { value: text } });
// Past the palette's typing pause, so the chat search has caught up.
await passTime(200);
}

const closePalette = () => act(() => useUiStore.getState().setCommandPaletteOpen(false));

it("leaves the hidden chat out of the recent chats and out of search, and offers it again after Undo", async () => {
const { fetcher, deletes } = workspace();
await openWorkspace(fetcher);

await deleteAlphaAndControlTime();
expect(toastRegion().getByRole("button", { name: "Undo" })).toBeInTheDocument();

const recent = await openPalette();
expect(recent.getByText("Beta notes")).toBeInTheDocument();
expect(recent.queryByText("Alpha plan")).not.toBeInTheDocument();

await searchPalette("Alpha");
expect(palette().queryByText("Alpha plan")).not.toBeInTheDocument();
expect(palette().getByText("No results.")).toBeInTheDocument();
closePalette();
await passTime(0);

fireEvent.click(toastRegion().getByRole("button", { name: "Undo" }));
await passTime(0);

const restored = await openPalette();
expect(restored.getByText("Alpha plan")).toBeInTheDocument();
expect(restored.getByText("Beta notes")).toBeInTheDocument();
await searchPalette("Alpha");
expect(palette().getByText("Alpha plan")).toBeInTheDocument();
expect(palette().queryByText("Beta notes")).not.toBeInTheDocument();
expect(deletes).toEqual([]);
});

it("still leaves the chat out once the Undo window has closed and the delete was sent", async () => {
const { fetcher, deletes } = workspace();
await openWorkspace(fetcher);

await deleteAlphaAndControlTime();
await passTime(UNDO_WINDOW_MS);
await passTime(50);
expect(deletes).toEqual(["chat-a"]);

const recent = await openPalette();
expect(recent.getByText("Beta notes")).toBeInTheDocument();
expect(recent.queryByText("Alpha plan")).not.toBeInTheDocument();
await searchPalette("Alpha");
expect(palette().queryByText("Alpha plan")).not.toBeInTheDocument();
});
});
Loading
Loading