Tighten app-server lifecycle and settings boundaries

This commit is contained in:
murashit 2026-06-21 21:16:57 +09:00
parent 2de0dc8399
commit b7e024ada0
21 changed files with 324 additions and 179 deletions

View file

@ -1,5 +1,11 @@
import type { AppServerClient } from "./client";
export interface AppServerClientAccess {
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: { unhandledServerRequestMessage?: string }): Promise<T>;
export type AppServerClientRequestPolicy = { kind: "interactive" } | { kind: "reject"; message: string };
export interface AppServerClientAccessOptions {
serverRequests?: AppServerClientRequestPolicy;
}
export interface AppServerClientAccess {
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: AppServerClientAccessOptions): Promise<T>;
}

View file

@ -203,8 +203,8 @@ export class AppServerClient {
}
async connect(): Promise<InitializeResponse> {
if (this.activeTransport()?.isRunning()) {
throw new Error("Codex app-server is already running.");
if (this.lifecycle.kind !== "disconnected") {
throw new Error("Codex app-server client is already connecting or connected.");
}
const transportRef: { current: AppServerTransport | null } = { current: null };
@ -232,9 +232,10 @@ export class AppServerClient {
if (!transport) return;
if (!this.isActiveTransport(transport)) return;
const intentional = this.intentionallyStoppedTransports.has(transport);
const wasInitialized = this.lifecycle.kind === "initialized";
this.lifecycle = { kind: "disconnected" };
this.rpc.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
if (intentional) return;
if (intentional || !wasInitialized) return;
this.handlers.onExit(code, signal);
},
};
@ -243,22 +244,32 @@ export class AppServerClient {
: new StdioAppServerTransport(this.codexPath, this.cwd, transportHandlers);
transportRef.current = transport;
this.lifecycle = { kind: "starting", transport };
transport.start();
const init = await this.request("initialize", {
clientInfo: {
name: "obsidian_codex_panel",
title: "Codex Panel",
version: CLIENT_VERSION,
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
},
});
this.notify({ method: "initialized" });
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
return init;
try {
transport.start();
const init = await this.request("initialize", {
clientInfo: {
name: "obsidian_codex_panel",
title: "Codex Panel",
version: CLIENT_VERSION,
},
capabilities: {
experimentalApi: true,
requestAttestation: false,
},
});
this.notify({ method: "initialized" });
this.lifecycle = { kind: "initialized", transport, initializeResponse: init };
return init;
} catch (error) {
if (this.isActiveTransport(transport)) {
const normalized = error instanceof Error ? error : new Error(String(error));
this.lifecycle = { kind: "disconnected" };
this.rpc.rejectAll(normalized);
this.intentionallyStoppedTransports.add(transport);
transport.stop();
}
throw error;
}
}
disconnect(): void {

View file

@ -1,14 +1,11 @@
import { AppServerClient } from "./client";
interface ShortLivedAppServerClientOptions {
unhandledServerRequestMessage?: string;
}
import type { AppServerClientAccessOptions } from "./client-access";
export async function withShortLivedAppServerClient<T>(
codexPath: string,
cwd: string,
operation: (client: AppServerClient) => Promise<T>,
options: ShortLivedAppServerClientOptions = {},
options: AppServerClientAccessOptions = {},
): Promise<T> {
let client!: AppServerClient;
client = new AppServerClient(codexPath, cwd, {
@ -17,7 +14,9 @@ export async function withShortLivedAppServerClient<T>(
client.rejectServerRequest(
request.id,
-32601,
options.unhandledServerRequestMessage ?? "This Codex Panel view does not handle server requests.",
options.serverRequests?.kind === "reject"
? options.serverRequests.message
: "This Codex Panel view does not handle server requests.",
);
},
onLog: () => undefined,

View file

@ -1,6 +1,7 @@
import { QueryClient, QueryObserver, type QueryObserverResult } from "@tanstack/query-core";
import type { AppServerClient } from "../connection/client";
import type { AppServerClientAccessOptions } from "../connection/client-access";
import { listModelMetadata } from "../catalog";
import { readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
@ -29,7 +30,7 @@ export interface AppServerQueryClientRunner {
runWithClient<T>(
context: AppServerQueryContext,
operation: (client: AppServerClient) => Promise<T>,
options?: { unhandledServerRequestMessage?: string },
options?: AppServerClientAccessOptions,
): Promise<T>;
}
@ -386,7 +387,7 @@ export class AppServerQueryCache {
queryFn: async (): Promise<readonly ModelMetadata[]> => {
return cloneModelMetadata(
await this.runWithClient(refreshContext, (client) => listModelMetadata(client), {
unhandledServerRequestMessage: "Codex model list refresh does not handle server requests.",
serverRequests: { kind: "reject", message: "Codex model list refresh does not handle server requests." },
}),
);
},
@ -452,7 +453,7 @@ export class AppServerQueryCache {
private runWithClient<T>(
context: AppServerQueryContext,
operation: (client: AppServerClient) => Promise<T>,
options: { unhandledServerRequestMessage?: string } = {},
options: AppServerClientAccessOptions = {},
): Promise<T> {
if (!this.clientRunner) {
throw new Error("Codex app-server query client runner is not configured.");

View file

@ -4,6 +4,7 @@ import {
type AppServerStartEphemeralThreadOptions,
type AppServerStartStructuredTurnOptions,
} from "../connection/client";
import type { AppServerClientRequestPolicy } from "../connection/client-access";
import type { RequestId, ServerNotification } from "../connection/rpc-messages";
import type { ModelMetadataClient } from "../catalog";
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
@ -51,7 +52,7 @@ export interface RunEphemeralStructuredTurnOptions {
prompt: string;
outputSchema: StructuredTurnOutputSchema;
timeoutMs: number;
unhandledServerRequestMessage: string;
serverRequests: Extract<AppServerClientRequestPolicy, { kind: "reject" }>;
exitedMessage: string;
timedOutMessage: string;
abortMessage?: string;
@ -105,7 +106,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
handleNotification(notification);
},
onServerRequest: (request) => {
client.rejectServerRequest(request.id, -32601, options.unhandledServerRequestMessage);
client.rejectServerRequest(request.id, -32601, options.serverRequests.message);
},
onLog: () => undefined,
onExit: () => {

View file

@ -57,7 +57,7 @@ export async function generateThreadTitleWithCodex(
prompt: threadTitlePrompt(context),
outputSchema: TITLE_OUTPUT_SCHEMA,
timeoutMs: THREAD_TITLE_TIMEOUT_MS,
unhandledServerRequestMessage: "Thread title generation does not handle server requests.",
serverRequests: { kind: "reject", message: "Thread title generation does not handle server requests." },
exitedMessage: "Codex title generation app-server exited.",
timedOutMessage: "Timed out while generating a Codex thread title.",
resolveRuntime: (client) => threadTitleRuntimeOverrideForClient(client, runtimeSettings),

View file

@ -19,6 +19,7 @@ import { toolInventoryAppsFromAppInfos, toolInventoryPluginsFromInstalledRespons
const APP_PAGE_LIMIT = 100;
const APP_PAGE_LOOP_LIMIT = 20;
const PLUGIN_DETAILS_CONCURRENCY = 4;
export interface ReadToolInventoryOptions {
readonly threadId?: string | null;
@ -101,6 +102,7 @@ async function listAllApps(
threadId: string | null,
): Promise<Awaited<ReturnType<AppServerClient["listApps"]>>["data"]> {
let cursor: string | null = null;
const seenCursors = new Set<string>();
const apps: Awaited<ReturnType<AppServerClient["listApps"]>>["data"] = [];
for (let page = 0; page < APP_PAGE_LOOP_LIMIT; page += 1) {
const response = await client.listApps({
@ -111,8 +113,12 @@ async function listAllApps(
apps.push(...response.data);
cursor = response.nextCursor;
if (!cursor) return apps;
if (seenCursors.has(cursor)) {
throw new Error("Codex app-server returned a repeated app list cursor.");
}
seenCursors.add(cursor);
}
return apps;
throw new Error("Codex app-server returned too many app list pages.");
}
async function readPlugins(
@ -128,7 +134,7 @@ async function readPlugins(
try {
const response = await client.listInstalledPlugins(cwd);
const { plugins, marketplaceErrors } = toolInventoryPluginsFromInstalledResponse(response);
const pluginsWithDetails = await Promise.all(plugins.map((plugin) => readPluginDetails(client, plugin)));
const pluginsWithDetails = await mapLimit(plugins, PLUGIN_DETAILS_CONCURRENCY, (plugin) => readPluginDetails(client, plugin));
return {
data: pluginsWithDetails,
marketplaceErrors,
@ -145,6 +151,24 @@ async function readPlugins(
}
}
async function mapLimit<T, R>(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
for (;;) {
const index = nextIndex;
nextIndex += 1;
if (index >= items.length) return;
results[index] = await fn(items[index] as T, index);
}
}
const workers = Array.from({ length: Math.min(Math.max(concurrency, 1), items.length) }, () => worker());
await Promise.all(workers);
return results;
}
async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
if (!isUsablePlugin(plugin)) return plugin;
try {

View file

@ -50,7 +50,7 @@ export async function runSelectionRewrite(options: RunSelectionRewriteOptions):
prompt: options.prompt,
outputSchema: SELECTION_REWRITE_OUTPUT_SCHEMA,
timeoutMs: SELECTION_REWRITE_TIMEOUT_MS,
unhandledServerRequestMessage: "Selection rewrite does not handle server requests.",
serverRequests: { kind: "reject", message: "Selection rewrite does not handle server requests." },
exitedMessage: "Selection rewrite app-server exited.",
timedOutMessage: "Timed out while rewriting the selection.",
abortMessage: "Selection rewrite cancelled.",

View file

@ -4,7 +4,7 @@ import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"
import { AppServerQueryCache } from "./app-server/query/cache";
import { AppServerSharedQueries } from "./app-server/query/shared-queries";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess } from "./app-server/connection/client-access";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import { appServerQueryContextIsComplete, type AppServerQueryContext } from "./app-server/query/keys";
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
@ -169,7 +169,7 @@ export class CodexPanelRuntime implements AppServerClientAccess {
};
}
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: { unhandledServerRequestMessage?: string } = {}): Promise<T> {
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
return this.runWithAppServerClient(this.appServerQueryContext(), operation, options);
}
@ -224,12 +224,12 @@ export class CodexPanelRuntime implements AppServerClientAccess {
private async runWithAppServerClient<T>(
context: AppServerQueryContext,
operation: (client: AppServerClient) => Promise<T>,
options: { unhandledServerRequestMessage?: string } = {},
options: AppServerClientAccessOptions = {},
): Promise<T> {
if (!appServerQueryContextIsComplete(context)) {
throw new Error("Codex app-server query context is incomplete.");
}
if (options.unhandledServerRequestMessage !== undefined) {
if (options.serverRequests?.kind === "reject") {
return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options);
}
const chatSurface = this.connectedClientSurface();

View file

@ -2,18 +2,10 @@ import type { ComponentChild as UiNode } from "preact";
import type { Thread } from "../domain/threads/model";
import { threadArchiveDisplayTitle } from "../domain/threads/title";
import { ObsidianExtraButton, ObsidianTextInput, ObsidianToggle } from "../shared/ui/components";
import { shortThreadId } from "../utils";
import type { ArchivedThreadSectionState } from "./section-state";
import {
SettingRow,
SettingsGroup,
SettingsHeading,
SettingsIconButton,
SettingsItems,
SettingsStatusRow,
TextControl,
ToggleControl,
} from "./setting-components";
import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems, SettingsStatusRow } from "./setting-components";
export function ArchivedThreadSection({ state }: { state: ArchivedThreadSectionState }): UiNode {
return (
@ -38,7 +30,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
return (
<SettingsItems>
<SettingRow name="Save note by default" desc="Save a Markdown note during the default archive action.">
<ToggleControl
<ObsidianToggle
checked={state.exportEnabled}
onChange={(checked) => {
state.onExportEnabledChange(checked);
@ -46,7 +38,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
/>
</SettingRow>
<SettingRow name="Saved note folder" desc="Vault folder for saved thread notes.">
<TextControl
<ObsidianTextInput
placeholder="Codex archives"
value={state.exportFolderTemplate}
onChange={(value) => {
@ -55,7 +47,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
/>
</SettingRow>
<SettingRow name="Saved note filename" desc="Filename template. Supports {{date}}, {{time}}, {{title}}, {{id}}, and {{shortId}}.">
<TextControl
<ObsidianTextInput
placeholder="{{date}} {{time}} {{title}} {{shortId}}.md"
value={state.exportFilenameTemplate}
onChange={(value) => {
@ -64,7 +56,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
/>
</SettingRow>
<SettingRow name="Saved note tags" desc="Tags added to saved notes, separated by commas.">
<TextControl
<ObsidianTextInput
placeholder="Codex, archive"
value={state.exportTags}
onChange={(value) => {
@ -104,7 +96,7 @@ function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedT
}
>
{!deleteConfirming ? (
<SettingsIconButton
<ObsidianExtraButton
icon="rotate-ccw"
label="Restore thread"
className="codex-panel-settings__archived-restore"
@ -113,7 +105,7 @@ function ArchivedThreadRow({ thread, state }: { thread: Thread; state: ArchivedT
}}
/>
) : null}
<SettingsIconButton
<ObsidianExtraButton
icon={deleteConfirming ? "check" : "shredder"}
label="Delete thread"
className={deleteConfirming ? "codex-panel-settings__archived-delete-confirm" : "codex-panel-settings__archived-delete"}

View file

@ -13,9 +13,7 @@ import { loadHookData } from "./app-server-data";
import type { SettingsDynamicDataHost } from "./host";
import {
createSettingsDynamicSectionLifecycle,
transitionSettingsDataRefreshLifecycle,
transitionSettingsDynamicSectionLifecycle,
type SettingsDataRefreshLifecycleState,
type SettingsDynamicSectionLifecycleState,
} from "./lifecycle";
@ -49,7 +47,6 @@ export class SettingsDynamicDataController {
private modelsOperationToken = 0;
private hooksOperationToken = 0;
private archivedThreadsOperationToken = 0;
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
private archivedThreads: Thread[] = [];
private archivedThreadsLoaded = false;
@ -108,7 +105,6 @@ export class SettingsDynamicDataController {
this.modelsOperationToken += 1;
this.hooksOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.settingsDataRefreshLifecycle = { kind: "idle" };
this.models = [...(this.host.appServerData.modelsSnapshot() ?? [])];
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
this.hooks = [];
@ -156,10 +152,6 @@ export class SettingsDynamicDataController {
const modelsOperationToken = this.nextModelsOperationToken();
const hooksOperationToken = this.nextHooksOperationToken();
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "started",
operationToken,
});
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "started",
status: "Loading models...",
@ -274,11 +266,6 @@ export class SettingsDynamicDataController {
}
} finally {
const staleRefresh = this.isStaleSettingsRefreshOperation(operationToken);
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "completed",
failedCount,
operationToken,
});
if (!staleRefresh) {
if (failedCount > 0) {
this.callbacks.notify("Could not refresh all Codex data.");
@ -289,7 +276,9 @@ export class SettingsDynamicDataController {
}
settingsDataLoading(): boolean {
return this.settingsDataRefreshLifecycle.kind === "loading";
return (
this.modelsLifecycle.kind === "loading" || this.hooksLifecycle.kind === "loading" || this.archivedThreadsLifecycle.kind === "loading"
);
}
snapshot(): SettingsDynamicDataSnapshot {
@ -475,7 +464,7 @@ export class SettingsDynamicDataController {
private async withSettingsConnection<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
return this.host.clientAccess.withClient(operation, {
unhandledServerRequestMessage: "Codex Panel settings does not handle server requests.",
serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." },
});
}

View file

@ -2,8 +2,9 @@ import type { ComponentChild as UiNode } from "preact";
import type { ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
import { findModelMetadataByIdOrName, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
import { ObsidianDropdown } from "../shared/ui/components";
import type { HelperSettingsState } from "./section-state";
import { SelectControl, SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
const CODEX_DEFAULT_VALUE = "__codex-default__";
@ -56,14 +57,14 @@ function ModelEffortSetting({
const efforts = reasoningEffortsForSelectedModel(models, modelValue);
return (
<SettingRow name={name} desc={desc}>
<SelectControl
<ObsidianDropdown
value={modelValue ?? CODEX_DEFAULT_VALUE}
onChange={(value) => {
onModelChange(value === CODEX_DEFAULT_VALUE ? null : value);
}}
options={modelSelectOptions(models, modelValue)}
/>
<SelectControl
<ObsidianDropdown
value={effortValue ?? CODEX_DEFAULT_VALUE}
onChange={(value) => {
onEffortChange(value === CODEX_DEFAULT_VALUE ? null : value);

View file

@ -1,12 +1,3 @@
export type SettingsDataRefreshLifecycleState =
| { kind: "idle" }
| { kind: "loading"; operationToken: number }
| { kind: "completed"; failedCount: number; operationToken: number };
export type SettingsDataRefreshLifecycleEvent =
| { type: "started"; operationToken: number }
| { type: "completed"; failedCount: number; operationToken: number };
export type SettingsDynamicSectionLifecycleState =
| { kind: "idle"; status: "" }
| { kind: "loading"; status: string; operationToken: number }
@ -19,20 +10,6 @@ export type SettingsDynamicSectionLifecycleEvent =
| { type: "failed"; status: string; operationToken: number }
| { type: "reset" };
export function transitionSettingsDataRefreshLifecycle(
state: SettingsDataRefreshLifecycleState,
event: SettingsDataRefreshLifecycleEvent,
): SettingsDataRefreshLifecycleState {
switch (event.type) {
case "started":
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
return { kind: "loading", operationToken: event.operationToken };
case "completed":
if (isStaleSettingsDataRefreshEvent(state, event.operationToken)) return state;
return { kind: "completed", failedCount: event.failedCount, operationToken: event.operationToken };
}
}
export function createSettingsDynamicSectionLifecycle(): SettingsDynamicSectionLifecycleState {
return { kind: "idle", status: "" };
}
@ -59,7 +36,3 @@ export function transitionSettingsDynamicSectionLifecycle(
function isStaleSettingsDynamicSectionEvent(state: SettingsDynamicSectionLifecycleState, operationToken: number): boolean {
return "operationToken" in state && state.operationToken > operationToken;
}
function isStaleSettingsDataRefreshEvent(state: SettingsDataRefreshLifecycleState, operationToken: number): boolean {
return "operationToken" in state && state.operationToken > operationToken;
}

View file

@ -1,13 +1,5 @@
import type { ComponentChild as UiNode } from "preact";
import {
ObsidianDropdown,
type ObsidianDropdownOption,
ObsidianExtraButton,
ObsidianTextInput,
ObsidianToggle,
} from "../shared/ui/components";
export function SettingsGroup({ className, children }: { className: string; children: UiNode }): UiNode {
return <section className={`setting-group ${className}`}>{children}</section>;
}
@ -62,45 +54,3 @@ export function SettingRow({
</div>
);
}
export function SettingsIconButton({
icon,
label,
className,
onClick,
}: {
icon: string;
label: string;
className: string;
onClick: () => void;
}): UiNode {
return <ObsidianExtraButton icon={icon} label={label} className={className} onClick={onClick} />;
}
export function SelectControl({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: readonly ObsidianDropdownOption[];
}): UiNode {
return <ObsidianDropdown value={value} options={options} onChange={onChange} />;
}
export function TextControl({
value,
placeholder,
onChange,
}: {
value: string;
placeholder: string;
onChange: (value: string) => void;
}): UiNode {
return <ObsidianTextInput value={value} placeholder={placeholder} onChange={onChange} />;
}
export function ToggleControl({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode {
return <ObsidianToggle checked={checked} onChange={onChange} />;
}

View file

@ -285,6 +285,114 @@ describe("AppServerClient", () => {
expect(onExit).toHaveBeenCalledOnce();
});
it("cleans up the active transport when initialize fails", async () => {
const transports: FakeTransport[] = [];
const onExit = vi.fn();
const client = new AppServerClient(
"/bin/codex",
"/vault",
{
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
500,
(handlers) => {
const transport = new FakeTransport(handlers);
transports.push(transport);
return transport;
},
);
const connecting = client.connect();
const firstTransport = expectTransport(transports[0]);
const initialize = latestSent(firstTransport);
if (!("id" in initialize) || typeof initialize.id !== "number") throw new Error("Expected initialize request.");
const rejection = expect(connecting).rejects.toThrow("initialize failed");
firstTransport.emitLine({ id: initialize.id, error: { code: -32000, message: "initialize failed" } });
await rejection;
expect(client.isConnected()).toBe(false);
expect(firstTransport.isRunning()).toBe(false);
firstTransport.emitExit(1);
expect(onExit).not.toHaveBeenCalled();
const reconnecting = client.connect();
const secondTransport = expectTransport(transports[1]);
const secondInitialize = latestSent(secondTransport);
if (!("id" in secondInitialize) || typeof secondInitialize.id !== "number") throw new Error("Expected initialize request.");
secondTransport.emitLine({ id: secondInitialize.id, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await expect(reconnecting).resolves.toMatchObject({ codexHome: "/tmp/codex" });
});
it("cleans up the active transport when initialize times out", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
clearTimeout,
setTimeout,
});
let transport!: FakeTransport;
const onExit = vi.fn();
const client = new AppServerClient(
"/bin/codex",
"/vault",
{
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
500,
(handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
);
const connecting = client.connect();
const rejection = expect(connecting).rejects.toThrow("Codex app-server request timed out: initialize");
await vi.advanceTimersByTimeAsync(500);
await rejection;
expect(client.isConnected()).toBe(false);
expect(transport.isRunning()).toBe(false);
transport.emitExit(1);
expect(onExit).not.toHaveBeenCalled();
});
it("rejects connect without notifying external exit handlers when transport exits during initialize", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = new AppServerClient(
"/bin/codex",
"/vault",
{
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
500,
(handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
);
const connecting = client.connect();
const rejection = expect(connecting).rejects.toThrow("Codex app-server exited: 1");
transport.emitExit(1);
await rejection;
expect(client.isConnected()).toBe(false);
expect(onExit).not.toHaveBeenCalled();
});
it("ignores stale transport events after reconnecting", async () => {
const transports: FakeTransport[] = [];
const onExit = vi.fn();

View file

@ -120,7 +120,7 @@ describe("ConnectionManager", () => {
expect(manager.currentClient()).toBeInstanceOf(AppServerClient);
});
it("reports app-server exit during initialization", async () => {
it("rejects app-server exit during initialization without reporting a connected-client exit", async () => {
let transport!: SilentTransport;
const onExit = vi.fn();
const manager = new ConnectionManager(
@ -137,7 +137,7 @@ describe("ConnectionManager", () => {
transport.emitExit();
await expect(connecting).rejects.toThrow("Codex app-server exited: unknown");
expect(onExit).toHaveBeenCalledOnce();
expect(onExit).not.toHaveBeenCalled();
expect(manager.currentClient()).toBeNull();
});

View file

@ -226,7 +226,7 @@ function runOptions(clientFactory: EphemeralStructuredTurnClientFactory): Parame
prompt: "Run.",
outputSchema: { type: "object" },
timeoutMs: 10_000,
unhandledServerRequestMessage: "Structured test does not handle server requests.",
serverRequests: { kind: "reject", message: "Structured test does not handle server requests." },
exitedMessage: "Structured test app-server exited.",
timedOutMessage: "Structured test timed out.",
clientFactory,

View file

@ -76,4 +76,114 @@ describe("tool inventory", () => {
["remote-plugin", { skillCount: 0, hookCount: 0, appCount: 0, mcpServerCount: 1 }, null],
]);
});
it("fails app inventory when app pagination repeats a cursor", async () => {
const client = toolInventoryClient({
listApps: vi
.fn()
.mockResolvedValueOnce({ data: [{ name: "first", title: "First", readOnlyHint: false }], nextCursor: "repeat" })
.mockResolvedValueOnce({ data: [{ name: "second", title: "Second", readOnlyHint: false }], nextCursor: "repeat" }),
});
const result = await readToolInventory(client, "/vault");
expect(result.inventory.apps).toBeNull();
expect(result.inventory.appsError).toBe("Codex app-server returned a repeated app list cursor.");
});
it("fails app inventory when app pagination exceeds the page limit", async () => {
let page = 0;
const client = toolInventoryClient({
listApps: vi.fn().mockImplementation(() => {
page += 1;
return Promise.resolve({ data: [], nextCursor: `cursor-${String(page)}` });
}),
});
const result = await readToolInventory(client, "/vault");
expect(result.inventory.apps).toBeNull();
expect(result.inventory.appsError).toBe("Codex app-server returned too many app list pages.");
});
it("limits plugin detail reads while preserving plugin order", async () => {
let activeReads = 0;
let maxActiveReads = 0;
const plugins = Array.from({ length: 10 }, (_, index) => installedPlugin(`plugin-${String(index)}`));
const readPlugin = vi.fn(async (params: Parameters<AppServerClient["readPlugin"]>[0]) => {
activeReads += 1;
maxActiveReads = Math.max(maxActiveReads, activeReads);
await Promise.resolve();
activeReads -= 1;
return {
plugin: {
skills: [{ name: `${params.pluginName}-skill` }],
hooks: [],
apps: [],
appTemplates: [],
mcpServers: [],
},
};
});
const client = toolInventoryClient({
listInstalledPlugins: vi.fn().mockResolvedValue({
marketplaces: [{ name: "local-marketplace", path: "/marketplaces/local.json", plugins }],
marketplaceLoadErrors: [],
}),
readPlugin,
});
const result = await readToolInventory(client, "/vault");
expect(maxActiveReads).toBe(4);
expect(result.inventory.plugins?.map((plugin) => plugin.name)).toEqual(plugins.map((plugin) => plugin.name));
expect(result.inventory.plugins?.every((plugin) => plugin.details?.skillCount === 1)).toBe(true);
});
});
function toolInventoryClient(
overrides: Partial<{
listApps: ReturnType<typeof vi.fn>;
listInstalledPlugins: ReturnType<typeof vi.fn>;
readPlugin: ReturnType<typeof vi.fn>;
}> = {},
): AppServerClient {
return {
listApps: overrides.listApps ?? vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
listInstalledPlugins:
overrides.listInstalledPlugins ??
vi.fn().mockResolvedValue({
marketplaces: [],
marketplaceLoadErrors: [],
}),
readPlugin:
overrides.readPlugin ??
vi.fn().mockResolvedValue({
plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] },
}),
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
} as unknown as AppServerClient;
}
function installedPlugin(name: string): {
id: string;
name: string;
interface: { displayName: string };
localVersion: string;
installed: boolean;
enabled: boolean;
availability: string;
source: { type: string; path: string };
} {
return {
id: `${name}@local-marketplace`,
name,
interface: { displayName: name },
localVersion: "1.0.0",
installed: true,
enabled: true,
availability: "AVAILABLE",
source: { type: "local", path: `/plugins/${name}` },
};
}

View file

@ -572,13 +572,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
plugin.settings.codexPath = "codex";
const result = await plugin.runtime.withClient((client) => client.readEffectiveConfig("/vault") as Promise<unknown>, {
unhandledServerRequestMessage: "Settings refresh does not handle server requests.",
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
expect(result).toEqual({});
expect(runWithAppServerClient).not.toHaveBeenCalled();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex", "/vault", expect.any(Function), {
unhandledServerRequestMessage: "Settings refresh does not handle server requests.",
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
expect(shortLivedClient.readEffectiveConfig).toHaveBeenCalledWith("/vault");
});

View file

@ -2,30 +2,9 @@ import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import { loadHookData } from "../../src/settings/app-server-data";
import {
createSettingsDynamicSectionLifecycle,
transitionSettingsDynamicSectionLifecycle,
transitionSettingsDataRefreshLifecycle,
} from "../../src/settings/lifecycle";
import { createSettingsDynamicSectionLifecycle, transitionSettingsDynamicSectionLifecycle } from "../../src/settings/lifecycle";
describe("settings lifecycle", () => {
it("tracks settings data refresh lifecycle", () => {
const idle = { kind: "idle" } as const;
const loading = transitionSettingsDataRefreshLifecycle(idle, { type: "started", operationToken: 1 });
expect(loading).toEqual({ kind: "loading", operationToken: 1 });
expect(transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 0 })).toBe(loading);
const newerLoading = transitionSettingsDataRefreshLifecycle(loading, { type: "started", operationToken: 2 });
expect(newerLoading).toEqual({ kind: "loading", operationToken: 2 });
expect(transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 1 })).toBe(
newerLoading,
);
const completed = transitionSettingsDataRefreshLifecycle(newerLoading, { type: "completed", failedCount: 2, operationToken: 2 });
expect(completed).toEqual({ kind: "completed", failedCount: 2, operationToken: 2 });
});
it("tracks dynamic section lifecycle", () => {
const idle = createSettingsDynamicSectionLifecycle();
expect(idle).toEqual({ kind: "idle", status: "" });

View file

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
import type { AppServerObservedQueryResult } from "../../src/app-server/query/cache";
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
@ -1060,7 +1061,7 @@ function settingsTabHost(
settings,
vaultPath: "/vault",
clientAccess: {
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: { unhandledServerRequestMessage?: string }) =>
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
},
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),