diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index b30dca45..1b67bb36 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -39,6 +39,19 @@ describe("AppServerQueryCache", () => { expect(fetchThreads).toHaveBeenCalledOnce(); }); + it("preserves the last-known-good active thread list when a refresh fails", async () => { + const fetchThreads = vi + .fn() + .mockResolvedValueOnce([thread("cached")]) + .mockRejectedValueOnce(new Error("offline")); + const cache = cacheWithThreads(fetchThreads); + await cache.refreshActiveThreads(); + + await expect(cache.refreshActiveThreads()).rejects.toThrow("offline"); + + expect(cache.activeThreadsSnapshot()).toEqual([thread("cached")]); + }); + it("shares concurrent active thread refreshes within one resource identity", async () => { const pending = deferred[]>(); const fetchThreads = vi.fn(() => pending.promise); diff --git a/tests/features/chat/host/session-runtime.test.ts b/tests/features/chat/host/session-runtime.test.ts index 5920a589..5a0808ae 100644 --- a/tests/features/chat/host/session-runtime.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -4,21 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StaleAppServerResourceContextError } from "../../../../src/app-server/query/resource-store"; import type { Thread } from "../../../../src/domain/threads/model"; -import { activeThreadId } from "../../../../src/features/chat/application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store"; -import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller"; import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/threads/resume-work"; import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/contracts"; import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/session/deferred-work"; import { ChatPanelSessionRuntime } from "../../../../src/features/chat/host/session-runtime"; -import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller"; -import { ThreadStreamPresenter } from "../../../../src/features/chat/panel/surface/thread-stream-presenter"; import { createChatThreadStreamScrollBinding } from "../../../../src/features/chat/panel/thread-stream-scroll-binding"; import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator"; import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model"; -import { waitForAsyncWork } from "../../../support/async"; +import { deferred, waitForAsyncWork } from "../../../support/async"; import { installObsidianDomShims } from "../../../support/dom"; import { chatPanelSettingsAccess } from "../support/settings"; +import { composerModelFromChatState } from "../support/shell-selectors"; installObsidianDomShims(); @@ -34,15 +31,24 @@ describe("ChatPanelSessionRuntime actions", () => { document.body.replaceChildren(); }); - it("invalidates thread work through the runtime action", () => { - const { runtime, resumeWork } = sessionRuntimeFixture(); + it("invalidates active resume, history, and restoration work through the runtime action", async () => { + const { runtime, resumeWork, stateStore } = sessionRuntimeFixture(); const resume = resumeWork.begin("thread-1"); - const invalidateHistory = vi.spyOn(HistoryController.prototype, "invalidate"); + stateStore.dispatch({ type: "thread-stream/history-loading-set", loading: true }); + stateStore.dispatch({ type: "panel/restored-thread-applied", threadId: "thread-1", fallbackTitle: null }); + const restored = deferred(); + const loadRestoredThread = vi.fn(() => restored.promise); + const firstRestoration = runtime.thread.restoration.ensureLoaded(loadRestoredThread); runtime.actions.invalidateThreadWork(); expect(resumeWork.isStale(resume)).toBe(true); - expect(invalidateHistory).toHaveBeenCalledOnce(); + expect(stateStore.getState().threadStream.loadingHistory).toBe(false); + const secondRestoration = runtime.thread.restoration.ensureLoaded(loadRestoredThread); + expect(loadRestoredThread).toHaveBeenCalledTimes(2); + + restored.resolve(undefined); + await Promise.all([firstRestoration, secondRestoration]); }); it("clears rename work before replacing the app-server context", () => { @@ -92,16 +98,11 @@ describe("ChatPanelSessionRuntime actions", () => { expect(stateStore.getState().threadList.listedThreads).toEqual([]); }); - it("starts a new thread from runtime state and composer actions", async () => { - const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); + it("fans out active-thread identity changes after starting a new thread", async () => { const refreshLiveState = vi.fn(); - const requestWorkspaceLayoutSave = vi.fn(); const refreshTabHeader = vi.fn(); const { runtime, stateStore } = sessionRuntimeFixture({ environment: { - obsidian: { - requestWorkspaceLayoutSave, - }, plugin: { workspace: { refreshThreadsViewLiveState: refreshLiveState, @@ -127,47 +128,14 @@ describe("ChatPanelSessionRuntime actions", () => { serviceTier: null, approvalsReviewer: null, }); - stateStore.dispatch({ type: "ui/panel-set", panel: "history" }); - await runtime.actions.startNewThread(); - expect(activeThreadId(stateStore.getState())).toBeNull(); - expect(stateStore.getState().ui.toolbarPanel).toBeNull(); - expect(stateStore.getState().connection.statusText).toBe("New chat."); - expect(focusComposer).toHaveBeenCalledOnce(); expect(refreshTabHeader).toHaveBeenCalledOnce(); - expect(requestWorkspaceLayoutSave).toHaveBeenCalledOnce(); expect(refreshLiveState).toHaveBeenCalledOnce(); }); - it("does not clear the current thread while a turn is busy", async () => { - const focusComposer = vi.spyOn(ChatComposerController.prototype, "focusComposer").mockImplementation(() => undefined); - const { runtime, stateStore } = sessionRuntimeFixture(); - stateStore.dispatch({ - type: "turn/optimistic-started", - item: { - id: "local-user", - kind: "dialogue", - dialogueKind: "user", - role: "user", - text: "prompt", - }, - pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] }, - }); - stateStore.dispatch({ - type: "turn/started", - threadId: "thread-1", - turnId: "turn-1", - }); - - await runtime.actions.startNewThread(); - - expect(activeThreadId(stateStore.getState())).toBe("thread-1"); - expect(focusComposer).not.toHaveBeenCalled(); - }); - it("wires reconnect cleanup through the runtime toolbar action", async () => { - const { runtime, stateStore, deferredTasks } = sessionRuntimeFixture(); + const { runtime, stateStore } = sessionRuntimeFixture(); stateStore.dispatch({ type: "active-thread/resumed", approvalPolicyKnown: true, @@ -183,10 +151,7 @@ describe("ChatPanelSessionRuntime actions", () => { serviceTier: null, approvalsReviewer: null, }); - const invalidateConnection = vi.spyOn(runtime.connection.actions, "invalidate"); - const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics"); - const resetConnection = vi.spyOn(runtime.connection.manager, "resetConnection"); - const ensureConnected = vi.spyOn(runtime.connection.actions, "ensureConnected").mockResolvedValue(undefined); + vi.spyOn(runtime.connection.actions, "ensureConnected").mockResolvedValue(undefined); vi.spyOn(runtime.connection.manager, "isConnected").mockReturnValue(true); const resumeThread = vi.spyOn(runtime.thread.resume, "resumeThread").mockResolvedValue(undefined); @@ -195,22 +160,57 @@ describe("ChatPanelSessionRuntime actions", () => { await waitForAsyncWork(() => { expect(resumeThread).toHaveBeenCalledWith("thread-1"); }); - expect(invalidateConnection).toHaveBeenCalledOnce(); - expect(clearDiagnostics).toHaveBeenCalledOnce(); - expect(resetConnection).toHaveBeenCalledOnce(); expect(stateStore.getState().connection.statusText).toBe("Reconnecting..."); - expect(ensureConnected).toHaveBeenCalledOnce(); }); - it("disposes presenter and composer resources through the complete runtime lifecycle", async () => { - const disposePresenter = vi.spyOn(ThreadStreamPresenter.prototype, "dispose").mockImplementation(() => undefined); - const disposeComposer = vi.spyOn(ChatComposerController.prototype, "dispose").mockImplementation(() => undefined); - const { runtime } = sessionRuntimeFixture(); + it("disposes scheduled, subscribed, composer, scroll, and connection resources", async () => { + vi.useFakeTimers(); + const unsubscribeThreads = vi.fn(); + const unsubscribeMetadata = vi.fn(); + const unsubscribeModels = vi.fn(); + const refreshLiveState = vi.fn(); + const { runtime, stateStore, deferredTasks, threadStreamScrollBinding } = sessionRuntimeFixture({ + environment: { + plugin: { + workspace: { refreshThreadsViewLiveState: refreshLiveState }, + threadCatalog: { observeActive: vi.fn(() => unsubscribeThreads) }, + appServerQueries: { + observeAppServerMetadataResult: vi.fn(() => unsubscribeMetadata), + observeModelsResult: vi.fn(() => unsubscribeModels), + }, + }, + }, + }); + runtime.runtime.sharedState.subscribe(); + const diagnostics = vi.fn(); + const warmup = vi.fn(); + deferredTasks.scheduleDiagnostics(diagnostics); + deferredTasks.scheduleAppServerWarmup(warmup); + const dispatchScrollCommand = vi.fn(); + threadStreamScrollBinding.mountScrollPort({ dispatchScrollCommand }); + const composer = document.body.createEl("textarea"); + runtime.composer.controller.renderState(composerModelFromChatState(stateStore.getState()), { submit: vi.fn() }).onComposer(composer); + composer.focus(); + expect(runtime.composer.controller.hasFocus()).toBe(true); + const disconnect = vi.spyOn(runtime.connection.manager, "disconnect"); + const unmount = vi.fn(); - await runtime.dispose(() => undefined); + await runtime.dispose(unmount); - expect(disposePresenter).toHaveBeenCalledOnce(); - expect(disposeComposer).toHaveBeenCalledOnce(); + expect([unsubscribeThreads, unsubscribeMetadata, unsubscribeModels].every((unsubscribe) => unsubscribe.mock.calls.length === 1)).toBe( + true, + ); + expect(runtime.composer.controller.hasFocus()).toBe(false); + threadStreamScrollBinding.showLatest(); + expect(dispatchScrollCommand).not.toHaveBeenCalled(); + expect(unmount).toHaveBeenCalledOnce(); + expect(disconnect).toHaveBeenCalledOnce(); + expect(refreshLiveState).toHaveBeenCalledOnce(); + + await vi.runAllTimersAsync(); + expect(diagnostics).not.toHaveBeenCalled(); + expect(warmup).not.toHaveBeenCalled(); + expect(refreshLiveState).toHaveBeenCalledTimes(2); }); function sessionRuntimeFixture(options: { environment?: PartialChatPanelEnvironment } = {}): { @@ -218,21 +218,23 @@ describe("ChatPanelSessionRuntime actions", () => { stateStore: ChatStateStore; resumeWork: ChatResumeWorkTracker; deferredTasks: ReturnType; + threadStreamScrollBinding: ReturnType; } { const stateStore = createChatStateStore(); const resumeWork = new ChatResumeWorkTracker(); const deferredTasks = createChatViewDeferredTasks(() => window); + const threadStreamScrollBinding = createChatThreadStreamScrollBinding(); const environment = chatPanelEnvironmentFixture(options.environment); const runtime = new ChatPanelSessionRuntime({ environment, stateStore, deferredTasks, resumeWork, - threadStreamScrollBinding: createChatThreadStreamScrollBinding(), + threadStreamScrollBinding, getClosing: () => false, viewWindow: () => window, }); - return { runtime, stateStore, resumeWork, deferredTasks }; + return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding }; } interface PartialChatPanelEnvironment { diff --git a/tests/main.test.ts b/tests/main.test.ts index 63cfbfc5..e397573e 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,45 +1,22 @@ // @vitest-environment jsdom -import { FileSystemAdapter } from "obsidian"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../src/constants"; -import type { Thread } from "../src/domain/threads/model"; -import type { CodexChatHost } from "../src/features/chat/host/contracts"; import type { CodexChatView } from "../src/features/chat/host/view.obsidian"; -import { createThreadNameMutationCoordinator } from "../src/features/threads/workflows/thread-name-mutation-coordinator"; -import type CodexPanelPlugin from "../src/main"; -import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../src/settings/model"; -import { WorkspacePanelCoordinator } from "../src/workspace/panel-coordinator"; -import { chatPanelSettingsAccess } from "./features/chat/support/settings"; -import { deferred, waitForAsyncWork } from "./support/async"; +import { waitForAsyncWork } from "./support/async"; import { installObsidianDomShims } from "./support/dom"; +import { chatView, leaf, pluginWithLeaves, type TestLeaf } from "./support/plugin-fixtures"; installObsidianDomShims(); -const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({ - withShortLivedAppServerClientMock: vi.fn(), -})); - -vi.mock("../src/app-server/connection/short-lived-client", () => ({ - withShortLivedAppServerClient: withShortLivedAppServerClientMock, -})); - -function panels(plugin: CodexPanelPlugin) { - return new WorkspacePanelCoordinator({ - app: plugin.app, - refreshThreadsViewLiveState: vi.fn(), - }); -} - -function threadCatalog(plugin: CodexPanelPlugin) { - return plugin.runtime.chatHost().threadCatalog; -} - -describe("CodexPanelPlugin workspace panel reconciliation", () => { +describe("CodexPanelPlugin lifecycle", () => { beforeEach(() => { vi.useRealTimers(); - withShortLivedAppServerClientMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); }); it("loads restored Codex panel leaves after startup without blocking onload", async () => { @@ -52,318 +29,28 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled(); expect(secondLeaf.loadIfDeferred).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(0); - expect(firstLeaf.loadIfDeferred).toHaveBeenCalledTimes(1); - expect(secondLeaf.loadIfDeferred).toHaveBeenCalledTimes(1); + expect(firstLeaf.loadIfDeferred).toHaveBeenCalledOnce(); + expect(secondLeaf.loadIfDeferred).toHaveBeenCalledOnce(); }); - it("cancels pending workspace panel reconciliation on unload", async () => { + it("cancels pending workspace reconciliation on unload", async () => { vi.useFakeTimers(); - const firstLeaf = leaf(); - const plugin = await pluginWithLeaves([firstLeaf]); + const panelLeaf = leaf(); + const plugin = await pluginWithLeaves([panelLeaf]); await plugin.onload(); plugin.onunload(); await vi.advanceTimersByTimeAsync(0); - expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled(); + expect(panelLeaf.loadIfDeferred).not.toHaveBeenCalled(); }); - it("clears pending workspace panel reconciliation timers on reset without cancelling future schedules", async () => { - vi.useFakeTimers(); - const firstLeaf = leaf(); - const coordinator = panels(await pluginWithLeaves([firstLeaf])); - - coordinator.scheduleWorkspacePanelReconcile(); - coordinator.reset(); - await vi.advanceTimersByTimeAsync(0); - - expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled(); - - coordinator.scheduleWorkspacePanelReconcile(); - await vi.advanceTimersByTimeAsync(0); - - expect(firstLeaf.loadIfDeferred).toHaveBeenCalledOnce(); - }); - - it("loads and focuses a deferred restored panel before opening another panel", async () => { - const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); - const plugin = await pluginWithLeaves([restoredLeaf]); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - restoredLeaf.loadIfDeferred.mockImplementation(async () => { - restoredLeaf.view = chatView(CodexChatView, restoredLeaf); - }); - - await panels(plugin).openThreadInAvailableView("thread-1"); - - expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledTimes(1); - expect(restoredLeaf.view).toBeInstanceOf(CodexChatView); - }); - - it("includes deferred restored panels in open panel snapshots", async () => { - const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); - const plugin = await pluginWithLeaves([restoredLeaf]); - - expect(panels(plugin).getOpenPanelSnapshots()).toMatchObject([ - { - threadId: "thread-1", - turnLifecycle: { kind: "idle" }, - pendingRequests: { actionable: 0 }, - hasComposerDraft: false, - connected: false, - lastFocused: false, - }, - ]); - }); - - it("focuses an already open thread before reusing an empty panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const openLeaf = leaf(); - openLeaf.view = chatView(CodexChatView, openLeaf); - const openView = openLeaf.view as CodexChatView; - vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" })); - vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined); - const emptyLeaf = leaf(); - emptyLeaf.view = chatView(CodexChatView, emptyLeaf); - const emptyView = emptyLeaf.view as CodexChatView; - const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]); - - await panels(plugin).openThreadInAvailableView("thread-1"); - - expect((plugin.app.workspace.revealLeaf as ReturnType).mock.calls).toContainEqual([openLeaf]); - expect(openEmptyThread).not.toHaveBeenCalled(); - }); - - it("reuses an idle empty panel before opening a new panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const busyLeaf = leaf(); - busyLeaf.view = chatView(CodexChatView, busyLeaf); - const busyView = busyLeaf.view as CodexChatView; - vi.spyOn(busyView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "busy-view", threadId: "other-thread" })); - const emptyLeaf = leaf(); - emptyLeaf.view = chatView(CodexChatView, emptyLeaf); - const emptyView = emptyLeaf.view as CodexChatView; - vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "empty-view", threadId: null })); - const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([busyLeaf, emptyLeaf]); - - await panels(plugin).openThreadInAvailableView("thread-1"); - - expect(openEmptyThread).toHaveBeenCalledWith("thread-1"); - }); - - it("does not reuse an idle panel with a pending MCP elicitation as empty", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const pendingLeaf = leaf(); - pendingLeaf.view = chatView(CodexChatView, pendingLeaf); - const pendingView = pendingLeaf.view as CodexChatView; - vi.spyOn(pendingView.surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "pending-view", threadId: null, pendingMcpElicitations: 1 }), - ); - const openPendingThread = vi.spyOn(pendingView.surface, "openThread").mockResolvedValue(undefined); - const emptyLeaf = leaf(); - emptyLeaf.view = chatView(CodexChatView, emptyLeaf); - const emptyView = emptyLeaf.view as CodexChatView; - vi.spyOn(emptyView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "empty-view", threadId: null })); - const openEmptyThread = vi.spyOn(emptyView.surface, "openThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([pendingLeaf, emptyLeaf]); - - await panels(plugin).openThreadInAvailableView("thread-1"); - - expect(openPendingThread).not.toHaveBeenCalled(); - expect(openEmptyThread).toHaveBeenCalledWith("thread-1"); - }); - - it("opens picker Enter selections in the most recent panel when the thread is not already open", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const olderLeaf = leaf(); - olderLeaf.view = chatView(CodexChatView, olderLeaf); - const olderView = olderLeaf.view as CodexChatView; - const openOlderThread = vi.spyOn(olderView.surface, "openThread").mockResolvedValue(undefined); - const currentLeaf = leaf(); - currentLeaf.view = chatView(CodexChatView, currentLeaf); - const currentView = currentLeaf.view as CodexChatView; - const openCurrentThread = vi.spyOn(currentView.surface, "openThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([olderLeaf, currentLeaf]); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(currentLeaf); - - await panels(plugin).openThreadInCurrentView("thread-1"); - - expect(openCurrentThread).toHaveBeenCalledWith("thread-1"); - expect(openOlderThread).not.toHaveBeenCalled(); - }); - - it("opens picker Enter selections in the active Codex panel before the right-sidebar fallback", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const fallbackLeaf = leaf(); - fallbackLeaf.view = chatView(CodexChatView, fallbackLeaf); - const fallbackView = fallbackLeaf.view as CodexChatView; - const openFallbackThread = vi.spyOn(fallbackView.surface, "openThread").mockResolvedValue(undefined); - const activeLeaf = leaf(); - activeLeaf.view = chatView(CodexChatView, activeLeaf); - const activeView = activeLeaf.view as CodexChatView; - const openActiveThread = vi.spyOn(activeView.surface, "openThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([fallbackLeaf, activeLeaf]); - (plugin.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(activeView); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(fallbackLeaf); - - await panels(plugin).openThreadInCurrentView("thread-1"); - - expect(openActiveThread).toHaveBeenCalledWith("thread-1"); - expect(openFallbackThread).not.toHaveBeenCalled(); - }); - - it("focuses an already open thread before picker Enter overwrites the current panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const openLeaf = leaf(); - openLeaf.view = chatView(CodexChatView, openLeaf); - const openView = openLeaf.view as CodexChatView; - vi.spyOn(openView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "open-view", threadId: "thread-1" })); - const focusOpenThread = vi.spyOn(openView.surface, "focusThread").mockResolvedValue(undefined); - const currentLeaf = leaf(); - currentLeaf.view = chatView(CodexChatView, currentLeaf); - const currentView = currentLeaf.view as CodexChatView; - const openCurrentThread = vi.spyOn(currentView.surface, "openThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([openLeaf, currentLeaf]); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(currentLeaf); - - await panels(plugin).openThreadInCurrentView("thread-1"); - - expect(focusOpenThread).toHaveBeenCalledWith("thread-1"); - expect(openCurrentThread).not.toHaveBeenCalled(); - }); - - it("opens picker Enter selections in a deferred current panel even when it restored another thread", async () => { - const restoredLeaf = leaf({ state: { threadId: "restored-thread", threadTitle: "Restored thread" } }); - const plugin = await pluginWithLeaves([restoredLeaf]); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(restoredLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const view = chatView(CodexChatView, restoredLeaf); - const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); - const focusThread = vi.spyOn(view.surface, "focusThread").mockResolvedValue(undefined); - restoredLeaf.loadIfDeferred.mockImplementation(async () => { - restoredLeaf.view = view; - }); - - await panels(plugin).openThreadInCurrentView("selected-thread"); - - expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce(); - expect(openThread).toHaveBeenCalledWith("selected-thread"); - expect(focusThread).not.toHaveBeenCalled(); - }); - - it("opens picker selections in a new panel when a restored current leaf no longer loads a Codex view", async () => { - const restoredLeaf = leaf({ state: { threadId: "restored-thread" } }); - const newLeaf = leaf(); - const plugin = await pluginWithLeaves([restoredLeaf]); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(restoredLeaf); - (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const view = chatView(CodexChatView, newLeaf); - const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); - newLeaf.setViewState.mockImplementation(async () => { - newLeaf.view = view; - }); - - await panels(plugin).openThreadInCurrentView("selected-thread"); - - expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce(); - expect(newLeaf.setViewState).toHaveBeenCalledOnce(); - expect(openThread).toHaveBeenCalledWith("selected-thread"); - }); - - it("opens a thread in a new panel without a separate pre-connect", async () => { - const newLeaf = leaf(); - const plugin = await pluginWithLeaves([]); - (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const view = chatView(CodexChatView, newLeaf); - newLeaf.setViewState.mockImplementation(async () => { - newLeaf.view = view; - }); - const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined); - const focusComposer = vi.spyOn(view.surface, "focusComposer").mockImplementation(() => undefined); - const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); - - await panels(plugin).openThreadInNewView("thread-1"); - - expect(connect).not.toHaveBeenCalled(); - expect(focusComposer).toHaveBeenCalledOnce(); - expect(openThread).toHaveBeenCalledWith("thread-1"); - }); - - it("activates the active Codex panel instead of the first existing panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const firstLeaf = leaf(); - firstLeaf.view = chatView(CodexChatView, firstLeaf); - const firstView = firstLeaf.view as CodexChatView; - const connectFirst = vi.spyOn(firstView.surface, "connect").mockResolvedValue(undefined); - const focusFirst = vi.spyOn(firstView.surface, "focusThread").mockResolvedValue(undefined); - const activeLeaf = leaf(); - activeLeaf.view = chatView(CodexChatView, activeLeaf); - const activeView = activeLeaf.view as CodexChatView; - const connectActive = vi.spyOn(activeView.surface, "connect").mockResolvedValue(undefined); - const focusActive = vi.spyOn(activeView.surface, "focusThread").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]); - (plugin.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(activeView); - - await expect(panels(plugin).activateView()).resolves.toBe(activeView); - - expect((plugin.app.workspace.revealLeaf as ReturnType).mock.calls).toContainEqual([activeLeaf]); - expect(connectActive).toHaveBeenCalledOnce(); - expect(focusActive).toHaveBeenCalledOnce(); - expect(connectFirst).not.toHaveBeenCalled(); - expect(focusFirst).not.toHaveBeenCalled(); - expect((plugin.app.workspace.ensureSideLeaf as ReturnType).mock.calls).toHaveLength(0); - }); - - it("marks snapshots for the last focused Codex panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian"); - const firstLeaf = leaf(); - firstLeaf.view = chatView(CodexChatView, firstLeaf); - vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "first", threadId: "thread-1" }), - ); - const secondLeaf = leaf(); - secondLeaf.view = chatView(CodexChatView, secondLeaf); - vi.spyOn((secondLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "second", threadId: "thread-2" }), - ); - const activeLeafHandlers: ((leaf: TestLeaf | null) => void)[] = []; - const refreshThreadsViewLiveState = vi.fn(); - const threadsView = Object.assign(Object.create(CodexThreadsView.prototype), { - refreshLiveState: refreshThreadsViewLiveState, - }) as InstanceType; - const threadsLeaf = leaf(); - threadsLeaf.view = threadsView; - const pluginWithThreads = await pluginWithLeaves([firstLeaf, secondLeaf], { threadsLeaves: [threadsLeaf] }); - (pluginWithThreads.app.workspace.on as ReturnType).mockImplementation( - (name: string, handler: (leaf: TestLeaf | null) => void) => { - if (name === "active-leaf-change") activeLeafHandlers.push(handler); - return {}; - }, - ); - - await pluginWithThreads.onload(); - const activeLeafHandler = activeLeafHandlers.at(0); - if (!activeLeafHandler) throw new Error("Expected active leaf handler to be registered."); - activeLeafHandler(secondLeaf); - - expect(refreshThreadsViewLiveState).toHaveBeenCalledOnce(); - expect(pluginWithThreads.runtime.threadsHost().openPanelActivities()).toMatchObject([ - { threadId: "thread-1", selected: false }, - { threadId: "thread-2", selected: true }, - ]); - }); - - it("hydrates restored panels when Obsidian activates an open Codex tab", async () => { + it("hydrates a restored panel when Obsidian activates its leaf", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const panelLeaf = leaf(); panelLeaf.view = chatView(CodexChatView, panelLeaf); - const hydrateRestoredThread = vi.spyOn((panelLeaf.view as CodexChatView).surface, "hydrateRestoredThread").mockResolvedValue(undefined); + const hydrate = vi.spyOn((panelLeaf.view as CodexChatView).surface, "hydrateRestoredThread").mockResolvedValue(undefined); const activeLeafHandlers: ((leaf: TestLeaf | null) => void)[] = []; const plugin = await pluginWithLeaves([panelLeaf]); (plugin.app.workspace.on as ReturnType).mockImplementation((name: string, handler: (leaf: TestLeaf | null) => void) => { @@ -372,21 +59,21 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { }); await plugin.onload(); - const activeLeafHandler = activeLeafHandlers.at(0); - if (!activeLeafHandler) throw new Error("Expected active leaf handler to be registered."); - activeLeafHandler(panelLeaf); + const handler = activeLeafHandlers.at(0); + if (!handler) throw new Error("Expected active leaf handler to be registered."); + handler(panelLeaf); await waitForAsyncWork(() => { - expect(hydrateRestoredThread).toHaveBeenCalledOnce(); + expect(hydrate).toHaveBeenCalledOnce(); }); }); - it("loads and hydrates the startup foreground Codex panel through workspace reconciliation", async () => { + it("loads and hydrates the startup foreground panel", async () => { vi.useFakeTimers(); const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const activeLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); const view = chatView(CodexChatView, activeLeaf); - const hydrateRestoredThread = vi.spyOn(view.surface, "hydrateRestoredThread").mockResolvedValue(undefined); + const hydrate = vi.spyOn(view.surface, "hydrateRestoredThread").mockResolvedValue(undefined); activeLeaf.loadIfDeferred.mockImplementation(async () => { activeLeaf.view = view; }); @@ -398,608 +85,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { await waitForAsyncWork(() => { expect(activeLeaf.loadIfDeferred).toHaveBeenCalledOnce(); - expect(hydrateRestoredThread).toHaveBeenCalledOnce(); + expect(hydrate).toHaveBeenCalledOnce(); }); }); - - it("hydrates the right sidebar Codex panel on startup when the active leaf is a note", async () => { - vi.useFakeTimers(); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); - const noteLeaf = nonPanelLeaf(); - const view = chatView(CodexChatView, restoredLeaf); - const hydrateRestoredThread = vi.spyOn(view.surface, "hydrateRestoredThread").mockResolvedValue(undefined); - restoredLeaf.loadIfDeferred.mockImplementation(async () => { - restoredLeaf.view = view; - }); - const plugin = await pluginWithLeaves([restoredLeaf]); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockImplementation((root?: unknown) => - root === plugin.app.workspace.rightSplit ? restoredLeaf : noteLeaf, - ); - - await plugin.onload(); - await vi.advanceTimersByTimeAsync(0); - - await waitForAsyncWork(() => { - expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce(); - expect(hydrateRestoredThread).toHaveBeenCalledOnce(); - }); - }); - - it("initially marks the active Codex panel before focus events arrive", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const firstLeaf = leaf(); - firstLeaf.view = chatView(CodexChatView, firstLeaf); - vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "first", threadId: "thread-1" }), - ); - const activeLeaf = leaf(); - activeLeaf.view = chatView(CodexChatView, activeLeaf); - const activeView = activeLeaf.view as CodexChatView; - vi.spyOn(activeView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" })); - const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]); - (plugin.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(activeView); - - expect(panels(plugin).getOpenPanelSnapshots()).toMatchObject([ - { viewId: "first", lastFocused: false }, - { viewId: "active", lastFocused: true }, - ]); - }); - - it("initially falls back to the most recent right sidebar Codex panel", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const recentLeaf = leaf(); - recentLeaf.view = chatView(CodexChatView, recentLeaf); - vi.spyOn((recentLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "recent", threadId: "thread-1" }), - ); - const otherLeaf = leaf(); - otherLeaf.view = chatView(CodexChatView, otherLeaf); - vi.spyOn((otherLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "other", threadId: "thread-2" }), - ); - const plugin = await pluginWithLeaves([recentLeaf, otherLeaf]); - (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(recentLeaf); - - expect(panels(plugin).getOpenPanelSnapshots()).toMatchObject([ - { viewId: "recent", lastFocused: true }, - { viewId: "other", lastFocused: false }, - ]); - }); - - it("opens an empty new panel from the threads view action", async () => { - const newLeaf = leaf(); - const plugin = await pluginWithLeaves([]); - (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const view = chatView(CodexChatView, newLeaf); - newLeaf.setViewState.mockImplementation(async () => { - newLeaf.view = view; - }); - const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined); - const focusComposer = vi.spyOn(view.surface, "focusComposer").mockImplementation(() => undefined); - const openThread = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); - - await panels(plugin).openNewPanel(); - - expect(connect).toHaveBeenCalledOnce(); - expect(focusComposer).toHaveBeenCalledOnce(); - expect(openThread).not.toHaveBeenCalled(); - }); - - it("opens side chats as regular Codex panels with ephemeral source metadata", async () => { - const newLeaf = leaf(); - const plugin = await pluginWithLeaves([]); - (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const view = chatView(CodexChatView, newLeaf); - newLeaf.setViewState.mockImplementation(async () => { - newLeaf.view = view; - }); - const openSideChat = vi.spyOn(view.surface, "openSideChat").mockResolvedValue(true); - - await panels(plugin).openSideChat("source", "Source thread"); - - expect(newLeaf.setViewState).toHaveBeenCalledWith({ - type: VIEW_TYPE_CODEX_PANEL, - active: true, - state: { version: 2, ephemeralSource: { threadId: "source", title: "Source thread" } }, - }); - expect(openSideChat).toHaveBeenCalledWith({ sourceThreadId: "source", sourceThreadTitle: "Source thread" }); - }); - - it("does not refresh shared thread lists after known archive mutations", 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 refreshSharedThreads = vi.spyOn(connectedView.surface, "refreshSharedThreads").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([connectedLeaf]); - - threadCatalog(plugin).apply({ type: "thread-archived", threadId: "thread-1" }); - - expect(refreshSharedThreads).not.toHaveBeenCalled(); - }); - - 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 secondLeaf = leaf(); - secondLeaf.view = chatView(CodexChatView, secondLeaf); - const secondArchived = vi.spyOn((secondLeaf.view as CodexChatView).surface, "applyThreadArchived").mockImplementation(() => 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"); - }); - - it("keeps catalog archive notifications separate from explicit panel close requests", 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" })); - 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(restoredMatchingLeaf.detach).not.toHaveBeenCalled(); - expect(matchingLeaf.detach).not.toHaveBeenCalled(); - expect(otherLeaf.detach).not.toHaveBeenCalled(); - - plugin.runtime.threadsHost().closeOpenPanelsForThread("thread-1"); - - expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce(); - expect(matchingLeaf.detach).toHaveBeenCalledOnce(); - expect(otherLeaf.detach).not.toHaveBeenCalled(); - }); - - it("does not refresh shared thread lists after known rename mutations", 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 refreshSharedThreads = vi.spyOn(connectedView.surface, "refreshSharedThreads").mockResolvedValue(undefined); - const plugin = await pluginWithLeaves([connectedLeaf]); - - threadCatalog(plugin).apply({ type: "thread-renamed", threadId: "thread-1", name: "Renamed thread" }); - - expect(refreshSharedThreads).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 secondLeaf = leaf(); - secondLeaf.view = chatView(CodexChatView, secondLeaf); - const secondRenamed = vi.spyOn((secondLeaf.view as CodexChatView).surface, "applyThreadRenamed").mockImplementation(() => 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"); - }); - - 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("keeps the previous shared thread list when refresh fails", 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); - const runWithAppServerClient = vi - .spyOn(connectedView.surface, "runWithAppServerClient") - .mockImplementationOnce((operation) => operation(threadListClient(() => Promise.resolve([thread("cached")])))) - .mockImplementationOnce(() => Promise.reject(new Error("boom"))); - const plugin = await pluginWithLeaves([connectedLeaf]); - await threadCatalog(plugin).refreshActive(); - - await expect(threadCatalog(plugin).refreshActive()).rejects.toThrow("boom"); - - expect(runWithAppServerClient).toHaveBeenCalledTimes(2); - expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("cached")]); - }); - - 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("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(); - }); - - it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => { - const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); - const sourceLeaf = leaf(); - sourceLeaf.view = chatView(CodexChatView, sourceLeaf); - const sourceView = sourceLeaf.view as CodexChatView; - vi.spyOn(sourceView.surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "source", threadId: "thread-1", connected: true }), - ); - const sourceRefresh = vi.spyOn(sourceView.surface, "refreshSharedThreads").mockResolvedValue(undefined); - - const remainingLeaf = leaf(); - remainingLeaf.view = chatView(CodexChatView, remainingLeaf); - const remainingView = remainingLeaf.view as CodexChatView; - vi.spyOn(remainingView.surface, "openPanelSnapshot").mockReturnValue( - panelSnapshot({ viewId: "remaining", threadId: "forked-thread", connected: true }), - ); - const remainingRefresh = vi.spyOn(remainingView.surface, "refreshSharedThreads").mockResolvedValue(undefined); - - const leaves = [sourceLeaf, remainingLeaf]; - sourceLeaf.detach.mockImplementation(() => { - leaves.splice(leaves.indexOf(sourceLeaf), 1); - }); - const plugin = await pluginWithLeaves(leaves); - - sourceLeaf.detach(); - threadCatalog(plugin).apply({ type: "thread-archived", threadId: "thread-1" }); - - expect(sourceRefresh).not.toHaveBeenCalled(); - expect(remainingRefresh).not.toHaveBeenCalled(); - }); }); - -async function pluginWithLeaves(leaves: ReturnType[], options: { threadsLeaves?: ReturnType[] } = {}) { - const { default: CodexPanelPlugin } = await import("../src/main"); - const adapter = new FileSystemAdapter(); - vi.spyOn(adapter, "getBasePath").mockReturnValue("/vault"); - const plugin = new CodexPanelPlugin( - { - vault: { - adapter, - }, - workspace: { - getLeavesOfType: vi.fn((type: string) => { - if (type === VIEW_TYPE_CODEX_PANEL) return leaves; - if (type === VIEW_TYPE_CODEX_THREADS) return options.threadsLeaves ?? []; - return []; - }), - revealLeaf: vi.fn().mockResolvedValue(undefined), - getRightLeaf: vi.fn(() => null), - getMostRecentLeaf: vi.fn(() => null), - getActiveViewOfType: vi.fn(() => null), - ensureSideLeaf: vi.fn(() => Promise.reject(new Error("Unexpected ensureSideLeaf call."))), - on: vi.fn(() => ({})), - activeLeaf: null, - rightSplit: {}, - }, - } as never, - {} as never, - ); - plugin.settings = { ...DEFAULT_SETTINGS }; - plugin.vaultPath = "/vault"; - plugin.runtime.initialize(); - return plugin; -} - -async function publishCodexPath(plugin: CodexPanelPlugin, codexPath: string): Promise { - await plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath }); -} - -type TestLeaf = ReturnType; - -function leaf(options: { state?: Record } = {}) { - return { - view: null as unknown, - getViewState: vi.fn(() => ({ type: VIEW_TYPE_CODEX_PANEL, state: options.state ?? {} })), - getRoot: vi.fn(() => ({})), - parent: {}, - setViewState: vi.fn().mockResolvedValue(undefined), - loadIfDeferred: vi.fn().mockResolvedValue(undefined), - detach: vi.fn(), - }; -} - -function nonPanelLeaf() { - return { - view: null as unknown, - getViewState: vi.fn(() => ({ type: "markdown", state: {} })), - }; -} - -function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) { - const containerEl = document.createElement("div"); - containerEl.createDiv(); - containerEl.createDiv(); - return new CodexChatViewCtor( - { - ...leaf, - app: { - workspace: { - getActiveFile: vi.fn(() => null), - getLastOpenFiles: vi.fn(() => []), - on: vi.fn(() => ({})), - openLinkText: vi.fn(), - requestSaveLayout: vi.fn(), - }, - vault: { - on: vi.fn(() => ({})), - offref: vi.fn(), - getFiles: vi.fn(() => []), - getMarkdownFiles: vi.fn(() => []), - getAbstractFileByPath: vi.fn(() => null), - }, - metadataCache: { - on: vi.fn(() => ({})), - offref: vi.fn(), - getFirstLinkpathDest: vi.fn(() => null), - fileToLinktext: vi.fn((_file: unknown, _sourcePath: string) => ""), - getFileCache: vi.fn(() => null), - }, - }, - containerEl, - } as never, - chatHostFixture(), - ); -} - -function chatHostFixture(): CodexChatHost { - const settings: CodexPanelSettings = { ...DEFAULT_SETTINGS, codexPath: "codex", sendShortcut: "enter" }; - return { - threadNameMutations: createThreadNameMutationCoordinator(), - settingsRef: { - settings: chatPanelSettingsAccess(settings), - vaultPath: "/vault", - }, - workspace: { - openThreadInNewView: vi.fn(), - focusThreadInOpenView: vi.fn(), - openTurnDiff: vi.fn(), - refreshThreadsViewLiveState: vi.fn(), - openSideChat: vi.fn(), - }, - appServerQueries: { - contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath: "/vault" }, generation: 1 }), - appServerMetadataSnapshot: vi.fn(() => null), - refreshAppServerMetadata: vi.fn(() => Promise.resolve(null)), - refreshSkills: vi.fn(() => Promise.resolve(null)), - refreshRateLimits: vi.fn(() => Promise.resolve(null)), - modelsSnapshot: vi.fn(() => null), - fetchModels: vi.fn(() => Promise.resolve([])), - refreshModels: vi.fn(() => Promise.resolve([])), - observeAppServerMetadataResult: vi.fn(() => () => undefined), - observeModelsResult: vi.fn(() => () => undefined), - }, - threadCatalog: { - apply: vi.fn(), - applyConnectionEvent: vi.fn(), - loadActive: vi.fn(() => Promise.resolve([])), - refreshActive: vi.fn(() => Promise.resolve([])), - activeSnapshot: vi.fn(() => null), - observeActive: vi.fn(() => () => undefined), - }, - }; -} - -function threadListClient(fetchThreads: () => Promise): never { - return { - request: async (method: string) => { - if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`); - return { - data: await fetchThreads(), - nextCursor: null, - }; - }, - } as never; -} - -async function flushMicrotasks(): Promise { - await new Promise((resolve) => setImmediate(resolve)); -} - -function thread(id: string): Thread { - return { - id, - preview: id, - createdAt: 1, - updatedAt: 1, - name: null, - archived: false, - provenance: { kind: "interactive" }, - }; -} - -function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType { - const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, ...snapshotOverrides } = overrides; - const pendingRequests = snapshotOverrides.pendingRequests ?? { - approvals: pendingApprovals ?? 0, - userInputs: pendingUserInputs ?? 0, - mcpElicitations: pendingMcpElicitations ?? 0, - actionable: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0), - }; - return { - viewId: "view", - threadId: "thread", - turnLifecycle: { kind: "idle" }, - pendingRequests, - hasComposerDraft: false, - connected: true, - ...snapshotOverrides, - }; -} - -type PanelSnapshotFixtureOverrides = Partial> & { - pendingApprovals?: number; - pendingUserInputs?: number; - pendingMcpElicitations?: number; -}; diff --git a/tests/plugin-runtime.integration.test.ts b/tests/plugin-runtime.integration.test.ts new file mode 100644 index 00000000..d5e6c225 --- /dev/null +++ b/tests/plugin-runtime.integration.test.ts @@ -0,0 +1,279 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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("keeps catalog archive notifications separate from explicit panel close requests", 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" })); + 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(restoredMatchingLeaf.detach).not.toHaveBeenCalled(); + expect(matchingLeaf.detach).not.toHaveBeenCalled(); + expect(otherLeaf.detach).not.toHaveBeenCalled(); + + plugin.runtime.threadsHost().closeOpenPanelsForThread("thread-1"); + + expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce(); + expect(matchingLeaf.detach).toHaveBeenCalledOnce(); + 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("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(); + }); +}); diff --git a/tests/support/plugin-fixtures.ts b/tests/support/plugin-fixtures.ts new file mode 100644 index 00000000..e0587ea0 --- /dev/null +++ b/tests/support/plugin-fixtures.ts @@ -0,0 +1,194 @@ +// @vitest-environment jsdom + +import { FileSystemAdapter } from "obsidian"; +import { vi } from "vitest"; + +import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../../src/constants"; +import type { Thread } from "../../src/domain/threads/model"; +import type { CodexChatHost } from "../../src/features/chat/host/contracts"; +import type { CodexChatView } from "../../src/features/chat/host/view.obsidian"; +import { createThreadNameMutationCoordinator } from "../../src/features/threads/workflows/thread-name-mutation-coordinator"; +import type CodexPanelPlugin from "../../src/main"; +import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../src/settings/model"; +import { chatPanelSettingsAccess } from "../features/chat/support/settings"; + +export async function pluginWithLeaves(leaves: TestLeaf[], options: { threadsLeaves?: TestLeaf[] } = {}): Promise { + const { default: CodexPanelPlugin } = await import("../../src/main"); + const adapter = new FileSystemAdapter(); + vi.spyOn(adapter, "getBasePath").mockReturnValue("/vault"); + const plugin = new CodexPanelPlugin( + { + vault: { adapter }, + workspace: { + getLeavesOfType: vi.fn((type: string) => { + if (type === VIEW_TYPE_CODEX_PANEL) return leaves; + if (type === VIEW_TYPE_CODEX_THREADS) return options.threadsLeaves ?? []; + return []; + }), + revealLeaf: vi.fn().mockResolvedValue(undefined), + getRightLeaf: vi.fn(() => null), + getMostRecentLeaf: vi.fn(() => null), + getActiveViewOfType: vi.fn(() => null), + ensureSideLeaf: vi.fn(() => Promise.reject(new Error("Unexpected ensureSideLeaf call."))), + on: vi.fn(() => ({})), + activeLeaf: null, + rightSplit: {}, + }, + } as never, + {} as never, + ); + plugin.settings = { ...DEFAULT_SETTINGS }; + plugin.vaultPath = "/vault"; + plugin.runtime.initialize(); + return plugin; +} + +export async function publishCodexPath(plugin: CodexPanelPlugin, codexPath: string): Promise { + await plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, codexPath }); +} + +export interface TestLeaf { + view: unknown; + getViewState: ReturnType; + getRoot: ReturnType; + parent: object; + setViewState: ReturnType; + loadIfDeferred: ReturnType; + detach: ReturnType; +} + +export function leaf(options: { state?: Record } = {}): TestLeaf { + return { + view: null, + getViewState: vi.fn(() => ({ type: VIEW_TYPE_CODEX_PANEL, state: options.state ?? {} })), + getRoot: vi.fn(() => ({})), + parent: {}, + setViewState: vi.fn().mockResolvedValue(undefined), + loadIfDeferred: vi.fn().mockResolvedValue(undefined), + detach: vi.fn(), + }; +} + +export function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf): CodexChatView { + const containerEl = document.createElement("div"); + containerEl.createDiv(); + containerEl.createDiv(); + return new CodexChatViewCtor( + { + ...leaf, + app: { + workspace: { + getActiveFile: vi.fn(() => null), + getLastOpenFiles: vi.fn(() => []), + on: vi.fn(() => ({})), + openLinkText: vi.fn(), + requestSaveLayout: vi.fn(), + }, + vault: { + on: vi.fn(() => ({})), + offref: vi.fn(), + getFiles: vi.fn(() => []), + getMarkdownFiles: vi.fn(() => []), + getAbstractFileByPath: vi.fn(() => null), + }, + metadataCache: { + on: vi.fn(() => ({})), + offref: vi.fn(), + getFirstLinkpathDest: vi.fn(() => null), + fileToLinktext: vi.fn((_file: unknown, _sourcePath: string) => ""), + getFileCache: vi.fn(() => null), + }, + }, + containerEl, + } as never, + chatHostFixture(), + ); +} + +function chatHostFixture(): CodexChatHost { + const settings: CodexPanelSettings = { ...DEFAULT_SETTINGS, codexPath: "codex", sendShortcut: "enter" }; + return { + threadNameMutations: createThreadNameMutationCoordinator(), + settingsRef: { + settings: chatPanelSettingsAccess(settings), + vaultPath: "/vault", + }, + workspace: { + openThreadInNewView: vi.fn(), + focusThreadInOpenView: vi.fn(), + openTurnDiff: vi.fn(), + refreshThreadsViewLiveState: vi.fn(), + openSideChat: vi.fn(), + }, + appServerQueries: { + contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath: "/vault" }, generation: 1 }), + appServerMetadataSnapshot: vi.fn(() => null), + refreshAppServerMetadata: vi.fn(() => Promise.resolve(null)), + refreshSkills: vi.fn(() => Promise.resolve(null)), + refreshRateLimits: vi.fn(() => Promise.resolve(null)), + modelsSnapshot: vi.fn(() => null), + fetchModels: vi.fn(() => Promise.resolve([])), + refreshModels: vi.fn(() => Promise.resolve([])), + observeAppServerMetadataResult: vi.fn(() => () => undefined), + observeModelsResult: vi.fn(() => () => undefined), + }, + threadCatalog: { + apply: vi.fn(), + applyConnectionEvent: vi.fn(), + loadActive: vi.fn(() => Promise.resolve([])), + refreshActive: vi.fn(() => Promise.resolve([])), + activeSnapshot: vi.fn(() => null), + observeActive: vi.fn(() => () => undefined), + }, + }; +} + +export function threadListClient(fetchThreads: () => Promise): never { + return { + request: async (method: string) => { + if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`); + return { data: await fetchThreads(), nextCursor: null }; + }, + } as never; +} + +export async function flushMicrotasks(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +export function thread(id: string): Thread { + return { + id, + preview: id, + createdAt: 1, + updatedAt: 1, + name: null, + archived: false, + provenance: { kind: "interactive" }, + }; +} + +export function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType { + const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, ...snapshotOverrides } = overrides; + const pendingRequests = snapshotOverrides.pendingRequests ?? { + approvals: pendingApprovals ?? 0, + userInputs: pendingUserInputs ?? 0, + mcpElicitations: pendingMcpElicitations ?? 0, + actionable: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0), + }; + return { + viewId: "view", + threadId: "thread", + turnLifecycle: { kind: "idle" }, + pendingRequests, + hasComposerDraft: false, + connected: true, + ...snapshotOverrides, + }; +} + +type PanelSnapshotFixtureOverrides = Partial> & { + pendingApprovals?: number; + pendingUserInputs?: number; + pendingMcpElicitations?: number; +}; diff --git a/tests/workspace/panel-coordinator.test.ts b/tests/workspace/panel-coordinator.test.ts new file mode 100644 index 00000000..71e103cc --- /dev/null +++ b/tests/workspace/panel-coordinator.test.ts @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { VIEW_TYPE_CODEX_PANEL } from "../../src/constants"; +import type { CodexChatView } from "../../src/features/chat/host/view.obsidian"; +import type CodexPanelPlugin from "../../src/main"; +import { WorkspacePanelCoordinator } from "../../src/workspace/panel-coordinator"; +import { installObsidianDomShims } from "../support/dom"; +import { chatView, leaf, panelSnapshot, pluginWithLeaves } from "../support/plugin-fixtures"; + +installObsidianDomShims(); + +describe("WorkspacePanelCoordinator", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("cancels a pending reconciliation without cancelling later schedules", async () => { + vi.useFakeTimers(); + const panelLeaf = leaf(); + const coordinator = panels(await pluginWithLeaves([panelLeaf])); + + coordinator.scheduleWorkspacePanelReconcile(); + coordinator.reset(); + await vi.advanceTimersByTimeAsync(0); + expect(panelLeaf.loadIfDeferred).not.toHaveBeenCalled(); + + coordinator.scheduleWorkspacePanelReconcile(); + await vi.advanceTimersByTimeAsync(0); + expect(panelLeaf.loadIfDeferred).toHaveBeenCalledOnce(); + }); + + it("includes deferred restored panels in snapshots", async () => { + const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } }); + const coordinator = panels(await pluginWithLeaves([restoredLeaf])); + + expect(coordinator.getOpenPanelSnapshots()).toMatchObject([ + { threadId: "thread-1", connected: false, lastFocused: false, pendingRequests: { actionable: 0 } }, + ]); + }); + + it("focuses an already open thread before reusing an empty panel", async () => { + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const openLeaf = leaf(); + openLeaf.view = chatView(CodexChatView, openLeaf); + vi.spyOn((openLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( + panelSnapshot({ viewId: "open", threadId: "thread-1" }), + ); + const focus = vi.spyOn((openLeaf.view as CodexChatView).surface, "focusThread").mockResolvedValue(undefined); + const emptyLeaf = leaf(); + emptyLeaf.view = chatView(CodexChatView, emptyLeaf); + const open = vi.spyOn((emptyLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]); + + await panels(plugin).openThreadInAvailableView("thread-1"); + + expect(focus).toHaveBeenCalledWith("thread-1"); + expect(open).not.toHaveBeenCalled(); + }); + + it("reuses an idle empty panel but skips one with an actionable request", async () => { + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const pendingLeaf = leaf(); + pendingLeaf.view = chatView(CodexChatView, pendingLeaf); + vi.spyOn((pendingLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( + panelSnapshot({ viewId: "pending", threadId: null, pendingMcpElicitations: 1 }), + ); + const openPending = vi.spyOn((pendingLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const emptyLeaf = leaf(); + emptyLeaf.view = chatView(CodexChatView, emptyLeaf); + vi.spyOn((emptyLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( + panelSnapshot({ viewId: "empty", threadId: null }), + ); + const openEmpty = vi.spyOn((emptyLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + + await panels(await pluginWithLeaves([pendingLeaf, emptyLeaf])).openThreadInAvailableView("thread-1"); + + expect(openPending).not.toHaveBeenCalled(); + expect(openEmpty).toHaveBeenCalledWith("thread-1"); + }); + + it("uses the active Codex panel for current-view selections", async () => { + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const fallbackLeaf = leaf(); + fallbackLeaf.view = chatView(CodexChatView, fallbackLeaf); + const fallbackOpen = vi.spyOn((fallbackLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const activeLeaf = leaf(); + activeLeaf.view = chatView(CodexChatView, activeLeaf); + const activeView = activeLeaf.view as CodexChatView; + const activeOpen = vi.spyOn(activeView.surface, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([fallbackLeaf, activeLeaf]); + (plugin.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(activeView); + (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(fallbackLeaf); + + await panels(plugin).openThreadInCurrentView("thread-1"); + + expect(activeOpen).toHaveBeenCalledWith("thread-1"); + expect(fallbackOpen).not.toHaveBeenCalled(); + }); + + it("preserves an already open destination before replacing the current panel", async () => { + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const destinationLeaf = leaf(); + destinationLeaf.view = chatView(CodexChatView, destinationLeaf); + vi.spyOn((destinationLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( + panelSnapshot({ viewId: "destination", threadId: "thread-1" }), + ); + const focusDestination = vi.spyOn((destinationLeaf.view as CodexChatView).surface, "focusThread").mockResolvedValue(undefined); + const currentLeaf = leaf(); + currentLeaf.view = chatView(CodexChatView, currentLeaf); + const openCurrent = vi.spyOn((currentLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([destinationLeaf, currentLeaf]); + (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(currentLeaf); + + await panels(plugin).openThreadInCurrentView("thread-1"); + + expect(focusDestination).toHaveBeenCalledWith("thread-1"); + expect(openCurrent).not.toHaveBeenCalled(); + }); + + it("restores an exact deferred destination before replacing the current panel", async () => { + const restoredLeaf = leaf({ state: { threadId: "thread-1" } }); + const currentLeaf = leaf(); + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + currentLeaf.view = chatView(CodexChatView, currentLeaf); + const openCurrent = vi.spyOn((currentLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const restoredView = chatView(CodexChatView, restoredLeaf); + const focusRestored = vi.spyOn(restoredView.surface, "focusThread").mockResolvedValue(undefined); + restoredLeaf.loadIfDeferred.mockImplementation(async () => { + restoredLeaf.view = restoredView; + }); + const plugin = await pluginWithLeaves([restoredLeaf, currentLeaf]); + (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(currentLeaf); + + await panels(plugin).openThreadInCurrentView("thread-1"); + + expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce(); + expect(focusRestored).toHaveBeenCalledWith("thread-1"); + expect(openCurrent).not.toHaveBeenCalled(); + }); + + it("uses the most recent panel and otherwise falls back to the first panel", async () => { + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const firstLeaf = leaf(); + firstLeaf.view = chatView(CodexChatView, firstLeaf); + const openFirst = vi.spyOn((firstLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const recentLeaf = leaf(); + recentLeaf.view = chatView(CodexChatView, recentLeaf); + const openRecent = vi.spyOn((recentLeaf.view as CodexChatView).surface, "openThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([firstLeaf, recentLeaf]); + const mostRecentLeaf = plugin.app.workspace.getMostRecentLeaf as ReturnType; + mostRecentLeaf.mockReturnValue(recentLeaf); + + await panels(plugin).openThreadInCurrentView("thread-1"); + expect(openRecent).toHaveBeenCalledWith("thread-1"); + expect(openFirst).not.toHaveBeenCalled(); + + openRecent.mockClear(); + mostRecentLeaf.mockReturnValue(null); + await panels(plugin).openThreadInCurrentView("thread-2"); + expect(openFirst).toHaveBeenCalledWith("thread-2"); + expect(openRecent).not.toHaveBeenCalled(); + }); + + it("loads a deferred current panel before opening the selected thread", async () => { + const restoredLeaf = leaf({ state: { threadId: "restored-thread" } }); + const plugin = await pluginWithLeaves([restoredLeaf]); + (plugin.app.workspace.getMostRecentLeaf as ReturnType).mockReturnValue(restoredLeaf); + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const view = chatView(CodexChatView, restoredLeaf); + const open = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); + restoredLeaf.loadIfDeferred.mockImplementation(async () => { + restoredLeaf.view = view; + }); + + await panels(plugin).openThreadInCurrentView("selected-thread"); + + expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledOnce(); + expect(open).toHaveBeenCalledWith("selected-thread"); + }); + + it("opens a thread in a new panel without a separate pre-connect", async () => { + const newLeaf = leaf(); + const plugin = await pluginWithLeaves([]); + (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValue(newLeaf); + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const view = chatView(CodexChatView, newLeaf); + newLeaf.setViewState.mockImplementation(async () => { + newLeaf.view = view; + }); + const connect = vi.spyOn(view.surface, "connect").mockResolvedValue(undefined); + const open = vi.spyOn(view.surface, "openThread").mockResolvedValue(undefined); + + await panels(plugin).openThreadInNewView("thread-1"); + + expect(connect).not.toHaveBeenCalled(); + expect(open).toHaveBeenCalledWith("thread-1"); + }); + + it("activates and marks the active Codex panel", async () => { + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const firstLeaf = leaf(); + firstLeaf.view = chatView(CodexChatView, firstLeaf); + vi.spyOn((firstLeaf.view as CodexChatView).surface, "openPanelSnapshot").mockReturnValue( + panelSnapshot({ viewId: "first", threadId: "thread-1" }), + ); + const activeLeaf = leaf(); + activeLeaf.view = chatView(CodexChatView, activeLeaf); + const activeView = activeLeaf.view as CodexChatView; + vi.spyOn(activeView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "active", threadId: "thread-2" })); + const connect = vi.spyOn(activeView.surface, "connect").mockResolvedValue(undefined); + const focus = vi.spyOn(activeView.surface, "focusThread").mockResolvedValue(undefined); + const plugin = await pluginWithLeaves([firstLeaf, activeLeaf]); + (plugin.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(activeView); + + await expect(panels(plugin).activateView()).resolves.toBe(activeView); + expect(connect).toHaveBeenCalledOnce(); + expect(focus).toHaveBeenCalledOnce(); + expect(panels(plugin).getOpenPanelSnapshots()).toMatchObject([ + { viewId: "first", lastFocused: false }, + { viewId: "active", lastFocused: true }, + ]); + }); + + it("opens empty panels and side chats with their distinct startup contracts", async () => { + const emptyLeaf = leaf(); + const sideLeaf = leaf(); + const plugin = await pluginWithLeaves([]); + const { CodexChatView } = await import("../../src/features/chat/host/view.obsidian"); + const emptyView = chatView(CodexChatView, emptyLeaf); + const sideView = chatView(CodexChatView, sideLeaf); + const connect = vi.spyOn(emptyView.surface, "connect").mockResolvedValue(undefined); + const openSideChat = vi.spyOn(sideView.surface, "openSideChat").mockResolvedValue(true); + (plugin.app.workspace.getRightLeaf as ReturnType).mockReturnValueOnce(emptyLeaf).mockReturnValueOnce(sideLeaf); + emptyLeaf.setViewState.mockImplementation(async () => { + emptyLeaf.view = emptyView; + }); + sideLeaf.setViewState.mockImplementation(async () => { + sideLeaf.view = sideView; + }); + + await panels(plugin).openNewPanel(); + await panels(plugin).openSideChat("source", "Source thread"); + + expect(connect).toHaveBeenCalledOnce(); + expect(sideLeaf.setViewState).toHaveBeenCalledWith({ + type: VIEW_TYPE_CODEX_PANEL, + active: true, + state: { version: 2, ephemeralSource: { threadId: "source", title: "Source thread" } }, + }); + expect(openSideChat).toHaveBeenCalledWith({ sourceThreadId: "source", sourceThreadTitle: "Source thread" }); + }); +}); + +function panels(plugin: CodexPanelPlugin): WorkspacePanelCoordinator { + return new WorkspacePanelCoordinator({ app: plugin.app, refreshThreadsViewLiveState: vi.fn() }); +}