Pressing "New chat" while a brand-new chat's first turn was still streaming only removed the role badge — the thread, session and stream were kept. The thread remount is driven by a render-phase reconciler in useChatSession that fires only when activeChatId !== thread.chatId; on a not-yet-adopted new chat both are null, so startNewChat's setActiveChatId(null) was a no-op and nothing remounted. - useChatSession: add startFreshThread(), which unconditionally dispatches a reconcile to a fresh mount key so ChatThread remounts even when activeChatId stays null. startNewChat now calls it. - Guard against the abandoned thread's late finish: ai@6's useChat does not abort on unmount and proxies its callbacks, so onFinish/onError of the chat the user just left still fire and would adopt it back. onTurnFinished now takes the finishing thread's key and, when it no longer matches the mounted thread, only refreshes the chat list — no adoption, no fallback arming, no per-chat message invalidation. ChatThread forwards its threadKey in both onFinish and onError. An omitted key (legacy callers/tests) counts as the current thread, preserving the #137 adoption behavior. Tests: 3 new useChatSession cases (fresh-thread remount with activeChatId null, abandoned-thread finish rejected, current-thread finish still adopts). Full ai-chat suite green (183). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, act } 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("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("#161: startFreshThread remounts even when activeChatId stays null (New chat mid-stream)", () => {
|
|
// The bug: pressing New chat while still on a brand-new, not-yet-adopted chat
|
|
// leaves activeChatId === null, so the render-phase reconciler never fires.
|
|
// startFreshThread must remount UNCONDITIONALLY (a new mount key).
|
|
const { result } = setup({ activeChatId: null, chats: { items: [] } });
|
|
const keyBefore = result.current.threadKey;
|
|
act(() => result.current.startFreshThread());
|
|
expect(result.current.threadKey).not.toBe(keyBefore);
|
|
});
|
|
|
|
it("#161: a late finish from an ABANDONED thread does not adopt or invalidate messages", () => {
|
|
const {
|
|
result,
|
|
setActiveChatId,
|
|
onInvalidateChatList,
|
|
onInvalidateChatMessages,
|
|
} = setup({ activeChatId: null, chats: { items: [{ id: "x" }] } });
|
|
const abandonedKey = result.current.threadKey;
|
|
// User pressed New chat mid-stream → fresh thread (new mount key).
|
|
act(() => result.current.startFreshThread());
|
|
expect(result.current.threadKey).not.toBe(abandonedKey);
|
|
// The left-behind thread's onFinish fires late, carrying ITS (now stale) key
|
|
// and the server id of the chat the user just left. It must NOT be adopted.
|
|
act(() => result.current.onTurnFinished("A", abandonedKey));
|
|
expect(setActiveChatId).not.toHaveBeenCalled();
|
|
expect(onInvalidateChatMessages).not.toHaveBeenCalled();
|
|
// The abandoned chat should still surface in the history list.
|
|
expect(onInvalidateChatList).toHaveBeenCalled();
|
|
});
|
|
|
|
it("#161: a finish from the CURRENT thread (matching key) still adopts", () => {
|
|
const { result, setActiveChatId } = setup({
|
|
activeChatId: null,
|
|
chats: { items: [{ id: "x" }] },
|
|
});
|
|
// Same thread that is mounted reports its finish with the matching key.
|
|
act(() =>
|
|
result.current.onTurnFinished("A", result.current.threadKey),
|
|
);
|
|
expect(setActiveChatId).toHaveBeenCalledWith("A");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|