From 1ef344b02af6099005669f1ffc075f1b3a9ce088 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 19 Jul 2026 06:19:38 +0900 Subject: [PATCH] test(chat): split view lifecycle coverage for parallel execution --- .../chat/host/view-connection-harness.ts | 702 ++++++++ .../chat/host/view-connection.test.ts | 1441 +---------------- .../chat/host/view-restoration.test.ts | 346 ++++ .../chat/host/view-thread-state.test.ts | 345 ++++ 4 files changed, 1457 insertions(+), 1377 deletions(-) create mode 100644 tests/features/chat/host/view-connection-harness.ts create mode 100644 tests/features/chat/host/view-restoration.test.ts create mode 100644 tests/features/chat/host/view-thread-state.test.ts diff --git a/tests/features/chat/host/view-connection-harness.ts b/tests/features/chat/host/view-connection-harness.ts new file mode 100644 index 00000000..abd9e1f0 --- /dev/null +++ b/tests/features/chat/host/view-connection-harness.ts @@ -0,0 +1,702 @@ +// @vitest-environment jsdom + +import { afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; +import type { ServerNotification, ServerRequest } from "../../../../src/app-server/connection/rpc-messages"; +import { modelMetadataFromCatalogModels } from "../../../../src/app-server/protocol/catalog"; +import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; +import type { ObservedResult } from "../../../../src/app-server/query/observed-result"; +import type { ModelMetadata } from "../../../../src/domain/catalog/metadata"; +import { emptyRuntimeConfigSnapshot } from "../../../../src/domain/runtime/config"; +import { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics"; +import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../../src/domain/server/metadata"; +import type { Thread } from "../../../../src/domain/threads/model"; +import { createThreadGoalOperationCoordinator } from "../../../../src/features/chat/application/threads/goal-actions"; +import type { CodexChatHost } from "../../../../src/features/chat/host/contracts"; +import type { ThreadCatalogEvent } from "../../../../src/features/threads/catalog/thread-catalog"; +import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator"; +import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model"; +import { createKeyedOperationQueue } from "../../../../src/shared/runtime/keyed-operation-queue"; +import { notices } from "../../../mocks/obsidian"; +import { installObsidianDomShims } from "../../../support/dom"; +import { chatPanelSettingsAccess } from "../support/settings"; + +export interface TestCodexChatHost extends CodexChatHost { + readonly settingsSource: CodexPanelSettings; + receiveActiveThreads(threads: readonly Thread[]): void; +} +let CodexChatView: typeof import("../../../../src/features/chat/host/view.obsidian")["CodexChatView"]; +interface TrackedView { + view: { onClose(): Promise | void }; + opened: boolean; +} +let createdViews: TrackedView[] = []; + +const connectionMock = vi.hoisted(() => { + const state = { + client: null as Record | null, + connectCalls: 0, + connected: false, + onNotification: null as ((notification: ServerNotification) => void) | null, + onServerRequest: null as + | ((request: ServerRequest, responder: { respond(result: unknown): void; reject(code: number, message: string): void }) => void) + | null, + onExit: null as (() => void) | null, + }; + + return { + state, + reset(): void { + state.client = null; + state.connectCalls = 0; + state.connected = false; + state.onNotification = null; + state.onServerRequest = null; + state.onExit = null; + }, + }; +}); + +export function connectionMockState(): typeof connectionMock.state { + return connectionMock.state; +} + +vi.mock("../../../../src/app-server/connection/connection-manager", () => { + class StaleConnectionError extends Error {} + + class ConnectionManager { + private readonly codexPath: () => string; + private readonly cwd: string; + private handlers: { + onNotification: (notification: ServerNotification, context: { codexPath: string; cwd: string }) => void; + onServerRequest: ( + request: unknown, + responder: { respond(result: unknown): void; reject(code: number, message: string): void }, + ) => void; + onLog: (message: string) => void; + onExit: (context: { codexPath: string; cwd: string }) => void; + } | null; + + constructor(codexPath: () => string, cwd: string) { + this.codexPath = codexPath; + this.cwd = cwd; + this.handlers = null; + } + + connect(handlers: { + onNotification: (notification: ServerNotification, context: { codexPath: string; cwd: string }) => void; + onServerRequest: ( + request: unknown, + responder: { respond(result: unknown): void; reject(code: number, message: string): void }, + ) => void; + onLog: (message: string) => void; + onExit: (context: { codexPath: string; cwd: string }) => void; + }): Promise { + this.handlers = handlers; + this.publishHandlers(handlers); + connectionMock.state.connectCalls += 1; + connectionMock.state.connected = true; + return Promise.resolve({ + userAgent: "codex-test", + codexHome: "/tmp/codex", + platformFamily: "unix", + platformOs: "macos", + }); + } + + currentClient(): unknown { + return connectionMock.state.connected ? connectionMock.state.client : null; + } + + isConnected(): boolean { + return connectionMock.state.connected; + } + + resetConnection(): void { + this.disconnect(); + } + + disconnect(): void { + connectionMock.state.connected = false; + } + + exit(): void { + connectionMock.state.connected = false; + this.handlers?.onExit({ codexPath: this.codexPath(), cwd: this.cwd }); + } + + private publishHandlers(handlers: NonNullable): void { + const context = { codexPath: this.codexPath(), cwd: this.cwd }; + connectionMock.state.onNotification = (notification) => handlers.onNotification(notification, context); + connectionMock.state.onServerRequest = (request, responder) => handlers.onServerRequest(request, responder); + connectionMock.state.onExit = () => handlers.onExit(context); + } + } + + return { ConnectionManager, StaleConnectionError }; +}); + +installObsidianDomShims(); + +export function setupViewConnectionHarness(): void { + let restoreDefaultThreadStreamViewportMetrics: (() => void) | null = null; + + beforeAll(async () => { + ({ CodexChatView } = await import("../../../../src/features/chat/host/view.obsidian")); + }); + + beforeEach(() => { + vi.useRealTimers(); + notices.length = 0; + connectionMock.reset(); + restoreDefaultThreadStreamViewportMetrics = mockThreadStreamViewportOffsetMetrics({ clientHeight: 320, clientWidth: 240 }); + }); + + afterEach(async () => { + for (const entry of createdViews.reverse()) { + if (entry.opened) await entry.view.onClose(); + } + createdViews = []; + vi.useRealTimers(); + restoreDefaultThreadStreamViewportMetrics?.(); + restoreDefaultThreadStreamViewportMetrics = null; + document.body.replaceChildren(); + }); +} + +type RequestHandler = ReturnType unknown>>; +type RequestSpy = ReturnType unknown>>; +export type RequestHandlers = Record; +export type TestAppServerClient = { + requestHandlers: RequestHandlers; + request: RequestSpy; +}; + +export function connectedClient(overrides: RequestHandlers = {}): TestAppServerClient { + return requestClient({ ...baseClientHandlers(), ...overrides }); +} + +function baseClientHandlers(): RequestHandlers { + return { + "config/read": vi.fn().mockResolvedValue({}), + "model/list": vi.fn().mockResolvedValue({ data: [] }), + "skills/list": vi.fn().mockResolvedValue({ data: [] }), + "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), + "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: null }), + "thread/list": vi.fn().mockResolvedValue({ data: [] }), + "thread/start": vi.fn().mockResolvedValue(startedThread("thread-new")), + "thread/resume": vi.fn().mockResolvedValue(resumedThread("thread-1")), + "thread/turns/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), + "turn/start": vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }), + "thread/fork": vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }), + "thread/rollback": vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }), + "thread/name/set": vi.fn().mockResolvedValue({}), + "thread/goal/get": vi.fn().mockResolvedValue({ goal: null }), + "thread/goal/set": vi.fn().mockResolvedValue({ goal: goalFixture("thread-1") }), + "thread/inject_items": vi.fn().mockResolvedValue({}), + "thread/read": vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }), + "thread/archive": vi.fn().mockResolvedValue({}), + }; +} + +function requestClient(handlers: RequestHandlers): TestAppServerClient { + return { + requestHandlers: handlers, + request: vi.fn((method: string, params: unknown, options?: unknown) => { + const handler = handlers[method]; + if (!handler) throw new Error(`Unexpected app-server request: ${method}`); + return handler(params, options); + }), + }; +} + +export function requestMethods(client: TestAppServerClient): string[] { + return client.request.mock.calls.map(([method]) => method); +} + +export function expectRequestTimes(client: TestAppServerClient, method: string, times: number): void { + expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times); +} + +function goalFixture(threadId: string) { + return { + threadId, + objective: "Finish", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 1, + }; +} + +function startedThread(threadId: string) { + return { + thread: { + id: threadId, + name: null, + preview: "", + cwd: "/vault", + cliVersion: "0.0.0", + }, + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + }; +} + +export function resumedThread(threadId: string, threadOverrides: Record = {}) { + return { + thread: { + id: threadId, + name: "Restored thread", + preview: "Restored thread", + cwd: "/vault", + cliVersion: "0.0.0", + ...threadOverrides, + }, + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalsReviewer: null, + }; +} + +function threadFromRecord(record: ThreadRecord): Thread { + return { + id: record.id, + preview: record.preview, + name: record.name, + archived: false, + provenance: { kind: "interactive" }, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +export function threadFixture(threadId: string): ThreadRecord { + return { + id: threadId, + sessionId: "session", + forkedFromId: null, + parentThreadId: null, + preview: "Restored thread", + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "0.0.0", + source: "unknown", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} + +export function panelThread(overrides: Partial = {}): Thread { + return { + id: "thread-1", + preview: "", + createdAt: 1, + updatedAt: 1, + name: null, + archived: false, + provenance: { kind: "interactive" }, + ...overrides, + }; +} + +export function turnWithUserMessage(text: string) { + return { + id: "turn-1", + startedAt: 1, + completedAt: 2, + items: [{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text, text_elements: [] }] }], + }; +} + +export function completedTurn(turnId: string) { + return { + id: turnId, + status: "completed", + error: null, + startedAt: 1, + completedAt: 2, + durationMs: 1, + itemsView: "full", + items: [ + { type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] }, + { type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null }, + ], + }; +} + +export function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement { + const composer = view.containerEl.querySelector(".codex-panel__composer-input"); + if (!composer) throw new Error("Expected composer input"); + return composer; +} + +export function composerPlaceholder(view: { containerEl: HTMLElement }): string | null { + return composerElement(view).getAttribute("placeholder"); +} + +function mockThreadStreamViewportOffsetMetrics(metrics: { clientHeight: number; clientWidth: number }): () => void { + const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); + const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.clientHeight : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.clientWidth : 0; + }, + }); + return () => { + restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor); + restorePrototypeProperty(HTMLElement.prototype, "offsetWidth", offsetWidthDescriptor); + }; +} + +function restorePrototypeProperty(target: T, property: keyof T, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) { + Object.defineProperty(target, property, descriptor); + } else { + Reflect.deleteProperty(target, property); + } +} + +export function requiredTextArea(parent: ParentNode, selector: string): HTMLTextAreaElement { + const element = parent.querySelector(selector); + if (!element) throw new Error(`Missing ${selector}`); + return element; +} + +export function requiredButton(parent: ParentNode, selector: string): HTMLButtonElement { + const element = parent.querySelector(selector); + if (!element) throw new Error(`Missing ${selector}`); + return element; +} + +export async function submitComposerByEnter(view: { containerEl: HTMLElement }): Promise { + await flushAsyncTicks(); + const composer = requiredTextArea(view.containerEl, ".codex-panel__composer-input"); + composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + await flushAsyncTicks(); +} + +export async function flushAsyncTicks(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve(); + } +} + +export interface ChatHostFixtureOverrides { + settings?: Partial; + vaultPath?: string; + openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"]; + focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"]; + openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"]; + notifyPanelActivityChanged?: CodexChatHost["workspace"]["notifyPanelActivityChanged"]; + openSideChat?: CodexChatHost["workspace"]["openSideChat"]; + applyThreadCatalogEvent?: CodexChatHost["threadCatalog"]["apply"]; + refreshActive?: CodexChatHost["threadCatalog"]["refreshActive"]; + activeSnapshot?: CodexChatHost["threadCatalog"]["activeSnapshot"]; + appServerMetadataSnapshot?: CodexChatHost["appServerQueries"]["appServerMetadataSnapshot"]; + modelsSnapshot?: CodexChatHost["appServerQueries"]["modelsSnapshot"]; + fetchModels?: CodexChatHost["appServerQueries"]["fetchModels"]; + refreshModels?: CodexChatHost["appServerQueries"]["refreshModels"]; + refreshAppServerMetadata?: CodexChatHost["appServerQueries"]["refreshAppServerMetadata"]; + refreshSkills?: CodexChatHost["appServerQueries"]["refreshSkills"]; + refreshRateLimits?: CodexChatHost["appServerQueries"]["refreshRateLimits"]; + threadNameMutations?: CodexChatHost["threadNameMutations"]; +} + +export function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { + let activeThreads = overrides.activeSnapshot?.() ?? null; + let metadata = overrides.appServerMetadataSnapshot?.() ?? null; + let models = overrides.modelsSnapshot?.() ?? null; + const activeThreadResultListeners = new Set<(result: ObservedResult) => void>(); + const metadataResourceListeners = new Set<(resource: SharedServerMetadataResource) => void>(); + const modelResultListeners = new Set<(result: ObservedResult) => void>(); + const settings = { + ...DEFAULT_SETTINGS, + codexPath: "codex", + sendShortcut: "enter" as const, + ...overrides.settings, + }; + const vaultPath = overrides.vaultPath ?? "/vault"; + const applyMetadataToCache = (nextMetadata: SharedServerMetadata): SharedServerMetadata => { + metadata = nextMetadata; + const resources: SharedServerMetadataResource[] = [ + { id: "runtimeConfig", value: nextMetadata.runtimeConfig ?? undefined }, + { + id: "skills", + value: nextMetadata.availableSkills, + probe: nextMetadata.serverDiagnostics.probes.skills, + }, + { + id: "permissionProfiles", + value: nextMetadata.availablePermissionProfiles, + probe: nextMetadata.serverDiagnostics.probes.permissionProfiles, + }, + { + id: "rateLimits", + value: nextMetadata.rateLimit, + probe: nextMetadata.serverDiagnostics.probes.rateLimits, + }, + ]; + for (const listener of metadataResourceListeners) { + for (const resource of resources) listener(resource); + } + return nextMetadata; + }; + const loadAppServerMetadata = async (reloadSkills = false): Promise => { + const client = connectionMock.state.client as TestAppServerClient | null; + if (!client || !connectionMock.state.connected) return null; + const connectionStillCurrent = () => connectionMock.state.client === client && connectionMock.state.connected; + await client.request("config/read", { cwd: vaultPath, includeLayers: true }); + if (!connectionStillCurrent()) return null; + let fetchedModels: readonly ModelMetadata[]; + if (overrides.fetchModels) { + fetchedModels = await overrides.fetchModels(); + } else { + const modelsResponse = (await client.request("model/list", { includeHidden: false, limit: 100 })) as { + data: Parameters[0]; + }; + fetchedModels = modelMetadataFromCatalogModels(modelsResponse.data); + } + if (!connectionStillCurrent()) return null; + models = fetchedModels; + const modelsResource: SharedServerMetadataResource = { + id: "models", + value: fetchedModels, + probe: diagnosticProbeOk("models", `${String(fetchedModels.length)} models`, Date.now()), + }; + for (const listener of metadataResourceListeners) listener(modelsResource); + for (const listener of modelResultListeners) listener(queryResult(fetchedModels)); + const skillsResponse = (await client.request("skills/list", { cwds: [vaultPath], forceReload: reloadSkills })) as { + data: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }[]; + }; + if (!connectionStillCurrent()) return null; + const permissionProfilesResponse = (await client.request("permissionProfile/list", { cwd: vaultPath, cursor: null, limit: 100 })) as { + data: { id: string; description: string | null; allowed: boolean }[]; + nextCursor: string | null; + }; + if (!connectionStillCurrent()) return null; + await client.request("account/rateLimits/read", undefined); + if (!connectionStillCurrent()) return null; + return { + runtimeConfig: emptyRuntimeConfigSnapshot(), + availableSkills: skillsResponse.data.flatMap( + (entry: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }) => + entry.skills.map((skill) => ({ + name: skill.name, + description: skill.description ?? "", + path: skill.path ?? "", + enabled: skill.enabled ?? true, + })), + ), + availablePermissionProfiles: permissionProfilesResponse.data.map((profile) => ({ ...profile })), + rateLimit: null, + serverDiagnostics: createServerDiagnostics(), + }; + }; + const emitActiveThreads = (): void => { + if (!activeThreads) return; + for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); + }; + const upsertActiveThread = (thread: Thread): void => { + activeThreads = [thread, ...(activeThreads?.filter((item) => item.id !== thread.id) ?? [])]; + emitActiveThreads(); + }; + const applyThreadCatalogEvent = (event: ThreadCatalogEvent): void => { + switch (event.type) { + case "thread-archived": + case "thread-deleted": + activeThreads = activeThreads?.filter((thread) => thread.id !== event.threadId) ?? null; + emitActiveThreads(); + return; + case "thread-renamed": + activeThreads = activeThreads?.map((thread) => (thread.id === event.threadId ? { ...thread, name: event.name } : thread)) ?? null; + emitActiveThreads(); + return; + case "thread-upserted": + case "thread-restored": + upsertActiveThread(event.thread); + return; + case "thread-unarchived": + return; + } + }; + return { + settingsSource: settings, + receiveActiveThreads: (threads) => { + activeThreads = threads; + emitActiveThreads(); + }, + threadNameMutations: overrides.threadNameMutations ?? createThreadNameMutationCoordinator(), + threadGoalOperations: createThreadGoalOperationCoordinator(), + runtimeSettingsCommitQueue: createKeyedOperationQueue(), + settingsRef: { + settings: chatPanelSettingsAccess(settings), + vaultPath, + }, + workspace: { + openThreadInNewView: overrides.openThreadInNewView ?? vi.fn(), + focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false), + openTurnDiff: overrides.openTurnDiff ?? vi.fn(), + notifyPanelActivityChanged: overrides.notifyPanelActivityChanged ?? vi.fn(), + openSideChat: overrides.openSideChat ?? vi.fn().mockResolvedValue(undefined), + }, + appServerQueries: { + contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath }, generation: 1 }), + appServerMetadataSnapshot: overrides.appServerMetadataSnapshot ?? vi.fn(() => metadata), + refreshAppServerMetadata: + overrides.refreshAppServerMetadata ?? + vi.fn(async () => { + const nextMetadata = await loadAppServerMetadata(); + if (nextMetadata) applyMetadataToCache(nextMetadata); + }), + refreshSkills: + overrides.refreshSkills ?? + vi.fn(async () => { + const nextMetadata = await loadAppServerMetadata(true); + if (nextMetadata) applyMetadataToCache(nextMetadata); + }), + refreshRateLimits: + overrides.refreshRateLimits ?? + vi.fn(async () => { + const nextMetadata = await loadAppServerMetadata(); + if (nextMetadata) applyMetadataToCache(nextMetadata); + }), + modelsSnapshot: overrides.modelsSnapshot ?? vi.fn(() => models), + fetchModels: overrides.fetchModels ?? vi.fn(async () => models ?? []), + refreshModels: overrides.refreshModels ?? vi.fn(async () => models ?? []), + observeAppServerMetadataResources: (listener, options = {}) => { + metadataResourceListeners.add(listener); + if ((options.emitCurrent ?? true) && metadata) applyMetadataToCache(metadata); + if ((options.emitCurrent ?? true) && models) { + listener({ + id: "models", + value: models, + probe: diagnosticProbeOk("models", `${String(models.length)} models`, Date.now()), + }); + } + return () => { + metadataResourceListeners.delete(listener); + }; + }, + observeModelsResult: (listener, options = {}) => { + modelResultListeners.add(listener); + if ((options.emitCurrent ?? true) && models) listener(queryResult(models)); + return () => { + modelResultListeners.delete(listener); + }; + }, + }, + threadCatalog: { + apply: overrides.applyThreadCatalogEvent ?? applyThreadCatalogEvent, + applyConnectionEvent: (_context, event) => (overrides.applyThreadCatalogEvent ?? applyThreadCatalogEvent)(event), + refreshActive: + overrides.refreshActive ?? + (vi.fn(async () => { + const client = connectionMock.state.client; + if (!client) return activeThreads ?? []; + const request = client["request"] as (method: string, params: Record) => Promise<{ data: ThreadRecord[] }>; + const response = await request("thread/list", { + cwd: "/vault", + archived: false, + limit: 100, + sortKey: "recency_at", + sortDirection: "desc", + }); + activeThreads = response.data.map(threadFromRecord); + for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); + return activeThreads; + }) as CodexChatHost["threadCatalog"]["refreshActive"]), + loadActive: vi.fn(async () => activeThreads ?? []), + activeSnapshot: overrides.activeSnapshot ?? vi.fn(() => activeThreads), + observeActive: (listener, options = {}) => { + activeThreadResultListeners.add(listener); + if ((options.emitCurrent ?? true) && activeThreads) listener(queryResult(activeThreads)); + return () => { + activeThreadResultListeners.delete(listener); + }; + }, + }, + }; +} + +function queryResult(value: T | null): ObservedResult { + return { + value, + error: null, + isFetching: false, + }; +} + +export async function chatView(options: { host?: CodexChatHost; requestSaveLayout?: () => void } = {}) { + const host = options.host ?? chatHost(); + const containerEl = document.createElement("div"); + document.body.appendChild(containerEl); + containerEl.createDiv(); + containerEl.createDiv(); + const view = new CodexChatView( + { + app: { + workspace: { + getActiveFile: vi.fn(() => null), + getActiveViewOfType: vi.fn(() => null), + getLastOpenFiles: vi.fn(() => []), + on: vi.fn(() => ({})), + openLinkText: vi.fn(), + requestSaveLayout: options.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(() => ""), + getFileCache: vi.fn(() => null), + }, + }, + containerEl, + } as never, + host, + ); + (view.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(view); + const tracked: TrackedView = { view, opened: false }; + const onOpen = view.onOpen.bind(view); + const onClose = view.onClose.bind(view); + view.onOpen = async () => { + tracked.opened = true; + await onOpen(); + }; + view.onClose = async () => { + tracked.opened = false; + await onClose(); + }; + createdViews.push(tracked); + return view; +} diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 5abae45c..609c79b2 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -1,173 +1,39 @@ // @vitest-environment jsdom -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ServerNotification, ServerRequest } from "../../../../src/app-server/connection/rpc-messages"; -import { modelMetadataFromCatalogModels } from "../../../../src/app-server/protocol/catalog"; -import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; -import type { ObservedResult } from "../../../../src/app-server/query/observed-result"; -import type { ModelMetadata } from "../../../../src/domain/catalog/metadata"; -import { emptyRuntimeConfigSnapshot } from "../../../../src/domain/runtime/config"; -import { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics"; -import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../../src/domain/server/metadata"; -import type { Thread } from "../../../../src/domain/threads/model"; -import { createThreadGoalOperationCoordinator } from "../../../../src/features/chat/application/threads/goal-actions"; -import type { CodexChatHost } from "../../../../src/features/chat/host/contracts"; -import type { ThreadCatalogEvent } from "../../../../src/features/threads/catalog/thread-catalog"; +import { describe, expect, it, vi } from "vitest"; +import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages"; import { createThreadNameMutationCoordinator } from "../../../../src/features/threads/workflows/thread-name-mutation-coordinator"; -import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../../../../src/settings/model"; -import { createKeyedOperationQueue } from "../../../../src/shared/runtime/keyed-operation-queue"; import { notices } from "../../../mocks/obsidian"; import { deferred, waitForAsyncWork } from "../../../support/async"; -import { installObsidianDomShims } from "../../../support/dom"; -import { chatPanelSettingsAccess } from "../support/settings"; - -interface TestCodexChatHost extends CodexChatHost { - readonly settingsSource: CodexPanelSettings; - receiveActiveThreads(threads: readonly Thread[]): void; -} -let CodexChatView: typeof import("../../../../src/features/chat/host/view.obsidian")["CodexChatView"]; -interface TrackedView { - view: { onClose(): Promise | void }; - opened: boolean; -} -let createdViews: TrackedView[] = []; - -const connectionMock = vi.hoisted(() => { - const state = { - client: null as Record | null, - connectCalls: 0, - connected: false, - onNotification: null as ((notification: ServerNotification) => void) | null, - onServerRequest: null as - | ((request: ServerRequest, responder: { respond(result: unknown): void; reject(code: number, message: string): void }) => void) - | null, - onExit: null as (() => void) | null, - }; - - return { - state, - reset(): void { - state.client = null; - state.connectCalls = 0; - state.connected = false; - state.onNotification = null; - state.onServerRequest = null; - state.onExit = null; - }, - }; -}); - -vi.mock("../../../../src/app-server/connection/connection-manager", () => { - class StaleConnectionError extends Error {} - - class ConnectionManager { - private readonly codexPath: () => string; - private readonly cwd: string; - private handlers: { - onNotification: (notification: ServerNotification, context: { codexPath: string; cwd: string }) => void; - onServerRequest: ( - request: unknown, - responder: { respond(result: unknown): void; reject(code: number, message: string): void }, - ) => void; - onLog: (message: string) => void; - onExit: (context: { codexPath: string; cwd: string }) => void; - } | null; - - constructor(codexPath: () => string, cwd: string) { - this.codexPath = codexPath; - this.cwd = cwd; - this.handlers = null; - } - - connect(handlers: { - onNotification: (notification: ServerNotification, context: { codexPath: string; cwd: string }) => void; - onServerRequest: ( - request: unknown, - responder: { respond(result: unknown): void; reject(code: number, message: string): void }, - ) => void; - onLog: (message: string) => void; - onExit: (context: { codexPath: string; cwd: string }) => void; - }): Promise { - this.handlers = handlers; - this.publishHandlers(handlers); - connectionMock.state.connectCalls += 1; - connectionMock.state.connected = true; - return Promise.resolve({ - userAgent: "codex-test", - codexHome: "/tmp/codex", - platformFamily: "unix", - platformOs: "macos", - }); - } - - currentClient(): unknown { - return connectionMock.state.connected ? connectionMock.state.client : null; - } - - isConnected(): boolean { - return connectionMock.state.connected; - } - - resetConnection(): void { - this.disconnect(); - } - - disconnect(): void { - connectionMock.state.connected = false; - } - - exit(): void { - connectionMock.state.connected = false; - this.handlers?.onExit({ codexPath: this.codexPath(), cwd: this.cwd }); - } - - private publishHandlers(handlers: NonNullable): void { - const context = { codexPath: this.codexPath(), cwd: this.cwd }; - connectionMock.state.onNotification = (notification) => handlers.onNotification(notification, context); - connectionMock.state.onServerRequest = (request, responder) => handlers.onServerRequest(request, responder); - connectionMock.state.onExit = () => handlers.onExit(context); - } - } - - return { ConnectionManager, StaleConnectionError }; -}); - -installObsidianDomShims(); +import { + chatHost, + chatView, + connectedClient, + connectionMockState, + expectRequestTimes, + flushAsyncTicks, + panelThread, + requestMethods, + requiredButton, + requiredTextArea, + resumedThread, + setupViewConnectionHarness, + submitComposerByEnter, + type TestAppServerClient, +} from "./view-connection-harness"; describe("CodexChatView connection lifecycle", () => { - let restoreDefaultThreadStreamViewportMetrics: (() => void) | null = null; - - beforeAll(async () => { - ({ CodexChatView } = await import("../../../../src/features/chat/host/view.obsidian")); - }); - - beforeEach(() => { - vi.useRealTimers(); - notices.length = 0; - connectionMock.reset(); - restoreDefaultThreadStreamViewportMetrics = mockThreadStreamViewportOffsetMetrics({ clientHeight: 320, clientWidth: 240 }); - }); - - afterEach(async () => { - for (const entry of createdViews.reverse()) { - if (entry.opened) await entry.view.onClose(); - } - createdViews = []; - vi.useRealTimers(); - restoreDefaultThreadStreamViewportMetrics?.(); - restoreDefaultThreadStreamViewportMetrics = null; - document.body.replaceChildren(); - }); + setupViewConnectionHarness(); it("shares post-initialize metadata loading across concurrent connect calls", async () => { const client = connectedClient(); const fetchModels = vi.fn().mockResolvedValue([]); - connectionMock.state.client = client; + connectionMockState().client = client; const view = await chatView({ host: chatHost({ fetchModels }) }); await Promise.all([view.surface.connect(), view.surface.connect()]); - expect(connectionMock.state.connectCalls).toBe(1); + expect(connectionMockState().connectCalls).toBe(1); expectRequestTimes(client, "config/read", 1); expect(fetchModels).toHaveBeenCalledTimes(1); expectRequestTimes(client, "skills/list", 1); @@ -181,7 +47,7 @@ describe("CodexChatView connection lifecycle", () => { const client = connectedClient({ "config/read": vi.fn(() => config.promise), }); - connectionMock.state.client = client; + connectionMockState().client = client; const view = await chatView(); const firstConnect = view.surface.connect(); @@ -195,7 +61,7 @@ describe("CodexChatView connection lifecycle", () => { }); await Promise.resolve(); - expect(connectionMock.state.connected).toBe(true); + expect(connectionMockState().connected).toBe(true); expect(secondResolved).toBe(false); config.resolve({}); @@ -206,17 +72,17 @@ describe("CodexChatView connection lifecycle", () => { }); it("loads app-server metadata after connecting", async () => { - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const view = await chatView(); await view.surface.connect(); - expectRequestTimes(connectionMock.state.client as TestAppServerClient, "config/read", 1); + expectRequestTimes(connectionMockState().client as TestAppServerClient, "config/read", 1); expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true }); }); it("reconnects and resumes the active thread only when settings change the app-server context", async () => { - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const host = chatHost(); const view = await chatView({ host }); @@ -230,18 +96,18 @@ describe("CodexChatView connection lifecycle", () => { await view.surface.openThread("thread-1"); const nextClient = connectedClient(); - connectionMock.state.client = nextClient; + connectionMockState().client = nextClient; host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); await waitForAsyncWork(() => { - expect(connectionMock.state.connectCalls).toBe(2); + expect(connectionMockState().connectCalls).toBe(2); expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" }); }); }); it("invalidates the old connection before publishing a new app-server context", async () => { - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const host = chatHost(); const view = await chatView({ host }); @@ -252,19 +118,19 @@ describe("CodexChatView connection lifecycle", () => { expect(view.surface.openPanelSnapshot().connected).toBe(false); expect(host.settingsSource.codexPath).toBe("codex"); - expect(connectionMock.state.connectCalls).toBe(1); + expect(connectionMockState().connectCalls).toBe(1); - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); await waitForAsyncWork(() => { - expect(connectionMock.state.connectCalls).toBe(2); + expect(connectionMockState().connectCalls).toBe(2); expect(view.surface.openPanelSnapshot().connected).toBe(true); }); }); it("keeps a disconnected panel's awaiting thread across an app-server context replacement", async () => { - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const host = chatHost(); const view = await chatView({ host }); @@ -272,13 +138,13 @@ describe("CodexChatView connection lifecycle", () => { await view.surface.connect(); await view.surface.openThread("thread-1"); - connectionMock.state.connected = false; - connectionMock.state.onExit?.(); + connectionMockState().connected = false; + connectionMockState().onExit?.(); expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false, threadId: "thread-1" }); view.surface.prepareAppServerContextChange(); const nextClient = connectedClient(); - connectionMock.state.client = nextClient; + connectionMockState().client = nextClient; host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); @@ -290,7 +156,7 @@ describe("CodexChatView connection lifecycle", () => { it("does not run an inline rename queued before an app-server context replacement", async () => { const oldClient = connectedClient(); - connectionMock.state.client = oldClient; + connectionMockState().client = oldClient; const nameMutations = createThreadNameMutationCoordinator(); const mutationRun = vi.spyOn(nameMutations, "run"); const host = chatHost({ threadNameMutations: nameMutations }); @@ -317,10 +183,10 @@ describe("CodexChatView connection lifecycle", () => { view.surface.prepareAppServerContextChange(); const nextClient = connectedClient(); - connectionMock.state.client = nextClient; + connectionMockState().client = nextClient; host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); - await waitForAsyncWork(() => expect(connectionMock.state.connectCalls).toBe(2)); + await waitForAsyncWork(() => expect(connectionMockState().connectCalls).toBe(2)); blocker.resolve(undefined); await blockerWork; await flushAsyncTicks(); @@ -331,7 +197,7 @@ describe("CodexChatView connection lifecycle", () => { it("does not run a slash rename queued before an app-server context replacement", async () => { const oldClient = connectedClient(); - connectionMock.state.client = oldClient; + connectionMockState().client = oldClient; const nameMutations = createThreadNameMutationCoordinator(); const mutationRun = vi.spyOn(nameMutations, "run"); const host = chatHost({ threadNameMutations: nameMutations }); @@ -348,10 +214,10 @@ describe("CodexChatView connection lifecycle", () => { view.surface.prepareAppServerContextChange(); const nextClient = connectedClient(); - connectionMock.state.client = nextClient; + connectionMockState().client = nextClient; host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); - await waitForAsyncWork(() => expect(connectionMock.state.connectCalls).toBe(2)); + await waitForAsyncWork(() => expect(connectionMockState().connectCalls).toBe(2)); blocker.resolve(undefined); await blockerWork; await flushAsyncTicks(); @@ -367,7 +233,7 @@ describe("CodexChatView connection lifecycle", () => { return resumedThread(threadId); }); const oldClient = connectedClient({ "thread/resume": resumeRequestedThread }); - connectionMock.state.client = oldClient; + connectionMockState().client = oldClient; const first = await chatView({ host }); const second = await chatView({ host }); @@ -381,14 +247,14 @@ describe("CodexChatView connection lifecycle", () => { first.surface.prepareAppServerContextChange(); second.surface.prepareAppServerContextChange(); const nextClient = connectedClient({ "thread/resume": resumeRequestedThread }); - connectionMock.state.client = nextClient; + connectionMockState().client = nextClient; host.settingsSource.codexPath = "codex-next"; first.surface.refreshSettings(); await waitForAsyncWork(() => { expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); }); - connectionMock.state.connected = false; + connectionMockState().connected = false; second.surface.refreshSettings(); await waitForAsyncWork(() => { expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-2", cwd: "/vault" })); @@ -400,7 +266,7 @@ describe("CodexChatView connection lifecycle", () => { it("keeps the captured thread across consecutive app-server context replacements", async () => { const host = chatHost(); const firstClient = connectedClient(); - connectionMock.state.client = firstClient; + connectionMockState().client = firstClient; const view = await chatView({ host }); await view.onOpen(); @@ -410,7 +276,7 @@ describe("CodexChatView connection lifecycle", () => { const stalledConfig = deferred(); const stalledClient = connectedClient({ "config/read": vi.fn(() => stalledConfig.promise) }); view.surface.prepareAppServerContextChange(); - connectionMock.state.client = stalledClient; + connectionMockState().client = stalledClient; host.settingsSource.codexPath = "codex-broken"; view.surface.refreshSettings(); await waitForAsyncWork(() => { @@ -419,7 +285,7 @@ describe("CodexChatView connection lifecycle", () => { const recoveredClient = connectedClient(); view.surface.prepareAppServerContextChange(); - connectionMock.state.client = recoveredClient; + connectionMockState().client = recoveredClient; host.settingsSource.codexPath = "codex-recovered"; view.surface.refreshSettings(); @@ -438,7 +304,7 @@ describe("CodexChatView connection lifecycle", () => { it("keeps the captured thread after a context reconnect cannot resume it", async () => { const host = chatHost(); - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const view = await chatView({ host }); await view.onOpen(); @@ -447,7 +313,7 @@ describe("CodexChatView connection lifecycle", () => { const failedClient = connectedClient({ "thread/resume": vi.fn().mockResolvedValue(null) }); view.surface.prepareAppServerContextChange(); - connectionMock.state.client = failedClient; + connectionMockState().client = failedClient; host.settingsSource.codexPath = "codex-broken"; view.surface.refreshSettings(); await waitForAsyncWork(() => { @@ -458,7 +324,7 @@ describe("CodexChatView connection lifecycle", () => { const recoveredClient = connectedClient(); view.surface.prepareAppServerContextChange(); - connectionMock.state.client = recoveredClient; + connectionMockState().client = recoveredClient; host.settingsSource.codexPath = "codex-recovered"; view.surface.refreshSettings(); @@ -473,7 +339,7 @@ describe("CodexChatView connection lifecycle", () => { it("retries the captured thread when reconnecting after a context resume failure", async () => { const host = chatHost(); - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const view = await chatView({ host }); await view.onOpen(); @@ -482,7 +348,7 @@ describe("CodexChatView connection lifecycle", () => { const failedClient = connectedClient({ "thread/resume": vi.fn().mockResolvedValue(null) }); view.surface.prepareAppServerContextChange(); - connectionMock.state.client = failedClient; + connectionMockState().client = failedClient; host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); await waitForAsyncWork(() => { @@ -492,7 +358,7 @@ describe("CodexChatView connection lifecycle", () => { await new Promise((resolve) => setTimeout(resolve, 0)); const recoveredClient = connectedClient(); - connectionMock.state.client = recoveredClient; + connectionMockState().client = recoveredClient; view.surface.setComposerText("/reconnect"); await submitComposerByEnter(view); @@ -507,7 +373,7 @@ describe("CodexChatView connection lifecycle", () => { it("does not let a stale automatic reconnect resume again after a manual retry", async () => { const host = chatHost(); - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const view = await chatView({ host }); await view.onOpen(); @@ -517,7 +383,7 @@ describe("CodexChatView connection lifecycle", () => { const stalledConfig = deferred(); const stalledClient = connectedClient({ "config/read": vi.fn(() => stalledConfig.promise) }); view.surface.prepareAppServerContextChange(); - connectionMock.state.client = stalledClient; + connectionMockState().client = stalledClient; host.settingsSource.codexPath = "codex-next"; view.surface.refreshSettings(); await waitForAsyncWork(() => { @@ -525,7 +391,7 @@ describe("CodexChatView connection lifecycle", () => { }); const recoveredClient = connectedClient(); - connectionMock.state.client = recoveredClient; + connectionMockState().client = recoveredClient; view.surface.setComposerText("/reconnect"); await submitComposerByEnter(view); await waitForAsyncWork(() => { @@ -554,7 +420,7 @@ describe("CodexChatView connection lifecycle", () => { }, }), }); - connectionMock.state.client = client; + connectionMockState().client = client; const view = await chatView(); await view.onOpen(); @@ -613,7 +479,7 @@ describe("CodexChatView connection lifecycle", () => { }), ), }); - connectionMock.state.client = client; + connectionMockState().client = client; const view = await chatView(); const connecting = view.surface.connect(); @@ -628,7 +494,7 @@ describe("CodexChatView connection lifecycle", () => { it("notifies Threads immediately and after the workspace settles when the panel closes", async () => { const notifyPanelActivityChanged = vi.fn(); - connectionMock.state.client = connectedClient(); + connectionMockState().client = connectedClient(); const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) }); await view.onOpen(); @@ -640,7 +506,7 @@ describe("CodexChatView connection lifecycle", () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); expect(notifyPanelActivityChanged).toHaveBeenCalledTimes(2); - connectionMock.state.onNotification?.({ + connectionMockState().onNotification?.({ method: "turn/started", params: { threadId: "thread-1", @@ -664,15 +530,15 @@ describe("CodexChatView connection lifecycle", () => { const client = connectedClient({ "config/read": vi.fn(() => config.promise), }); - connectionMock.state.client = client; + connectionMockState().client = client; const view = await chatView(); const connecting = view.surface.connect(); await waitForAsyncWork(() => { expectRequestTimes(client, "config/read", 1); }); - connectionMock.state.connected = false; - connectionMock.state.onExit?.(); + connectionMockState().connected = false; + connectionMockState().onExit?.(); config.resolve({}); await connecting; @@ -680,1183 +546,4 @@ describe("CodexChatView connection lifecycle", () => { expect(requestMethods(client)).not.toContain("thread/list"); expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: false }); }); - - it("restores workspace thread state without hydrating it automatically", async () => { - vi.useFakeTimers(); - const client = connectedClient({ - "thread/resume": vi.fn().mockResolvedValue(resumedThread("thread-1")), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); - await view.onOpen(); - - expect(view.getDisplayText()).toBe("Codex: Restored thread"); - expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); - expect(connectionMock.state.connectCalls).toBe(0); - expect(requestMethods(client)).not.toContain("thread/resume"); - expect(view.containerEl.textContent).not.toContain("Thread restored. Send a message to resume it."); - - await vi.advanceTimersByTimeAsync(0); - - expect(connectionMock.state.connectCalls).toBe(1); - expectRequestTimes(client, "config/read", 1); - expectRequestTimes(client, "thread/list", 1); - expect(requestMethods(client)).not.toContain("thread/resume"); - expect(requestMethods(client)).not.toContain("thread/resume"); - expect(requestMethods(client)).not.toContain("thread/turns/list"); - }); - - it("formats the panel title from listed thread metadata", async () => { - const host = chatHost(); - const view = await chatView({ host }); - - await view.setState({ threadId: "thread-named" }, {} as never); - await view.onOpen(); - host.receiveActiveThreads([panelThread({ id: "thread-named", name: "作業メモ" })]); - expect(view.getDisplayText()).toBe("Codex: 作業メモ"); - - host.receiveActiveThreads([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]); - expect(view.getDisplayText()).toBe("Codex: 初回依頼"); - - await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never); - host.receiveActiveThreads([]); - expect(view.getDisplayText()).toBe("Codex: 019e061e"); - }); - - it("keeps late workspace thread state restored until explicit focus", async () => { - vi.useFakeTimers(); - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - await vi.advanceTimersByTimeAsync(0); - expect(connectionMock.state.connectCalls).toBe(1); - expectRequestTimes(client, "config/read", 1); - expectRequestTimes(client, "thread/list", 1); - expect(requestMethods(client)).not.toContain("thread/resume"); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); - - expect(view.getDisplayText()).toBe("Codex: Restored thread"); - expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); - expect(requestMethods(client)).not.toContain("thread/resume"); - expect(requestMethods(client)).not.toContain("thread/turns/list"); - - await view.surface.focusThread("thread-1"); - - expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); - expect(client.request).toHaveBeenCalledWith( - "thread/turns/list", - expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), - ); - }); - - it("replaces active thread-scoped state when late workspace state restores another thread", async () => { - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - await view.surface.openThread("thread-1"); - view.surface.setComposerText("stale draft"); - - await view.setState({ threadId: "thread-2", threadTitle: "Restored thread 2" }, {} as never); - - expect(view.getDisplayText()).toBe("Codex: Restored thread 2"); - expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread 2" }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ - threadId: "thread-2", - turnBusy: false, - hasComposerDraft: false, - }); - expect(composerElement(view).value).toBe(""); - }); - - it("warms app-server metadata for an empty restored panel after the shell is open", async () => { - vi.useFakeTimers(); - const client = connectedClient({ - "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")], nextCursor: null }), - }); - const fetchModels = vi.fn().mockResolvedValue([]); - connectionMock.state.client = client; - const view = await chatView({ host: chatHost({ fetchModels }) }); - - await view.onOpen(); - - expect(connectionMock.state.connectCalls).toBe(0); - await vi.advanceTimersByTimeAsync(0); - - expect(connectionMock.state.connectCalls).toBe(1); - expectRequestTimes(client, "config/read", 1); - expect(fetchModels).toHaveBeenCalledOnce(); - expectRequestTimes(client, "skills/list", 1); - expectRequestTimes(client, "permissionProfile/list", 1); - expectRequestTimes(client, "account/rateLimits/read", 1); - expect(client.request).toHaveBeenCalledWith("thread/list", { - cwd: "/vault", - archived: false, - limit: 100, - sortKey: "recency_at", - sortDirection: "desc", - }); - requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); - await waitForAsyncWork(() => { - expect(view.containerEl.textContent).toContain("Restored thread"); - }); - }); - - it("applies cached shared thread list and metadata when opened", async () => { - const cachedThread = threadFixture("thread-cached"); - const view = await chatView({ - host: chatHost({ - activeSnapshot: vi.fn(() => [cachedThread] as never[]), - appServerMetadataSnapshot: vi.fn( - () => - ({ - runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-cached" }, - availableSkills: [{ name: "writer", enabled: true }], - availablePermissionProfiles: [], - rateLimit: null, - serverDiagnostics: createServerDiagnostics(), - }) as never, - ), - }), - }); - - await view.onOpen(); - - requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); - await waitForAsyncWork(() => { - expect(view.containerEl.textContent).toContain("Restored thread"); - }); - requiredButton(view.containerEl, '[aria-label="Show status"]').click(); - await waitForAsyncWork(() => { - expect(view.containerEl.textContent).toContain("gpt-cached"); - }); - }); - - it("hydrates a focused restored thread immediately", async () => { - vi.useFakeTimers(); - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView(); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); - await view.onOpen(); - - expect(requestMethods(client)).not.toContain("thread/resume"); - await view.surface.focusThread("thread-1"); - - expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); - expect(client.request).toHaveBeenCalledWith( - "thread/turns/list", - expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), - ); - }); - - it("starts fresh hydration when the same restored view state is reapplied", async () => { - const resume = deferred>(); - const client = connectedClient({ - "thread/resume": vi.fn(() => resume.promise), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); - await view.onOpen(); - const firstHydration = view.surface.focusThread("thread-1"); - await waitForAsyncWork(() => { - expectRequestTimes(client, "thread/resume", 1); - }); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); - const secondHydration = view.surface.focusThread("thread-1"); - await waitForAsyncWork(() => { - expectRequestTimes(client, "thread/resume", 2); - }); - - resume.resolve(resumedThread("thread-1")); - await Promise.all([firstHydration, secondHydration]); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); - }); - - it("resumes a restored thread before sending the first message", async () => { - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView(); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); - await view.onOpen(); - view.surface.setComposerText("hello"); - await submitComposerByEnter(view); - - await waitForAsyncWork(() => { - expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); - expect(client.request).toHaveBeenCalledWith("turn/start", { - threadId: "thread-1", - cwd: "/vault", - input: [{ type: "text", text: "hello", text_elements: [] }], - clientUserMessageId: expect.stringMatching(/^local-user-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/), - }); - }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true }); - expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); - connectionMock.state.onNotification?.({ - method: "turn/started", - params: { - threadId: "thread-1", - turn: { - id: "turn-1", - status: "inProgress", - startedAt: 1, - completedAt: null, - durationMs: null, - error: null, - itemsView: "full", - items: [], - }, - }, - } satisfies Extract); - expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true }); - }); - - it("notifies Threads only when panel activity changes", async () => { - const notifyPanelActivityChanged = vi.fn(); - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) }); - - await view.onOpen(); - expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); - - await view.surface.openThread("thread-1"); - notifyPanelActivityChanged.mockClear(); - - view.surface.setComposerText("hello"); - expect(notifyPanelActivityChanged).not.toHaveBeenCalled(); - - await submitComposerByEnter(view); - expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: false }); - - notifyPanelActivityChanged.mockClear(); - connectionMock.state.onNotification?.({ - method: "turn/started", - params: { - threadId: "thread-1", - turn: { - id: "turn-1", - status: "inProgress", - startedAt: 1, - completedAt: null, - durationMs: null, - error: null, - itemsView: "full", - items: [], - }, - }, - } satisfies Extract); - expect(notifyPanelActivityChanged).not.toHaveBeenCalled(); - - connectionMock.state.onServerRequest?.( - { - id: 7, - method: "item/tool/requestUserInput", - params: { - threadId: "thread-1", - turnId: "turn-1", - itemId: "input-1", - questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }], - autoResolutionMs: null, - }, - }, - { respond: vi.fn(), reject: vi.fn() }, - ); - expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: true }); - - notifyPanelActivityChanged.mockClear(); - connectionMock.state.onNotification?.({ - method: "turn/completed", - params: { - threadId: "thread-1", - turn: { - id: "turn-1", - status: "completed", - startedAt: 1, - completedAt: 2, - durationMs: 1, - error: null, - itemsView: "full", - items: [], - }, - }, - } satisfies Extract); - expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: false, pending: true }); - }); - - it("requests a workspace layout save after resuming a thread", async () => { - const requestSaveLayout = vi.fn(); - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView({ requestSaveLayout }); - - await view.surface.openThread("thread-1"); - - expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); - expect(requestSaveLayout).toHaveBeenCalledTimes(1); - }); - - it("resumes another persistent thread before unsubscribing a running subagent", async () => { - const client = connectedClient({ - "thread/resume": vi.fn((params: unknown) => { - const threadId = (params as { threadId: string }).threadId; - return Promise.resolve( - threadId === "child" - ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) - : resumedThread(threadId), - ); - }), - "thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.surface.openThread("child"); - connectionMock.state.onNotification?.({ - method: "turn/started", - params: { - threadId: "child", - turn: { - id: "turn-child", - status: "inProgress", - startedAt: 1, - completedAt: null, - durationMs: null, - error: null, - itemsView: "full", - items: [], - }, - }, - } satisfies Extract); - - await view.surface.openThread("other"); - - const unsubscribeCall = client.request.mock.calls.findIndex(([method]) => method === "thread/unsubscribe"); - const otherResumeCall = client.request.mock.calls.findIndex( - ([method, params]) => method === "thread/resume" && (params as { threadId: string }).threadId === "other", - ); - expect(unsubscribeCall).toBeGreaterThanOrEqual(0); - expect(unsubscribeCall).toBeGreaterThan(otherResumeCall); - expect(client.request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything()); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "other" }); - }); - - it("keeps a running subagent subscribed when openThread cannot resume the target", async () => { - const client = connectedClient({ - "thread/resume": vi.fn((params: unknown) => { - const threadId = (params as { threadId: string }).threadId; - return Promise.resolve( - threadId === "child" ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) : null, - ); - }), - "thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.surface.openThread("child"); - connectionMock.state.onNotification?.({ - method: "turn/started", - params: { - threadId: "child", - turn: { - id: "turn-child", - status: "inProgress", - startedAt: 1, - completedAt: null, - durationMs: null, - error: null, - itemsView: "full", - items: [], - }, - }, - } satisfies Extract); - - await view.surface.openThread("other"); - - expect(requestMethods(client).filter((method) => method === "thread/unsubscribe")).toEqual([]); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnBusy: true }); - }); - - it("resets to an unstarted empty chat without starting a thread", async () => { - const requestSaveLayout = vi.fn(); - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView({ requestSaveLayout }); - - await view.surface.openThread("thread-1"); - await view.surface.startNewThread(); - - expect(requestMethods(client)).not.toContain("thread/start"); - expect(view.getState()).toEqual({ version: 1 }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false }); - expect(requestSaveLayout).toHaveBeenCalledTimes(2); - }); - - it("restores an unavailable side-chat tab as a normal empty chat", async () => { - const view = await chatView(); - await view.setState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } }, {} as never); - - expect(view.getState()).toEqual({ version: 1 }); - expect(view.getDisplayText()).not.toBe("Side chat"); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false }); - expect(view.containerEl.textContent).not.toContain("This side conversation is no longer available."); - }); - - it("focuses the composer after panel thread actions", async () => { - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined); - - await view.surface.openThread("thread-1"); - await view.surface.focusThread("thread-1"); - await view.surface.startNewThread(); - - expect(focus).toHaveBeenCalledTimes(3); - expect(focus).toHaveBeenCalledWith({ preventScroll: true }); - }); - - it("clears the active thread when another view archives it", async () => { - const requestSaveLayout = vi.fn(); - const client = connectedClient(); - connectionMock.state.client = client; - const view = await chatView({ requestSaveLayout }); - - await view.surface.openThread("thread-1"); - view.surface.applyThreadArchived("thread-1"); - - expect(view.getState()).toEqual({ version: 1 }); - expect(requestSaveLayout).toHaveBeenCalledTimes(2); - }); - - it("updates restored panel title from shared rename notifications", async () => { - const view = await chatView(); - - await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never); - view.surface.applyThreadRenamed("thread-1", "After rename"); - - expect(view.getDisplayText()).toBe("Codex: After rename"); - expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" }); - }); - - it("does not use restored thread identity as a composer name before the thread becomes active", async () => { - const host = chatHost(); - const view = await chatView({ host }); - - await view.setState({ threadId: "thread-1", threadTitle: "Restored title" }, {} as never); - await view.onOpen(); - - expect(composerPlaceholder(view)).toBe("Ask Codex..."); - - host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Explicit name" })]); - await waitForAsyncWork(() => { - expect(composerPlaceholder(view)).toBe("Ask Codex..."); - }); - - view.surface.applyThreadRenamed("thread-1", "Explicit name"); - - await waitForAsyncWork(() => { - expect(composerPlaceholder(view)).toBe("Ask Codex..."); - }); - }); - - it("keeps composer draft and selection while updating the placeholder", async () => { - const client = connectedClient(); - connectionMock.state.client = client; - const host = chatHost(); - const view = await chatView({ host }); - - await view.onOpen(); - await view.surface.openThread("thread-1"); - view.surface.setComposerText("keep this draft"); - const composer = composerElement(view); - await waitForAsyncWork(() => { - expect(composer.value).toBe("keep this draft"); - }); - composer.setSelectionRange(5, 9); - - host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]); - view.surface.applyThreadRenamed("thread-1", "Renamed thread"); - - await waitForAsyncWork(() => { - expect(composer.value).toBe("keep this draft"); - expect(composer.selectionStart).toBe(5); - expect(composer.selectionEnd).toBe(9); - expect(composer.getAttribute("placeholder")).toBe("Ask Codex in “Renamed thread”..."); - }); - }); - - it("renders resumed thread metadata before history hydration completes", async () => { - const history = deferred<{ data: unknown[]; nextCursor: null }>(); - const client = connectedClient({ - "thread/turns/list": vi.fn(() => history.promise), - }); - connectionMock.state.client = client; - const view = await chatView(); - - const opening = view.surface.openThread("thread-1"); - await waitForAsyncWork(() => { - expect(client.request).toHaveBeenCalledWith( - "thread/turns/list", - expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), - ); - }); - - expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); - expect(view.containerEl.textContent).not.toContain("Loading thread..."); - - history.resolve({ data: [], nextCursor: null }); - await opening; - }); - - it("hydrates resumed threads from the initial turns page without a second history request", async () => { - const client = connectedClient({ - "thread/resume": vi.fn().mockResolvedValue({ - ...resumedThread("thread-1"), - initialTurnsPage: { - data: [completedTurn("turn-1")], - nextCursor: "older-cursor", - backwardsCursor: null, - }, - }), - "thread/turns/list": vi.fn().mockResolvedValue({ data: [turnWithUserMessage("fallback prompt")], nextCursor: null }), - }); - connectionMock.state.client = client; - const view = await chatView(); - - await view.onOpen(); - await view.surface.openThread("thread-1"); - - expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); - expect(requestMethods(client)).not.toContain("thread/turns/list"); - await waitForAsyncWork(() => { - expect(view.containerEl.textContent).toContain("hello"); - expect(view.containerEl.textContent).toContain("done"); - }); - }); - - it("ignores stale resume results when another thread is opened first", async () => { - const firstResume = deferred>(); - const secondResume = deferred>(); - const client = connectedClient({ - "thread/resume": vi.fn((params: unknown) => - (params as { threadId: string }).threadId === "thread-1" ? firstResume.promise : secondResume.promise, - ), - }); - connectionMock.state.client = client; - const view = await chatView(); - - const firstOpen = view.surface.openThread("thread-1"); - await waitForAsyncWork(() => { - expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); - }); - const secondOpen = view.surface.openThread("thread-2"); - await waitForAsyncWork(() => { - expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-2", cwd: "/vault" })); - }); - - secondResume.resolve(resumedThread("thread-2")); - await secondOpen; - firstResume.resolve(resumedThread("thread-1")); - await firstOpen; - - expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); - expectRequestTimes(client, "thread/turns/list", 1); - expect(client.request).toHaveBeenCalledWith( - "thread/turns/list", - expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }), - ); - }); - - it("invalidates stale history hydration when a second resume starts", async () => { - const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>(); - const client = connectedClient({ - "thread/resume": vi.fn((params: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))), - "thread/turns/list": vi.fn((params: unknown) => - (params as { threadId: string }).threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }), - ), - }); - connectionMock.state.client = client; - const view = await chatView(); - - const firstOpen = view.surface.openThread("thread-1"); - await waitForAsyncWork(() => { - expect(client.request).toHaveBeenCalledWith( - "thread/turns/list", - expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), - ); - }); - const secondOpen = view.surface.openThread("thread-2"); - await waitForAsyncWork(() => { - expect(client.request).toHaveBeenCalledWith( - "thread/turns/list", - expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }), - ); - }); - - firstHistory.resolve({ data: [turnWithUserMessage("first prompt")], nextCursor: null }); - await firstOpen; - await secondOpen; - - expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); - expect(view.containerEl.textContent).not.toContain("first prompt"); - }); }); - -type RequestHandler = ReturnType unknown>>; -type RequestSpy = ReturnType unknown>>; -type RequestHandlers = Record; -type TestAppServerClient = { - requestHandlers: RequestHandlers; - request: RequestSpy; -}; - -function connectedClient(overrides: RequestHandlers = {}): TestAppServerClient { - return requestClient({ ...baseClientHandlers(), ...overrides }); -} - -function baseClientHandlers(): RequestHandlers { - return { - "config/read": vi.fn().mockResolvedValue({}), - "model/list": vi.fn().mockResolvedValue({ data: [] }), - "skills/list": vi.fn().mockResolvedValue({ data: [] }), - "permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), - "account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: null }), - "thread/list": vi.fn().mockResolvedValue({ data: [] }), - "thread/start": vi.fn().mockResolvedValue(startedThread("thread-new")), - "thread/resume": vi.fn().mockResolvedValue(resumedThread("thread-1")), - "thread/turns/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }), - "turn/start": vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }), - "thread/fork": vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }), - "thread/rollback": vi.fn().mockResolvedValue({ thread: threadFixture("thread-forked") }), - "thread/name/set": vi.fn().mockResolvedValue({}), - "thread/goal/get": vi.fn().mockResolvedValue({ goal: null }), - "thread/goal/set": vi.fn().mockResolvedValue({ goal: goalFixture("thread-1") }), - "thread/inject_items": vi.fn().mockResolvedValue({}), - "thread/read": vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }), - "thread/archive": vi.fn().mockResolvedValue({}), - }; -} - -function requestClient(handlers: RequestHandlers): TestAppServerClient { - return { - requestHandlers: handlers, - request: vi.fn((method: string, params: unknown, options?: unknown) => { - const handler = handlers[method]; - if (!handler) throw new Error(`Unexpected app-server request: ${method}`); - return handler(params, options); - }), - }; -} - -function requestMethods(client: TestAppServerClient): string[] { - return client.request.mock.calls.map(([method]) => method); -} - -function expectRequestTimes(client: TestAppServerClient, method: string, times: number): void { - expect(requestMethods(client).filter((calledMethod) => calledMethod === method)).toHaveLength(times); -} - -function goalFixture(threadId: string) { - return { - threadId, - objective: "Finish", - status: "active", - tokenBudget: null, - tokensUsed: 0, - timeUsedSeconds: 0, - createdAt: 1, - updatedAt: 1, - }; -} - -function startedThread(threadId: string) { - return { - thread: { - id: threadId, - name: null, - preview: "", - cwd: "/vault", - cliVersion: "0.0.0", - }, - cwd: "/vault", - model: null, - reasoningEffort: null, - serviceTier: null, - approvalsReviewer: null, - }; -} - -function resumedThread(threadId: string, threadOverrides: Record = {}) { - return { - thread: { - id: threadId, - name: "Restored thread", - preview: "Restored thread", - cwd: "/vault", - cliVersion: "0.0.0", - ...threadOverrides, - }, - cwd: "/vault", - model: null, - reasoningEffort: null, - serviceTier: null, - approvalsReviewer: null, - }; -} - -function threadFromRecord(record: ThreadRecord): Thread { - return { - id: record.id, - preview: record.preview, - name: record.name, - archived: false, - provenance: { kind: "interactive" }, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }; -} - -function threadFixture(threadId: string): ThreadRecord { - return { - id: threadId, - sessionId: "session", - forkedFromId: null, - parentThreadId: null, - preview: "Restored thread", - ephemeral: false, - modelProvider: "openai", - createdAt: 1, - updatedAt: 1, - status: { type: "idle" }, - path: null, - cwd: "/vault", - cliVersion: "0.0.0", - source: "unknown", - threadSource: null, - agentNickname: null, - agentRole: null, - gitInfo: null, - name: null, - turns: [], - }; -} - -function panelThread(overrides: Partial = {}): Thread { - return { - id: "thread-1", - preview: "", - createdAt: 1, - updatedAt: 1, - name: null, - archived: false, - provenance: { kind: "interactive" }, - ...overrides, - }; -} - -function turnWithUserMessage(text: string) { - return { - id: "turn-1", - startedAt: 1, - completedAt: 2, - items: [{ type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text, text_elements: [] }] }], - }; -} - -function completedTurn(turnId: string) { - return { - id: turnId, - status: "completed", - error: null, - startedAt: 1, - completedAt: 2, - durationMs: 1, - itemsView: "full", - items: [ - { type: "userMessage", id: "user-1", clientId: null, content: [{ type: "text", text: "hello", text_elements: [] }] }, - { type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null }, - ], - }; -} - -function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement { - const composer = view.containerEl.querySelector(".codex-panel__composer-input"); - if (!composer) throw new Error("Expected composer input"); - return composer; -} - -function composerPlaceholder(view: { containerEl: HTMLElement }): string | null { - return composerElement(view).getAttribute("placeholder"); -} - -function mockThreadStreamViewportOffsetMetrics(metrics: { clientHeight: number; clientWidth: number }): () => void { - const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); - const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); - Object.defineProperty(HTMLElement.prototype, "offsetHeight", { - configurable: true, - get() { - return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.clientHeight : 0; - }, - }); - Object.defineProperty(HTMLElement.prototype, "offsetWidth", { - configurable: true, - get() { - return this instanceof HTMLElement && this.classList.contains("codex-panel__thread-stream") ? metrics.clientWidth : 0; - }, - }); - return () => { - restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor); - restorePrototypeProperty(HTMLElement.prototype, "offsetWidth", offsetWidthDescriptor); - }; -} - -function restorePrototypeProperty(target: T, property: keyof T, descriptor: PropertyDescriptor | undefined): void { - if (descriptor) { - Object.defineProperty(target, property, descriptor); - } else { - Reflect.deleteProperty(target, property); - } -} - -function requiredTextArea(parent: ParentNode, selector: string): HTMLTextAreaElement { - const element = parent.querySelector(selector); - if (!element) throw new Error(`Missing ${selector}`); - return element; -} - -function requiredButton(parent: ParentNode, selector: string): HTMLButtonElement { - const element = parent.querySelector(selector); - if (!element) throw new Error(`Missing ${selector}`); - return element; -} - -async function submitComposerByEnter(view: { containerEl: HTMLElement }): Promise { - await flushAsyncTicks(); - const composer = requiredTextArea(view.containerEl, ".codex-panel__composer-input"); - composer.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); - await flushAsyncTicks(); -} - -async function flushAsyncTicks(): Promise { - for (let index = 0; index < 10; index += 1) { - await Promise.resolve(); - } -} - -interface ChatHostFixtureOverrides { - settings?: Partial; - vaultPath?: string; - openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"]; - focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"]; - openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"]; - notifyPanelActivityChanged?: CodexChatHost["workspace"]["notifyPanelActivityChanged"]; - openSideChat?: CodexChatHost["workspace"]["openSideChat"]; - applyThreadCatalogEvent?: CodexChatHost["threadCatalog"]["apply"]; - refreshActive?: CodexChatHost["threadCatalog"]["refreshActive"]; - activeSnapshot?: CodexChatHost["threadCatalog"]["activeSnapshot"]; - appServerMetadataSnapshot?: CodexChatHost["appServerQueries"]["appServerMetadataSnapshot"]; - modelsSnapshot?: CodexChatHost["appServerQueries"]["modelsSnapshot"]; - fetchModels?: CodexChatHost["appServerQueries"]["fetchModels"]; - refreshModels?: CodexChatHost["appServerQueries"]["refreshModels"]; - refreshAppServerMetadata?: CodexChatHost["appServerQueries"]["refreshAppServerMetadata"]; - refreshSkills?: CodexChatHost["appServerQueries"]["refreshSkills"]; - refreshRateLimits?: CodexChatHost["appServerQueries"]["refreshRateLimits"]; - threadNameMutations?: CodexChatHost["threadNameMutations"]; -} - -function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { - let activeThreads = overrides.activeSnapshot?.() ?? null; - let metadata = overrides.appServerMetadataSnapshot?.() ?? null; - let models = overrides.modelsSnapshot?.() ?? null; - const activeThreadResultListeners = new Set<(result: ObservedResult) => void>(); - const metadataResourceListeners = new Set<(resource: SharedServerMetadataResource) => void>(); - const modelResultListeners = new Set<(result: ObservedResult) => void>(); - const settings = { - ...DEFAULT_SETTINGS, - codexPath: "codex", - sendShortcut: "enter" as const, - ...overrides.settings, - }; - const vaultPath = overrides.vaultPath ?? "/vault"; - const applyMetadataToCache = (nextMetadata: SharedServerMetadata): SharedServerMetadata => { - metadata = nextMetadata; - const resources: SharedServerMetadataResource[] = [ - { id: "runtimeConfig", value: nextMetadata.runtimeConfig ?? undefined }, - { - id: "skills", - value: nextMetadata.availableSkills, - probe: nextMetadata.serverDiagnostics.probes.skills, - }, - { - id: "permissionProfiles", - value: nextMetadata.availablePermissionProfiles, - probe: nextMetadata.serverDiagnostics.probes.permissionProfiles, - }, - { - id: "rateLimits", - value: nextMetadata.rateLimit, - probe: nextMetadata.serverDiagnostics.probes.rateLimits, - }, - ]; - for (const listener of metadataResourceListeners) { - for (const resource of resources) listener(resource); - } - return nextMetadata; - }; - const loadAppServerMetadata = async (reloadSkills = false): Promise => { - const client = connectionMock.state.client as TestAppServerClient | null; - if (!client || !connectionMock.state.connected) return null; - const connectionStillCurrent = () => connectionMock.state.client === client && connectionMock.state.connected; - await client.request("config/read", { cwd: vaultPath, includeLayers: true }); - if (!connectionStillCurrent()) return null; - let fetchedModels: readonly ModelMetadata[]; - if (overrides.fetchModels) { - fetchedModels = await overrides.fetchModels(); - } else { - const modelsResponse = (await client.request("model/list", { includeHidden: false, limit: 100 })) as { - data: Parameters[0]; - }; - fetchedModels = modelMetadataFromCatalogModels(modelsResponse.data); - } - if (!connectionStillCurrent()) return null; - models = fetchedModels; - const modelsResource: SharedServerMetadataResource = { - id: "models", - value: fetchedModels, - probe: diagnosticProbeOk("models", `${String(fetchedModels.length)} models`, Date.now()), - }; - for (const listener of metadataResourceListeners) listener(modelsResource); - for (const listener of modelResultListeners) listener(queryResult(fetchedModels)); - const skillsResponse = (await client.request("skills/list", { cwds: [vaultPath], forceReload: reloadSkills })) as { - data: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }[]; - }; - if (!connectionStillCurrent()) return null; - const permissionProfilesResponse = (await client.request("permissionProfile/list", { cwd: vaultPath, cursor: null, limit: 100 })) as { - data: { id: string; description: string | null; allowed: boolean }[]; - nextCursor: string | null; - }; - if (!connectionStillCurrent()) return null; - await client.request("account/rateLimits/read", undefined); - if (!connectionStillCurrent()) return null; - return { - runtimeConfig: emptyRuntimeConfigSnapshot(), - availableSkills: skillsResponse.data.flatMap( - (entry: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }) => - entry.skills.map((skill) => ({ - name: skill.name, - description: skill.description ?? "", - path: skill.path ?? "", - enabled: skill.enabled ?? true, - })), - ), - availablePermissionProfiles: permissionProfilesResponse.data.map((profile) => ({ ...profile })), - rateLimit: null, - serverDiagnostics: createServerDiagnostics(), - }; - }; - const emitActiveThreads = (): void => { - if (!activeThreads) return; - for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); - }; - const upsertActiveThread = (thread: Thread): void => { - activeThreads = [thread, ...(activeThreads?.filter((item) => item.id !== thread.id) ?? [])]; - emitActiveThreads(); - }; - const applyThreadCatalogEvent = (event: ThreadCatalogEvent): void => { - switch (event.type) { - case "thread-archived": - case "thread-deleted": - activeThreads = activeThreads?.filter((thread) => thread.id !== event.threadId) ?? null; - emitActiveThreads(); - return; - case "thread-renamed": - activeThreads = activeThreads?.map((thread) => (thread.id === event.threadId ? { ...thread, name: event.name } : thread)) ?? null; - emitActiveThreads(); - return; - case "thread-upserted": - case "thread-restored": - upsertActiveThread(event.thread); - return; - case "thread-unarchived": - return; - } - }; - return { - settingsSource: settings, - receiveActiveThreads: (threads) => { - activeThreads = threads; - emitActiveThreads(); - }, - threadNameMutations: overrides.threadNameMutations ?? createThreadNameMutationCoordinator(), - threadGoalOperations: createThreadGoalOperationCoordinator(), - runtimeSettingsCommitQueue: createKeyedOperationQueue(), - settingsRef: { - settings: chatPanelSettingsAccess(settings), - vaultPath, - }, - workspace: { - openThreadInNewView: overrides.openThreadInNewView ?? vi.fn(), - focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false), - openTurnDiff: overrides.openTurnDiff ?? vi.fn(), - notifyPanelActivityChanged: overrides.notifyPanelActivityChanged ?? vi.fn(), - openSideChat: overrides.openSideChat ?? vi.fn().mockResolvedValue(undefined), - }, - appServerQueries: { - contextLease: () => ({ context: { codexPath: settings.codexPath, vaultPath }, generation: 1 }), - appServerMetadataSnapshot: overrides.appServerMetadataSnapshot ?? vi.fn(() => metadata), - refreshAppServerMetadata: - overrides.refreshAppServerMetadata ?? - vi.fn(async () => { - const nextMetadata = await loadAppServerMetadata(); - if (nextMetadata) applyMetadataToCache(nextMetadata); - }), - refreshSkills: - overrides.refreshSkills ?? - vi.fn(async () => { - const nextMetadata = await loadAppServerMetadata(true); - if (nextMetadata) applyMetadataToCache(nextMetadata); - }), - refreshRateLimits: - overrides.refreshRateLimits ?? - vi.fn(async () => { - const nextMetadata = await loadAppServerMetadata(); - if (nextMetadata) applyMetadataToCache(nextMetadata); - }), - modelsSnapshot: overrides.modelsSnapshot ?? vi.fn(() => models), - fetchModels: overrides.fetchModels ?? vi.fn(async () => models ?? []), - refreshModels: overrides.refreshModels ?? vi.fn(async () => models ?? []), - observeAppServerMetadataResources: (listener, options = {}) => { - metadataResourceListeners.add(listener); - if ((options.emitCurrent ?? true) && metadata) applyMetadataToCache(metadata); - if ((options.emitCurrent ?? true) && models) { - listener({ - id: "models", - value: models, - probe: diagnosticProbeOk("models", `${String(models.length)} models`, Date.now()), - }); - } - return () => { - metadataResourceListeners.delete(listener); - }; - }, - observeModelsResult: (listener, options = {}) => { - modelResultListeners.add(listener); - if ((options.emitCurrent ?? true) && models) listener(queryResult(models)); - return () => { - modelResultListeners.delete(listener); - }; - }, - }, - threadCatalog: { - apply: overrides.applyThreadCatalogEvent ?? applyThreadCatalogEvent, - applyConnectionEvent: (_context, event) => (overrides.applyThreadCatalogEvent ?? applyThreadCatalogEvent)(event), - refreshActive: - overrides.refreshActive ?? - (vi.fn(async () => { - const client = connectionMock.state.client; - if (!client) return activeThreads ?? []; - const request = client["request"] as (method: string, params: Record) => Promise<{ data: ThreadRecord[] }>; - const response = await request("thread/list", { - cwd: "/vault", - archived: false, - limit: 100, - sortKey: "recency_at", - sortDirection: "desc", - }); - activeThreads = response.data.map(threadFromRecord); - for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); - return activeThreads; - }) as CodexChatHost["threadCatalog"]["refreshActive"]), - loadActive: vi.fn(async () => activeThreads ?? []), - activeSnapshot: overrides.activeSnapshot ?? vi.fn(() => activeThreads), - observeActive: (listener, options = {}) => { - activeThreadResultListeners.add(listener); - if ((options.emitCurrent ?? true) && activeThreads) listener(queryResult(activeThreads)); - return () => { - activeThreadResultListeners.delete(listener); - }; - }, - }, - }; -} - -function queryResult(value: T | null): ObservedResult { - return { - value, - error: null, - isFetching: false, - }; -} - -async function chatView(options: { host?: CodexChatHost; requestSaveLayout?: () => void } = {}) { - const host = options.host ?? chatHost(); - const containerEl = document.createElement("div"); - document.body.appendChild(containerEl); - containerEl.createDiv(); - containerEl.createDiv(); - const view = new CodexChatView( - { - app: { - workspace: { - getActiveFile: vi.fn(() => null), - getActiveViewOfType: vi.fn(() => null), - getLastOpenFiles: vi.fn(() => []), - on: vi.fn(() => ({})), - openLinkText: vi.fn(), - requestSaveLayout: options.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(() => ""), - getFileCache: vi.fn(() => null), - }, - }, - containerEl, - } as never, - host, - ); - (view.app.workspace.getActiveViewOfType as ReturnType).mockReturnValue(view); - const tracked: TrackedView = { view, opened: false }; - const onOpen = view.onOpen.bind(view); - const onClose = view.onClose.bind(view); - view.onOpen = async () => { - tracked.opened = true; - await onOpen(); - }; - view.onClose = async () => { - tracked.opened = false; - await onClose(); - }; - createdViews.push(tracked); - return view; -} diff --git a/tests/features/chat/host/view-restoration.test.ts b/tests/features/chat/host/view-restoration.test.ts new file mode 100644 index 00000000..8452d311 --- /dev/null +++ b/tests/features/chat/host/view-restoration.test.ts @@ -0,0 +1,346 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages"; +import { emptyRuntimeConfigSnapshot } from "../../../../src/domain/runtime/config"; +import { createServerDiagnostics } from "../../../../src/domain/server/diagnostics"; +import { deferred, waitForAsyncWork } from "../../../support/async"; +import { + chatHost, + chatView, + composerElement, + connectedClient, + connectionMockState, + expectRequestTimes, + panelThread, + requestMethods, + requiredButton, + resumedThread, + setupViewConnectionHarness, + submitComposerByEnter, + threadFixture, +} from "./view-connection-harness"; + +describe("CodexChatView workspace restoration", () => { + setupViewConnectionHarness(); + + it("restores workspace thread state without hydrating it automatically", async () => { + vi.useFakeTimers(); + const client = connectedClient({ + "thread/resume": vi.fn().mockResolvedValue(resumedThread("thread-1")), + }); + connectionMockState().client = client; + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + await view.onOpen(); + + expect(view.getDisplayText()).toBe("Codex: Restored thread"); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); + expect(connectionMockState().connectCalls).toBe(0); + expect(requestMethods(client)).not.toContain("thread/resume"); + expect(view.containerEl.textContent).not.toContain("Thread restored. Send a message to resume it."); + + await vi.advanceTimersByTimeAsync(0); + + expect(connectionMockState().connectCalls).toBe(1); + expectRequestTimes(client, "config/read", 1); + expectRequestTimes(client, "thread/list", 1); + expect(requestMethods(client)).not.toContain("thread/resume"); + expect(requestMethods(client)).not.toContain("thread/resume"); + expect(requestMethods(client)).not.toContain("thread/turns/list"); + }); + + it("formats the panel title from listed thread metadata", async () => { + const host = chatHost(); + const view = await chatView({ host }); + + await view.setState({ threadId: "thread-named" }, {} as never); + await view.onOpen(); + host.receiveActiveThreads([panelThread({ id: "thread-named", name: "作業メモ" })]); + expect(view.getDisplayText()).toBe("Codex: 作業メモ"); + + host.receiveActiveThreads([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]); + expect(view.getDisplayText()).toBe("Codex: 初回依頼"); + + await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never); + host.receiveActiveThreads([]); + expect(view.getDisplayText()).toBe("Codex: 019e061e"); + }); + + it("keeps late workspace thread state restored until explicit focus", async () => { + vi.useFakeTimers(); + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView(); + + await view.onOpen(); + await vi.advanceTimersByTimeAsync(0); + expect(connectionMockState().connectCalls).toBe(1); + expectRequestTimes(client, "config/read", 1); + expectRequestTimes(client, "thread/list", 1); + expect(requestMethods(client)).not.toContain("thread/resume"); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + + expect(view.getDisplayText()).toBe("Codex: Restored thread"); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); + expect(requestMethods(client)).not.toContain("thread/resume"); + expect(requestMethods(client)).not.toContain("thread/turns/list"); + + await view.surface.focusThread("thread-1"); + + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); + expect(client.request).toHaveBeenCalledWith( + "thread/turns/list", + expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), + ); + }); + + it("replaces active thread-scoped state when late workspace state restores another thread", async () => { + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView(); + + await view.onOpen(); + await view.surface.openThread("thread-1"); + view.surface.setComposerText("stale draft"); + + await view.setState({ threadId: "thread-2", threadTitle: "Restored thread 2" }, {} as never); + + expect(view.getDisplayText()).toBe("Codex: Restored thread 2"); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread 2" }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ + threadId: "thread-2", + turnBusy: false, + hasComposerDraft: false, + }); + expect(composerElement(view).value).toBe(""); + }); + + it("warms app-server metadata for an empty restored panel after the shell is open", async () => { + vi.useFakeTimers(); + const client = connectedClient({ + "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")], nextCursor: null }), + }); + const fetchModels = vi.fn().mockResolvedValue([]); + connectionMockState().client = client; + const view = await chatView({ host: chatHost({ fetchModels }) }); + + await view.onOpen(); + + expect(connectionMockState().connectCalls).toBe(0); + await vi.advanceTimersByTimeAsync(0); + + expect(connectionMockState().connectCalls).toBe(1); + expectRequestTimes(client, "config/read", 1); + expect(fetchModels).toHaveBeenCalledOnce(); + expectRequestTimes(client, "skills/list", 1); + expectRequestTimes(client, "permissionProfile/list", 1); + expectRequestTimes(client, "account/rateLimits/read", 1); + expect(client.request).toHaveBeenCalledWith("thread/list", { + cwd: "/vault", + archived: false, + limit: 100, + sortKey: "recency_at", + sortDirection: "desc", + }); + requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); + await waitForAsyncWork(() => { + expect(view.containerEl.textContent).toContain("Restored thread"); + }); + }); + + it("applies cached shared thread list and metadata when opened", async () => { + const cachedThread = threadFixture("thread-cached"); + const view = await chatView({ + host: chatHost({ + activeSnapshot: vi.fn(() => [cachedThread] as never[]), + appServerMetadataSnapshot: vi.fn( + () => + ({ + runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-cached" }, + availableSkills: [{ name: "writer", enabled: true }], + availablePermissionProfiles: [], + rateLimit: null, + serverDiagnostics: createServerDiagnostics(), + }) as never, + ), + }), + }); + + await view.onOpen(); + + requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); + await waitForAsyncWork(() => { + expect(view.containerEl.textContent).toContain("Restored thread"); + }); + requiredButton(view.containerEl, '[aria-label="Show status"]').click(); + await waitForAsyncWork(() => { + expect(view.containerEl.textContent).toContain("gpt-cached"); + }); + }); + + it("hydrates a focused restored thread immediately", async () => { + vi.useFakeTimers(); + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + await view.onOpen(); + + expect(requestMethods(client)).not.toContain("thread/resume"); + await view.surface.focusThread("thread-1"); + + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); + expect(client.request).toHaveBeenCalledWith( + "thread/turns/list", + expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), + ); + }); + + it("starts fresh hydration when the same restored view state is reapplied", async () => { + const resume = deferred>(); + const client = connectedClient({ + "thread/resume": vi.fn(() => resume.promise), + }); + connectionMockState().client = client; + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + await view.onOpen(); + const firstHydration = view.surface.focusThread("thread-1"); + await waitForAsyncWork(() => { + expectRequestTimes(client, "thread/resume", 1); + }); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + const secondHydration = view.surface.focusThread("thread-1"); + await waitForAsyncWork(() => { + expectRequestTimes(client, "thread/resume", 2); + }); + + resume.resolve(resumedThread("thread-1")); + await Promise.all([firstHydration, secondHydration]); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1" }); + }); + + it("resumes a restored thread before sending the first message", async () => { + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never); + await view.onOpen(); + view.surface.setComposerText("hello"); + await submitComposerByEnter(view); + + await waitForAsyncWork(() => { + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); + expect(client.request).toHaveBeenCalledWith("turn/start", { + threadId: "thread-1", + cwd: "/vault", + input: [{ type: "text", text: "hello", text_elements: [] }], + clientUserMessageId: expect.stringMatching(/^local-user-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/), + }); + }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true }); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); + connectionMockState().onNotification?.({ + method: "turn/started", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true }); + }); + + it("notifies Threads only when panel activity changes", async () => { + const notifyPanelActivityChanged = vi.fn(); + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) }); + + await view.onOpen(); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + + await view.surface.openThread("thread-1"); + notifyPanelActivityChanged.mockClear(); + + view.surface.setComposerText("hello"); + expect(notifyPanelActivityChanged).not.toHaveBeenCalled(); + + await submitComposerByEnter(view); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: false }); + + notifyPanelActivityChanged.mockClear(); + connectionMockState().onNotification?.({ + method: "turn/started", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + expect(notifyPanelActivityChanged).not.toHaveBeenCalled(); + + connectionMockState().onServerRequest?.( + { + id: 7, + method: "item/tool/requestUserInput", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "input-1", + questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }], + autoResolutionMs: null, + }, + }, + { respond: vi.fn(), reject: vi.fn() }, + ); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: true }); + + notifyPanelActivityChanged.mockClear(); + connectionMockState().onNotification?.({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "completed", + startedAt: 1, + completedAt: 2, + durationMs: 1, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: false, pending: true }); + }); +}); diff --git a/tests/features/chat/host/view-thread-state.test.ts b/tests/features/chat/host/view-thread-state.test.ts new file mode 100644 index 00000000..d536b67f --- /dev/null +++ b/tests/features/chat/host/view-thread-state.test.ts @@ -0,0 +1,345 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages"; +import { deferred, waitForAsyncWork } from "../../../support/async"; +import { + chatHost, + chatView, + completedTurn, + composerElement, + composerPlaceholder, + connectedClient, + connectionMockState, + expectRequestTimes, + panelThread, + requestMethods, + resumedThread, + setupViewConnectionHarness, + turnWithUserMessage, +} from "./view-connection-harness"; + +describe("CodexChatView thread state", () => { + setupViewConnectionHarness(); + + it("requests a workspace layout save after resuming a thread", async () => { + const requestSaveLayout = vi.fn(); + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView({ requestSaveLayout }); + + await view.surface.openThread("thread-1"); + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); + expect(requestSaveLayout).toHaveBeenCalledTimes(1); + }); + + it("resumes another persistent thread before unsubscribing a running subagent", async () => { + const client = connectedClient({ + "thread/resume": vi.fn((params: unknown) => { + const threadId = (params as { threadId: string }).threadId; + return Promise.resolve( + threadId === "child" + ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) + : resumedThread(threadId), + ); + }), + "thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }), + }); + connectionMockState().client = client; + const view = await chatView(); + + await view.surface.openThread("child"); + connectionMockState().onNotification?.({ + method: "turn/started", + params: { + threadId: "child", + turn: { + id: "turn-child", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + + await view.surface.openThread("other"); + + const unsubscribeCall = client.request.mock.calls.findIndex(([method]) => method === "thread/unsubscribe"); + const otherResumeCall = client.request.mock.calls.findIndex( + ([method, params]) => method === "thread/resume" && (params as { threadId: string }).threadId === "other", + ); + expect(unsubscribeCall).toBeGreaterThanOrEqual(0); + expect(unsubscribeCall).toBeGreaterThan(otherResumeCall); + expect(client.request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything()); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "other" }); + }); + + it("keeps a running subagent subscribed when openThread cannot resume the target", async () => { + const client = connectedClient({ + "thread/resume": vi.fn((params: unknown) => { + const threadId = (params as { threadId: string }).threadId; + return Promise.resolve( + threadId === "child" ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) : null, + ); + }), + "thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }), + }); + connectionMockState().client = client; + const view = await chatView(); + + await view.surface.openThread("child"); + connectionMockState().onNotification?.({ + method: "turn/started", + params: { + threadId: "child", + turn: { + id: "turn-child", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + + await view.surface.openThread("other"); + + expect(requestMethods(client).filter((method) => method === "thread/unsubscribe")).toEqual([]); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnBusy: true }); + }); + + it("resets to an unstarted empty chat without starting a thread", async () => { + const requestSaveLayout = vi.fn(); + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView({ requestSaveLayout }); + + await view.surface.openThread("thread-1"); + await view.surface.startNewThread(); + + expect(requestMethods(client)).not.toContain("thread/start"); + expect(view.getState()).toEqual({ version: 1 }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false }); + expect(requestSaveLayout).toHaveBeenCalledTimes(2); + }); + + it("restores an unavailable side-chat tab as a normal empty chat", async () => { + const view = await chatView(); + await view.setState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } }, {} as never); + + expect(view.getState()).toEqual({ version: 1 }); + expect(view.getDisplayText()).not.toBe("Side chat"); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false }); + expect(view.containerEl.textContent).not.toContain("This side conversation is no longer available."); + }); + + it("focuses the composer after panel thread actions", async () => { + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView(); + + await view.onOpen(); + const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined); + + await view.surface.openThread("thread-1"); + await view.surface.focusThread("thread-1"); + await view.surface.startNewThread(); + + expect(focus).toHaveBeenCalledTimes(3); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it("clears the active thread when another view archives it", async () => { + const requestSaveLayout = vi.fn(); + const client = connectedClient(); + connectionMockState().client = client; + const view = await chatView({ requestSaveLayout }); + + await view.surface.openThread("thread-1"); + view.surface.applyThreadArchived("thread-1"); + + expect(view.getState()).toEqual({ version: 1 }); + expect(requestSaveLayout).toHaveBeenCalledTimes(2); + }); + + it("updates restored panel title from shared rename notifications", async () => { + const view = await chatView(); + + await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never); + view.surface.applyThreadRenamed("thread-1", "After rename"); + + expect(view.getDisplayText()).toBe("Codex: After rename"); + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" }); + }); + + it("does not use restored thread identity as a composer name before the thread becomes active", async () => { + const host = chatHost(); + const view = await chatView({ host }); + + await view.setState({ threadId: "thread-1", threadTitle: "Restored title" }, {} as never); + await view.onOpen(); + + expect(composerPlaceholder(view)).toBe("Ask Codex..."); + + host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Explicit name" })]); + await waitForAsyncWork(() => { + expect(composerPlaceholder(view)).toBe("Ask Codex..."); + }); + + view.surface.applyThreadRenamed("thread-1", "Explicit name"); + + await waitForAsyncWork(() => { + expect(composerPlaceholder(view)).toBe("Ask Codex..."); + }); + }); + + it("keeps composer draft and selection while updating the placeholder", async () => { + const client = connectedClient(); + connectionMockState().client = client; + const host = chatHost(); + const view = await chatView({ host }); + + await view.onOpen(); + await view.surface.openThread("thread-1"); + view.surface.setComposerText("keep this draft"); + const composer = composerElement(view); + await waitForAsyncWork(() => { + expect(composer.value).toBe("keep this draft"); + }); + composer.setSelectionRange(5, 9); + + host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]); + view.surface.applyThreadRenamed("thread-1", "Renamed thread"); + + await waitForAsyncWork(() => { + expect(composer.value).toBe("keep this draft"); + expect(composer.selectionStart).toBe(5); + expect(composer.selectionEnd).toBe(9); + expect(composer.getAttribute("placeholder")).toBe("Ask Codex in “Renamed thread”..."); + }); + }); + + it("renders resumed thread metadata before history hydration completes", async () => { + const history = deferred<{ data: unknown[]; nextCursor: null }>(); + const client = connectedClient({ + "thread/turns/list": vi.fn(() => history.promise), + }); + connectionMockState().client = client; + const view = await chatView(); + + const opening = view.surface.openThread("thread-1"); + await waitForAsyncWork(() => { + expect(client.request).toHaveBeenCalledWith( + "thread/turns/list", + expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), + ); + }); + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); + expect(view.containerEl.textContent).not.toContain("Loading thread..."); + + history.resolve({ data: [], nextCursor: null }); + await opening; + }); + + it("hydrates resumed threads from the initial turns page without a second history request", async () => { + const client = connectedClient({ + "thread/resume": vi.fn().mockResolvedValue({ + ...resumedThread("thread-1"), + initialTurnsPage: { + data: [completedTurn("turn-1")], + nextCursor: "older-cursor", + backwardsCursor: null, + }, + }), + "thread/turns/list": vi.fn().mockResolvedValue({ data: [turnWithUserMessage("fallback prompt")], nextCursor: null }), + }); + connectionMockState().client = client; + const view = await chatView(); + + await view.onOpen(); + await view.surface.openThread("thread-1"); + + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); + expect(requestMethods(client)).not.toContain("thread/turns/list"); + await waitForAsyncWork(() => { + expect(view.containerEl.textContent).toContain("hello"); + expect(view.containerEl.textContent).toContain("done"); + }); + }); + + it("ignores stale resume results when another thread is opened first", async () => { + const firstResume = deferred>(); + const secondResume = deferred>(); + const client = connectedClient({ + "thread/resume": vi.fn((params: unknown) => + (params as { threadId: string }).threadId === "thread-1" ? firstResume.promise : secondResume.promise, + ), + }); + connectionMockState().client = client; + const view = await chatView(); + + const firstOpen = view.surface.openThread("thread-1"); + await waitForAsyncWork(() => { + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); + }); + const secondOpen = view.surface.openThread("thread-2"); + await waitForAsyncWork(() => { + expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-2", cwd: "/vault" })); + }); + + secondResume.resolve(resumedThread("thread-2")); + await secondOpen; + firstResume.resolve(resumedThread("thread-1")); + await firstOpen; + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); + expectRequestTimes(client, "thread/turns/list", 1); + expect(client.request).toHaveBeenCalledWith( + "thread/turns/list", + expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }), + ); + }); + + it("invalidates stale history hydration when a second resume starts", async () => { + const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>(); + const client = connectedClient({ + "thread/resume": vi.fn((params: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))), + "thread/turns/list": vi.fn((params: unknown) => + (params as { threadId: string }).threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }), + ), + }); + connectionMockState().client = client; + const view = await chatView(); + + const firstOpen = view.surface.openThread("thread-1"); + await waitForAsyncWork(() => { + expect(client.request).toHaveBeenCalledWith( + "thread/turns/list", + expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }), + ); + }); + const secondOpen = view.surface.openThread("thread-2"); + await waitForAsyncWork(() => { + expect(client.request).toHaveBeenCalledWith( + "thread/turns/list", + expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }), + ); + }); + + firstHistory.resolve({ data: [turnWithUserMessage("first prompt")], nextCursor: null }); + await firstOpen; + await secondOpen; + + expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" }); + expect(view.containerEl.textContent).not.toContain("first prompt"); + }); +});