From 0aa20089a8de85e8d810e83f2d4a5091a3945d01 Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 28 May 2026 20:53:20 +0900 Subject: [PATCH] Continue state-machine cleanup --- src/app-server/client.ts | 43 ++--- src/features/selection-rewrite/popover.tsx | 25 +-- src/settings/data.ts | 38 +++++ src/settings/tab.ts | 185 +++++++++++++++------ tests/app-server/app-server-client.test.ts | 28 ++++ tests/settings/settings-data.test.ts | 25 +++ 6 files changed, 264 insertions(+), 80 deletions(-) diff --git a/src/app-server/client.ts b/src/app-server/client.ts index 24e921a4..200b43ae 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -100,17 +100,20 @@ interface ClientResponseByMethod { type TypedClientRequestMethod = Extract; +type AppServerClientLifecycleState = + | { kind: "disconnected" } + | { kind: "starting"; transport: AppServerTransport } + | { kind: "initialized"; transport: AppServerTransport; initializeResponse: InitializeResponse }; + function toUserInput(input: string | UserInput[]): UserInput[] { if (typeof input !== "string") return input; return [{ type: "text", text: input, text_elements: [] }]; } export class AppServerClient { - private transport: AppServerTransport | null = null; + private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" }; private nextId = 1; private pending = new Map(); - private initialized = false; - private initResponse: InitializeResponse | null = null; constructor( private readonly codexPath: string, @@ -121,7 +124,7 @@ export class AppServerClient { ) {} async connect(): Promise { - if (this.transport?.isRunning()) { + if (this.activeTransport()?.isRunning()) { throw new Error("Codex app-server is already running."); } @@ -134,16 +137,16 @@ export class AppServerClient { this.rejectAll(error); }, onExit: (code, signal) => { - this.initialized = false; - this.initResponse = null; + this.lifecycle = { kind: "disconnected" }; this.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`)); this.handlers.onExit(code, signal); }, }; - this.transport = this.transportFactory + const transport = this.transportFactory ? this.transportFactory(transportHandlers) : new StdioAppServerTransport(this.codexPath, this.cwd, transportHandlers); - this.transport.start(); + this.lifecycle = { kind: "starting", transport }; + transport.start(); const init = await this.request("initialize", { clientInfo: { @@ -157,26 +160,23 @@ export class AppServerClient { }, }); this.notify({ method: "initialized" }); - this.initialized = true; - this.initResponse = init; + this.lifecycle = { kind: "initialized", transport, initializeResponse: init }; return init; } disconnect(): void { - this.initialized = false; - this.initResponse = null; - this.transport?.stop(); - this.transport = null; + this.activeTransport()?.stop(); + this.lifecycle = { kind: "disconnected" }; this.rejectAll(new Error("Codex app-server disconnected.")); } isConnected(): boolean { - return this.initialized && this.transport?.isRunning() === true; + return this.lifecycle.kind === "initialized" && this.lifecycle.transport.isRunning(); } get initializeResponse(): InitializeResponse { - if (!this.initResponse) throw new Error("Codex app-server has not initialized."); - return this.initResponse; + if (this.lifecycle.kind !== "initialized") throw new Error("Codex app-server has not initialized."); + return this.lifecycle.initializeResponse; } readEffectiveConfig(cwd: string): Promise { @@ -438,10 +438,11 @@ export class AppServerClient { } private send(message: RpcOutboundMessage): void { - if (!this.transport?.isRunning()) { + const transport = this.activeTransport(); + if (!transport?.isRunning()) { throw new Error("Codex app-server is not running."); } - this.transport.send(message); + transport.send(message); } private handleLine(line: string): void { @@ -492,4 +493,8 @@ export class AppServerClient { } this.pending.clear(); } + + private activeTransport(): AppServerTransport | null { + return this.lifecycle.kind === "disconnected" ? null : this.lifecycle.transport; + } } diff --git a/src/features/selection-rewrite/popover.tsx b/src/features/selection-rewrite/popover.tsx index d4d2b622..95668a54 100644 --- a/src/features/selection-rewrite/popover.tsx +++ b/src/features/selection-rewrite/popover.tsx @@ -44,13 +44,17 @@ interface SelectionRewriteElements { type SelectionRewriteGenerationRunState = { kind: "idle" } | { kind: "running"; abortController: AbortController }; type ActiveSelectionRewriteGenerationRun = Extract; +interface SelectionRewritePopoverStatusState { + text: string; + active: boolean; +} + export class SelectionRewritePopover { private elements: SelectionRewriteElements | null = null; private generationRun: SelectionRewriteGenerationRunState = { kind: "idle" }; private readonly cleanups: Cleanup[] = []; private instructionDraft: string; - private statusText = ""; - private statusActive = false; + private status: SelectionRewritePopoverStatusState = { text: "", active: false }; constructor(private readonly options: SelectionRewritePopoverOptions) { this.instructionDraft = options.state.instruction; @@ -218,8 +222,7 @@ export class SelectionRewritePopover { } private setStatus(text: string, options: { active?: boolean } = {}): void { - this.statusText = text; - this.statusActive = Boolean(options.active); + this.status = { text, active: Boolean(options.active) }; this.renderView(); } @@ -298,8 +301,7 @@ export class SelectionRewritePopover { event.preventDefault(); this.apply(); }} - statusActive={this.statusActive} - statusText={this.statusText} + status={this.status} streamPreview={state.streamText.trim()} />, ); @@ -345,8 +347,7 @@ interface SelectionRewritePopoverViewProps { onGenerate: () => void; onInstructionInput: (value: string) => void; onInstructionKeyDown: (event: KeyboardEvent) => void; - statusActive: boolean; - statusText: string; + status: SelectionRewritePopoverStatusState; streamPreview: string; } @@ -365,8 +366,7 @@ function SelectionRewritePopoverView({ onGenerate, onInstructionInput, onInstructionKeyDown, - statusActive, - statusText, + status, streamPreview, }: SelectionRewritePopoverViewProps): ReactNode { return ( @@ -399,7 +399,7 @@ function SelectionRewritePopoverView({ /> - +
{streamPreview}
{diff ? : null}
@@ -425,7 +425,8 @@ function SelectionRewritePopoverView({ ); } -function SelectionRewriteStatus({ text, active }: { text: string; active: boolean }): ReactNode { +function SelectionRewriteStatus({ status }: { status: SelectionRewritePopoverStatusState }): ReactNode { + const { text, active } = status; if (!text && !active) return
; return (
diff --git a/src/settings/data.ts b/src/settings/data.ts index 0c17b89d..1a134c30 100644 --- a/src/settings/data.ts +++ b/src/settings/data.ts @@ -32,6 +32,18 @@ export type SettingsDataRefreshLifecycleState = { kind: "idle" } | { kind: "load export type SettingsDataRefreshLifecycleEvent = { type: "started" } | { type: "completed"; failedCount: number }; +export type SettingsDynamicSectionLifecycleState = + | { kind: "idle"; status: "" } + | { kind: "loading"; status: string; operationId: number } + | { kind: "loaded"; status: string; operationId: number } + | { kind: "failed"; status: string; operationId: number }; + +export type SettingsDynamicSectionLifecycleEvent = + | { type: "started"; status: string; operationId: number } + | { type: "loaded"; status: string; operationId: number } + | { type: "failed"; status: string; operationId: number } + | { type: "reset" }; + export function transitionSettingsDataRefreshLifecycle( state: SettingsDataRefreshLifecycleState, event: SettingsDataRefreshLifecycleEvent, @@ -48,6 +60,32 @@ export function settingsDataRefreshLoading(state: SettingsDataRefreshLifecycleSt return state.kind === "loading"; } +export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState { + return { kind: "idle", status: "" }; +} + +export function transitionSettingsDynamicSectionLifecycle( + state: SettingsDynamicSectionLifecycleState, + event: SettingsDynamicSectionLifecycleEvent, +): SettingsDynamicSectionLifecycleState { + switch (event.type) { + case "started": + return { kind: "loading", status: event.status, operationId: event.operationId }; + case "loaded": + if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state; + return { kind: "loaded", status: event.status, operationId: event.operationId }; + case "failed": + if (isStaleSettingsDynamicSectionEvent(state, event.operationId)) return state; + return { kind: "failed", status: event.status, operationId: event.operationId }; + case "reset": + return createSettingsDynamicSectionLifecycle(); + } +} + +function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationId: number): boolean { + return "operationId" in state && state.operationId > operationId; +} + export async function loadSettingsData(client: AppServerClient, cwd: string): Promise { const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([ client.listModels(false), diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 7f970342..c0deb345 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -11,9 +11,11 @@ import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supporte import { archivedThreadDisplayTitle } from "../domain/threads/model"; import { errorMessage } from "../utils"; import { + createSettingsDynamicSectionLifecycle, loadHookData, loadSettingsData, settingsDataRefreshLoading, + transitionSettingsDynamicSectionLifecycle, transitionSettingsDataRefreshLifecycle, type SettingsDataRefreshLifecycleState, } from "./data"; @@ -36,39 +38,18 @@ function settingsDataRefreshStatus(state: SettingsDataRefreshLifecycleState): st return ""; } -type SettingsDynamicSectionLifecycleState = - | { kind: "idle"; status: "" } - | { kind: "loading"; status: string } - | { kind: "loaded"; status: string } - | { kind: "failed"; status: string }; - -function idleSettingsDynamicSection(): SettingsDynamicSectionLifecycleState { - return { kind: "idle", status: "" }; -} - -function loadingSettingsDynamicSection(status: string): SettingsDynamicSectionLifecycleState { - return { kind: "loading", status }; -} - -function loadedSettingsDynamicSection(status: string): SettingsDynamicSectionLifecycleState { - return { kind: "loaded", status }; -} - -function failedSettingsDynamicSection(status: string): SettingsDynamicSectionLifecycleState { - return { kind: "failed", status }; -} - export class CodexPanelSettingTab extends PluginSettingTab { private settingsDataAutoLoadStarted = false; + private settingsDynamicOperationId = 0; private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" }; private archivedThreads: Thread[] = []; - private archivedThreadsLifecycle = idleSettingsDynamicSection(); + private archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle(); private hooks: HookMetadata[] = []; private hookWarnings: string[] = []; private hookErrors: string[] = []; - private hooksLifecycle = idleSettingsDynamicSection(); + private hooksLifecycle = createSettingsDynamicSectionLifecycle(); private models: Model[] = []; - private modelsLifecycle = idleSettingsDynamicSection(); + private modelsLifecycle = createSettingsDynamicSectionLifecycle(); constructor( app: App, @@ -260,10 +241,23 @@ export class CodexPanelSettingTab extends PluginSettingTab { } private async refreshSettingsData(): Promise { + const operationId = this.nextSettingsDynamicOperationId(); this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, { type: "started" }); - this.modelsLifecycle = loadingSettingsDynamicSection("Loading models..."); - this.archivedThreadsLifecycle = loadingSettingsDynamicSection("Loading archived threads..."); - this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks..."); + this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { + type: "started", + status: "Loading models...", + operationId, + }); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "started", + status: "Loading archived threads...", + operationId, + }); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "started", + status: "Loading hooks...", + operationId, + }); this.display(); let failedCount = 0; @@ -273,35 +267,71 @@ export class CodexPanelSettingTab extends PluginSettingTab { if (result.models.ok) { this.models = result.models.data; this.plugin.publishModels(result.models.data); - this.modelsLifecycle = loadedSettingsDynamicSection(result.models.status); + this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { + type: "loaded", + status: result.models.status, + operationId, + }); } else { failedCount += 1; - this.modelsLifecycle = failedSettingsDynamicSection(result.models.status); + this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { + type: "failed", + status: result.models.status, + operationId, + }); } if (result.hooks.ok) { this.hooks = result.hooks.data.hooks; this.hookWarnings = result.hooks.data.warnings; this.hookErrors = result.hooks.data.errors; - this.hooksLifecycle = loadedSettingsDynamicSection(result.hooks.status); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "loaded", + status: result.hooks.status, + operationId, + }); } else { failedCount += 1; - this.hooksLifecycle = failedSettingsDynamicSection(result.hooks.status); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "failed", + status: result.hooks.status, + operationId, + }); } if (result.archivedThreads.ok) { this.archivedThreads = result.archivedThreads.data; - this.archivedThreadsLifecycle = loadedSettingsDynamicSection(result.archivedThreads.status); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "loaded", + status: result.archivedThreads.status, + operationId, + }); } else { failedCount += 1; - this.archivedThreadsLifecycle = failedSettingsDynamicSection(result.archivedThreads.status); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "failed", + status: result.archivedThreads.status, + operationId, + }); } } catch (error) { failedCount = 3; const message = errorMessage(error); - this.modelsLifecycle = failedSettingsDynamicSection(`Could not load models: ${message}`); - this.hooksLifecycle = failedSettingsDynamicSection(`Could not load hooks: ${message}`); - this.archivedThreadsLifecycle = failedSettingsDynamicSection(`Could not load archived threads: ${message}`); + this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, { + type: "failed", + status: `Could not load models: ${message}`, + operationId, + }); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "failed", + status: `Could not load hooks: ${message}`, + operationId, + }); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "failed", + status: `Could not load archived threads: ${message}`, + operationId, + }); } finally { this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, { type: "completed", @@ -319,16 +349,29 @@ export class CodexPanelSettingTab extends PluginSettingTab { } private async loadHooks(): Promise { - this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks..."); + const operationId = this.nextSettingsDynamicOperationId(); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "started", + status: "Loading hooks...", + operationId, + }); this.display(); try { const hooks = await this.withSettingsConnection((client) => loadHookData(client, this.plugin.vaultPath)); this.hooks = hooks.hooks; this.hookWarnings = hooks.warnings; this.hookErrors = hooks.errors; - this.hooksLifecycle = loadedSettingsDynamicSection(hooks.status); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "loaded", + status: hooks.status, + operationId, + }); } catch (error) { - this.hooksLifecycle = failedSettingsDynamicSection(`Could not load hooks: ${errorMessage(error)}`); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "failed", + status: `Could not load hooks: ${errorMessage(error)}`, + operationId, + }); new Notice("Could not load Codex hooks."); } finally { this.display(); @@ -336,28 +379,54 @@ export class CodexPanelSettingTab extends PluginSettingTab { } private async trustHook(hook: HookMetadata): Promise { - this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks..."); + const operationId = this.nextSettingsDynamicOperationId(); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "started", + status: "Loading hooks...", + operationId, + }); this.display(); try { await this.withSettingsConnection((client) => client.trustHook(hook)); - this.hooksLifecycle = loadedSettingsDynamicSection("Trusted hook definition."); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "loaded", + status: "Trusted hook definition.", + operationId, + }); await this.loadHooks(); } catch (error) { - this.hooksLifecycle = failedSettingsDynamicSection(`Could not trust hook: ${errorMessage(error)}`); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "failed", + status: `Could not trust hook: ${errorMessage(error)}`, + operationId, + }); new Notice("Could not trust Codex hook."); this.display(); } } private async setHookEnabled(hook: HookMetadata, enabled: boolean): Promise { - this.hooksLifecycle = loadingSettingsDynamicSection("Loading hooks..."); + const operationId = this.nextSettingsDynamicOperationId(); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "started", + status: "Loading hooks...", + operationId, + }); this.display(); try { await this.withSettingsConnection((client) => client.setHookEnabled(hook, enabled)); - this.hooksLifecycle = loadedSettingsDynamicSection(enabled ? "Enabled hook." : "Disabled hook."); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "loaded", + status: enabled ? "Enabled hook." : "Disabled hook.", + operationId, + }); await this.loadHooks(); } catch (error) { - this.hooksLifecycle = failedSettingsDynamicSection(`Could not update hook: ${errorMessage(error)}`); + this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, { + type: "failed", + status: `Could not update hook: ${errorMessage(error)}`, + operationId, + }); new Notice("Could not update Codex hook."); this.display(); } @@ -385,15 +454,28 @@ export class CodexPanelSettingTab extends PluginSettingTab { } private async restoreArchivedThread(threadId: string): Promise { - this.archivedThreadsLifecycle = loadingSettingsDynamicSection("Loading archived threads..."); + const operationId = this.nextSettingsDynamicOperationId(); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "started", + status: "Loading archived threads...", + operationId, + }); this.display(); try { const response = await this.withSettingsConnection((client) => client.unarchiveThread(threadId)); this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); - this.archivedThreadsLifecycle = loadedSettingsDynamicSection(`Restored "${archivedThreadDisplayTitle(response.thread)}".`); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "loaded", + status: `Restored "${archivedThreadDisplayTitle(response.thread)}".`, + operationId, + }); this.plugin.refreshSharedThreadListFromOpenSurface(); } catch (error) { - this.archivedThreadsLifecycle = failedSettingsDynamicSection(`Could not restore archived thread: ${errorMessage(error)}`); + this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { + type: "failed", + status: `Could not restore archived thread: ${errorMessage(error)}`, + operationId, + }); new Notice("Could not restore archived Codex thread."); } finally { this.display(); @@ -404,6 +486,11 @@ export class CodexPanelSettingTab extends PluginSettingTab { return withAppServerConnection(this.plugin.settings.codexPath, this.plugin.vaultPath, operation); } + private nextSettingsDynamicOperationId(): number { + this.settingsDynamicOperationId += 1; + return this.settingsDynamicOperationId; + } + private modelOptions(): Model[] { return sortedAvailableModels(this.models); } diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index 0bb95835..82ef1709 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -33,6 +33,11 @@ class FakeTransport implements AppServerTransport { emitLine(message: unknown): void { this.handlers.onLine(JSON.stringify(message)); } + + emitExit(code: number | null = 0, signal: NodeJS.Signals | null = null): void { + this.running = false; + this.handlers.onExit(code, signal); + } } async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> { @@ -148,6 +153,29 @@ describe("AppServerClient", () => { expect(serverRequests[0]?.method).toBe("item/commandExecution/requestApproval"); }); + it("exposes initialized state through a single connection lifecycle", async () => { + const { client, transport } = await connectedClient(); + + expect(client.isConnected()).toBe(true); + expect(client.initializeResponse).toMatchObject({ codexHome: "/tmp/codex" }); + + transport.emitExit(0); + + expect(client.isConnected()).toBe(false); + expect(() => client.initializeResponse).toThrow("Codex app-server has not initialized."); + }); + + it("clears initialized state and rejects pending requests on disconnect", async () => { + const { client } = await connectedClient(); + const listing = client.listModels(); + + client.disconnect(); + + expect(client.isConnected()).toBe(false); + expect(() => client.initializeResponse).toThrow("Codex app-server has not initialized."); + await expect(listing).rejects.toThrow("Codex app-server disconnected."); + }); + it("sends typed turn steering requests", async () => { let transport: FakeTransport; const getTransport = () => transport; diff --git a/tests/settings/settings-data.test.ts b/tests/settings/settings-data.test.ts index 317da516..4d001e88 100644 --- a/tests/settings/settings-data.test.ts +++ b/tests/settings/settings-data.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../src/app-server/client"; import { + createSettingsDynamicSectionLifecycle, loadHookData, loadSettingsData, settingsDataRefreshLoading, + transitionSettingsDynamicSectionLifecycle, transitionSettingsDataRefreshLifecycle, } from "../../src/settings/data"; @@ -22,6 +24,29 @@ describe("settings data", () => { expect(settingsDataRefreshLoading(completed)).toBe(false); }); + it("tracks dynamic section lifecycle", () => { + const idle = createSettingsDynamicSectionLifecycle(); + expect(idle).toEqual({ kind: "idle", status: "" }); + + const loading = transitionSettingsDynamicSectionLifecycle(idle, { type: "started", status: "Loading hooks...", operationId: 1 }); + expect(loading).toEqual({ kind: "loading", status: "Loading hooks...", operationId: 1 }); + + expect(transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Stale result.", operationId: 0 })).toBe(loading); + + const loaded = transitionSettingsDynamicSectionLifecycle(loading, { type: "loaded", status: "Loaded 1 hook.", operationId: 1 }); + expect(loaded).toEqual({ kind: "loaded", status: "Loaded 1 hook.", operationId: 1 }); + + const failed = transitionSettingsDynamicSectionLifecycle(loaded, { type: "failed", status: "Could not load hooks.", operationId: 1 }); + expect(failed).toEqual({ kind: "failed", status: "Could not load hooks.", operationId: 1 }); + + const laterLoaded = transitionSettingsDynamicSectionLifecycle(failed, { type: "loaded", status: "Loaded 2 hooks.", operationId: 2 }); + expect(transitionSettingsDynamicSectionLifecycle(laterLoaded, { type: "failed", status: "Late old failure.", operationId: 1 })).toBe( + laterLoaded, + ); + + expect(transitionSettingsDynamicSectionLifecycle(failed, { type: "reset" })).toEqual(idle); + }); + it("uses only hook rows for the requested cwd", async () => { const client = { listHooks: vi.fn().mockResolvedValue({