Files
multica/packages/core/chat/mutations.test.tsx
YYClaw 9eddcaff10 fix(chat): defer cancellation-time finalization until the task transcript is stable (#5246)
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus.

Closes #5219
2026-07-15 00:52:27 +08:00

194 lines
6.4 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { useConsumeChatDraftRestore, useSetChatSessionArchived } from "./mutations";
import { chatKeys } from "./queries";
import type { ChatSession } from "../types";
vi.mock("../hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
const WS_ID = "ws-1";
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
return {
id: "s1",
workspace_id: WS_ID,
agent_id: "agent-1",
creator_id: "user-1",
title: "Session 1",
status: "active",
has_unread: true,
unread_count: 2,
created_at: "2026-07-10T00:00:00Z",
updated_at: "2026-07-10T00:00:00Z",
...overrides,
};
}
function createWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useSetChatSessionArchived", () => {
let qc: QueryClient;
let setChatSessionArchived: ReturnType<
typeof vi.fn<(id: string, archived: boolean) => Promise<ChatSession>>
>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setChatSessionArchived = vi.fn();
setApiInstance({ setChatSessionArchived } as unknown as ApiClient);
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
// MUL-4360: archiving must zero the row's unread locally so no badge (FAB,
// sidebar Chat tab, chat-window header) keeps counting a just-archived
// session in the frame before the refetch lands. Mirrors the backend, which
// forces unread to 0 for archived rows in ListAllChatSessionsByCreator.
it("optimistically zeroes unread when archiving", async () => {
setChatSessionArchived.mockResolvedValue(
makeSession({ status: "archived" }),
);
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
const { result } = renderHook(() => useSetChatSessionArchived(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ sessionId: "s1", archived: true });
});
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
expect(row.status).toBe("archived");
expect(row.unread_count).toBe(0);
expect(row.has_unread).toBe(false);
});
// Unarchive must NOT fabricate an unread count — the true state comes back
// from the server refetch (last_read_at is untouched), so the optimistic
// patch leaves the row's unread fields as-is.
it("does not resurrect unread when unarchiving", async () => {
setChatSessionArchived.mockResolvedValue(makeSession({ status: "active" }));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [
makeSession({ status: "archived", has_unread: false, unread_count: 0 }),
]);
const { result } = renderHook(() => useSetChatSessionArchived(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ sessionId: "s1", archived: false });
});
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
expect(row.status).toBe("active");
expect(row.unread_count).toBe(0);
expect(row.has_unread).toBe(false);
});
// On failure the optimistic patch (status + zeroed unread) rolls back whole.
it("rolls back the unread patch when the request fails", async () => {
setChatSessionArchived.mockRejectedValue(new Error("boom"));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(WS_ID), [makeSession()]);
const { result } = renderHook(() => useSetChatSessionArchived(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await expect(
result.current.mutateAsync({ sessionId: "s1", archived: true }),
).rejects.toThrow("boom");
});
const row = qc.getQueryData<ChatSession[]>(chatKeys.sessions(WS_ID))![0]!;
expect(row.status).toBe("active");
expect(row.unread_count).toBe(2);
expect(row.has_unread).toBe(true);
});
});
// The consume DELETE is the last step of a durable draft restore (#5219), and
// mutations are `retry: false` app-wide — a single dropped request would leave
// the row behind, to be re-offered later. The endpoint is idempotent, so this
// one retries instead of giving up on the first failure.
describe("useConsumeChatDraftRestore", () => {
let qc: QueryClient;
beforeEach(() => {
vi.useFakeTimers();
qc = new QueryClient();
});
afterEach(() => {
vi.useRealTimers();
qc.clear();
vi.restoreAllMocks();
});
it("retries a lost consume until the server acknowledges it", async () => {
const consumeChatDraftRestore = vi
.fn<(sessionId: string, restoreId: string) => Promise<void>>()
.mockRejectedValueOnce(new Error("network"))
.mockResolvedValueOnce(undefined);
setApiInstance({ consumeChatDraftRestore } as unknown as ApiClient);
const onSuccess = vi.fn();
const { result } = renderHook(() => useConsumeChatDraftRestore(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ sessionId: "s1", restoreId: "r1" }, { onSuccess });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(consumeChatDraftRestore).toHaveBeenCalledTimes(2);
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it("drops the row from the cache immediately so it is not re-offered", async () => {
const consumeChatDraftRestore = vi.fn().mockResolvedValue(undefined);
setApiInstance({ consumeChatDraftRestore } as unknown as ApiClient);
qc.setQueryData(chatKeys.draftRestores("s1"), {
restores: [
{ id: "r1", chat_session_id: "s1", content: "keep me" },
{ id: "r2", chat_session_id: "s1", content: "other" },
],
});
const { result } = renderHook(() => useConsumeChatDraftRestore(), {
wrapper: createWrapper(qc),
});
act(() => {
result.current.mutate({ sessionId: "s1", restoreId: "r1" });
});
const cached = qc.getQueryData(chatKeys.draftRestores("s1")) as {
restores: { id: string }[];
};
expect(cached.restores.map((r) => r.id)).toEqual(["r2"]);
});
});