murashit_codex-panel/tests/settings/dynamic-sections-controller.test.ts

466 lines
20 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
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 type { Thread } from "../../src/domain/threads/model";
import { SettingsDynamicSectionsController } from "../../src/settings/dynamic-sections-controller";
import { StaleExecutionRuntimeError } from "../../src/shared/runtime/execution-runtime-lifetime";
import { deferred } from "../support/async";
import {
appServerThread,
flushPromises,
hook,
model,
panelThread,
setSettingsShortLivedClientMock,
settingsClient,
settingsRequestClient,
settingsTabHost,
useShortLivedClients,
} from "./test-support";
type SettingsDynamicSectionsSnapshot = ReturnType<SettingsDynamicSectionsController["snapshot"]>;
const { withShortLivedAppServerClientMock } = vi.hoisted(() => ({
withShortLivedAppServerClientMock: vi.fn(),
}));
vi.mock("../../src/app-server/connection/short-lived-client", () => ({
withShortLivedAppServerClient: withShortLivedAppServerClientMock,
}));
setSettingsShortLivedClientMock(withShortLivedAppServerClientMock);
describe("SettingsDynamicSectionsController", () => {
beforeEach(() => {
withShortLivedAppServerClientMock.mockReset();
});
it("publishes archived thread catalog updates", () => {
let emitArchived = (_threads: readonly Thread[]): void => {
throw new Error("Expected archived thread observer");
};
const display = vi.fn();
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
observeArchived: (listener) => {
emitArchived = (threads) => {
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
};
return () => undefined;
},
}),
{ display, notify: vi.fn() },
);
controller.activate();
emitArchived([panelThread({ id: "thread-archived", preview: "Archived elsewhere", archived: true })]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["Archived elsewhere"]);
expect(controller.snapshot().archivedThreadsLifecycle.kind).toBe("loaded");
expect(display).toHaveBeenCalledOnce();
});
it("ignores stale dynamic sections refresh results after a newer refresh completes", async () => {
const firstModels = deferred<ModelMetadata[]>();
const firstClient = settingsClient();
const secondClient = settingsClient({ models: [model("gpt-new")] });
useShortLivedClients(firstClient, secondClient);
const refreshModels = vi
.fn()
.mockReturnValueOnce(firstModels.promise)
.mockResolvedValueOnce(modelMetadataFromCatalogModels([model("gpt-new")]));
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ refreshModels, refreshArchived }), {
display: vi.fn(),
notify: vi.fn(),
});
const firstRefresh = controller.refreshDynamicSections();
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
firstModels.resolve(modelMetadataFromCatalogModels([model("gpt-old")]));
await firstRefresh;
expect(controller.snapshot().models.map((item) => item.model)).toEqual(["gpt-new"]);
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New"]);
});
it("does not display dynamic refresh results after disposal", async () => {
const models = deferred<ModelMetadata[]>();
const display = vi.fn();
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
refreshModels: vi.fn(() => models.promise),
refreshArchived: vi.fn().mockResolvedValue([]),
}),
{ display, notify: vi.fn() },
);
const refresh = controller.refreshDynamicSections();
await flushPromises();
display.mockClear();
controller.dispose();
models.resolve([]);
await refresh;
expect(display).not.toHaveBeenCalled();
});
it("reloads hooks after the settings view is hidden and shown again", async () => {
const firstHooks = deferred<unknown>();
const firstClient = settingsClient();
firstClient.requestHandlers["hooks/list"] = vi.fn(() => firstHooks.promise);
const secondClient = settingsClient({ hooks: [hook({ key: "hook-after-reopen" })] });
useShortLivedClients(firstClient, secondClient);
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
controller.activate();
controller.maybeAutoLoadDynamicSections();
await flushPromises();
controller.dispose();
firstHooks.resolve({ data: [{ cwd: "/vault", hooks: [], warnings: [], errors: [] }] });
await flushPromises();
controller.activate();
controller.maybeAutoLoadDynamicSections();
await flushPromises();
expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-after-reopen" })]);
expect(firstClient.requestHandlers["hooks/list"]).toHaveBeenCalledOnce();
expect(secondClient.requestHandlers["hooks/list"]).toHaveBeenCalledOnce();
});
it("settles a pending hook mutation after the settings view is hidden and shown again", async () => {
const write = deferred<unknown>();
const client = settingsClient({ hooks: [hook({ key: "hook-after-write", trustStatus: "trusted" })] });
client.requestHandlers["config/batchWrite"] = vi.fn(() => write.promise);
useShortLivedClients(client);
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
controller.activate();
const mutation = controller.trustHook(hook({ key: "hook-after-write", trustStatus: "untrusted" }));
await flushPromises();
controller.dispose();
controller.activate();
controller.maybeAutoLoadDynamicSections();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledOnce();
write.resolve({});
await mutation;
expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-after-write", trustStatus: "trusted" })]);
expect(controller.snapshot().hooksLifecycle).toEqual({ kind: "loaded", status: "Trusted hook definition." });
});
it("reloads authoritative hooks on the same client after a mutation", async () => {
const client = settingsClient({
hooks: [hook({ key: "hook-trusted", currentHash: "trusted-hash", trustStatus: "trusted" })],
});
useShortLivedClients(client);
const controller = new SettingsDynamicSectionsController(settingsTabHost(), { display: vi.fn(), notify: vi.fn() });
await controller.trustHook(hook({ key: "hook-trusted", currentHash: "untrusted-hash", trustStatus: "untrusted" }));
expect(client.request.mock.calls.map(([method]) => method)).toEqual(["config/batchWrite", "hooks/list"]);
expect(withShortLivedAppServerClientMock).toHaveBeenCalledOnce();
expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-trusted", currentHash: "trusted-hash" })]);
expect(controller.snapshot().hooksLifecycle).toEqual({ kind: "loaded", status: "Trusted hook definition." });
});
it("does not publish an old-context hook mutation over a replacement-context refresh", async () => {
const oldWrite = deferred<unknown>();
const oldClient = settingsClient({ hooks: [hook({ key: "hook-old-context" })] });
oldClient.requestHandlers["config/batchWrite"] = vi.fn(() => oldWrite.promise);
const newClient = settingsClient({ hooks: [hook({ key: "hook-new-context" })] });
useShortLivedClients(oldClient, newClient);
const host = settingsTabHost();
const notify = vi.fn();
const controller = new SettingsDynamicSectionsController(host, { display: vi.fn(), notify });
controller.activate();
const oldMutation = controller.trustHook(hook({ key: "hook-old-context", trustStatus: "untrusted" }));
await flushPromises();
const publication = await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
controller.replaceDynamicData(publication.replacementDynamicData as NonNullable<typeof publication.replacementDynamicData>);
await controller.refreshDynamicSections();
expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-new-context" })]);
oldWrite.resolve({});
await oldMutation;
expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-new-context" })]);
expect(notify).not.toHaveBeenCalled();
});
it("publishes stale-view archived restore facts without replacing a newer section result", async () => {
const staleRestore = deferred<{ thread: ThreadRecord }>();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn(() => staleRestore.promise),
});
const newerClient = settingsClient();
useShortLivedClients(initialClient, restoreClient, newerClient);
const refreshArchived = vi
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent, refreshArchived }), {
display: vi.fn(),
notify: vi.fn(),
});
await controller.refreshDynamicSections();
const restore = controller.restoreArchivedThread("thread-old");
await flushPromises();
await controller.refreshDynamicSections();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
await restore;
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
type: "thread-restored",
thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }),
});
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
});
it("serializes conflicting archived mutations and publishes their facts in order", async () => {
const restoreResult = deferred<{ thread: ThreadRecord }>();
const deleteResult = deferred<unknown>();
const restoreRequest = vi.fn(() => restoreResult.promise);
const deleteRequest = vi.fn(() => deleteResult.promise);
const restoreClient = settingsRequestClient({
"thread/unarchive": restoreRequest,
});
const deleteClient = settingsRequestClient({
"thread/delete": deleteRequest,
});
const applyThreadCatalogEvent = vi.fn();
useShortLivedClients(restoreClient, deleteClient);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent }), {
display: vi.fn(),
notify: vi.fn(),
});
const restore = controller.restoreArchivedThread("thread-old");
const deletion = controller.deleteArchivedThread("thread-old");
await flushPromises();
expect(restoreRequest).toHaveBeenCalledOnce();
expect(deleteRequest).not.toHaveBeenCalled();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledOnce();
restoreResult.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
await flushPromises();
expect(deleteRequest).toHaveBeenCalledOnce();
expect(applyThreadCatalogEvent).toHaveBeenCalledTimes(1);
deleteResult.resolve({});
await Promise.all([restore, deletion]);
expect(applyThreadCatalogEvent.mock.calls.map(([event]) => event.type)).toEqual(["thread-restored", "thread-deleted"]);
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(2);
});
it("publishes a completed archived mutation after the settings view is disposed", async () => {
const restoreResult = deferred<{ thread: ThreadRecord }>();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn(() => restoreResult.promise),
});
const applyThreadCatalogEvent = vi.fn();
const display = vi.fn();
useShortLivedClients(restoreClient);
const controller = new SettingsDynamicSectionsController(settingsTabHost({ applyThreadCatalogEvent }), {
display,
notify: vi.fn(),
});
const restore = controller.restoreArchivedThread("thread-old");
await flushPromises();
controller.dispose();
display.mockClear();
restoreResult.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored after close" }) });
await restore;
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
type: "thread-restored",
thread: expect.objectContaining({ id: "thread-old", preview: "Restored after close", archived: false }),
});
expect(display).not.toHaveBeenCalled();
});
it("does not publish archived mutation facts after its app-server context is replaced", async () => {
const restoreResult = deferred<{ thread: ThreadRecord }>();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn(() => restoreResult.promise),
});
const applyThreadCatalogEvent = vi.fn();
useShortLivedClients(restoreClient);
const host = settingsTabHost({ applyThreadCatalogEvent });
const controller = new SettingsDynamicSectionsController(host, { display: vi.fn(), notify: vi.fn() });
const restore = controller.restoreArchivedThread("thread-old");
await flushPromises();
await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
restoreResult.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored after replacement" }) });
await restore;
expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
});
it("starts a replacement-context archived mutation while old work is still pending", async () => {
const oldRestore = deferred<{ thread: ThreadRecord }>();
const oldClient = settingsRequestClient({
"thread/unarchive": vi.fn(() => oldRestore.promise),
});
const newClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({
thread: appServerThread({ id: "thread-shared", preview: "New context" }),
}),
});
useShortLivedClients(oldClient, newClient);
const host = settingsTabHost();
const staleMutation = host.dynamicData.restoreArchivedThread("thread-shared");
await flushPromises();
const publication = await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
if (!publication.replacementDynamicData) throw new Error("Expected replacement settings data.");
const currentMutation = publication.replacementDynamicData.restoreArchivedThread("thread-shared");
await expect(currentMutation).resolves.toMatchObject({ id: "thread-shared", preview: "New context" });
expect(newClient.requestHandlers["thread/unarchive"]).toHaveBeenCalledOnce();
oldRestore.resolve({
thread: appServerThread({ id: "thread-shared", preview: "Old context" }),
});
await expect(staleMutation).rejects.toBeInstanceOf(StaleExecutionRuntimeError);
});
it("records restored archived threads in the active catalog", async () => {
const notify = vi.fn();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
});
useShortLivedClients(initialClient, restoreClient);
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
applyThreadCatalogEvent,
}),
{ display: vi.fn(), notify },
);
await controller.refreshDynamicSections();
await controller.restoreArchivedThread("thread-old");
const snapshot = controller.snapshot();
expect(snapshot.archivedThreads).toEqual([]);
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
type: "thread-restored",
thread: expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }),
});
expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread.");
});
it("displays restored archived thread state after recording the active catalog event", async () => {
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
const initialClient = settingsClient();
const restoreClient = settingsRequestClient({
"thread/unarchive": vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
});
useShortLivedClients(initialClient, restoreClient);
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
let emitArchived = (_threads: readonly Thread[]): void => undefined;
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
observeArchived: (listener) => {
emitArchived = (threads) => {
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
};
return () => undefined;
},
applyThreadCatalogEvent: () => {
emitArchived([]);
},
}),
{
display: () => {
const snapshot = controllerRef.current?.snapshot();
if (snapshot) snapshots.push(snapshot);
},
notify: vi.fn(),
},
);
controllerRef.current = controller;
await controller.refreshDynamicSections();
snapshots.length = 0;
await controller.restoreArchivedThread("thread-old");
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Restored "Restored old".');
});
it("displays deleted archived thread status after recording the catalog event", async () => {
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
const initialClient = settingsClient();
const deleteClient = settingsRequestClient({
"thread/delete": vi.fn().mockResolvedValue({}),
});
useShortLivedClients(initialClient, deleteClient);
const controllerRef: { current: SettingsDynamicSectionsController | null } = { current: null };
let emitArchived = (_threads: readonly Thread[]): void => undefined;
const controller = new SettingsDynamicSectionsController(
settingsTabHost({
archivedThreads: [panelThread({ id: "thread-old", preview: "Old archived", archived: true })],
observeArchived: (listener) => {
emitArchived = (threads) => {
listener({ value: threads, error: null, isFetching: false } satisfies ObservedResult<readonly Thread[]>);
};
return () => undefined;
},
applyThreadCatalogEvent: () => {
emitArchived([]);
},
}),
{
display: () => {
const snapshot = controllerRef.current?.snapshot();
if (snapshot) snapshots.push(snapshot);
},
notify: vi.fn(),
},
);
controllerRef.current = controller;
await controller.refreshDynamicSections();
snapshots.length = 0;
await controller.deleteArchivedThread("thread-old");
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
expect(snapshots.at(-1)?.archivedThreadsLifecycle.status).toBe('Deleted "Old archived".');
});
});