// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; import { VIEW_TYPE_CODEX_PANEL } from "../src/constants"; import type { Thread } from "../src/domain/threads/model"; import type { CodexChatView } from "../src/features/chat/host/view.obsidian"; import type CodexPanelPlugin from "../src/main"; import { deferred } from "./support/async"; import { installObsidianDomShims } from "./support/dom"; import { chatView, flushMicrotasks, leaf, panelSnapshot, pluginWithLeaves, publishCodexPath, thread, threadListClient, } from "./support/plugin-fixtures"; installObsidianDomShims(); const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({ withShortLivedAppServerClientMock: vi.fn(), })); vi.mock("../src/app-server/connection/short-lived-client", () => ({ withShortLivedAppServerClient: withShortLivedAppServerClientMock, })); function threadCatalog(plugin: CodexPanelPlugin) { return plugin.runtime.chatHost().threadCatalog; } describe("CodexPanelPlugin runtime integration", () => { beforeEach(() => { vi.useRealTimers(); withShortLivedAppServerClientMock.mockReset(); }); it("applies known archive mutations to open chat surfaces", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const firstLeaf = leaf(); firstLeaf.view = chatView(CodexChatView, firstLeaf); const firstArchived = vi.spyOn((firstLeaf.view as CodexChatView).surface, "applyThreadArchived").mockImplementation(() => undefined); const firstRefresh = vi.spyOn((firstLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined); const secondLeaf = leaf(); secondLeaf.view = chatView(CodexChatView, secondLeaf); const secondArchived = vi.spyOn((secondLeaf.view as CodexChatView).surface, "applyThreadArchived").mockImplementation(() => undefined); const secondRefresh = vi.spyOn((secondLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]); threadCatalog(plugin).apply({ type: "thread-archived", threadId: "thread-1" }); expect(firstArchived).toHaveBeenCalledWith("thread-1"); expect(secondArchived).toHaveBeenCalledWith("thread-1"); expect(firstRefresh).not.toHaveBeenCalled(); expect(secondRefresh).not.toHaveBeenCalled(); }); it("clears active and restored identities without detaching panel leaves", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const restoredMatchingLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored" } }); const matchingLeaf = leaf(); matchingLeaf.view = chatView(CodexChatView, matchingLeaf); vi.spyOn((matchingLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-1" })); const matchingArchived = vi .spyOn((matchingLeaf.view as CodexChatView).surface, "applyThreadArchived") .mockImplementation(() => undefined); vi.spyOn((matchingLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined); const otherLeaf = leaf(); otherLeaf.view = chatView(CodexChatView, otherLeaf); vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ threadId: "thread-2" })); vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]); threadCatalog(plugin).apply({ type: "thread-archived", threadId: "thread-1" }); expect(matchingArchived).toHaveBeenCalledWith("thread-1"); expect(restoredMatchingLeaf.detach).not.toHaveBeenCalled(); expect(matchingLeaf.detach).not.toHaveBeenCalled(); expect(otherLeaf.detach).not.toHaveBeenCalled(); expect(restoredMatchingLeaf.setViewState).toHaveBeenCalledWith({ type: VIEW_TYPE_CODEX_PANEL, state: { version: 1 }, }); expect(matchingLeaf.detach).not.toHaveBeenCalled(); expect(otherLeaf.detach).not.toHaveBeenCalled(); }); it("applies known rename mutations to open chat surfaces", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const firstLeaf = leaf(); firstLeaf.view = chatView(CodexChatView, firstLeaf); const firstRenamed = vi.spyOn((firstLeaf.view as CodexChatView).surface, "applyThreadRenamed").mockImplementation(() => undefined); const firstRefresh = vi.spyOn((firstLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined); const secondLeaf = leaf(); secondLeaf.view = chatView(CodexChatView, secondLeaf); const secondRenamed = vi.spyOn((secondLeaf.view as CodexChatView).surface, "applyThreadRenamed").mockImplementation(() => undefined); const secondRefresh = vi.spyOn((secondLeaf.view as CodexChatView).surface, "refreshSharedThreads").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]); threadCatalog(plugin).apply({ type: "thread-renamed", threadId: "thread-1", name: "Renamed thread" }); expect(firstRenamed).toHaveBeenCalledWith("thread-1", "Renamed thread"); expect(secondRenamed).toHaveBeenCalledWith("thread-1", "Renamed thread"); expect(firstRefresh).not.toHaveBeenCalled(); expect(secondRefresh).not.toHaveBeenCalled(); }); it("keeps shared thread list refreshes separate across app-server cache contexts", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true); let resolveFirst!: (threads: Thread[]) => void; const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient"); const plugin = await pluginWithLeaves([connectedLeaf]); await publishCodexPath(plugin, "codex-a"); runWithAppServerClient.mockImplementation((operation) => operation( threadListClient(() => plugin.settings.codexPath === "codex-a" ? new Promise((resolve) => { resolveFirst = resolve; }) : Promise.resolve([thread("second")]), ), ), ); const first = threadCatalog(plugin).refreshActive(); const staleFirst = expect(first).rejects.toThrow("Codex app-server resource context changed while loading."); await flushMicrotasks(); await publishCodexPath(plugin, "codex-b"); const second = threadCatalog(plugin).refreshActive(); await flushMicrotasks(); expect(runWithAppServerClient).toHaveBeenCalledTimes(2); await expect(second).resolves.toEqual([thread("second")]); expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]); resolveFirst([thread("first")]); await staleFirst; expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]); await publishCodexPath(plugin, "codex-a"); expect(threadCatalog(plugin).activeSnapshot()).toBeNull(); }); it("does not reuse a connected panel whose app-server context does not match the shared query", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(false); const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue([thread("wrong-context")]); withShortLivedAppServerClientMock.mockImplementation( (_codexPath: string, _vaultPath: string, operation: (client: ReturnType) => Promise) => operation(threadListClient(() => Promise.resolve([thread("matching-context")]))), ); const plugin = await pluginWithLeaves([connectedLeaf]); await publishCodexPath(plugin, "codex-b"); await expect(threadCatalog(plugin).refreshActive()).resolves.toEqual([thread("matching-context")]); expect(runWithAppServerClient).not.toHaveBeenCalled(); expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex-b", "/vault", expect.any(Function), {}); expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("matching-context")]); }); it("uses a short-lived client when the operation declares an unhandled server-request policy", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true })); const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue("chat-result"); const shortLivedClient = { request: vi.fn().mockResolvedValue({}) }; withShortLivedAppServerClientMock.mockImplementation( (_codexPath: string, _vaultPath: string, operation: (client: typeof shortLivedClient) => Promise) => operation(shortLivedClient), ); const plugin = await pluginWithLeaves([connectedLeaf]); plugin.settings.codexPath = "codex"; const result = await plugin.runtime.withClient((client) => client.request("config/read", { cwd: "/vault", includeLayers: true }), { serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." }, }); expect(result).toEqual({}); expect(runWithAppServerClient).not.toHaveBeenCalled(); expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex", "/vault", expect.any(Function), { serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." }, }); expect(shortLivedClient.request).toHaveBeenCalledWith("config/read", { cwd: "/vault", includeLayers: true }); }); it("rejects a connected-panel result when the app-server context changes during the operation", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const connectedLeaf = leaf(); connectedLeaf.view = chatView(CodexChatView, connectedLeaf); const connectedView = connectedLeaf.view as CodexChatView; vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ connected: true })); vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true); const result = deferred(); vi.spyOn(connectedView.surface, "runWithAppServerClient").mockReturnValue(result.promise); const plugin = await pluginWithLeaves([connectedLeaf]); await publishCodexPath(plugin, "codex-a"); const operation = plugin.runtime.withClient(() => Promise.resolve("unused")); await publishCodexPath(plugin, "codex-b"); result.resolve("stale-result"); await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading."); }); it("rejects a short-lived result when the app-server context changes during the operation", async () => { const result = deferred(); withShortLivedAppServerClientMock.mockReturnValue(result.promise); const plugin = await pluginWithLeaves([]); await publishCodexPath(plugin, "codex-a"); const operation = plugin.runtime.withClient(() => Promise.resolve("unused"), { serverRequests: { kind: "reject", message: "test" }, }); await publishCodexPath(plugin, "codex-b"); result.resolve("stale-result"); await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading."); }); it("does not start a short-lived operation when its app-server context changes while connecting", async () => { const clientReady = deferred(); const shortLivedClient = { request: vi.fn().mockResolvedValue({}) }; withShortLivedAppServerClientMock.mockImplementation( (_codexPath: string, _vaultPath: string, operation: (client: typeof shortLivedClient) => Promise) => clientReady.promise.then(() => operation(shortLivedClient)), ); const plugin = await pluginWithLeaves([]); await publishCodexPath(plugin, "codex-a"); const callback = vi.fn(() => Promise.resolve("unused")); const operation = plugin.runtime.withClient(callback, { serverRequests: { kind: "reject", message: "test" }, }); await publishCodexPath(plugin, "codex-b"); clientReady.resolve(); await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading."); expect(callback).not.toHaveBeenCalled(); expect(shortLivedClient.request).not.toHaveBeenCalled(); }); it("publishes a persisted context and its new resource lease atomically", async () => { const plugin = await pluginWithLeaves([]); const firstLease = plugin.runtime.appServerContextLease(); const save = deferred(); const saveSettings = vi.spyOn(plugin, "saveSettings").mockReturnValue(save.promise); const publication = plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath: "codex-next" }); await Promise.resolve(); expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ codexPath: "codex-next" })); expect(plugin.settings.codexPath).toBe(firstLease.context.codexPath); expect(plugin.runtime.appServerContextLease()).toBe(firstLease); save.resolve(undefined); await publication; expect(plugin.settings.codexPath).toBe("codex-next"); expect(plugin.runtime.appServerContextLease()).toEqual({ context: { codexPath: "codex-next", vaultPath: "/vault" }, generation: firstLease.generation + 1, }); }); it("coordinates context replacement with every open Threads view", async () => { const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian"); const prepareAppServerContextChange = vi.fn(); const refreshSettings = vi.fn(); const threadsView = Object.assign(Object.create(CodexThreadsView.prototype), { prepareAppServerContextChange, refreshSettings, }) as InstanceType; const threadsLeaf = leaf(); threadsLeaf.view = threadsView; const plugin = await pluginWithLeaves([], { threadsLeaves: [threadsLeaf] }); await publishCodexPath(plugin, "codex-next"); expect(prepareAppServerContextChange).toHaveBeenCalledOnce(); expect(refreshSettings).toHaveBeenCalledOnce(); }); it("cancels selection rewrites before publishing a new app-server context", async () => { const plugin = await pluginWithLeaves([]); const closeAll = vi.fn(); plugin.runtime.setSelectionRewriteController({ closeAll }); await publishCodexPath(plugin, "codex-next"); expect(closeAll).toHaveBeenCalledOnce(); }); });