murashit_codex-panel/tests/features/chat/threads/history-controller.test.ts
2026-06-24 14:54:53 +09:00

124 lines
5.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { ChatThreadHistoryPage } from "../../../../src/features/chat/app-server/threads/projection";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { HistoryController, type HistoryControllerHost } from "../../../../src/features/chat/application/threads/history-controller";
import type { MessageStreamItem } from "../../../../src/features/chat/domain/message-stream/items";
import { deferred } from "../../../support/async";
import { chatStateMessageStreamItems } from "../support/message-stream";
import { chatStateFixture, chatStateWith } from "../support/state";
describe("HistoryController", () => {
it("keeps the latest history load when an older request resolves later", async () => {
const first = deferred<ChatThreadHistoryPage>();
const second = deferred<ChatThreadHistoryPage>();
const { loader, stateStore } = historyFixture({
readHistoryPage: vi.fn<HistoryPageReader>().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
});
const firstLoad = loader.loadLatest();
const secondLoad = loader.loadLatest();
second.resolve(historyPage([], "second-cursor"));
await secondLoad;
first.resolve(historyPage([], "first-cursor"));
await firstLoad;
expect(stateStore.getState().messageStream.historyCursor).toBe("second-cursor");
expect(stateStore.getState().messageStream.loadingHistory).toBe(false);
});
it("ignores a history load that is invalidated while pending", async () => {
const pending = deferred<ChatThreadHistoryPage>();
const { loader, stateStore, addSystemMessage } = historyFixture({
readHistoryPage: vi.fn<HistoryPageReader>().mockReturnValue(pending.promise),
});
const loading = loader.loadLatest();
expect(stateStore.getState().messageStream.loadingHistory).toBe(true);
loader.invalidate();
pending.resolve(historyPage([message("assistant", "Stale")], "stale-cursor"));
await loading;
expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]);
expect(stateStore.getState().messageStream.historyCursor).toBeNull();
expect(stateStore.getState().messageStream.loadingHistory).toBe(false);
expect(addSystemMessage).not.toHaveBeenCalled();
});
it("applies an already returned latest turns page without requesting history", () => {
const readHistoryPage = vi.fn<HistoryPageReader>();
const { loader, stateStore, showLatestPageAtBottom } = historyFixture({ readHistoryPage });
const applied = loader.applyLatestPage("thread", historyPage([message("assistant", "Ready")], "older"));
expect(applied).toBe(true);
expect(readHistoryPage).not.toHaveBeenCalled();
expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([
expect.objectContaining({ id: "assistant", text: "Ready", turnId: "turn" }),
]);
expect(stateStore.getState().messageStream.historyCursor).toBe("older");
expect(showLatestPageAtBottom).toHaveBeenCalledOnce();
});
it("ignores already returned latest turns pages for stale threads", () => {
const { loader, stateStore } = historyFixture({ readHistoryPage: vi.fn<HistoryPageReader>() });
const applied = loader.applyLatestPage("other", historyPage([message("assistant", "Stale")], "older"));
expect(applied).toBe(false);
expect(chatStateMessageStreamItems(stateStore.getState())).toEqual([]);
expect(stateStore.getState().messageStream.historyCursor).toBeNull();
});
it("loads older history without coupling message stream replacement to bottom pin state", async () => {
const readHistoryPage = vi.fn<HistoryPageReader>().mockResolvedValue(historyPage([message("older", "Older")], "next"));
const { loader, stateStore, dispatch, showLatestPageAtBottom } = historyFixture({ readHistoryPage });
stateStore.dispatch({ type: "message-stream/items-replaced", items: [message("current", "Current")], historyCursor: "cursor" });
await loader.loadOlder();
expect(readHistoryPage).toHaveBeenCalledWith(expect.anything(), "thread", "cursor", 20);
expect(chatStateMessageStreamItems(stateStore.getState()).map((item) => item.id)).toEqual(["older", "current"]);
expect(stateStore.getState().messageStream.historyCursor).toBe("next");
expect(showLatestPageAtBottom).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "message-stream/items-replaced" }));
});
});
type HistoryPageReader = NonNullable<HistoryControllerHost["readHistoryPage"]>;
function historyFixture(options: { readHistoryPage: ReturnType<typeof vi.fn<HistoryPageReader>> }) {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread" } });
const stateStore = createChatStateStore(state);
const dispatch = vi.spyOn(stateStore, "dispatch");
const addSystemMessage = vi.fn();
const showLatestPageAtBottom = vi.fn();
const loader = new HistoryController({
stateStore,
currentClient: () => ({}) as AppServerClient,
addSystemMessage,
showLatestPageAtBottom,
setThreadTurnPresence: vi.fn(),
readHistoryPage: options.readHistoryPage,
});
return { loader, stateStore, addSystemMessage, dispatch, showLatestPageAtBottom };
}
function historyPage(items: MessageStreamItem[], nextCursor: string | null): ChatThreadHistoryPage {
return { items, nextCursor, hadTurns: items.length > 0 };
}
function message(id: string, text: string): MessageStreamItem {
return {
id,
kind: "message" as const,
role: "assistant" as const,
text,
messageKind: "assistantResponse" as const,
messageState: "completed" as const,
turnId: "turn",
};
}