// @vitest-environment jsdom import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { AssistantRuntime, MessageFormatAdapter, MessageFormatRepository, ThreadHistoryAdapter, ThreadMessage, } from "@assistant-ui/core"; vi.mock("@assistant-ui/store", () => ({ useAui: () => ({ threadListItem: Object.assign(() => null, { source: undefined }), }), })); import { MessageRepository } from "@assistant-ui/core/internal"; import { toExportedMessageRepository, useExternalHistory, } from "./useExternalHistory"; const noopThread = { subscribe: () => () => {}, getState: () => ({ isRunning: false, messages: [] }), import: () => {}, export: () => ({ headId: null, messages: [] }), } as unknown as AssistantRuntime["thread"]; const runtimeRef = { current: { thread: noopThread } as AssistantRuntime, }; const storageFormat: MessageFormatAdapter> = { format: "test", encode: (item) => ({ data: item.message }), decode: (stored) => ({ parentId: stored.parent_id, message: stored.content, }), getId: () => "id", }; const toThreadMessages = (_messages: unknown[]): ThreadMessage[] => []; const onSetMessages = () => {}; describe("useExternalHistory withFormat contract", () => { it("throws when the adapter omits withFormat", () => { const adapterWithoutWithFormat: ThreadHistoryAdapter = { load: vi.fn().mockResolvedValue({ headId: null, messages: [] }), append: vi.fn().mockResolvedValue(undefined), }; const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); expect(() => renderHook(() => useExternalHistory( runtimeRef, adapterWithoutWithFormat, toThreadMessages, storageFormat, onSetMessages, ), ), ).toThrow(/withFormat/); errorSpy.mockRestore(); }); it("does not throw when no adapter is supplied", () => { expect(() => renderHook(() => useExternalHistory( runtimeRef, undefined, toThreadMessages, storageFormat, onSetMessages, ), ), ).not.toThrow(); }); it("accepts an adapter that implements withFormat", () => { const withFormatResult = { load: vi.fn().mockResolvedValue({ headId: null, messages: [] }), append: vi.fn().mockResolvedValue(undefined), }; const adapter: ThreadHistoryAdapter = { load: vi.fn(), append: vi.fn(), withFormat: vi.fn().mockReturnValue(withFormatResult), }; expect(() => renderHook(() => useExternalHistory( runtimeRef, adapter, toThreadMessages, storageFormat, onSetMessages, ), ), ).not.toThrow(); expect(adapter.withFormat).toHaveBeenCalledWith(storageFormat); }); }); describe("toExportedMessageRepository", () => { const convert = (items: { id: string; ok: boolean }[]): ThreadMessage[] => items[0]!.ok ? [{ id: items[0]!.id } as ThreadMessage] : []; it("drops a malformed row together with its now-orphaned descendants", () => { const repo: MessageFormatRepository<{ id: string; ok: boolean }> = { headId: "c", messages: [ { parentId: null, message: { id: "a", ok: true } }, { parentId: "a", message: { id: "b", ok: false } }, { parentId: "b", message: { id: "c", ok: true } }, ], }; const result = toExportedMessageRepository(convert, repo); expect(result.messages.map((m) => m.message.id)).toEqual(["a"]); expect(result.headId).toBeNull(); expect(() => new MessageRepository().import(result)).not.toThrow(); }); it("drops a headId that points at a filtered row", () => { const repo: MessageFormatRepository<{ id: string; ok: boolean }> = { headId: "b", messages: [ { parentId: null, message: { id: "a", ok: true } }, { parentId: "a", message: { id: "b", ok: false } }, ], }; const result = toExportedMessageRepository(convert, repo); expect(result.messages.map((m) => m.message.id)).toEqual(["a"]); expect(result.headId).toBeNull(); expect(() => new MessageRepository().import(result)).not.toThrow(); }); it("drops a malformed root and its entire subtree", () => { const repo: MessageFormatRepository<{ id: string; ok: boolean }> = { headId: "b", messages: [ { parentId: null, message: { id: "a", ok: false } }, { parentId: "a", message: { id: "b", ok: true } }, ], }; const result = toExportedMessageRepository(convert, repo); expect(result.messages).toHaveLength(0); expect(result.headId).toBeNull(); expect(() => new MessageRepository().import(result)).not.toThrow(); }); });