import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../src/app-server/connection/client"; import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import { createThreadOperationsTransport } from "../../../../src/features/threads/app-server/workflow-transports"; import type { ArchiveExportDestination } from "../../../../src/features/threads/workflows/archive-export"; import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator"; import { type ArchiveThreadResult, createThreadOperations, type ThreadOperationsHost, } from "../../../../src/features/threads/workflows/thread-operations"; import { DEFAULT_SETTINGS } from "../../../../src/settings/model"; import { deferred } from "../../../support/async"; describe("ThreadOperations", () => { it("renames a thread and notifies shared surfaces after success", async () => { const { operations, client, catalog } = operationsFixture(); await expect(operations.renameThread("thread", " Saved title ")).resolves.toBe(true); expect(client?.request).toHaveBeenCalledWith("thread/name/set", { threadId: "thread", name: "Saved title" }); expect(catalog.apply).toHaveBeenCalledWith({ type: "thread-renamed", threadId: "thread", name: "Saved title" }); }); it("can skip rename publication when the caller invalidates the save", async () => { const { operations, catalog } = operationsFixture(); await operations.renameThread("thread", "Generated title", { shouldPublish: () => false }); expect(catalog.apply).not.toHaveBeenCalled(); }); it("serializes successive names for the same thread", async () => { const generatedSave = deferred(); const client = clientMock(); client.request.mockImplementationOnce(async (method: string) => { if (method !== "thread/name/set") throw new Error(`Unexpected app-server request: ${method}`); return generatedSave.promise; }); const { operations } = operationsFixture({ client }); const generated = operations.renameThread("thread", "Generated title"); await Promise.resolve(); const firstManual = operations.renameThread("thread", "First manual title"); const latestManual = operations.renameThread("thread", "Latest manual title"); await Promise.resolve(); expect(client.request).toHaveBeenCalledTimes(1); generatedSave.resolve({}); await Promise.all([generated, firstManual, latestManual]); expect(client.request).toHaveBeenNthCalledWith(1, "thread/name/set", { threadId: "thread", name: "Generated title" }); expect(client.request).toHaveBeenNthCalledWith(2, "thread/name/set", { threadId: "thread", name: "First manual title" }); expect(client.request).toHaveBeenNthCalledWith(3, "thread/name/set", { threadId: "thread", name: "Latest manual title" }); }); it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => { const { operations, catalog, notice, client, archiveDestination } = operationsFixture(); await expect(operations.archiveThread("thread", { saveMarkdown: true })).resolves.toEqual({ exportedPath: "Archive/Archived Thread abcdef12.md", }); expect(client?.request).toHaveBeenCalledWith("thread/read", { threadId: "thread", includeTurns: true }); expect(archiveDestination.createMarkdownFile).toHaveBeenCalledWith( "Archive/Archived Thread abcdef12.md", expect.stringContaining('thread_id: "abcdef12-9999"'), ); expect(client?.request).toHaveBeenCalledWith("thread/archive", { threadId: "thread" }); expect(callOrder(archiveDestination.createMarkdownFile)).toBeLessThan(requestCallOrder(client, "thread/archive")); expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/Archived Thread abcdef12.md."); expect(catalog.apply).toHaveBeenCalledWith({ type: "thread-archived", threadId: "thread", }); }); it("archives without reading transcript history when markdown export is disabled", async () => { const { operations, client, archiveDestinationFactory, archiveExportSettings } = operationsFixture(); await expect(operations.archiveThread("thread")).resolves.toEqual({ exportedPath: null } satisfies ArchiveThreadResult); expect(requestMethods(client)).not.toContain("thread/read"); expect(archiveExportSettings).not.toHaveBeenCalled(); expect(archiveDestinationFactory).not.toHaveBeenCalled(); expect(client?.request).toHaveBeenCalledWith("thread/archive", { threadId: "thread" }); }); it("does not notify surfaces when an operation has no current client", async () => { const { operations, catalog } = operationsFixture({ client: null }); await expect(operations.renameThread("thread", "Title")).rejects.toThrow("No current client."); await expect(operations.archiveThread("thread")).rejects.toThrow("No current client."); expect(catalog.apply).not.toHaveBeenCalled(); }); it("does not publish stale rename results after the current client changes", async () => { const firstClient = clientMock(); const secondClient = clientMock(); let currentClient: MockClient | null = firstClient; const { operations, catalog } = operationsFixture({ client: () => currentClient }); firstClient.request.mockImplementationOnce(async (method: string) => { if (method !== "thread/name/set") throw new Error(`Unexpected app-server request: ${method}`); currentClient = secondClient; return {}; }); await expect(operations.renameThread("thread", "Title")).rejects.toThrow("Client changed."); expect(catalog.apply).not.toHaveBeenCalled(); }); it("does not publish stale archive results after the current client changes", async () => { const firstClient = clientMock(); const secondClient = clientMock(); let currentClient: MockClient | null = firstClient; const { operations, catalog } = operationsFixture({ client: () => currentClient }); firstClient.request.mockImplementationOnce(async (method: string) => { if (method !== "thread/archive") throw new Error(`Unexpected app-server request: ${method}`); currentClient = secondClient; return {}; }); await expect(operations.archiveThread("thread")).rejects.toThrow("Client changed."); expect(catalog.apply).not.toHaveBeenCalled(); }); }); function operationsFixture(options: { client?: MockClient | null | (() => MockClient | null) } = {}) { const configuredClient = options.client === undefined ? clientMock() : options.client; const currentClient = typeof configuredClient === "function" ? configuredClient : () => configuredClient; const archiveDestination = archiveDestinationMock(); const archiveDestinationFactory = vi.fn(() => archiveDestination); const archiveExportSettings = vi.fn(() => ({ archiveExportFolderTemplate: "Archive", archiveExportFilenameTemplate: "{{title}} {{shortId}}", archiveExportTags: DEFAULT_SETTINGS.archiveExportTags, })); const catalog = { apply: vi.fn(), }; const notice = vi.fn(); const host: ThreadOperationsHost = { transport: createThreadOperationsTransport({ withClient: async (operation) => { const client = currentClient() as AppServerClient | null; if (!client) throw new Error("No current client."); const result = await operation(client); if ((currentClient() as AppServerClient | null) !== client) throw new Error("Client changed."); return result; }, }), nameMutations: createThreadNameMutationCoordinator(), archiveExport: { settings: archiveExportSettings, enabled: () => false, vaultPath: "/vault", vaultConfigDir: "vault-config", }, archiveDestination: archiveDestinationFactory, catalog, notice, }; return { operations: createThreadOperations(host), client: currentClient(), archiveDestination, archiveDestinationFactory, archiveExportSettings, catalog, notice, }; } type MockClient = ReturnType; function clientMock() { return { request: vi.fn((method: string, params: { threadId: string; name?: string }) => { if (method === "thread/name/set") return Promise.resolve({ threadId: params.threadId, name: params.name }); if (method === "thread/read") return Promise.resolve({ thread: archivedThread() }); if (method === "thread/archive") return Promise.resolve({}); throw new Error(`Unexpected app-server request: ${method}`); }), }; } function archiveDestinationMock(): ArchiveExportDestination & { createMarkdownFile: ReturnType>; } { return { normalizePath: (path) => path, exists: vi.fn().mockResolvedValue(false), createFolder: vi.fn().mockResolvedValue(undefined), createMarkdownFile: vi.fn().mockResolvedValue(undefined), }; } function archivedThread(): ThreadRecord { return { id: "abcdef12-9999", sessionId: "abcdef12-9999", preview: "Archived Thread", source: { kind: "local" }, cwd: "/vault", createdAt: 1, updatedAt: 2, name: "Archived Thread", status: "idle", gitInfo: null, turns: [], }; } function callOrder(fn: ReturnType): number { return fn.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY; } function requestMethods(client: { request: ReturnType } | null): string[] { return client?.request.mock.calls.map(([method]) => method) ?? []; } function requestCallOrder(client: { request: ReturnType } | null, method: string): number { const index = client?.request.mock.calls.findIndex(([calledMethod]) => calledMethod === method) ?? -1; return index === -1 ? Number.POSITIVE_INFINITY : (client?.request.mock.invocationCallOrder[index] ?? Number.POSITIVE_INFINITY); }