15-point review of the persistent-history PR. Architecture decisions: crash recovery = recency threshold; tool-label duplication = leave as-is. Must-fix: 1. Boot-sweep bounded by recency. sweepStreaming now also requires `updatedAt < now() - SWEEP_STREAMING_STALE_MS` (10 min), so a fresh replica's startup sweep can't abort a turn another replica is actively streaming (multi-instance deploy). Int-spec: a FRESH 'streaming' row is NOT swept, a STALE one IS. 2. Restore export during the FIRST streaming turn of a new chat (#174). The server chatId is now adopted EARLY (in-place, on the start-chunk metadata) via a new `onServerChatId` callback wired through use-chat-session → chat-thread, so `activeChatId` is set at turn start and the Copy button is live mid-first- turn (canExport = !!activeChatId). Hook tests for early/in-place/no-op adopt. 3. Cover finalizeAssistant's fallback-insert branch: extracted pure `planFinalizeAssistant(assistantId)` (update when id present, insert when the upfront insert failed) + a dispatch harness test for both arms. Tests: onModuleInit lifecycle spec (sweep called; throw → resolves + warns); int-spec updatedAt assertion → toBeGreaterThan. Cleanups: cap findAllByChat at 5000 rows; upfront-insert-failure log carries chatId+workspaceId; removed the now-dead buildPartialAssistantRecord (only the spec consumed it; shapes still pinned by the flushAssistant suite); controller passes `lang: dto.lang` (normalizeLang handles undefined); dropped a no-op `?? undefined` in errorOf; documented the content-column semantics change (concatenated step text, UI renders from metadata.parts); CHANGELOG [Unreleased] entry (#183, #174); reworded the stale LABELS parity comment. Verified: server build + 323 ai-chat unit + 5 integration; client tsc + 160 ai-chat unit; prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook } from "@testing-library/react";
|
|
import { useChatSession } from "./use-chat-session";
|
|
import type { UseChatSessionOptions } from "./use-chat-session";
|
|
|
|
// The props the test drives: the parent-owned subset of UseChatSessionOptions
|
|
// (the spies are injected by setup, not per-render). messagesLoading is optional
|
|
// here (defaulted to false in setup) for terser test call sites.
|
|
type DriverProps = Pick<UseChatSessionOptions, "activeChatId" | "chats"> & {
|
|
messagesLoading?: boolean;
|
|
};
|
|
|
|
// Drive the hook the way the window does: the parent owns `activeChatId` and
|
|
// passes it back in. `setActiveChatId` is a spy so we can assert the EXACT id the
|
|
// hook adopts (the #137 regression: it must be the authoritative streamed id, not
|
|
// the newest chat in the list).
|
|
function setup(initial: DriverProps) {
|
|
const setActiveChatId = vi.fn();
|
|
const onInvalidateChatList = vi.fn();
|
|
const onInvalidateChatMessages = vi.fn();
|
|
const { result, rerender } = renderHook(
|
|
(props: DriverProps) =>
|
|
useChatSession({
|
|
activeChatId: props.activeChatId,
|
|
setActiveChatId,
|
|
chats: props.chats,
|
|
messagesLoading: props.messagesLoading ?? false,
|
|
onInvalidateChatList,
|
|
onInvalidateChatMessages,
|
|
}),
|
|
{ initialProps: initial },
|
|
);
|
|
return {
|
|
result,
|
|
rerender,
|
|
setActiveChatId,
|
|
onInvalidateChatList,
|
|
onInvalidateChatMessages,
|
|
};
|
|
}
|
|
|
|
describe("useChatSession", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("#137 REGRESSION LOCK: adopts the authoritative streamed id, NOT items[0]", () => {
|
|
// Brand-new chat, list already holds a SIBLING chat B as items[0] (a second
|
|
// tab just created it). The server streams the real id "A" for THIS chat.
|
|
const { result, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "B" }] },
|
|
});
|
|
result.current.onTurnFinished("A");
|
|
// Must adopt the authoritative id, not the newest-in-list guess.
|
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
|
expect(setActiveChatId).not.toHaveBeenCalledWith("B");
|
|
});
|
|
|
|
it("fallback adopt: arms on a server-id-less finish, adopts the single new id after refetch", () => {
|
|
const { result, rerender, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }] },
|
|
});
|
|
// No server id => arm the fallback (no adoption yet).
|
|
result.current.onTurnFinished(undefined);
|
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
|
// The refetch lands with the new row => adopt it.
|
|
rerender({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }, { id: "new" }] },
|
|
});
|
|
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
|
});
|
|
|
|
it("fallback ambiguous: two new ids appear => no adoption", () => {
|
|
const { result, rerender, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }] },
|
|
});
|
|
result.current.onTurnFinished(undefined);
|
|
rerender({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }, { id: "n1" }, { id: "n2" }] },
|
|
});
|
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fallback add+delete in one window: adopts the new id (membership compare)", () => {
|
|
const { result, rerender, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "a" }, { id: "b" }] },
|
|
});
|
|
result.current.onTurnFinished(undefined);
|
|
// a was deleted, new was added — same length, but membership changed.
|
|
rerender({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "b" }, { id: "new" }] },
|
|
});
|
|
expect(setActiveChatId).toHaveBeenCalledWith("new");
|
|
});
|
|
|
|
it("disarm on reconcile: a fallback armed then switched away is NOT adopted by a late refetch", () => {
|
|
// Arm the error-path fallback on a brand-new chat (snapshot before=["x"]).
|
|
const { result, rerender, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }] },
|
|
});
|
|
result.current.onTurnFinished(undefined);
|
|
// The user switches to an existing chat C BEFORE the refetch lands; the
|
|
// render-phase reconciler must DISARM the pending fallback.
|
|
rerender({ activeChatId: "C", chats: { items: [{ id: "x" }] } });
|
|
// ...then starts a fresh new chat again (back to null), without re-arming.
|
|
rerender({ activeChatId: null, chats: { items: [{ id: "x" }] } });
|
|
// A late refetch now brings a new row. Because the earlier fallback was
|
|
// disarmed on the switch (not left armed with the stale ["x"] snapshot), it
|
|
// must NOT be adopted. (Without the disarm this would wrongly adopt "new".)
|
|
rerender({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }, { id: "new" }] },
|
|
});
|
|
expect(setActiveChatId).not.toHaveBeenCalledWith("new");
|
|
});
|
|
|
|
it("startNewChat while already in a new chat: cancelPendingAdoption stops a late refetch adopting the failed chat", () => {
|
|
// The Warning path the render-phase reconciler can't catch: pressing "New
|
|
// chat" while already in a new chat keeps activeChatId === null (a no-op for
|
|
// the atom), so only the explicit cancelPendingAdoption() disarms.
|
|
const { result, rerender, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }] },
|
|
});
|
|
result.current.onTurnFinished(undefined); // first turn failed → arm (before=["x"])
|
|
result.current.cancelPendingAdoption(); // window calls this from startNewChat
|
|
// The just-failed row lands in a late refetch; it must NOT be adopted.
|
|
rerender({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }, { id: "failed" }] },
|
|
});
|
|
expect(setActiveChatId).not.toHaveBeenCalledWith("failed");
|
|
});
|
|
|
|
it("onTurnFinished for an existing chat: no adoption, invalidates that chat's messages", () => {
|
|
const {
|
|
result,
|
|
setActiveChatId,
|
|
onInvalidateChatList,
|
|
onInvalidateChatMessages,
|
|
} = setup({ activeChatId: "chat-1", chats: { items: [{ id: "chat-1" }] } });
|
|
result.current.onTurnFinished("chat-1");
|
|
expect(setActiveChatId).not.toHaveBeenCalled(); // existing chat is never re-adopted
|
|
expect(onInvalidateChatList).toHaveBeenCalled();
|
|
expect(onInvalidateChatMessages).toHaveBeenCalledWith("chat-1");
|
|
});
|
|
|
|
it("double onTurnFinished on a failed-after-start turn: primary adopt, 2nd no-id call does NOT re-arm the fallback", () => {
|
|
// ai@6 fires onFinish AND onError on a failed turn. If the failure happened
|
|
// AFTER the `start` chunk, onFinish carries the streamed id and onError does
|
|
// not — so onTurnFinished runs twice in one turn (id, then no-id) before any
|
|
// re-render. The 2nd call must NOT re-arm the fallback off the still-null
|
|
// closure; otherwise a late refetch (parent hasn't reflected the adoption yet)
|
|
// would wrongly adopt a sibling row.
|
|
const { result, rerender, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }] },
|
|
});
|
|
result.current.onTurnFinished("A"); // onFinish: primary adoption
|
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
|
result.current.onTurnFinished(undefined); // onError: same turn, no id
|
|
// Even in the worst case (the parent has NOT yet reflected activeChatId="A"
|
|
// and a late refetch lands a new row), the just-failed sibling must NOT be
|
|
// adopted. Two layers guarantee this: the ref guard keeps the 2nd call from
|
|
// re-arming at the source, and the render-phase reconciler disarms anything
|
|
// stale once thread.chatId ("A") diverges from the still-null activeChatId.
|
|
rerender({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }, { id: "late" }] },
|
|
});
|
|
expect(setActiveChatId).not.toHaveBeenCalledWith("late");
|
|
});
|
|
|
|
it("#174 early adopt: onServerChatId adopts the streamed id mid-stream (Copy button available during the first turn)", () => {
|
|
// Brand-new chat: no id yet. The server streams the real chat id "A" on the
|
|
// `start` chunk WHILE the first turn is still streaming (before onTurnFinished
|
|
// fires at the terminal outcome). The hook must adopt it immediately so the
|
|
// window's activeChatId-gated Copy/export button lights up during the stream.
|
|
const { result, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [] },
|
|
});
|
|
result.current.onServerChatId("A");
|
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
|
});
|
|
|
|
it("#174 early adopt is in-place: threadKey stays stable (live stream not torn down)", () => {
|
|
const chats = { items: [] };
|
|
const { result, rerender } = setup({ activeChatId: null, chats });
|
|
const keyBefore = result.current.threadKey;
|
|
result.current.onServerChatId("A");
|
|
// Parent reflects the adopted id back in; the SAME mount key is kept so the
|
|
// in-flight useChat store (the streaming turn) is preserved.
|
|
rerender({ activeChatId: "A", chats });
|
|
expect(result.current.threadKey).toBe(keyBefore);
|
|
});
|
|
|
|
it("#174 early adopt: no-op for an existing chat and for a missing id", () => {
|
|
const { result, setActiveChatId } = setup({
|
|
activeChatId: "chat-1",
|
|
chats: { items: [{ id: "chat-1" }] },
|
|
});
|
|
result.current.onServerChatId("chat-1"); // already has an id
|
|
result.current.onServerChatId(undefined); // no streamed id
|
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("in-place adopt keeps threadKey stable; an external switch remounts", () => {
|
|
const chats = { items: [{ id: "B" }] };
|
|
const { result, rerender } = setup({ activeChatId: null, chats });
|
|
const keyBefore = result.current.threadKey;
|
|
// Adopt the streamed id; the PARENT then reflects activeChatId="A" back in.
|
|
result.current.onTurnFinished("A");
|
|
rerender({ activeChatId: "A", chats });
|
|
// In-place adoption: SAME mount key (the live useChat store is preserved).
|
|
expect(result.current.threadKey).toBe(keyBefore);
|
|
|
|
// An EXTERNAL switch (not via adopt) to a different chat must remount: the
|
|
// key becomes the chat id.
|
|
rerender({ activeChatId: "C", chats });
|
|
expect(result.current.threadKey).toBe("C");
|
|
});
|
|
|
|
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
|
|
// Open an existing chat whose history is still loading => loader on.
|
|
const { result, rerender } = setup({
|
|
activeChatId: "chat-1",
|
|
chats: { items: [{ id: "chat-1" }] },
|
|
messagesLoading: true,
|
|
});
|
|
expect(result.current.waitingForHistory).toBe(true);
|
|
// Once loading finishes, the latch flips and the loader is off.
|
|
rerender({
|
|
activeChatId: "chat-1",
|
|
chats: { items: [{ id: "chat-1" }] },
|
|
messagesLoading: false,
|
|
});
|
|
expect(result.current.waitingForHistory).toBe(false);
|
|
});
|
|
});
|