Continue state-machine cleanup

This commit is contained in:
murashit 2026-05-28 20:53:20 +09:00
parent b1ddd7891b
commit 0aa20089a8
6 changed files with 264 additions and 80 deletions

View file

@ -100,17 +100,20 @@ interface ClientResponseByMethod {
type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
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<RequestId, PendingRequest>();
private initialized = false;
private initResponse: InitializeResponse | null = null;
constructor(
private readonly codexPath: string,
@ -121,7 +124,7 @@ export class AppServerClient {
) {}
async connect(): Promise<InitializeResponse> {
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<ConfigReadResponse> {
@ -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;
}
}

View file

@ -44,13 +44,17 @@ interface SelectionRewriteElements {
type SelectionRewriteGenerationRunState = { kind: "idle" } | { kind: "running"; abortController: AbortController };
type ActiveSelectionRewriteGenerationRun = Extract<SelectionRewriteGenerationRunState, { kind: "running" }>;
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<HTMLTextAreaElement>) => 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({
/>
</div>
</div>
<SelectionRewriteStatus text={statusText} active={statusActive} />
<SelectionRewriteStatus status={status} />
<pre className={`codex-panel-selection-rewrite__stream-preview${streamPreview ? "" : " is-hidden"}`}>{streamPreview}</pre>
<div className={`codex-panel-selection-rewrite__result-row${hasReplacement ? "" : " is-hidden"}`}>
<div className="codex-panel-selection-rewrite__diff">{diff ? <SelectionRewriteDiff diff={diff} /> : null}</div>
@ -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 <div className="codex-panel-selection-rewrite__status" />;
return (
<div className={`codex-panel-selection-rewrite__status${active ? " is-active" : ""}`}>

View file

@ -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<SettingsDataLoad> {
const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([
client.listModels(false),

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}

View file

@ -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;

View file

@ -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({