Move app-server method semantics into services

This commit is contained in:
murashit 2026-06-28 11:44:08 +09:00
parent bf1e96d19e
commit 470c00a1f1
39 changed files with 1050 additions and 1296 deletions

View file

@ -1,16 +1,8 @@
import { CLIENT_VERSION } from "../../constants";
import type { CodexInput } from "../../domain/chat/input";
import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings";
import type { ThreadGoalUpdate } from "../../domain/threads/goal";
import type { CollaborationMode } from "../../generated/app-server/CollaborationMode";
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
import type { RequestId } from "../../generated/app-server/RequestId";
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue";
import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer";
import type { AppsListParams } from "../../generated/app-server/v2/AppsListParams";
import type { AppsListResponse } from "../../generated/app-server/v2/AppsListResponse";
import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse";
import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigReadResponse";
@ -18,16 +10,12 @@ import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWr
import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse";
import type { GetAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse";
import type { HooksListResponse } from "../../generated/app-server/v2/HooksListResponse";
import type { ListMcpServerStatusParams } from "../../generated/app-server/v2/ListMcpServerStatusParams";
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
import type { PluginInstalledParams } from "../../generated/app-server/v2/PluginInstalledParams";
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
import type { PluginReadParams } from "../../generated/app-server/v2/PluginReadParams";
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
import type { SortDirection } from "../../generated/app-server/v2/SortDirection";
import type { ThreadArchiveResponse } from "../../generated/app-server/v2/ThreadArchiveResponse";
import type { ThreadCompactStartResponse } from "../../generated/app-server/v2/ThreadCompactStartResponse";
import type { ThreadDeleteResponse } from "../../generated/app-server/v2/ThreadDeleteResponse";
@ -46,14 +34,8 @@ import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadSt
import type { ThreadTurnsListResponse } from "../../generated/app-server/v2/ThreadTurnsListResponse";
import type { ThreadUnarchiveResponse } from "../../generated/app-server/v2/ThreadUnarchiveResponse";
import type { TurnInterruptResponse } from "../../generated/app-server/v2/TurnInterruptResponse";
import type { TurnItemsView } from "../../generated/app-server/v2/TurnItemsView";
import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartResponse";
import type { TurnSteerResponse } from "../../generated/app-server/v2/TurnSteerResponse";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import type { AppServerHookOperation } from "../protocol/catalog";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import { appServerThreadGoalUpdate } from "../protocol/thread-goal";
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
import { JsonRpcClient } from "./json-rpc-client";
import type { ClientRequestMethod, ClientRequestParams, RpcOutboundMessage } from "./rpc-messages";
import { type AppServerTransport, type AppServerTransportHandlers, StdioAppServerTransport } from "./transport";
@ -74,53 +56,7 @@ export interface AppServerServerRequestResponder {
export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport;
interface AppServerTurnRuntimeOverrides {
serviceTier?: RuntimeServiceTierRequest;
collaborationMode?: CollaborationMode;
model?: string | null;
effort?: ReasoningEffort | null;
approvalsReviewer?: ApprovalsReviewer | null;
}
type AppServerTurnRuntimeParams = Pick<
ClientRequestParams<"turn/start">,
"serviceTier" | "collaborationMode" | "model" | "effort" | "approvalsReviewer"
>;
export interface AppServerStartThreadOptions {
cwd: string;
serviceTier?: RuntimeServiceTierRequest;
}
export interface AppServerThreadListOptions {
archived?: boolean;
cursor?: string | null;
limit?: number | null;
}
export interface AppServerStartEphemeralThreadOptions {
cwd: string;
serviceName: string;
developerInstructions: string;
}
export interface AppServerStartTurnOptions {
threadId: string;
cwd: string;
input: string | CodexInput;
clientUserMessageId?: string | null;
runtime?: AppServerTurnRuntimeOverrides;
}
export interface AppServerStartStructuredTurnOptions {
threadId: string;
cwd: string;
text: string;
outputSchema: JsonValue;
runtime?: AppServerTurnRuntimeOverrides;
}
interface ClientResponseByMethod {
export interface ClientResponseByMethod {
initialize: InitializeResponse;
"config/batchWrite": ConfigWriteResponse;
"config/read": ConfigReadResponse;
@ -157,33 +93,13 @@ interface ClientResponseByMethod {
"fs/readFile": FsReadFileResponse;
}
type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
export type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
type AppServerClientLifecycleState =
| { kind: "disconnected" }
| { kind: "starting"; transport: AppServerTransport }
| { kind: "initialized"; transport: AppServerTransport; initializeResponse: InitializeResponse };
function toUserInput(input: string | CodexInput): UserInput[] {
if (typeof input !== "string") return toAppServerUserInput(input);
return toAppServerUserInput([{ type: "text", text: input }]);
}
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
if (typeof input === "string") return undefined;
return additionalContextFromCodexInput(input);
}
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
const params: AppServerTurnRuntimeParams = {};
if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier;
if (runtime?.collaborationMode !== undefined) params.collaborationMode = runtime.collaborationMode;
if (runtime?.model !== undefined) params.model = runtime.model;
if (runtime?.effort !== undefined) params.effort = runtime.effort;
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
return params;
}
export class AppServerClient {
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
private readonly rpc: JsonRpcClient;
@ -304,257 +220,6 @@ export class AppServerClient {
return this.lifecycle.initializeResponse;
}
readEffectiveConfig(cwd: string): Promise<ConfigReadResponse> {
return this.request("config/read", { cwd, includeLayers: true });
}
listHooks(cwd: string): Promise<HooksListResponse> {
return this.request("hooks/list", { cwds: [cwd] });
}
trustHook(hook: AppServerHookOperation): Promise<ConfigWriteResponse> {
return this.writeHookState(hook.key, {
enabled: true,
trusted_hash: hook.currentHash,
});
}
setHookEnabled(hook: AppServerHookOperation, enabled: boolean): Promise<ConfigWriteResponse> {
const state: Record<string, JsonValue> = hook.trustStatus === "trusted" ? { enabled, trusted_hash: hook.currentHash } : { enabled };
return this.writeHookState(hook.key, state);
}
startThread(options: AppServerStartThreadOptions): Promise<ThreadStartResponse> {
const { cwd, serviceTier } = options;
return this.request("thread/start", {
cwd,
serviceName: "codex-panel",
...(serviceTier !== undefined ? { serviceTier } : {}),
});
}
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<ThreadStartResponse> {
const { cwd, serviceName, developerInstructions } = options;
return this.request("thread/start", {
cwd,
serviceName,
developerInstructions,
ephemeral: true,
sandbox: "read-only",
approvalPolicy: "never",
multiAgentMode: "none",
environments: [],
});
}
resumeThread(threadId: string, cwd: string): Promise<ThreadResumeResponse> {
return this.request("thread/resume", {
threadId,
cwd,
excludeTurns: true,
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
});
}
forkThread(threadId: string, cwd: string): Promise<ThreadForkResponse> {
return this.request("thread/fork", {
threadId,
cwd,
excludeTurns: true,
});
}
listThreads(cwd: string, options: AppServerThreadListOptions = {}): Promise<ThreadListResponse> {
return this.request("thread/list", {
cwd,
...(options.cursor ? { cursor: options.cursor } : {}),
...(options.limit === undefined ? {} : { limit: options.limit }),
archived: options.archived ?? false,
sortKey: "recency_at",
sortDirection: "desc",
});
}
archiveThread(threadId: string): Promise<ThreadArchiveResponse> {
return this.request("thread/archive", { threadId });
}
deleteThread(threadId: string, options: { timeoutMs?: number } = {}): Promise<ThreadDeleteResponse> {
return this.request("thread/delete", { threadId }, options);
}
readThread(threadId: string, includeTurns = true): Promise<ThreadReadResponse> {
return this.request("thread/read", { threadId, includeTurns });
}
readFile(path: string, options: { timeoutMs?: number } = {}): Promise<FsReadFileResponse> {
return this.request("fs/readFile", { path }, options);
}
unarchiveThread(threadId: string): Promise<ThreadUnarchiveResponse> {
return this.request("thread/unarchive", { threadId });
}
rollbackThread(threadId: string, numTurns = 1): Promise<ThreadRollbackResponse> {
return this.request("thread/rollback", { threadId, numTurns });
}
setThreadName(threadId: string, name: string): Promise<ThreadSetNameResponse> {
return this.request("thread/name/set", { threadId, name });
}
getThreadGoal(threadId: string): Promise<ThreadGoalGetResponse> {
return this.request("thread/goal/get", { threadId });
}
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoalSetResponse> {
return this.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) });
}
clearThreadGoal(threadId: string): Promise<ThreadGoalClearResponse> {
return this.request("thread/goal/clear", { threadId });
}
injectThreadItems(threadId: string, items: ClientRequestParams<"thread/inject_items">["items"]): Promise<ThreadInjectItemsResponse> {
return this.request("thread/inject_items", { threadId, items });
}
updateThreadSettings(threadId: string, settings: RuntimeSettingsPatch): Promise<ThreadSettingsUpdateResponse> {
return this.request("thread/settings/update", { threadId, ...appServerRuntimeSettingsPatch(settings) });
}
threadTurnsList(
threadId: string,
cursor: string | null = null,
limit = 20,
sortDirection: SortDirection = "desc",
itemsView: TurnItemsView = "full",
): Promise<ThreadTurnsListResponse> {
return this.request("thread/turns/list", {
threadId,
cursor,
limit,
sortDirection,
itemsView,
});
}
listSkills(cwd: string, forceReload = false): Promise<SkillsListResponse> {
return this.request("skills/list", {
cwds: [cwd],
forceReload,
});
}
listApps(params: AppsListParams = { limit: 100 }): Promise<AppsListResponse> {
return this.request("app/list", params);
}
listInstalledPlugins(cwd: string): Promise<PluginInstalledResponse> {
const params: PluginInstalledParams = { cwds: [cwd] };
return this.request("plugin/installed", params);
}
readPlugin(params: PluginReadParams): Promise<PluginReadResponse> {
return this.request("plugin/read", params);
}
listModels(includeHidden = false): Promise<ModelListResponse> {
return this.request("model/list", {
includeHidden,
limit: 100,
});
}
readAccountRateLimits(): Promise<GetAccountRateLimitsResponse> {
return this.request("account/rateLimits/read", undefined);
}
listMcpServerStatus(
params: ListMcpServerStatusParams = { detail: "toolsAndAuthOnly", limit: 100 },
): Promise<ListMcpServerStatusResponse> {
return this.request("mcpServerStatus/list", params);
}
listCollaborationModes(): Promise<CollaborationModeListResponse> {
return this.request("collaborationMode/list", {});
}
readModelProviderCapabilities(): Promise<ModelProviderCapabilitiesReadResponse> {
return this.request("modelProvider/capabilities/read", {});
}
compactThread(threadId: string): Promise<ThreadCompactStartResponse> {
return this.request("thread/compact/start", { threadId });
}
startTurn(options: AppServerStartTurnOptions): Promise<TurnStartResponse> {
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
const additionalContext = toAdditionalContext(input);
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
...appServerTurnRuntimeParams(runtime),
input: toUserInput(input),
};
return this.request("turn/start", params);
}
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
const { threadId, cwd, text, outputSchema, runtime } = options;
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
input: [
{
type: "text",
text,
text_elements: [],
},
],
outputSchema,
...appServerTurnRuntimeParams(runtime),
};
return this.request("turn/start", params);
}
steerTurn(
threadId: string,
expectedTurnId: string,
input: string | CodexInput,
clientUserMessageId?: string | null,
): Promise<TurnSteerResponse> {
const additionalContext = toAdditionalContext(input);
return this.request("turn/steer", {
threadId,
expectedTurnId,
input: toUserInput(input),
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
});
}
interruptTurn(threadId: string, turnId: string): Promise<TurnInterruptResponse> {
return this.request("turn/interrupt", { threadId, turnId });
}
private writeHookState(key: string, state: Record<string, JsonValue>): Promise<ConfigWriteResponse> {
return this.request("config/batchWrite", {
edits: [
{
keyPath: "hooks.state",
value: {
[key]: state,
},
mergeStrategy: "upsert",
},
],
reloadUserConfig: true,
});
}
respondToServerRequest(requestId: RequestId, result: unknown): void {
this.rpc.respond(requestId, result);
}
@ -563,7 +228,7 @@ export class AppServerClient {
this.rpc.reject(requestId, code, message);
}
private request<M extends TypedClientRequestMethod>(
request<M extends TypedClientRequestMethod>(
method: M,
params: ClientRequestParams<M>,
options: { timeoutMs?: number } = {},

View file

@ -8,6 +8,7 @@ import type { AppServerClient } from "../connection/client";
import type { AppServerClientAccessOptions } from "../connection/client-access";
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
import { listModelMetadata } from "../services/catalog";
import { readEffectiveConfig } from "../services/config";
import { listThreads } from "../services/threads";
import {
type AppServerQueryContext,
@ -268,7 +269,7 @@ export class AppServerQueryCache {
queryKey: appServerMetadataQueryKey(refreshContext),
queryFn: async (): Promise<SharedServerMetadata> => {
return this.runWithClient(refreshContext, async (client) => {
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await client.readEffectiveConfig(refreshContext.vaultPath));
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, refreshContext.vaultPath));
const [models, skills, rateLimit] = await Promise.all([
this.readModelMetadataProbe(refreshContext, client),
readSkillMetadataProbe(client, refreshContext.vaultPath, options.forceSkills ?? false),

View file

@ -37,7 +37,7 @@ export async function readRateLimitMetadataProbe(client: AppServerClient | null)
};
}
try {
const response = await client.readAccountRateLimits();
const response = await client.request("account/rateLimits/read", undefined);
return {
value: rateLimitSnapshotFromAccountRateLimitsResponse(response),
probe: diagnosticProbeOk("account/rateLimits/read", accountRateLimitsSummaryFromResponse(response), Date.now()),

View file

@ -1,12 +1,13 @@
import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
import type { AppServerClient } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import {
type AppServerHookOperation,
appServerHookOperationFromHookItem,
type CatalogModel,
hookItemsFromCatalogHooks,
modelMetadataFromCatalogModels,
skillMetadataFromCatalogSkills,
} from "../protocol/catalog";
import type { AppServerRequestClient } from "./request-client";
export interface HookCatalog {
hooks: HookItem[];
@ -15,20 +16,26 @@ export interface HookCatalog {
}
export interface ModelMetadataClient {
listModels(includeHidden: boolean): Promise<{ data: readonly CatalogModel[] }>;
request: AppServerRequestClient["request"];
}
export async function listModelMetadata(client: ModelMetadataClient, options: { includeHidden?: boolean } = {}): Promise<ModelMetadata[]> {
const response = await client.listModels(options.includeHidden ?? false);
const response = await client.request("model/list", {
includeHidden: options.includeHidden ?? false,
limit: 100,
});
return modelMetadataFromCatalogModels(response.data);
}
export async function listSkillCatalog(
client: AppServerClient,
client: AppServerRequestClient,
cwd: string,
options: { forceReload?: boolean; enabledOnly?: boolean } = {},
): Promise<{ skills: SkillMetadata[]; totalCount: number }> {
const response = options.forceReload === undefined ? await client.listSkills(cwd) : await client.listSkills(cwd, options.forceReload);
const response = await client.request("skills/list", {
cwds: [cwd],
forceReload: options.forceReload ?? false,
});
const skills = response.data.flatMap((entry) => entry.skills);
return {
skills: skillMetadataFromCatalogSkills(options.enabledOnly === false ? skills : skills.filter((skill) => skill.enabled)),
@ -36,8 +43,8 @@ export async function listSkillCatalog(
};
}
export async function listHookCatalog(client: AppServerClient, cwd: string): Promise<HookCatalog> {
const response = await client.listHooks(cwd);
export async function listHookCatalog(client: AppServerRequestClient, cwd: string): Promise<HookCatalog> {
const response = await client.request("hooks/list", { cwds: [cwd] });
const entry = response.data.find((item) => item.cwd === cwd);
if (!entry) return { hooks: [], warnings: [], errors: [] };
return {
@ -47,10 +54,37 @@ export async function listHookCatalog(client: AppServerClient, cwd: string): Pro
};
}
export async function trustHookItem(client: AppServerClient, hook: HookItem): Promise<void> {
await client.trustHook(appServerHookOperationFromHookItem(hook));
export async function trustHookItem(client: AppServerRequestClient, hook: HookItem): Promise<void> {
const operation = appServerHookOperationFromHookItem(hook);
await writeHookState(client, operation.key, {
enabled: true,
trusted_hash: operation.currentHash,
});
}
export async function setHookItemEnabled(client: AppServerClient, hook: HookItem, enabled: boolean): Promise<void> {
await client.setHookEnabled(appServerHookOperationFromHookItem(hook), enabled);
export async function setHookItemEnabled(client: AppServerRequestClient, hook: HookItem, enabled: boolean): Promise<void> {
const operation = appServerHookOperationFromHookItem(hook);
const state: HookConfigState = operation.trustStatus === "trusted" ? { enabled, trusted_hash: operation.currentHash } : { enabled };
await writeHookState(client, operation.key, state);
}
type HookConfigState = Record<string, string | boolean | null>;
type ConfigBatchWriteParams = ClientRequestParams<"config/batchWrite">;
function writeHookState(client: AppServerRequestClient, key: AppServerHookOperation["key"], state: HookConfigState): Promise<unknown> {
const params: ConfigBatchWriteParams = {
edits: [
{
keyPath: "hooks.state",
value: {
[key]: state,
},
mergeStrategy: "upsert",
},
],
reloadUserConfig: true,
};
return client.request("config/batchWrite", {
...params,
});
}

View file

@ -0,0 +1,6 @@
import type { ClientResponseByMethod } from "../connection/client";
import type { AppServerRequestClient } from "./request-client";
export function readEffectiveConfig(client: AppServerRequestClient, cwd: string): Promise<ClientResponseByMethod["config/read"]> {
return client.request("config/read", { cwd, includeLayers: true });
}

View file

@ -1,14 +1,12 @@
import { listenAbortSignal } from "../../shared/lifecycle/abort-signal";
import {
AppServerClient,
type AppServerClientHandlers,
type AppServerStartEphemeralThreadOptions,
type AppServerStartStructuredTurnOptions,
} from "../connection/client";
import { AppServerClient, type AppServerClientHandlers } from "../connection/client";
import type { AppServerClientRequestPolicy } from "../connection/client-access";
import type { ServerNotification } from "../connection/rpc-messages";
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
import type { ModelMetadataClient } from "./catalog";
import type { AppServerRequestClient } from "./request-client";
import { deleteThread, startEphemeralThread } from "./threads";
import { type AppServerStartStructuredTurnOptions, startStructuredTurn } from "./turns";
export type StructuredTurnOutputSchema = AppServerStartStructuredTurnOptions["outputSchema"];
@ -31,11 +29,9 @@ const DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS: EphemeralStructuredTurnTimers =
};
export interface EphemeralStructuredTurnClient {
request: AppServerRequestClient["request"];
connect(): Promise<unknown>;
disconnect(): void;
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<{ thread: { id: string } }>;
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<{ turn: TurnRecord }>;
deleteThread(threadId: string, options?: { timeoutMs?: number }): Promise<unknown>;
}
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
@ -122,7 +118,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
await runAbortable(client.connect());
const runtime = options.resolveRuntime ? await runAbortable(options.resolveRuntime(client)) : (options.runtime ?? {});
const threadResponse = await runAbortable(
client.startEphemeralThread({
startEphemeralThread(client, {
cwd: options.cwd,
serviceName: options.serviceName,
developerInstructions: options.developerInstructions,
@ -137,7 +133,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
}),
};
const turnResponse = await runAbortable(
client.startStructuredTurn({
startStructuredTurn(client, {
threadId,
cwd: options.cwd,
text: options.prompt,
@ -265,7 +261,7 @@ function turnWithCollectedItems(turn: TurnRecord, completedItems: readonly TurnI
async function deleteEphemeralStructuredTurnThread(client: EphemeralStructuredTurnClient, threadId: string | null): Promise<void> {
if (!threadId) return;
try {
await client.deleteThread(threadId, { timeoutMs: EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS });
await deleteThread(client, threadId, { timeoutMs: EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS });
} catch {
// Ephemeral helpers must not fail visible workflows because cleanup raced app-server shutdown.
}

View file

@ -0,0 +1,10 @@
import type { ClientResponseByMethod, TypedClientRequestMethod } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
export interface AppServerRequestClient {
request<M extends TypedClientRequestMethod>(
method: M,
params: ClientRequestParams<M>,
options?: { timeoutMs?: number },
): Promise<ClientResponseByMethod[M]>;
}

View file

@ -1,7 +1,7 @@
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import type { AppServerClient } from "../connection/client";
import type { AppServerRequestClient } from "./request-client";
import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "./thread-archive-markdown";
import { readThreadForArchiveExport } from "./threads";
import { archiveThread, readThreadForArchiveExport } from "./threads";
export interface ArchiveThreadOptions {
settings: ArchiveExportSettings;
@ -16,7 +16,7 @@ export interface ArchiveThreadResult {
}
export async function archiveThreadOnAppServer(
client: AppServerClient,
client: AppServerRequestClient,
threadId: string,
options: ArchiveThreadOptions,
): Promise<ArchiveThreadResult> {
@ -30,6 +30,6 @@ export async function archiveThreadOnAppServer(
exportedPath = result.path;
}
await client.archiveThread(threadId);
await archiveThread(client, threadId);
return { exportedPath };
}

View file

@ -1,6 +1,7 @@
import { normalizeReasoningEffort } from "../../domain/catalog/metadata";
import type { ApprovalsReviewer, ServiceTier } from "../../domain/runtime/policy";
import { parseServiceTier } from "../../domain/runtime/policy";
import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings";
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
@ -8,22 +9,25 @@ import type { HistoricalTurn } from "../../domain/threads/history";
import type { Thread } from "../../domain/threads/model";
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
import type { AppServerClient } from "../connection/client";
import type { ClientResponseByMethod } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import { type ThreadRecord, threadFromThreadRecord, threadsFromThreadRecords } from "../protocol/thread";
import { appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal";
import { appServerThreadGoalUpdate, appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal";
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
import {
chronologicalConversationSummariesFromTurnRecords,
completedConversationSummariesFromTurnRecords,
transcriptEntriesFromTurnRecords,
} from "../protocol/turn";
import type { AppServerRequestClient } from "./request-client";
const THREAD_LIST_PAGE_LIMIT = 100;
export type ThreadTurnSortDirection = "asc" | "desc";
export type ThreadConversationSummaryClient = Pick<AppServerClient, "threadTurnsList">;
export type ThreadForkClient = Pick<AppServerClient, "forkThread">;
export type ThreadRollbackClient = Pick<AppServerClient, "rollbackThread">;
export type ThreadCompactionClient = Pick<AppServerClient, "compactThread">;
export type ThreadConversationSummaryClient = AppServerRequestClient;
export type ThreadForkClient = AppServerRequestClient;
export type ThreadRollbackClient = AppServerRequestClient;
export type ThreadCompactionClient = AppServerRequestClient;
interface ThreadConversationSummaryPage {
summaries: ThreadConversationSummary[];
@ -39,14 +43,73 @@ interface ThreadActivationResponse {
reasoningEffort: string | null;
}
export async function listThreads(client: AppServerClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
export interface AppServerStartThreadOptions {
cwd: string;
serviceTier?: RuntimeServiceTierRequest;
}
export interface AppServerStartEphemeralThreadOptions {
cwd: string;
serviceName: string;
developerInstructions: string;
}
interface AppServerThreadListOptions {
archived?: boolean;
cursor?: string | null;
limit?: number | null;
}
export function startThread(
client: AppServerRequestClient,
options: AppServerStartThreadOptions,
): Promise<ClientResponseByMethod["thread/start"]> {
const { cwd, serviceTier } = options;
return client.request("thread/start", {
cwd,
serviceName: "codex-panel",
...(serviceTier !== undefined ? { serviceTier } : {}),
});
}
export function startEphemeralThread(
client: AppServerRequestClient,
options: AppServerStartEphemeralThreadOptions,
): Promise<ClientResponseByMethod["thread/start"]> {
const { cwd, serviceName, developerInstructions } = options;
return client.request("thread/start", {
cwd,
serviceName,
developerInstructions,
ephemeral: true,
sandbox: "read-only",
approvalPolicy: "never",
multiAgentMode: "none",
environments: [],
});
}
export function resumeThread(
client: AppServerRequestClient,
threadId: string,
cwd: string,
): Promise<ClientResponseByMethod["thread/resume"]> {
return client.request("thread/resume", {
threadId,
cwd,
excludeTurns: true,
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
});
}
export async function listThreads(client: AppServerRequestClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
const archived = options.archived ?? false;
const records: ThreadRecord[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
for (;;) {
const response = await client.listThreads(cwd, {
const response = await listThreadPage(client, cwd, {
archived,
cursor,
limit: THREAD_LIST_PAGE_LIMIT,
@ -68,8 +131,8 @@ export function threadFromAppServerRecord(thread: ThreadRecord, options: { archi
return threadFromThreadRecord(thread, options);
}
export async function readThreadForArchiveExport(client: AppServerClient, threadId: string): Promise<ArchiveThreadInput> {
const response = await client.readThread(threadId, true);
export async function readThreadForArchiveExport(client: AppServerRequestClient, threadId: string): Promise<ArchiveThreadInput> {
const response = await client.request("thread/read", { threadId, includeTurns: true });
return {
...threadFromThreadRecord(response.thread, { archived: true }),
transcriptEntries: transcriptEntriesFromTurnRecords(response.thread.turns),
@ -83,7 +146,7 @@ export async function readCompletedConversationSummariesPage(
limit: number,
sortDirection: ThreadTurnSortDirection = "asc",
): Promise<ThreadConversationSummaryPage> {
const response = await client.threadTurnsList(threadId, cursor, limit, sortDirection);
const response = await listThreadTurns(client, threadId, cursor, limit, sortDirection);
return {
summaries: completedConversationSummariesFromTurnRecords(response.data),
nextCursor: response.nextCursor,
@ -95,7 +158,7 @@ export async function readReferencedThreadConversationSummaries(
threadId: string,
limit = REFERENCED_THREAD_TURN_LIMIT,
): Promise<ThreadConversationSummary[]> {
const response = await client.threadTurnsList(threadId, null, limit);
const response = await listThreadTurns(client, threadId, null, limit);
return chronologicalConversationSummariesFromTurnRecords(response.data);
}
@ -121,21 +184,33 @@ function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackRes
}
export async function rollbackThread(client: ThreadRollbackClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
const response = numTurns === undefined ? await client.rollbackThread(threadId) : await client.rollbackThread(threadId, numTurns);
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
return threadRollbackSnapshotFromAppServerResponse(response);
}
export async function forkThread(client: ThreadForkClient, threadId: string, cwd: string): Promise<Thread> {
const response = await client.forkThread(threadId, cwd);
const response = await client.request("thread/fork", {
threadId,
cwd,
excludeTurns: true,
});
return threadFromThreadRecord(response.thread);
}
export async function compactThread(client: ThreadCompactionClient, threadId: string): Promise<void> {
await client.compactThread(threadId);
await client.request("thread/compact/start", { threadId });
}
export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise<Thread> {
const response = await client.unarchiveThread(threadId);
export async function archiveThread(client: AppServerRequestClient, threadId: string): Promise<void> {
await client.request("thread/archive", { threadId });
}
export async function deleteThread(client: AppServerRequestClient, threadId: string, options: { timeoutMs?: number } = {}): Promise<void> {
await client.request("thread/delete", { threadId }, options);
}
export async function restoreArchivedThread(client: AppServerRequestClient, threadId: string): Promise<Thread> {
const response = await client.request("thread/unarchive", { threadId });
return threadFromThreadRecord(response.thread);
}
@ -150,16 +225,72 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
};
}
export async function readThreadGoal(client: AppServerClient, threadId: string): Promise<ThreadGoal | null> {
const response = await client.getThreadGoal(threadId);
export async function readThreadGoal(client: AppServerRequestClient, threadId: string): Promise<ThreadGoal | null> {
const response = await client.request("thread/goal/get", { threadId });
return threadGoalFromAppServerGoal(response.goal);
}
export async function setThreadGoal(client: AppServerClient, threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoal | null> {
const response = await client.setThreadGoal(threadId, params);
export async function setThreadGoal(
client: AppServerRequestClient,
threadId: string,
params: ThreadGoalUpdate,
): Promise<ThreadGoal | null> {
const response = await client.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) });
return threadGoalFromAppServerGoal(response.goal);
}
export async function recordThreadGoalUserMessage(client: AppServerClient, threadId: string, objective: string): Promise<void> {
await client.injectThreadItems(threadId, [appServerThreadGoalUserHistoryItem(objective)]);
export async function clearThreadGoal(client: AppServerRequestClient, threadId: string): Promise<void> {
await client.request("thread/goal/clear", { threadId });
}
export async function recordThreadGoalUserMessage(client: AppServerRequestClient, threadId: string, objective: string): Promise<void> {
await client.request("thread/inject_items", { threadId, items: [appServerThreadGoalUserHistoryItem(objective)] });
}
export async function renameThread(client: AppServerRequestClient, threadId: string, name: string): Promise<void> {
await client.request("thread/name/set", { threadId, name });
}
export async function updateThreadSettings(
client: AppServerRequestClient,
threadId: string,
settings: RuntimeSettingsPatch,
): Promise<void> {
await client.request("thread/settings/update", { threadId, ...appServerRuntimeSettingsPatch(settings) });
}
export function readThreadRolloutFile(
client: AppServerRequestClient,
path: string,
options: { timeoutMs?: number } = {},
): Promise<ClientResponseByMethod["fs/readFile"]> {
return client.request("fs/readFile", { path }, options);
}
export function listThreadTurns(
client: AppServerRequestClient,
threadId: string,
cursor: string | null = null,
limit = 20,
sortDirection: ClientRequestParams<"thread/turns/list">["sortDirection"] = "desc",
itemsView: ClientRequestParams<"thread/turns/list">["itemsView"] = "full",
) {
return client.request("thread/turns/list", {
threadId,
cursor,
limit,
sortDirection,
itemsView,
});
}
function listThreadPage(client: AppServerRequestClient, cwd: string, options: AppServerThreadListOptions) {
return client.request("thread/list", {
cwd,
...(options.cursor ? { cursor: options.cursor } : {}),
...(options.limit === undefined ? {} : { limit: options.limit }),
archived: options.archived ?? false,
sortKey: "recency_at",
sortDirection: "desc",
});
}

View file

@ -8,9 +8,10 @@ import {
mcpServerStatusSummariesFromStatuses,
} from "../../domain/server/diagnostics";
import type { ToolInventoryMarketplaceError, ToolInventoryPlugin, ToolInventorySnapshot } from "../../domain/server/tool-inventory";
import type { AppServerClient } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import { toolInventoryPluginsFromInstalledResponse } from "../protocol/tool-inventory";
import { listSkillCatalog } from "./catalog";
import type { AppServerRequestClient } from "./request-client";
const PLUGIN_DETAILS_CONCURRENCY = 4;
@ -28,7 +29,7 @@ export interface ReadToolInventoryResult {
}
export async function readToolInventory(
client: AppServerClient,
client: AppServerRequestClient,
cwd: string,
options: ReadToolInventoryOptions = {},
): Promise<ReadToolInventoryResult> {
@ -74,7 +75,7 @@ function readCachedSkills(
}
async function readPlugins(
client: AppServerClient,
client: AppServerRequestClient,
cwd: string,
checkedAt: number,
): Promise<{
@ -84,7 +85,7 @@ async function readPlugins(
probe: DiagnosticProbeResult;
}> {
try {
const response = await client.listInstalledPlugins(cwd);
const response = await client.request("plugin/installed", { cwds: [cwd] });
const { plugins, marketplaceErrors } = toolInventoryPluginsFromInstalledResponse(response);
const pluginsWithDetails = await mapLimit(plugins, PLUGIN_DETAILS_CONCURRENCY, (plugin) => readPluginDetails(client, plugin));
return {
@ -121,10 +122,10 @@ async function mapLimit<T, R>(items: readonly T[], concurrency: number, fn: (ite
return results;
}
async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
async function readPluginDetails(client: AppServerRequestClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
if (!isUsablePlugin(plugin)) return plugin;
try {
const response = await client.readPlugin(pluginReadParams(plugin));
const response = await client.request("plugin/read", pluginReadParams(plugin));
return {
...plugin,
details: {
@ -140,7 +141,7 @@ async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryP
}
}
function pluginReadParams(plugin: ToolInventoryPlugin): Parameters<AppServerClient["readPlugin"]>[0] {
function pluginReadParams(plugin: ToolInventoryPlugin): ClientRequestParams<"plugin/read"> {
if (plugin.marketplacePath) {
return {
pluginName: plugin.name,
@ -158,12 +159,12 @@ function isUsablePlugin(plugin: ToolInventoryPlugin): boolean {
}
async function readMcpServers(
client: AppServerClient,
client: AppServerRequestClient,
threadId: string | null,
checkedAt: number,
): Promise<{ items: McpServerStatusSummary[] | null; error: string | null; probe: DiagnosticProbeResult }> {
try {
const response = await client.listMcpServerStatus({
const response = await client.request("mcpServerStatus/list", {
detail: "toolsAndAuthOnly",
limit: 100,
...(threadId ? { threadId } : {}),
@ -189,7 +190,7 @@ function mcpSummary(servers: readonly McpServerStatusSummary[]): string {
}
async function readSkills(
client: AppServerClient,
client: AppServerRequestClient,
cwd: string,
checkedAt: number,
): Promise<{ items: ToolInventorySnapshot["skills"]; error: string | null; probe: DiagnosticProbeResult }> {

View file

@ -0,0 +1,107 @@
import type { CodexInput } from "../../domain/chat/input";
import type { ClientResponseByMethod } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import type { AppServerRequestClient } from "./request-client";
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
type AppServerTurnRuntimeParams = Pick<
ClientRequestParams<"turn/start">,
"serviceTier" | "collaborationMode" | "model" | "effort" | "approvalsReviewer"
>;
export interface AppServerStartTurnOptions {
threadId: string;
cwd: string;
input: string | CodexInput;
clientUserMessageId?: string | null;
runtime?: AppServerTurnRuntimeOverrides;
}
export interface AppServerStartStructuredTurnOptions {
threadId: string;
cwd: string;
text: string;
outputSchema: NonNullable<ClientRequestParams<"turn/start">["outputSchema"]>;
runtime?: AppServerTurnRuntimeOverrides;
}
export function startTurn(
client: AppServerRequestClient,
options: AppServerStartTurnOptions,
): Promise<ClientResponseByMethod["turn/start"]> {
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
const additionalContext = toAdditionalContext(input);
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
...appServerTurnRuntimeParams(runtime),
input: toUserInput(input),
};
return client.request("turn/start", params);
}
export function startStructuredTurn(
client: AppServerRequestClient,
options: AppServerStartStructuredTurnOptions,
): Promise<ClientResponseByMethod["turn/start"]> {
const { threadId, cwd, text, outputSchema, runtime } = options;
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
input: [
{
type: "text",
text,
text_elements: [],
},
],
outputSchema,
...appServerTurnRuntimeParams(runtime),
};
return client.request("turn/start", params);
}
export function steerTurn(
client: AppServerRequestClient,
threadId: string,
expectedTurnId: string,
input: string | CodexInput,
clientUserMessageId?: string | null,
): Promise<unknown> {
const additionalContext = toAdditionalContext(input);
return client.request("turn/steer", {
threadId,
expectedTurnId,
input: toUserInput(input),
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
});
}
export function interruptTurn(client: AppServerRequestClient, threadId: string, turnId: string): Promise<unknown> {
return client.request("turn/interrupt", { threadId, turnId });
}
function toUserInput(input: string | CodexInput): ClientRequestParams<"turn/start">["input"] {
if (typeof input !== "string") return toAppServerUserInput(input);
return toAppServerUserInput([{ type: "text", text: input }]);
}
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
if (typeof input === "string") return undefined;
return additionalContextFromCodexInput(input);
}
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
const params: AppServerTurnRuntimeParams = {};
if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier;
if (runtime?.collaborationMode !== undefined) params.collaborationMode = runtime.collaborationMode;
if (runtime?.model !== undefined) params.model = runtime.model;
if (runtime?.effort !== undefined) params.effort = runtime.effort;
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
return params;
}

View file

@ -1,5 +1,6 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { readRateLimitMetadataProbe } from "../../../../app-server/query/metadata-probes";
import { listModelMetadata } from "../../../../app-server/services/catalog";
import { readToolInventory } from "../../../../app-server/services/tool-inventory";
import {
cloneServerDiagnostics,
@ -69,8 +70,8 @@ async function refreshServerDiagnostics(
probes.push(
probeDiagnostic(
"model/list",
() => client.listModels(false),
(response) => `${String(response.data.length)} models`,
() => listModelMetadata(client),
(models) => `${String(models.length)} models`,
),
readRateLimitDiagnosticProbe(client),
);

View file

@ -1,5 +1,8 @@
import type { ThreadCatalogEvent } from "../../../../app-server/query/thread-catalog";
import { threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads";
import {
startThread as startAppServerThread,
threadActivationSnapshotFromAppServerResponse,
} from "../../../../app-server/services/threads";
import { runtimeConfigOrDefault } from "../../../../domain/runtime/config";
import type { Thread } from "../../../../domain/threads/model";
import { resumedThreadAction } from "../../application/state/actions";
@ -48,7 +51,7 @@ async function startThread(
host.runtimeSnapshotForState(requestState),
runtimeConfigOrDefault(requestState.connection.runtimeConfig),
);
const response = await scope.client.startThread({ cwd: host.vaultPath, serviceTier });
const response = await startAppServerThread(scope.client, { cwd: host.vaultPath, serviceTier });
if (scope.isStale()) return null;
const state = host.stateStore.getState();
const fallbackPreview = preview?.trim();

View file

@ -1,4 +1,4 @@
import { readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/services/threads";
import { clearThreadGoal, readThreadGoal, recordThreadGoalUserMessage, setThreadGoal } from "../../../../app-server/services/threads";
import type { ThreadGoalReadTransport, ThreadGoalTransport } from "../../application/threads/goal-transport";
import type { ConnectedChatAppServerClientHost, CurrentChatAppServerClientHost } from "../connection/client-scope";
import { chatAppServerClientIsStale, withConnectedChatAppServerClient, withCurrentChatAppServerClient } from "../connection/client-scope";
@ -21,7 +21,7 @@ export function createChatThreadGoalTransport(host: ConnectedChatAppServerClient
},
clearThreadGoal: async (threadId) => {
const result = await withConnectedChatAppServerClient(host, async (client) => {
await client.clearThreadGoal(threadId);
await clearThreadGoal(client, threadId);
return true;
});
return result ?? false;

View file

@ -1,3 +1,4 @@
import { updateThreadSettings } from "../../../../app-server/services/threads";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import type { RuntimeSettingsTransport } from "../../application/runtime/settings-transport";
import type { CurrentChatAppServerClientHost } from "../connection/client-scope";
@ -7,7 +8,7 @@ export function createChatRuntimeSettingsTransport(host: CurrentChatAppServerCli
return {
updateThreadSettings: async (threadId: string, update: RuntimeSettingsPatch) => {
const result = await withCurrentChatAppServerClient(host, async (client) => {
await client.updateThreadSettings(threadId, update);
await updateThreadSettings(client, threadId, update);
return true;
});
return result ?? false;

View file

@ -1,11 +1,11 @@
import type { AppServerClient } from "../../../../app-server/connection/client";
import { threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads";
import type { AppServerRequestClient } from "../../../../app-server/services/request-client";
import { listThreadTurns, resumeThread, threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads";
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
import type { ThreadHistoryPage, ThreadResumeSnapshot } from "../../application/threads/thread-loading-transport";
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
type ChatThreadHistoryClient = Pick<AppServerClient, "threadTurnsList">;
type ChatThreadResumeClient = Pick<AppServerClient, "resumeThread">;
type ChatThreadHistoryClient = AppServerRequestClient;
type ChatThreadResumeClient = AppServerRequestClient;
interface AppServerThreadTurnsPage {
readonly data: ThreadTurnsPage["turns"];
@ -18,7 +18,7 @@ export async function readChatThreadHistoryPage(
cursor: string | null,
limit = 20,
): Promise<ThreadHistoryPage> {
return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await client.threadTurnsList(threadId, cursor, limit)));
return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await listThreadTurns(client, threadId, cursor, limit)));
}
function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage {
@ -30,7 +30,7 @@ function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistor
}
export async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise<ThreadResumeSnapshot> {
const response = await client.resumeThread(threadId, cwd);
const response = await resumeThread(client, threadId, cwd);
return {
activation: threadActivationSnapshotFromAppServerResponse(response),
rolloutPath: response.thread.path,

View file

@ -1,3 +1,4 @@
import { interruptTurn, startTurn, steerTurn } from "../../../../app-server/services/turns";
import type { ChatTurnTransport } from "../../application/conversation/turn-transport";
import type { ConnectedChatAppServerClientHost } from "../connection/client-scope";
import { withCurrentChatAppServerClient } from "../connection/client-scope";
@ -11,7 +12,7 @@ export function createChatTurnTransport(host: ChatTurnTransportHost): ChatTurnTr
ensureConnected: async () => (await host.connectedClient()) !== null,
startTurn: (request) =>
withCurrentChatAppServerClient(host, async (client) => {
const response = await client.startTurn({
const response = await startTurn(client, {
threadId: request.threadId,
cwd: host.vaultPath,
input: request.input,
@ -21,14 +22,14 @@ export function createChatTurnTransport(host: ChatTurnTransportHost): ChatTurnTr
}),
steerTurn: async (request) => {
const steered = await withCurrentChatAppServerClient(host, async (client) => {
await client.steerTurn(request.threadId, request.turnId, request.input, request.clientUserMessageId);
await steerTurn(client, request.threadId, request.turnId, request.input, request.clientUserMessageId);
return true;
});
return steered ?? false;
},
interruptTurn: async (threadId, turnId) => {
const interrupted = await withCurrentChatAppServerClient(host, async (client) => {
await client.interruptTurn(threadId, turnId);
await interruptTurn(client, threadId, turnId);
return true;
});
return interrupted ?? false;

View file

@ -2,6 +2,7 @@ import { Notice } from "obsidian";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import { recoverRolloutTokenUsage } from "../../../app-server/services/rollout-token-usage";
import { readThreadRolloutFile, renameThread as renameAppServerThread } from "../../../app-server/services/threads";
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
import type { LocalIdSource } from "../../../shared/id/local-id";
import { createThreadOperations, type ThreadOperations } from "../../threads/workflows/thread-operations";
@ -269,7 +270,7 @@ function createSessionAutoTitleCoordinator(
const client = currentClient();
if (!client) return false;
await client.setThreadName(threadId, name);
await renameAppServerThread(client, threadId, name);
if (currentClient() !== client) return false;
if (options.shouldPublish()) {
host.environment.plugin.threadCatalog.apply({ type: "thread-renamed", threadId, name });
@ -438,7 +439,8 @@ function createSessionThreadLifecycle(
getClosing: host.getClosing,
recoverTokenUsageFromRollout: (path) =>
recoverRolloutTokenUsage(path, async (filePath, options) => {
const response = await currentClient()?.readFile(filePath, options);
const client = currentClient();
const response = client ? await readThreadRolloutFile(client, filePath, options) : null;
return response?.dataBase64 ?? "";
}),
},

View file

@ -2,6 +2,7 @@ import type { AppServerClientAccess } from "../../../app-server/connection/clien
import type { ThreadCatalogEventSink } from "../../../app-server/query/thread-catalog";
import { type ArchiveThreadResult, archiveThreadOnAppServer } from "../../../app-server/services/thread-archive";
import type { ArchiveExportDestination } from "../../../app-server/services/thread-archive-markdown";
import { renameThread as renameAppServerThread } from "../../../app-server/services/threads";
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
@ -48,7 +49,7 @@ async function renameThread(
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await host.clientAccess.withClient((client) => client.setThreadName(threadId, name));
await host.clientAccess.withClient((client) => renameAppServerThread(client, threadId, name));
if (options.shouldPublish?.() ?? true) {
host.catalog.apply({ type: "thread-renamed", threadId, name });
}

View file

@ -3,7 +3,7 @@ import type { AppServerClientAccess } from "../../app-server/connection/client-a
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../../app-server/query/thread-catalog";
import { listHookCatalog, setHookItemEnabled, trustHookItem } from "../../app-server/services/catalog";
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../../app-server/services/threads";
import { deleteThread, restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../../app-server/services/threads";
import type { ModelMetadata } from "../../domain/catalog/metadata";
import type { ObservedResultListener } from "../../shared/query/observed-result";
import { type SettingsDynamicDataAccess, type SettingsHookCatalog, StaleSettingsDynamicDataContextError } from "../dynamic-data";
@ -50,7 +50,7 @@ export function createSettingsAppServerDynamicData(options: SettingsAppServerDyn
return thread;
},
deleteArchivedThread: async (threadId, mutationOptions) => {
await withSettingsConnection((client) => client.deleteThread(threadId));
await withSettingsConnection((client) => deleteThread(client, threadId));
if (mutationOptions?.shouldPublish?.() ?? true) {
options.threadCatalog.apply({ type: "thread-deleted", threadId });
}

View file

@ -1,10 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import manifest from "../../manifest.json";
import type {
AppServerServerRequestResponder,
AppServerStartStructuredTurnOptions,
AppServerStartTurnOptions,
} from "../../src/app-server/connection/client";
import type { AppServerServerRequestResponder } from "../../src/app-server/connection/client";
import { AppServerClient } from "../../src/app-server/connection/client";
import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages";
import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport";
@ -94,6 +90,14 @@ async function expectRequest(
await request;
}
function listModels(client: AppServerClient): Promise<unknown> {
return client.request("model/list", { includeHidden: false, limit: 100 });
}
function readFile(client: AppServerClient, path: string, options: { timeoutMs?: number } = {}): Promise<unknown> {
return client.request("fs/readFile", { path }, options);
}
describe("AppServerClient", () => {
beforeEach(() => {
vi.stubGlobal("window", {
@ -176,16 +180,19 @@ describe("AppServerClient", () => {
expect(latestSent(getTransport())).toEqual({ id: 99, error: { code: -32601, message: "Request not handled." } });
});
it("injects raw items into a thread", async () => {
it("sends typed client requests", async () => {
const { client, transport } = await connectedClient();
const request = client.injectThreadItems("thread-1", [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship this" }],
},
]);
const request = client.request("thread/inject_items", {
threadId: "thread-1",
items: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Ship this" }],
},
],
});
await expectRequest(
transport,
@ -221,7 +228,7 @@ describe("AppServerClient", () => {
it("clears initialized state and rejects pending requests on disconnect", async () => {
const { client } = await connectedClient();
const listing = client.listModels();
const listing = listModels(client);
client.disconnect();
@ -279,7 +286,7 @@ describe("AppServerClient", () => {
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const listing = client.listModels();
const listing = listModels(client);
transport.emitError(new Error("transport failed"));
@ -472,89 +479,6 @@ describe("AppServerClient", () => {
expect(onExit).not.toHaveBeenCalled();
});
it("sends typed turn steering requests", async () => {
let transport: FakeTransport;
const getTransport = () => transport;
const client = new AppServerClient(
"/bin/codex",
"/vault",
{
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit: () => undefined,
},
500,
(handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
);
const connecting = client.connect();
getTransport().emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const steering = client.steerTurn("thread-1", "turn-1", "Please adjust course.", "local-steer-1");
expect(getTransport().sent[2]).toMatchObject({
id: 2,
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
clientUserMessageId: "local-steer-1",
input: [{ type: "text", text: "Please adjust course.", text_elements: [] }],
},
});
getTransport().emitLine({ id: 2, result: { turnId: "turn-1" } });
await expect(steering).resolves.toEqual({ turnId: "turn-1" });
});
it("reads files through app-server fs requests", async () => {
const { client, transport } = await connectedClient();
const reading = client.readFile("/tmp/rollout.jsonl");
await expectRequest(
transport,
reading,
{ method: "fs/readFile", params: { path: "/tmp/rollout.jsonl" } },
{ dataBase64: btoa("hello") },
);
await expect(reading).resolves.toEqual({ dataBase64: btoa("hello") });
});
it("sends thread goal management requests", async () => {
const { client, transport } = await connectedClient();
const reading = client.getThreadGoal("thread");
await expectRequest(transport, reading, { method: "thread/goal/get", params: { threadId: "thread" } }, { goal: null });
await expect(reading).resolves.toEqual({ goal: null });
const setting = client.setThreadGoal("thread", { objective: "Finish", status: "active", tokenBudget: 1000 });
const goal = {
threadId: "thread",
objective: "Finish",
status: "active",
tokenBudget: 1000,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 1,
};
await expectRequest(
transport,
setting,
{ method: "thread/goal/set", params: { threadId: "thread", objective: "Finish", status: "active", tokenBudget: 1000 } },
{ goal },
);
await expect(setting).resolves.toEqual({ goal });
const clearing = client.clearThreadGoal("thread");
await expectRequest(transport, clearing, { method: "thread/goal/clear", params: { threadId: "thread" } }, { cleared: true });
await expect(clearing).resolves.toEqual({ cleared: true });
});
it("suppresses late responses after per-request timeouts", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", {
@ -582,7 +506,7 @@ describe("AppServerClient", () => {
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const reading = client.readFile("/tmp/slow.jsonl", { timeoutMs: 10 });
const reading = readFile(client, "/tmp/slow.jsonl", { timeoutMs: 10 });
const rejection = expect(reading).rejects.toThrow("Codex app-server request timed out: fs/readFile");
const sent = latestSent(transport);
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
@ -623,7 +547,7 @@ describe("AppServerClient", () => {
const timedOutRequests: { id: number; rejection: Promise<void> }[] = [];
for (let index = 0; index < 257; index += 1) {
const promise = client.readFile(`/tmp/slow-${String(index)}.jsonl`, { timeoutMs: 10 });
const promise = readFile(client, `/tmp/slow-${String(index)}.jsonl`, { timeoutMs: 10 });
const rejection = expect(promise).rejects.toThrow("Codex app-server request timed out");
const sent = latestSent(transport);
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
@ -645,408 +569,10 @@ describe("AppServerClient", () => {
expect(logs).toHaveLength(1);
});
it("sends golden thread and turn request payloads", async () => {
const { client, transport } = await connectedClient();
const startingThread = client.startThread({ cwd: "/vault", serviceTier: "fast" });
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "thread/start",
params: {
cwd: "/vault",
serviceName: "codex-panel",
serviceTier: "fast",
},
});
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, serviceTier: "fast" } });
await startingThread;
const startingTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "hello",
clientUserMessageId: "local-user-1",
runtime: { serviceTier: null },
});
expect(transport.sent[3]).toMatchObject({
id: 3,
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
clientUserMessageId: "local-user-1",
serviceTier: null,
input: [{ type: "text", text: "hello", text_elements: [] }],
},
});
transport.emitLine({ id: 3, result: { turn: { id: "turn-1" } } });
await startingTurn;
});
it("sends structured user input for turns and steering", async () => {
const { client, transport } = await connectedClient();
const input = [
{ type: "text" as const, text: "Read [[Alpha]].", text_elements: [] },
{ type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" },
{
type: "additionalContext" as const,
key: "codex_panel_wikilinks",
kind: "untrusted" as const,
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md",
},
];
const serializedInput = [
{ type: "text" as const, text: "Read [[Alpha]].", text_elements: [] },
{ type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" },
];
const additionalContext = {
codex_panel_wikilinks: {
kind: "untrusted",
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md",
},
};
const startingTurn = client.startTurn({ threadId: "thread-1", cwd: "/vault", input });
expect(transport.sent[2]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
input: serializedInput,
additionalContext,
},
});
transport.emitLine({ id: 2, result: { turn: { id: "turn-1" } } });
await startingTurn;
const steering = client.steerTurn("thread-1", "turn-1", input);
expect(transport.sent[3]).toMatchObject({
method: "turn/steer",
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input: serializedInput,
additionalContext,
},
});
transport.emitLine({ id: 3, result: { turnId: "turn-1" } });
await steering;
});
it("sends explicit null service tier when fast mode is disabled", async () => {
const { client, transport } = await connectedClient();
const startingThread = client.startThread({ cwd: "/vault", serviceTier: null });
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "thread/start",
params: { cwd: "/vault", serviceTier: null },
});
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, serviceTier: null } });
await startingThread;
});
it("sends collaboration mode only for Plan turns", async () => {
const { client, transport } = await connectedClient();
const defaultTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "default",
runtime: { serviceTier: null },
});
expect(transport.sent[2]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
input: [{ type: "text", text: "default", text_elements: [] }],
},
});
expect((transport.sent[2] as { params?: Record<string, unknown> }).params?.["collaborationMode"]).toBeUndefined();
transport.emitLine({ id: 2, result: { turn: { id: "turn-default" } } });
await defaultTurn;
const planTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "plan",
runtime: {
serviceTier: null,
collaborationMode: {
mode: "plan",
settings: {
model: "gpt-5.5",
reasoning_effort: "medium",
developer_instructions: null,
},
},
},
});
expect(transport.sent[3]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
input: [{ type: "text", text: "plan", text_elements: [] }],
collaborationMode: {
mode: "plan",
settings: {
model: "gpt-5.5",
reasoning_effort: "medium",
developer_instructions: null,
},
},
},
});
transport.emitLine({ id: 3, result: { turn: { id: "turn-plan" } } });
await planTurn;
});
it("sends model and effort turn overrides when provided", async () => {
const { client, transport } = await connectedClient();
const startingTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "override",
runtime: { serviceTier: null, model: "gpt-5.5", effort: "high" },
});
expect(transport.sent[2]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
model: "gpt-5.5",
effort: "high",
input: [{ type: "text", text: "override", text_elements: [] }],
},
});
transport.emitLine({ id: 2, result: { turn: { id: "turn-override" } } });
await startingTurn;
const resetTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "reset",
runtime: { serviceTier: null, model: null, effort: null, approvalsReviewer: null },
});
expect(transport.sent[3]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
model: null,
effort: null,
approvalsReviewer: null,
input: [{ type: "text", text: "reset", text_elements: [] }],
},
});
transport.emitLine({ id: 3, result: { turn: { id: "turn-reset" } } });
await resetTurn;
});
it("omits undefined runtime turn overrides while preserving explicit null resets", async () => {
const { client, transport } = await connectedClient();
const runtimeWithUndefined = {
serviceTier: null,
collaborationMode: undefined,
model: undefined,
effort: undefined,
approvalsReviewer: undefined,
} as unknown as NonNullable<AppServerStartTurnOptions["runtime"]>;
const turn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "runtime boundary",
runtime: runtimeWithUndefined,
});
const params = (transport.sent[2] as { params?: Record<string, unknown> }).params ?? {};
expect(params).toMatchObject({
threadId: "thread-1",
cwd: "/vault",
serviceTier: null,
input: [{ type: "text", text: "runtime boundary", text_elements: [] }],
});
expect(params).not.toHaveProperty("collaborationMode");
expect(params).not.toHaveProperty("model");
expect(params).not.toHaveProperty("effort");
expect(params).not.toHaveProperty("approvalsReviewer");
transport.emitLine({ id: 2, result: { turn: { id: "turn-runtime-boundary" } } });
await turn;
const structuredRuntimeWithUndefined = {
serviceTier: undefined,
collaborationMode: undefined,
model: undefined,
effort: undefined,
approvalsReviewer: undefined,
} as unknown as NonNullable<AppServerStartStructuredTurnOptions["runtime"]>;
const structuredTurn = client.startStructuredTurn({
threadId: "structured-thread",
cwd: "/vault",
text: "structured runtime boundary",
outputSchema: { type: "object", properties: {}, additionalProperties: false },
runtime: structuredRuntimeWithUndefined,
});
const structuredParams = (transport.sent[3] as { params?: Record<string, unknown> }).params ?? {};
expect(structuredParams).toMatchObject({
threadId: "structured-thread",
cwd: "/vault",
input: [{ type: "text", text: "structured runtime boundary", text_elements: [] }],
outputSchema: { type: "object", properties: {}, additionalProperties: false },
});
expect(structuredParams).not.toHaveProperty("serviceTier");
expect(structuredParams).not.toHaveProperty("collaborationMode");
expect(structuredParams).not.toHaveProperty("model");
expect(structuredParams).not.toHaveProperty("effort");
expect(structuredParams).not.toHaveProperty("approvalsReviewer");
transport.emitLine({ id: 3, result: { turn: { id: "turn-structured-runtime-boundary" } } });
await structuredTurn;
});
it("sends approval reviewer turn overrides when provided", async () => {
const { client, transport } = await connectedClient();
const autoReviewTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "review this",
runtime: { serviceTier: null, approvalsReviewer: "auto_review" },
});
expect(transport.sent[2]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
approvalsReviewer: "auto_review",
input: [{ type: "text", text: "review this", text_elements: [] }],
},
});
transport.emitLine({ id: 2, result: { turn: { id: "turn-auto-review" } } });
await autoReviewTurn;
const userReviewTurn = client.startTurn({
threadId: "thread-1",
cwd: "/vault",
input: "ask me",
runtime: { serviceTier: null, approvalsReviewer: "user" },
});
expect(transport.sent[3]).toMatchObject({
method: "turn/start",
params: {
threadId: "thread-1",
cwd: "/vault",
approvalsReviewer: "user",
input: [{ type: "text", text: "ask me", text_elements: [] }],
},
});
transport.emitLine({ id: 3, result: { turn: { id: "turn-user-review" } } });
await userReviewTurn;
});
it("sends thread settings updates for subsequent turns", async () => {
const { client, transport } = await connectedClient();
const update = client.updateThreadSettings("thread-1", {
model: "gpt-5.5",
effort: "high",
serviceTier: "fast",
approvalsReviewer: "auto_review",
collaborationMode: {
mode: "plan",
settings: {
model: "gpt-5.5",
reasoningEffort: "high",
developerInstructions: null,
},
},
});
expect(transport.sent[2]).toMatchObject({
method: "thread/settings/update",
params: {
threadId: "thread-1",
model: "gpt-5.5",
effort: "high",
serviceTier: "fast",
approvalsReviewer: "auto_review",
collaborationMode: {
mode: "plan",
settings: {
model: "gpt-5.5",
reasoning_effort: "high",
developer_instructions: null,
},
},
},
});
transport.emitLine({ id: 2, result: {} });
await update;
});
it("sends inventory and diagnostic requests with panel-specific query bounds", async () => {
const { client, transport } = await connectedClient();
await expectRequest(
transport,
client.readEffectiveConfig("/vault"),
{ method: "config/read", params: { cwd: "/vault", includeLayers: true } },
{ config: {}, origins: {}, layers: [] },
);
await expectRequest(
transport,
client.listModels(),
{ method: "model/list", params: { includeHidden: false, limit: 100 } },
{ data: [], nextCursor: null },
);
await expectRequest(
transport,
client.listThreads("/vault", { archived: true }),
{ method: "thread/list", params: { cwd: "/vault", archived: true, sortKey: "recency_at", sortDirection: "desc" } },
{ data: [], nextCursor: null },
);
await expectRequest(
transport,
client.listThreads("/vault", { archived: false, cursor: "cursor-1", limit: 100 }),
{
method: "thread/list",
params: { cwd: "/vault", cursor: "cursor-1", limit: 100, archived: false, sortKey: "recency_at", sortDirection: "desc" },
},
{ data: [], nextCursor: null },
);
await expectRequest(
transport,
client.listSkills("/vault"),
{ method: "skills/list", params: { cwds: ["/vault"], forceReload: false } },
{ data: [] },
);
expect((latestSent(transport) as { params?: Record<string, unknown> }).params?.["perCwdExtraUserRoots"]).toBeUndefined();
await expectRequest(
transport,
client.listSkills("/vault", true),
{ method: "skills/list", params: { cwds: ["/vault"], forceReload: true } },
{ data: [] },
);
await expectRequest(
transport,
client.listMcpServerStatus(),
{ method: "mcpServerStatus/list", params: { detail: "toolsAndAuthOnly", limit: 100 } },
{ data: [], nextCursor: null },
);
await expectRequest(transport, client.listCollaborationModes(), { method: "collaborationMode/list", params: {} }, { data: [] });
await expectRequest(
transport,
client.readModelProviderCapabilities(),
{ method: "modelProvider/capabilities/read", params: {} },
{ namespaceTools: true, imageGeneration: false, webSearch: true },
);
});
it("preserves app-server RPC error codes", async () => {
const { client, transport } = await connectedClient();
const listing = client.listModels();
const listing = listModels(client);
transport.emitLine({ id: 2, error: { code: -32601, message: "Method not found" } });
await expect(listing).rejects.toMatchObject({
@ -1056,152 +582,4 @@ describe("AppServerClient", () => {
message: "Method not found",
});
});
it("resumes and forks threads without loading full turn history", async () => {
const { client, transport } = await connectedClient();
const resuming = client.resumeThread("thread-1", "/vault");
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "thread/resume",
params: {
threadId: "thread-1",
cwd: "/vault",
excludeTurns: true,
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
},
});
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, cwd: "/vault" } });
await resuming;
const forking = client.forkThread("thread-1", "/vault");
expect(transport.sent[3]).toMatchObject({
id: 3,
method: "thread/fork",
params: { threadId: "thread-1", cwd: "/vault", excludeTurns: true },
});
transport.emitLine({ id: 3, result: { thread: { id: "thread-2", title: null }, cwd: "/vault" } });
await forking;
});
it("loads turn history pages in the requested direction with full items", async () => {
const { client, transport } = await connectedClient();
const turns = client.threadTurnsList("thread-1", "cursor-1", 10);
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
});
transport.emitLine({ id: 2, result: { data: [], nextCursor: null } });
await turns;
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
expect(transport.sent[3]).toMatchObject({
id: 3,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
});
transport.emitLine({ id: 3, result: { data: [], nextCursor: null } });
await firstTurn;
});
it("rolls back exactly one latest turn in thread history", async () => {
const { client, transport } = await connectedClient();
const rollback = client.rollbackThread("thread-1");
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "thread/rollback",
params: { threadId: "thread-1", numTurns: 1 },
});
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", turns: [] } } });
await rollback;
});
it("starts title generation in a read-only ephemeral thread", async () => {
const { client, transport } = await connectedClient();
const namingThread = client.startEphemeralThread({
cwd: "/vault",
serviceName: "naming",
developerInstructions: "Return a title.",
});
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "thread/start",
params: {
cwd: "/vault",
serviceName: "naming",
developerInstructions: "Return a title.",
ephemeral: true,
sandbox: "read-only",
approvalPolicy: "never",
multiAgentMode: "none",
environments: [],
},
});
transport.emitLine({ id: 2, result: { thread: { id: "naming-thread" } } });
await namingThread;
});
it("requests structured title output with optional naming runtime overrides", async () => {
const { client, transport } = await connectedClient();
const structuredTurn = client.startStructuredTurn({
threadId: "naming-thread",
cwd: "/vault",
text: "title please",
outputSchema: {
type: "object",
properties: { title: { type: "string" } },
required: ["title"],
},
runtime: {
serviceTier: null,
collaborationMode: {
mode: "plan",
settings: { model: "gpt-5.4-mini", reasoning_effort: "minimal", developer_instructions: null },
},
model: "gpt-5.4-mini",
effort: "minimal",
approvalsReviewer: null,
},
});
expect(transport.sent[2]).toMatchObject({
id: 2,
method: "turn/start",
params: {
threadId: "naming-thread",
cwd: "/vault",
serviceTier: null,
collaborationMode: {
mode: "plan",
settings: { model: "gpt-5.4-mini", reasoning_effort: "minimal", developer_instructions: null },
},
model: "gpt-5.4-mini",
effort: "minimal",
approvalsReviewer: null,
input: [{ type: "text", text: "title please", text_elements: [] }],
outputSchema: {
type: "object",
properties: { title: { type: "string" } },
required: ["title"],
},
},
});
transport.emitLine({ id: 2, result: { turn: { id: "naming-turn" } } });
await structuredTurn;
});
it("saves the user-confirmed thread title through app-server metadata", async () => {
const { client, transport } = await connectedClient();
await expectRequest(
transport,
client.setThreadName("thread-1", "Codex Panel自動命名"),
{ method: "thread/name/set", params: { threadId: "thread-1", name: "Codex Panel自動命名" } },
{},
);
});
});

View file

@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import {
appServerHookOperationFromHookItem,
hookItemsFromCatalogHooks,
@ -7,6 +6,7 @@ import {
skillMetadataFromCatalogSkills,
} from "../../src/app-server/protocol/catalog";
import { listHookCatalog, listSkillCatalog } from "../../src/app-server/services/catalog";
import type { AppServerRequestClient } from "../../src/app-server/services/request-client";
import type { HookMetadata } from "../../src/generated/app-server/v2/HookMetadata";
import type { Model } from "../../src/generated/app-server/v2/Model";
import type { SkillMetadata } from "../../src/generated/app-server/v2/SkillMetadata";
@ -82,7 +82,7 @@ describe("app-server catalog mappers", () => {
describe("app-server catalog adapters", () => {
it("returns enabled skill options while preserving total app-server skill count", async () => {
const client = {
listSkills: vi.fn().mockResolvedValue({
request: vi.fn(async () => ({
data: [
{
cwd: "/vault",
@ -92,38 +92,40 @@ describe("app-server catalog adapters", () => {
],
},
],
}),
} as unknown as AppServerClient;
})),
} as unknown as AppServerRequestClient;
await expect(listSkillCatalog(client, "/vault")).resolves.toMatchObject({
skills: [{ name: "enabled", description: "Enabled skill", path: "/skills/enabled", enabled: true }],
totalCount: 2,
});
expect(client.request).toHaveBeenCalledWith("skills/list", { cwds: ["/vault"], forceReload: false });
});
it("uses only hook rows for the requested cwd", async () => {
const client = {
listHooks: vi.fn().mockResolvedValue({
request: vi.fn(async () => ({
data: [
{ cwd: "/other", hooks: [{ key: "other" }], warnings: ["skip"], errors: [{ message: "skip" }] },
{ cwd: "/vault", hooks: [{ key: "vault" }], warnings: ["warn"], errors: [{ message: "err" }] },
],
}),
} as unknown as AppServerClient;
})),
} as unknown as AppServerRequestClient;
await expect(listHookCatalog(client, "/vault")).resolves.toMatchObject({
hooks: [{ key: "vault" }],
warnings: ["warn"],
errors: ['{"message":"err"}'],
});
expect(client.request).toHaveBeenCalledWith("hooks/list", { cwds: ["/vault"] });
});
it("does not fall back to unrelated hook rows", async () => {
const client = {
listHooks: vi.fn().mockResolvedValue({
request: vi.fn().mockResolvedValue({
data: [{ cwd: "/other", hooks: [{ key: "other" }], warnings: ["skip"], errors: [{ message: "skip" }] }],
}),
} as unknown as AppServerClient;
} as unknown as AppServerRequestClient;
await expect(listHookCatalog(client, "/vault")).resolves.toEqual({
hooks: [],

View file

@ -1,26 +1,26 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import type {
AppServerClientHandlers,
AppServerStartEphemeralThreadOptions,
AppServerStartStructuredTurnOptions,
} from "../../src/app-server/connection/client";
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../src/app-server/connection/client";
import type { ClientRequestParams } from "../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../src/app-server/protocol/turn";
import {
type EphemeralStructuredTurnClient,
type EphemeralStructuredTurnClientFactory,
runEphemeralStructuredTurn,
} from "../../src/app-server/services/ephemeral-structured-turn";
import type { AppServerStartEphemeralThreadOptions } from "../../src/app-server/services/threads";
import type { AppServerStartStructuredTurnOptions } from "../../src/app-server/services/turns";
import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse";
import type { RequestId } from "../../src/generated/app-server/RequestId";
import type { ServerNotification } from "../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
import type { Thread as AppServerThread } from "../../src/generated/app-server/v2/Thread";
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
type TurnStartResponse = Awaited<ReturnType<EphemeralStructuredTurnClient["startStructuredTurn"]>>;
interface TurnStartResponse {
turn: TurnRecord;
}
describe("runEphemeralStructuredTurn", () => {
it("fills completed turn items from item completion notifications", async () => {
@ -156,7 +156,7 @@ describe("runEphemeralStructuredTurn", () => {
it("rejects server requests with the configured message", async () => {
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
fake.connectImpl = async () => {
fake.request(serverRequest(123));
fake.emitServerRequest(serverRequest(123));
return { codexHome: "/tmp/codex" } as InitializeResponse;
};
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
@ -186,13 +186,13 @@ describe("runEphemeralStructuredTurn", () => {
resolveRuntime: async (runtimeClient) => {
callOrder.push("resolve-runtime");
expect(runtimeClient).toBe(expectPresent(client.current));
await runtimeClient.listModels(false);
await runtimeClient.request("model/list", { includeHidden: false, limit: 100 });
return { model: "gpt-5.1", effort: "low" };
},
});
expect(callOrder).toEqual(["resolve-runtime", "start-thread"]);
expect(expectPresent(client.current).listModels).toHaveBeenCalledWith(false);
expect(expectPresent(client.current).modelListRequests).toEqual([{ includeHidden: false, limit: 100 }]);
expect(expectPresent(client.current).startStructuredTurnOptions).toEqual({
threadId: "thread",
cwd: "/vault",
@ -316,9 +316,9 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
startEphemeralThreadOptions: AppServerStartEphemeralThreadOptions | null = null;
startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
readonly listModels = vi.fn(async (): Promise<ModelListResponse> => ({ data: [], nextCursor: null }));
readonly modelListRequests: ClientRequestParams<"model/list">[] = [];
readonly rejectServerRequest = vi.fn();
readonly deleteThread = vi.fn(async () => undefined);
readonly deleteThread = vi.fn(async (_threadId: string, _options?: { timeoutMs?: number }) => undefined);
readonly disconnect = vi.fn();
readonly structuredTurnStarted: Promise<void>;
private resolveStructuredTurnStarted!: () => void;
@ -333,22 +333,39 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
return this.connectImpl ? this.connectImpl() : ({ codexHome: "/tmp/codex" } as InitializeResponse);
}
async startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<ThreadStartResponse> {
this.startEphemeralThreadOptions = options;
return this.startEphemeralThreadImpl ? this.startEphemeralThreadImpl() : threadStartResponse("thread");
}
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
this.startStructuredTurnOptions = options;
this.resolveStructuredTurnStarted();
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
async request<M extends TypedClientRequestMethod>(
method: M,
params: ClientRequestParams<M>,
options: { timeoutMs?: number } = {},
): Promise<ClientResponseByMethod[M]> {
switch (method) {
case "model/list":
this.modelListRequests.push(params as ClientRequestParams<"model/list">);
return { data: [], nextCursor: null } as unknown as ClientResponseByMethod[M];
case "thread/start":
this.startEphemeralThreadOptions = ephemeralThreadOptionsFromParams(params as ClientRequestParams<"thread/start">);
return (this.startEphemeralThreadImpl
? await this.startEphemeralThreadImpl()
: threadStartResponse("thread")) as unknown as ClientResponseByMethod[M];
case "turn/start":
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
this.resolveStructuredTurnStarted();
return (this.startStructuredTurnImpl
? await this.startStructuredTurnImpl()
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
case "thread/delete":
await this.deleteThread((params as ClientRequestParams<"thread/delete">).threadId, options);
return {} as unknown as ClientResponseByMethod[M];
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}
emit(notification: ServerNotification): void {
this.handlers.onNotification(notification);
}
request(request: ServerRequest): void {
emitServerRequest(request: ServerRequest): void {
this.handlers.onServerRequest(request, {
respond: () => undefined,
reject: (code, message) => {
@ -358,6 +375,39 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
}
}
function ephemeralThreadOptionsFromParams(params: ClientRequestParams<"thread/start">): AppServerStartEphemeralThreadOptions {
if (!params.cwd || !params.serviceName || !params.developerInstructions) throw new Error("Expected ephemeral thread params.");
return {
cwd: params.cwd,
serviceName: params.serviceName,
developerInstructions: params.developerInstructions,
};
}
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
const textItem = params.input[0];
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
const runtime = structuredTurnRuntimeFromParams(params);
return {
threadId: params.threadId,
cwd: params.cwd,
text: textItem.text,
outputSchema: params.outputSchema,
...(runtime !== undefined ? { runtime } : {}),
};
}
function structuredTurnRuntimeFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions["runtime"] {
const runtime = {
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
...(params.collaborationMode !== undefined && params.collaborationMode !== null ? { collaborationMode: params.collaborationMode } : {}),
...(params.model !== undefined ? { model: params.model } : {}),
...(params.effort !== undefined ? { effort: params.effort } : {}),
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
};
return Object.keys(runtime).length > 0 ? runtime : undefined;
}
function timerHarness(): {
setTimeout: ReturnType<typeof vi.fn<(callback: () => void, delayMs: number) => number>>;
clearTimeout: ReturnType<typeof vi.fn<(timer: number) => void>>;

View file

@ -285,10 +285,13 @@ function cacheWithThreads(
clientRunner: {
runWithClient: async (context, operation) => {
return operation({
listThreads: async (_cwd: string, options: { archived?: boolean }) => ({
data: await fetchThreads(context, options.archived ?? false),
nextCursor: null,
}),
request: async (method: string, params: { archived?: boolean }) => {
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
return {
data: await fetchThreads(context, params.archived ?? false),
nextCursor: null,
};
},
} as never);
},
},
@ -296,13 +299,44 @@ function cacheWithThreads(
}
function cacheWithClient(client: Record<string, unknown>): AppServerQueryCache {
const requestClient = "request" in client ? client : clientWithRequest(client);
return new AppServerQueryCache({
clientRunner: {
runWithClient: async (_context, operation) => operation(client as never),
runWithClient: async (_context, operation) => operation(requestClient as never),
},
});
}
function clientWithRequest(client: Record<string, unknown>): Record<string, unknown> {
return {
...client,
request: async (method: string, params: unknown) => {
switch (method) {
case "thread/list":
return (client["listThreads"] as (cwd: string, options: { archived?: boolean }) => Promise<unknown>)(
(params as { cwd: string }).cwd,
params as { archived?: boolean },
);
case "config/read":
return (client["readEffectiveConfig"] as (cwd: string) => Promise<unknown>)((params as { cwd: string }).cwd);
case "model/list":
return (client["listModels"] as (includeHidden: boolean) => Promise<unknown>)(
(params as { includeHidden?: boolean }).includeHidden ?? false,
);
case "skills/list":
return (client["listSkills"] as (cwd: string, forceReload?: boolean) => Promise<unknown>)(
(params as { cwds: string[] }).cwds[0] ?? "",
(params as { forceReload?: boolean }).forceReload,
);
case "account/rateLimits/read":
return (client["readAccountRateLimits"] as () => Promise<unknown>)();
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
},
};
}
function metadata(
overrides: {
availableModels?: readonly ModelMetadata[];

View file

@ -57,9 +57,16 @@ function fakeClient(): AppServerClient & {
readThread: ReturnType<typeof vi.fn>;
archiveThread: ReturnType<typeof vi.fn>;
} {
const readThread = vi.fn().mockResolvedValue({ thread: archivedThread() });
const archiveThread = vi.fn().mockResolvedValue({});
return {
readThread: vi.fn().mockResolvedValue({ thread: archivedThread() }),
archiveThread: vi.fn().mockResolvedValue({}),
readThread,
archiveThread,
request: vi.fn((method: string, params: { threadId: string; includeTurns?: boolean }) => {
if (method === "thread/read") return readThread(params.threadId, params.includeTurns);
if (method === "thread/archive") return archiveThread(params.threadId);
throw new Error(`Unexpected app-server request: ${method}`);
}),
} as unknown as AppServerClient & {
readThread: ReturnType<typeof vi.fn>;
archiveThread: ReturnType<typeof vi.fn>;

View file

@ -396,10 +396,13 @@ function cacheWithThreads(
clientRunner: {
runWithClient: async (context, operation) => {
return operation({
listThreads: async (_cwd: string, options: { archived?: boolean } = {}) => ({
data: await fetchThreads(context, options.archived ?? false),
nextCursor: null,
}),
request: async (method: string, params: { archived?: boolean } = {}) => {
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
return {
data: await fetchThreads(context, params.archived ?? false),
nextCursor: null,
};
},
} as never);
},
},

View file

@ -2,14 +2,15 @@
import { describe, expect, it } from "vitest";
import type { AppServerClient, AppServerClientHandlers, AppServerStartStructuredTurnOptions } from "../../src/app-server/connection/client";
import type { RequestId, ServerNotification } from "../../src/app-server/connection/rpc-messages";
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../src/app-server/connection/client";
import type { ClientRequestParams, RequestId, ServerNotification } from "../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../src/app-server/protocol/turn";
import type {
EphemeralStructuredTurnClient,
EphemeralStructuredTurnClientFactory,
} from "../../src/app-server/services/ephemeral-structured-turn";
import { generateThreadTitleWithCodex } from "../../src/app-server/services/thread-title-generation";
import type { AppServerStartStructuredTurnOptions } from "../../src/app-server/services/turns";
import type { ServerInitialization } from "../../src/domain/server/initialization";
import {
findThreadTitleContext,
@ -18,12 +19,14 @@ import {
threadTitleFromGeneratedText,
threadTitlePrompt,
} from "../../src/domain/threads/title-generation-model";
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;
type TurnStartResponse = Awaited<ReturnType<EphemeralStructuredTurnClient["startStructuredTurn"]>>;
interface TurnStartResponse {
turn: TurnRecord;
}
describe("thread title", () => {
it("builds title context from a conversation summary", () => {
@ -244,19 +247,30 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
async deleteThread(): Promise<void> {}
async listModels(): Promise<ModelListResponse> {
return { data: this.modelList, nextCursor: null };
}
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {}
async startEphemeralThread(): Promise<ThreadStartResponse> {
return threadStartResponse("thread");
}
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
this.startStructuredTurnOptions = options;
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
async request<M extends TypedClientRequestMethod>(
method: M,
params: ClientRequestParams<M>,
options: { timeoutMs?: number } = {},
): Promise<ClientResponseByMethod[M]> {
void options;
switch (method) {
case "model/list":
return { data: this.modelList, nextCursor: null } as unknown as ClientResponseByMethod[M];
case "thread/start":
return threadStartResponse("thread") as unknown as ClientResponseByMethod[M];
case "turn/start":
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
return (this.startStructuredTurnImpl
? await this.startStructuredTurnImpl()
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
case "thread/delete":
await this.deleteThread();
return {} as unknown as ClientResponseByMethod[M];
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}
emit(notification: ServerNotification): void {
@ -264,6 +278,26 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
}
}
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
const textItem = params.input[0];
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
return {
threadId: params.threadId,
cwd: params.cwd,
text: textItem.text,
outputSchema: params.outputSchema,
runtime: {
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
...(params.collaborationMode !== undefined && params.collaborationMode !== null
? { collaborationMode: params.collaborationMode }
: {}),
...(params.model !== undefined ? { model: params.model } : {}),
...(params.effort !== undefined ? { effort: params.effort } : {}),
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
},
};
}
function threadStartResponse(threadId: string): ThreadStartResponse {
return {
thread: threadFixture(threadId),

View file

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import type { AppServerRequestClient } from "../../src/app-server/services/request-client";
import { listThreads } from "../../src/app-server/services/threads";
describe("app-server thread response adapters", () => {
@ -9,21 +9,27 @@ describe("app-server thread response adapters", () => {
data: [{ id: "thread-1", preview: "Preview", name: null, createdAt: 10, updatedAt: 20 }],
});
const client = {
listThreads: clientListThreads,
} as unknown as AppServerClient;
request: clientListThreads,
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault", { archived: true })).resolves.toEqual([
{ id: "thread-1", preview: "Preview", name: null, archived: true, createdAt: 10, updatedAt: 20 },
]);
expect(clientListThreads).toHaveBeenCalledWith("/vault", { archived: true, cursor: null, limit: 100 });
expect(clientListThreads).toHaveBeenCalledWith("thread/list", {
cwd: "/vault",
archived: true,
limit: 100,
sortKey: "recency_at",
sortDirection: "desc",
});
});
it("preserves app-server recency timestamps when available", async () => {
const client = {
listThreads: vi.fn().mockResolvedValue({
request: vi.fn().mockResolvedValue({
data: [{ id: "thread-1", preview: "Preview", name: null, createdAt: 10, updatedAt: 20, recencyAt: 30 }],
}),
} as unknown as AppServerClient;
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).resolves.toEqual([
{ id: "thread-1", preview: "Preview", name: null, archived: false, createdAt: 10, updatedAt: 20, recencyAt: 30 },
@ -32,10 +38,10 @@ describe("app-server thread response adapters", () => {
it("preserves nullable app-server recency timestamps", async () => {
const client = {
listThreads: vi.fn().mockResolvedValue({
request: vi.fn().mockResolvedValue({
data: [{ id: "thread-1", preview: "Preview", name: null, createdAt: 10, updatedAt: 20, recencyAt: null }],
}),
} as unknown as AppServerClient;
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).resolves.toEqual([
{ id: "thread-1", preview: "Preview", name: null, archived: false, createdAt: 10, updatedAt: 20, recencyAt: null },
@ -54,24 +60,37 @@ describe("app-server thread response adapters", () => {
nextCursor: null,
});
const client = {
listThreads: clientListThreads,
} as unknown as AppServerClient;
request: clientListThreads,
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).resolves.toEqual([
{ id: "thread-1", preview: "First", name: null, archived: false, createdAt: 10, updatedAt: 20 },
{ id: "thread-2", preview: "Second", name: null, archived: false, createdAt: 30, updatedAt: 40 },
]);
expect(clientListThreads).toHaveBeenNthCalledWith(1, "/vault", { archived: false, cursor: null, limit: 100 });
expect(clientListThreads).toHaveBeenNthCalledWith(2, "/vault", { archived: false, cursor: "next", limit: 100 });
expect(clientListThreads).toHaveBeenNthCalledWith(1, "thread/list", {
cwd: "/vault",
archived: false,
limit: 100,
sortKey: "recency_at",
sortDirection: "desc",
});
expect(clientListThreads).toHaveBeenNthCalledWith(2, "thread/list", {
cwd: "/vault",
cursor: "next",
archived: false,
limit: 100,
sortKey: "recency_at",
sortDirection: "desc",
});
});
it("rejects repeated thread list cursors", async () => {
const client = {
listThreads: vi.fn().mockResolvedValue({
request: vi.fn().mockResolvedValue({
data: [],
nextCursor: "same",
}),
} as unknown as AppServerClient;
} as unknown as AppServerRequestClient;
await expect(listThreads(client, "/vault")).rejects.toThrow("repeated thread list cursor");
});

View file

@ -1,11 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import type { AppServerRequestClient } from "../../src/app-server/services/request-client";
import { readToolInventory } from "../../src/app-server/services/tool-inventory";
import type { PluginReadParams } from "../../src/generated/app-server/v2/PluginReadParams";
describe("tool inventory", () => {
it("reads plugin details with exactly one marketplace locator", async () => {
const readPlugin = vi.fn(async (params: Parameters<AppServerClient["readPlugin"]>[0]) => ({
const readPlugin = vi.fn(async (params: PluginReadParams) => ({
plugin: {
skills: params.pluginName === "local-plugin" ? [{ name: "local-skill" }] : [],
hooks: [],
@ -14,8 +15,7 @@ describe("tool inventory", () => {
mcpServers: params.pluginName === "remote-plugin" ? ["remote-server"] : [],
},
}));
const client = {
listApps: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
const client = toolInventoryClient({
listInstalledPlugins: vi.fn().mockResolvedValue({
marketplaces: [
{
@ -54,9 +54,7 @@ describe("tool inventory", () => {
marketplaceLoadErrors: [],
}),
readPlugin,
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
} as unknown as AppServerClient;
});
const result = await readToolInventory(client, "/vault");
@ -78,14 +76,11 @@ describe("tool inventory", () => {
});
it("skips app catalog loading during diagnostics", async () => {
const listApps = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
const client = toolInventoryClient({
listApps,
});
const client = toolInventoryClient();
const result = await readToolInventory(client, "/vault");
expect(listApps).not.toHaveBeenCalled();
expect(client.request).not.toHaveBeenCalledWith("app/list", expect.anything());
expect(result.probes.some((probe) => probe.method === "app/list")).toBe(false);
});
@ -93,7 +88,7 @@ describe("tool inventory", () => {
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]) => {
const readPlugin = vi.fn(async (params: PluginReadParams) => {
activeReads += 1;
maxActiveReads = Math.max(maxActiveReads, activeReads);
await Promise.resolve();
@ -126,27 +121,38 @@ describe("tool inventory", () => {
function toolInventoryClient(
overrides: Partial<{
listApps: ReturnType<typeof vi.fn>;
listInstalledPlugins: ReturnType<typeof vi.fn>;
readPlugin: ReturnType<typeof vi.fn>;
}> = {},
): AppServerClient {
): AppServerRequestClient & { request: ReturnType<typeof vi.fn> } {
const listInstalledPlugins =
overrides.listInstalledPlugins ??
vi.fn().mockResolvedValue({
marketplaces: [],
marketplaceLoadErrors: [],
});
const readPlugin =
overrides.readPlugin ??
vi.fn().mockResolvedValue({
plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] },
});
const request = vi.fn(async (method: string, params: unknown) => {
switch (method) {
case "plugin/installed":
return (listInstalledPlugins as unknown as (params: unknown) => Promise<unknown>)(params);
case "plugin/read":
return (readPlugin as unknown as (params: unknown) => Promise<unknown>)(params);
case "mcpServerStatus/list":
return { data: [] };
case "skills/list":
return { data: [{ cwd: "/vault", skills: [] }] };
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
});
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;
request,
} as unknown as AppServerRequestClient & { request: ReturnType<typeof vi.fn> };
}
function installedPlugin(name: string): {

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
import type { AppServerClient, ClientResponseByMethod } from "../../../../../src/app-server/connection/client";
import type { ClientRequestParams } from "../../../../../src/app-server/connection/rpc-messages";
import {
type CatalogModel,
type CatalogSkillMetadata,
@ -26,7 +27,7 @@ import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/ap
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import { chatStateFixture, chatStateWith } from "../../support/state";
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startThread"]>>;
type ThreadStartResponse = ClientResponseByMethod["thread/start"];
describe("chat app-server actions", () => {
it("publishes newly started threads before the first turn completes", async () => {
@ -39,8 +40,8 @@ describe("chat app-server actions", () => {
const existingThread = threadFromThreadRecord(existing);
const applyThreadCatalogEvent = vi.fn();
const syncThreadGoal = vi.fn();
const client = {
startThread: vi.fn().mockResolvedValue({
const client = startThreadClient(
vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
@ -48,7 +49,7 @@ describe("chat app-server actions", () => {
approvalsReviewer: null,
reasoningEffort: null,
}),
} as unknown as AppServerClient;
);
const controller = createChatServerThreadActions({
stateStore,
@ -82,9 +83,7 @@ describe("chat app-server actions", () => {
approvalsReviewer: null,
reasoningEffort: null,
});
const client = {
startThread,
} as unknown as AppServerClient;
const client = startThreadClient(startThread);
const controller = createChatServerThreadActions({
stateStore,
@ -110,8 +109,8 @@ describe("chat app-server actions", () => {
const stateStore = createChatStateStore(chatStateFixture());
const started = threadFixture("started");
const syncThreadGoal = vi.fn();
const client = {
startThread: vi.fn().mockResolvedValue({
const client = startThreadClient(
vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
@ -119,7 +118,7 @@ describe("chat app-server actions", () => {
approvalsReviewer: null,
reasoningEffort: null,
}),
} as unknown as AppServerClient;
);
const controller = createChatServerThreadActions({
stateStore,
@ -148,9 +147,7 @@ describe("chat app-server actions", () => {
approvalsReviewer: null,
reasoningEffort: null,
});
const client = {
startThread,
} as unknown as AppServerClient;
const client = startThreadClient(startThread);
const controller = createChatServerThreadActions({
stateStore,
@ -170,8 +167,8 @@ describe("chat app-server actions", () => {
const stateStore = createChatStateStore(chatStateFixture());
const started = threadFixture("started", { preview: "server preview" });
const applyThreadCatalogEvent = vi.fn();
const client = {
startThread: vi.fn().mockResolvedValue({
const client = startThreadClient(
vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
@ -179,7 +176,7 @@ describe("chat app-server actions", () => {
approvalsReviewer: null,
reasoningEffort: null,
}),
} as unknown as AppServerClient;
);
const controller = createChatServerThreadActions({
stateStore,
@ -197,10 +194,8 @@ describe("chat app-server actions", () => {
it("does not apply newly started threads after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const start = deferred<Awaited<ReturnType<AppServerClient["startThread"]>>>();
const firstClient = {
startThread: vi.fn().mockReturnValue(start.promise),
} as unknown as AppServerClient;
const start = deferred<ThreadStartResponse>();
const firstClient = startThreadClient(vi.fn().mockReturnValue(start.promise));
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const applyThreadCatalogEvent = vi.fn();
@ -257,9 +252,9 @@ describe("chat app-server actions", () => {
),
});
const refreshAppServerMetadata = vi.fn<() => Promise<SharedServerMetadata | null>>().mockResolvedValue(refreshedMetadata);
const client = {
const client = legacyClient({
listMcpServerStatus,
} as unknown as AppServerClient;
});
const metadataCache = metadataCacheHost({ current: null });
const metadata = createChatServerMetadataActions({
@ -329,12 +324,12 @@ describe("chat app-server actions", () => {
const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] });
const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null });
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] });
const client = {
const client = legacyClient({
listModels,
listSkills,
readAccountRateLimits,
listMcpServerStatus,
} as unknown as AppServerClient;
});
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
@ -359,12 +354,12 @@ describe("chat app-server actions", () => {
const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] });
const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] });
const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null });
const client = {
const client = legacyClient({
listModels,
listSkills,
readAccountRateLimits,
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
} as unknown as AppServerClient;
});
const diagnostics = createChatServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
@ -375,7 +370,7 @@ describe("chat app-server actions", () => {
await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true });
expect(listModels).toHaveBeenCalledWith(false);
expect(listSkills).toHaveBeenCalledWith("/vault");
expect(listSkills).toHaveBeenCalledWith("/vault", false);
expect(readAccountRateLimits).toHaveBeenCalledOnce();
expect(stateStore.getState().connection.serverDiagnostics.probes["model/list"]).toMatchObject({
status: "ok",
@ -387,9 +382,9 @@ describe("chat app-server actions", () => {
const stateStore = createChatStateStore(chatStateFixture());
const mcpStatusRefresh = deferred<{ data: ReturnType<typeof mcpServerStatus>[] }>();
const listMcpServerStatus = vi.fn().mockReturnValue(mcpStatusRefresh.promise);
const firstClient = {
const firstClient = legacyClient({
listMcpServerStatus,
} as unknown as AppServerClient;
});
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const updateAppServerMetadata = vi.fn(() => null);
@ -477,7 +472,7 @@ describe("chat app-server actions", () => {
const stateStore = createChatStateStore(chatStateFixture());
const skillRefresh = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>();
const listSkills = vi.fn().mockReturnValue(skillRefresh.promise);
const firstClient = { listSkills } as unknown as AppServerClient;
const firstClient = legacyClient({ listSkills });
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const updateAppServerMetadata = vi.fn(() => null);
@ -506,9 +501,9 @@ describe("chat app-server actions", () => {
const stateStore = createChatStateStore(state);
const rateLimit = rateLimitFixture({ primary: { usedPercent: 64, windowDurationMins: 300, resetsAt: null } });
const cachedMetadata = { current: serverMetadataFixture() as SharedServerMetadata | null };
const client = {
const client = legacyClient({
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: rateLimit, rateLimitsByLimitId: null }),
} as unknown as AppServerClient;
});
const controller = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
@ -531,9 +526,9 @@ describe("chat app-server actions", () => {
});
state = chatStateWith(state, { connection: { rateLimit: previousRateLimit } });
const stateStore = createChatStateStore(state);
const client = {
const client = legacyClient({
readAccountRateLimits: vi.fn().mockRejectedValue(new Error("offline")),
} as unknown as AppServerClient;
});
const controller = createChatServerMetadataActions({
stateStore,
vaultPath: "/vault",
@ -551,9 +546,9 @@ describe("chat app-server actions", () => {
it("does not apply or publish sparse rate limit refreshes after the client changes", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const rateLimitRefresh = deferred<{ rateLimits: RateLimitSnapshot; rateLimitsByLimitId: null }>();
const firstClient = {
const firstClient = legacyClient({
readAccountRateLimits: vi.fn().mockReturnValue(rateLimitRefresh.promise),
} as unknown as AppServerClient;
});
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const updateAppServerMetadata = vi.fn(() => null);
@ -584,12 +579,12 @@ describe("chat app-server actions", () => {
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
const stateStore = createChatStateStore(state);
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [mcpServerStatus()] });
const client = {
const client = legacyClient({
listApps: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
listInstalledPlugins: vi.fn().mockResolvedValue({ marketplaces: [], marketplaceLoadErrors: [] }),
listMcpServerStatus,
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
} as unknown as AppServerClient;
});
const metadataCache = metadataCacheHost({ current: serverMetadataFixture() });
const metadata = createChatServerMetadataActions({
stateStore,
@ -627,6 +622,58 @@ describe("chat app-server actions", () => {
});
});
function legacyClient(methods: Record<string, unknown>): AppServerClient {
return {
...methods,
request: vi.fn((method: string, params: unknown) => {
switch (method) {
case "model/list":
return (methods["listModels"] as (includeHidden: boolean) => Promise<unknown>)(
(params as { includeHidden?: boolean }).includeHidden ?? false,
);
case "skills/list":
if (!methods["listSkills"]) return Promise.resolve({ data: [{ cwd: (params as { cwds: string[] }).cwds[0] ?? "", skills: [] }] });
return (methods["listSkills"] as (cwd: string, forceReload?: boolean) => Promise<unknown>)(
(params as { cwds: string[] }).cwds[0] ?? "",
(params as { forceReload?: boolean }).forceReload,
);
case "account/rateLimits/read":
return (methods["readAccountRateLimits"] as () => Promise<unknown>)();
case "mcpServerStatus/list":
return (methods["listMcpServerStatus"] as (params: unknown) => Promise<unknown>)(params);
case "plugin/installed":
if (!methods["listInstalledPlugins"]) return Promise.resolve({ marketplaces: [], marketplaceLoadErrors: [] });
return (methods["listInstalledPlugins"] as (cwd: string) => Promise<unknown>)((params as { cwds: string[] }).cwds[0] ?? "");
case "plugin/read":
if (!methods["readPlugin"]) {
return Promise.resolve({ plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] } });
}
return (methods["readPlugin"] as (params: unknown) => Promise<unknown>)(params);
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}),
} as unknown as AppServerClient;
}
function startThreadClient(startThread: ReturnType<typeof vi.fn>): AppServerClient {
return {
request: vi.fn((method: string, params: ClientRequestParams<"thread/start">) => {
if (method !== "thread/start") throw new Error(`Unexpected app-server request: ${method}`);
if (!params.cwd) throw new Error("Expected thread/start cwd.");
return (
startThread as unknown as (options: {
cwd: string;
serviceTier?: ClientRequestParams<"thread/start">["serviceTier"];
}) => Promise<ThreadStartResponse>
)({
cwd: params.cwd,
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
});
}),
} as unknown as AppServerClient;
}
function threadFixture(id: string, overrides: Partial<ThreadStartResponse["thread"]> = {}): ThreadStartResponse["thread"] {
return {
id,

View file

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { AppServerClient, ClientResponseByMethod } from "../../../../src/app-server/connection/client";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import type { CodexInput } from "../../../../src/domain/chat/input";
@ -19,8 +19,8 @@ const textInput = (text: string): CodexInput => [{ type: "text", text }];
describe("chat app-server transports", () => {
it("starts turns with the session vault path and returns chat-owned turn ids", async () => {
const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });
const client = { startTurn } as unknown as AppServerClient;
const request = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });
const client = { request } as unknown as AppServerClient;
const transport = createChatTurnTransport({
vaultPath: "/vault",
currentClient: () => client,
@ -35,17 +35,17 @@ describe("chat app-server transports", () => {
}),
).resolves.toEqual({ turnId: "turn-1" });
expect(startTurn).toHaveBeenCalledWith({
expect(request).toHaveBeenCalledWith("turn/start", {
threadId: "thread",
cwd: "/vault",
input: textInput("hello"),
input: [{ type: "text", text: "hello", text_elements: [] }],
clientUserMessageId: "local-user",
});
});
it("drops stale turn transport responses after the current client changes", async () => {
const start = deferred<{ turn: { id: string } }>();
const firstClient = { startTurn: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient;
const firstClient = { request: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatTurnTransport({
@ -62,8 +62,8 @@ describe("chat app-server transports", () => {
});
it("compacts threads through a connected app-server client", async () => {
const compactThread = vi.fn().mockResolvedValue({});
const client = { compactThread } as unknown as AppServerClient;
const request = vi.fn().mockResolvedValue({});
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => client,
@ -72,12 +72,12 @@ describe("chat app-server transports", () => {
await expect(transport.compactThread("thread")).resolves.toBe(true);
expect(compactThread).toHaveBeenCalledWith("thread");
expect(request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread" });
});
it("forks threads with the session vault path and returns panel threads", async () => {
const forkThread = vi.fn().mockResolvedValue({ thread: threadRecord("forked") });
const client = { forkThread } as unknown as AppServerClient;
const request = vi.fn().mockResolvedValue({ thread: threadRecord("forked") });
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => client,
@ -86,13 +86,13 @@ describe("chat app-server transports", () => {
const thread = await transport.forkThread("source");
expect(forkThread).toHaveBeenCalledWith("source", "/vault");
expect(request).toHaveBeenCalledWith("thread/fork", { threadId: "source", cwd: "/vault", excludeTurns: true });
expect(thread).toMatchObject({ id: "forked", preview: "Preview", archived: false });
});
it("drops stale fork transport responses after the current client changes", async () => {
const fork = deferred<{ thread: ThreadRecord }>();
const firstClient = { forkThread: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient;
const firstClient = { request: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadMutationTransport({
@ -109,8 +109,8 @@ describe("chat app-server transports", () => {
});
it("projects rollback turns into message stream items", async () => {
const rollbackThread = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) });
const client = { rollbackThread } as unknown as AppServerClient;
const request = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) });
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadMutationTransport({
vaultPath: "/vault",
currentClient: () => client,
@ -119,25 +119,31 @@ describe("chat app-server transports", () => {
const snapshot = await transport.rollbackThread("thread");
expect(rollbackThread).toHaveBeenCalledWith("thread");
expect(request).toHaveBeenCalledWith("thread/rollback", { threadId: "thread", numTurns: 1 });
expect(snapshot?.thread.id).toBe("thread");
expect(snapshot?.cwd).toBe("/vault");
expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "message", role: "user", text: "prompt" })]);
});
it("reads thread history pages as message stream items", async () => {
const threadTurnsList = vi.fn().mockResolvedValue({
const request = vi.fn().mockResolvedValue({
data: [turn([userMessage("u1", "prompt"), agentMessage("a1", "answer")])],
nextCursor: "older",
});
const client = { threadTurnsList } as unknown as AppServerClient;
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadHistoryTransport({
currentClient: () => client,
});
const page = await transport.readHistoryPage("thread", "cursor", 20);
expect(threadTurnsList).toHaveBeenCalledWith("thread", "cursor", 20);
expect(request).toHaveBeenCalledWith("thread/turns/list", {
threadId: "thread",
cursor: "cursor",
limit: 20,
sortDirection: "desc",
itemsView: "full",
});
expect(page?.nextCursor).toBe("older");
expect(page?.hadTurns).toBe(true);
expect(page?.items).toEqual([
@ -148,7 +154,7 @@ describe("chat app-server transports", () => {
it("drops stale history transport responses after the current client changes", async () => {
const history = deferred<{ data: TurnRecord[]; nextCursor: string | null }>();
const firstClient = { threadTurnsList: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient;
const firstClient = { request: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadHistoryTransport({
@ -163,7 +169,7 @@ describe("chat app-server transports", () => {
});
it("resumes threads with the session vault path and projects initial history", async () => {
const resumeThread = vi.fn().mockResolvedValue({
const request = vi.fn().mockResolvedValue({
thread: { ...threadRecord("thread"), path: "/tmp/rollout.jsonl" },
cwd: "/vault",
model: "gpt-test",
@ -175,7 +181,7 @@ describe("chat app-server transports", () => {
nextCursor: "older",
},
});
const client = { resumeThread } as unknown as AppServerClient;
const client = { request } as unknown as AppServerClient;
const transport = createChatThreadResumeTransport({
vaultPath: "/vault",
currentClient: () => client,
@ -184,7 +190,12 @@ describe("chat app-server transports", () => {
const snapshot = await transport.resumeThread("thread");
expect(resumeThread).toHaveBeenCalledWith("thread", "/vault");
expect(request).toHaveBeenCalledWith("thread/resume", {
threadId: "thread",
cwd: "/vault",
excludeTurns: true,
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
});
expect(snapshot?.activation.thread.id).toBe("thread");
expect(snapshot?.activation.cwd).toBe("/vault");
expect(snapshot?.rolloutPath).toBe("/tmp/rollout.jsonl");
@ -197,7 +208,7 @@ describe("chat app-server transports", () => {
it("drops stale resume transport responses after the current client changes", async () => {
const resume = deferred<AppServerThreadResumeResponse>();
const firstClient = { resumeThread: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient;
const firstClient = { request: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatThreadResumeTransport({
@ -224,7 +235,7 @@ describe("chat app-server transports", () => {
});
it("distinguishes absent goals from unavailable goal clients", async () => {
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
const client = { request: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
const transport = createChatThreadGoalTransport({
currentClient: () => client,
connectedClient: vi.fn().mockResolvedValue(client),
@ -244,7 +255,8 @@ describe("chat app-server transports", () => {
it("drops stale runtime settings updates after the current client changes", async () => {
const update = deferred<void>();
const firstClient = { updateThreadSettings: vi.fn().mockReturnValue(update.promise) } as unknown as AppServerClient;
const request = vi.fn().mockReturnValue(update.promise);
const firstClient = { request } as unknown as AppServerClient;
const secondClient = {} as unknown as AppServerClient;
let currentClient = firstClient;
const transport = createChatRuntimeSettingsTransport({
@ -256,15 +268,15 @@ describe("chat app-server transports", () => {
update.resolve(undefined);
await expect(updating).resolves.toBe(false);
expect(firstClient.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" });
expect(request).toHaveBeenCalledWith("thread/settings/update", { threadId: "thread", model: "gpt-5.5" });
});
it("resolves referenced thread input at the app-server boundary", async () => {
const threadTurnsList = vi.fn().mockResolvedValue({
const request = vi.fn().mockResolvedValue({
data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])],
nextCursor: null,
});
const client = { threadTurnsList } as unknown as AppServerClient;
const client = { request } as unknown as AppServerClient;
const setStatus = vi.fn();
const resolver = createThreadReferenceResolver({
currentClient: () => client,
@ -278,7 +290,13 @@ describe("chat app-server transports", () => {
"summarize",
);
expect(threadTurnsList).toHaveBeenCalledWith("019abcde-0000-7000-8000-000000000001", null, 20);
expect(request).toHaveBeenCalledWith("thread/turns/list", {
threadId: "019abcde-0000-7000-8000-000000000001",
cursor: null,
limit: 20,
sortDirection: "desc",
itemsView: "full",
});
expect(result?.input[0]).toMatchObject({
type: "text",
text: expect.stringContaining("Reference thread history:"),
@ -288,7 +306,7 @@ describe("chat app-server transports", () => {
});
});
type AppServerThreadResumeResponse = Awaited<ReturnType<AppServerClient["resumeThread"]>>;
type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"];
function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial<ThreadRecord> = {}): ThreadRecord {
return {

View file

@ -152,7 +152,7 @@ function coordinatorFixture(
completedTurnTitleContext: (turnId: string, completedSummary) => titleService.completedTurnContext(turnId, completedSummary),
generateTitleFromContext: (context) => titleService.generate(context),
renameGeneratedTitle: async (threadId: string, value: string, options: { shouldPublish: () => boolean }) => {
await currentClient().setThreadName(threadId, value);
await currentClient().request("thread/name/set", { threadId, name: value });
if (options.shouldPublish()) notifyThreadRenamed(threadId, value);
return true;
},
@ -161,8 +161,12 @@ function coordinatorFixture(
}
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
const setThreadName = options.setThreadName ?? vi.fn().mockResolvedValue({});
return {
setThreadName: options.setThreadName ?? vi.fn().mockResolvedValue({}),
request: vi.fn((method: string, params: { threadId: string; name: string }) => {
if (method !== "thread/name/set") throw new Error(`Unexpected app-server request: ${method}`);
return (setThreadName as unknown as (threadId: string, name: string) => Promise<unknown>)(params.threadId, params.name);
}),
} as unknown as AppServerClient;
}

View file

@ -175,7 +175,7 @@ function actionsFixture(
renameThread: async (threadId: string, value: string) => {
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await currentClient().setThreadName(threadId, name);
await currentClient().request("thread/name/set", { threadId, name });
stateStore.dispatch({
type: "thread-list/applied",
threads: stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)),
@ -189,11 +189,19 @@ function actionsFixture(
}
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
const setThreadName = options.setThreadName ?? vi.fn().mockResolvedValue({});
return {
setThreadName: options.setThreadName ?? vi.fn().mockResolvedValue({}),
threadTurnsList: vi.fn().mockResolvedValue({
data: [turnFixture([userMessage("user", "Please name this."), assistantMessage("assistant", "Done.")])],
nextCursor: null,
request: vi.fn((method: string, params: { threadId: string; name: string }) => {
if (method === "thread/name/set") {
return (setThreadName as unknown as (threadId: string, name: string) => Promise<unknown>)(params.threadId, params.name);
}
if (method === "thread/turns/list") {
return Promise.resolve({
data: [turnFixture([userMessage("user", "Please name this."), assistantMessage("assistant", "Done.")])],
nextCursor: null,
});
}
throw new Error(`Unexpected app-server request: ${method}`);
}),
} as unknown as AppServerClient;
}

View file

@ -721,14 +721,15 @@ describe("CodexChatView connection lifecycle", () => {
});
function connectedClient(overrides: Partial<ReturnType<typeof baseClient>> = {}): ReturnType<typeof baseClient> {
return {
...baseClient(),
...overrides,
};
return withClientRequest({ ...baseClientMethods(), ...overrides }) as ReturnType<typeof baseClient>;
}
function baseClient() {
return {
return withClientRequest(baseClientMethods());
}
function baseClientMethods() {
const client = {
readEffectiveConfig: vi.fn().mockResolvedValue({}),
listModels: vi.fn().mockResolvedValue({ data: [] }),
listSkills: vi.fn().mockResolvedValue({ data: [] }),
@ -747,6 +748,84 @@ function baseClient() {
readThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }),
archiveThread: vi.fn().mockResolvedValue({}),
};
return client;
}
function withClientRequest<T extends Record<string, unknown>>(client: T): T & { request: ReturnType<typeof vi.fn> } {
return {
...client,
request: vi.fn((method: string, params: Record<string, unknown>) => {
switch (method) {
case "config/read":
return (client["readEffectiveConfig"] as (cwd: unknown) => Promise<unknown>)(params["cwd"]);
case "model/list":
return (client["listModels"] as (includeHidden: unknown) => Promise<unknown>)(params["includeHidden"] ?? false);
case "skills/list":
return (client["listSkills"] as (cwd: string) => Promise<unknown>)((params["cwds"] as string[])[0] ?? "");
case "account/rateLimits/read":
return (client["readAccountRateLimits"] as () => Promise<unknown>)();
case "thread/list":
return (client["listThreads"] as (cwd: unknown, params: unknown) => Promise<unknown>)(params["cwd"], params);
case "thread/start":
return (client["startThread"] as (options: unknown) => Promise<unknown>)({
cwd: params["cwd"],
serviceTier: params["serviceTier"],
});
case "thread/resume":
return (client["resumeThread"] as (threadId: unknown, cwd: unknown) => Promise<unknown>)(params["threadId"], params["cwd"]);
case "thread/turns/list":
if (params["sortDirection"] === "desc") {
return (client["threadTurnsList"] as (threadId: unknown, cursor: unknown, limit: unknown) => Promise<unknown>)(
params["threadId"],
params["cursor"],
params["limit"],
);
}
return (
client["threadTurnsList"] as (threadId: unknown, cursor: unknown, limit: unknown, sortDirection?: unknown) => Promise<unknown>
)(params["threadId"], params["cursor"], params["limit"], params["sortDirection"]);
case "turn/start":
return (client["startTurn"] as (options: unknown) => Promise<unknown>)({
threadId: params["threadId"],
cwd: params["cwd"],
input: codexInputFromRequestInput(params["input"] as { type: string; text?: string }[]),
clientUserMessageId: params["clientUserMessageId"],
});
case "thread/fork":
return (client["forkThread"] as (threadId: unknown, cwd: unknown) => Promise<unknown>)(params["threadId"], params["cwd"]);
case "thread/rollback":
return (client["rollbackThread"] as (threadId: unknown) => Promise<unknown>)(params["threadId"]);
case "thread/name/set":
return (client["setThreadName"] as (threadId: unknown, name: unknown) => Promise<unknown>)(params["threadId"], params["name"]);
case "thread/goal/get":
return (client["getThreadGoal"] as (threadId: unknown) => Promise<unknown>)(params["threadId"]);
case "thread/goal/set":
return (client["setThreadGoal"] as (threadId: unknown, goal: unknown) => Promise<unknown>)(params["threadId"], {
objective: params["objective"],
status: params["status"],
tokenBudget: params["tokenBudget"],
});
case "thread/inject_items":
return (client["injectThreadItems"] as (threadId: unknown, items: unknown) => Promise<unknown>)(
params["threadId"],
params["items"],
);
case "thread/read":
return (client["readThread"] as (threadId: unknown, includeTurns: unknown) => Promise<unknown>)(
params["threadId"],
params["includeTurns"],
);
case "thread/archive":
return (client["archiveThread"] as (threadId: unknown) => Promise<unknown>)(params["threadId"]);
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}),
};
}
function codexInputFromRequestInput(input: { type: string; text?: string }[]): { type: "text"; text: string }[] {
return input.flatMap((item) => (item.type === "text" ? [{ type: "text", text: item.text ?? "" }] : []));
}
function goalFixture(threadId: string) {

View file

@ -2,13 +2,10 @@
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
AppServerClient,
AppServerClientHandlers,
AppServerStartStructuredTurnOptions,
} from "../../../src/app-server/connection/client";
import type { RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../../src/app-server/connection/client";
import type { ClientRequestParams, RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
import type { AppServerStartStructuredTurnOptions } from "../../../src/app-server/services/turns";
import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata";
import type { ServerInitialization } from "../../../src/domain/server/initialization";
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
@ -24,16 +21,18 @@ import { positionSelectionRewritePopover } from "../../../src/features/selection
import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt";
import * as selectionRewriteRunner from "../../../src/features/selection-rewrite/runner";
import { runSelectionRewrite } from "../../../src/features/selection-rewrite/runner";
import type { ModelListResponse } from "../../../src/generated/app-server/v2/ModelListResponse";
import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse";
import { deferred } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";
type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;
interface TurnStartResponse {
turn: TurnRecord;
}
type SelectionRewriteClientFactory = NonNullable<Parameters<typeof runSelectionRewrite>[0]["clientFactory"]>;
type SelectionRewriteClient = ReturnType<SelectionRewriteClientFactory>;
type TurnStartResponse = Awaited<ReturnType<SelectionRewriteClient["startStructuredTurn"]>>;
installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@ -814,20 +813,31 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
async deleteThread(): Promise<void> {}
async listModels(): Promise<ModelListResponse> {
return this.modelList;
}
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {}
async startEphemeralThread(): Promise<ThreadStartResponse> {
return threadStartResponse("thread");
}
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
this.startStructuredTurnOptions = options;
this.resolveStructuredTurnStarted();
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
async request<M extends TypedClientRequestMethod>(
method: M,
params: ClientRequestParams<M>,
options: { timeoutMs?: number } = {},
): Promise<ClientResponseByMethod[M]> {
void options;
switch (method) {
case "model/list":
return this.modelList as unknown as ClientResponseByMethod[M];
case "thread/start":
return threadStartResponse("thread") as unknown as ClientResponseByMethod[M];
case "turn/start":
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
this.resolveStructuredTurnStarted();
return (this.startStructuredTurnImpl
? await this.startStructuredTurnImpl()
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
case "thread/delete":
await this.deleteThread();
return {} as unknown as ClientResponseByMethod[M];
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}
emit(notification: ServerNotification): void {
@ -835,6 +845,26 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
}
}
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
const textItem = params.input[0];
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
return {
threadId: params.threadId,
cwd: params.cwd,
text: textItem.text,
outputSchema: params.outputSchema,
runtime: {
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
...(params.collaborationMode !== undefined && params.collaborationMode !== null
? { collaborationMode: params.collaborationMode }
: {}),
...(params.model !== undefined ? { model: params.model } : {}),
...(params.effort !== undefined ? { effort: params.effort } : {}),
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
},
};
}
function threadStartResponse(threadId: string): ThreadStartResponse {
return {
thread: thread(threadId),

View file

@ -503,7 +503,7 @@ describe("CodexThreadsView", () => {
});
function clientFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
const client = {
listThreads: vi.fn().mockResolvedValue({ data: [] }),
archiveThread: vi.fn().mockResolvedValue({}),
setThreadName: vi.fn().mockResolvedValue({}),
@ -511,6 +511,28 @@ function clientFixture(overrides: Record<string, unknown> = {}): Record<string,
rejectServerRequest: vi.fn(),
...overrides,
};
return {
...client,
request: vi.fn((method: string, params: Record<string, unknown>) => {
switch (method) {
case "thread/list":
return (client.listThreads as (cwd: string, options: unknown) => Promise<unknown>)(params["cwd"] as string, params);
case "thread/archive":
return (client.archiveThread as (threadId: string) => Promise<unknown>)(params["threadId"] as string);
case "thread/name/set":
return (client.setThreadName as (threadId: string, name: string) => Promise<unknown>)(
params["threadId"] as string,
params["name"] as string,
);
case "thread/turns/list":
return (
client.threadTurnsList as (threadId: string, cursor: string | null, limit: number, sortDirection?: string) => Promise<unknown>
)(params["threadId"] as string, params["cursor"] as string | null, params["limit"] as number, params["sortDirection"] as string);
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}),
};
}
function threadsHost(overrides: Record<string, unknown> = {}) {

View file

@ -126,10 +126,18 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
type MockClient = ReturnType<typeof clientMock>;
function clientMock() {
return {
const client = {
setThreadName: vi.fn().mockResolvedValue({}),
archiveThread: vi.fn().mockResolvedValue({}),
};
return {
...client,
request: vi.fn((method: string, params: { threadId: string; name?: string }) => {
if (method === "thread/name/set") return client.setThreadName(params.threadId, params.name);
if (method === "thread/archive") return client.archiveThread(params.threadId);
throw new Error(`Unexpected app-server request: ${method}`);
}),
};
}
function archiveDestinationMock(): ArchiveExportDestination {

View file

@ -674,7 +674,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue("chat-result");
const shortLivedClient = { readEffectiveConfig: vi.fn().mockResolvedValue({}) };
const shortLivedClient = { request: vi.fn().mockResolvedValue({}) };
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _vaultPath: string, operation: (client: typeof shortLivedClient) => Promise<unknown>) =>
operation(shortLivedClient),
@ -682,7 +682,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
const plugin = await pluginWithLeaves([connectedLeaf]);
plugin.settings.codexPath = "codex";
const result = await plugin.runtime.withClient((client) => client.readEffectiveConfig("/vault") as Promise<unknown>, {
const result = await plugin.runtime.withClient((client) => client.request("config/read", { cwd: "/vault", includeLayers: true }), {
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
@ -691,7 +691,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex", "/vault", expect.any(Function), {
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
expect(shortLivedClient.readEffectiveConfig).toHaveBeenCalledWith("/vault");
expect(shortLivedClient.request).toHaveBeenCalledWith("config/read", { cwd: "/vault", includeLayers: true });
});
it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => {
@ -849,10 +849,13 @@ function chatHostFixture(): CodexChatHost {
function threadListClient(fetchThreads: () => Promise<readonly Thread[]>): never {
return {
listThreads: async () => ({
data: await fetchThreads(),
nextCursor: null,
}),
request: async (method: string) => {
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
return {
data: await fetchThreads(),
nextCursor: null,
};
},
} as never;
}

View file

@ -348,9 +348,9 @@ describe("settings tab", () => {
const initialClient = settingsClient({
hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })],
});
const trustClient = {
const trustClient = withSettingsRequest({
trustHook: vi.fn().mockResolvedValue({}),
};
});
const staleClient = settingsClient();
staleClient.listHooks.mockReturnValue(staleHooks.promise);
const newerClient = settingsClient({
@ -393,9 +393,9 @@ describe("settings tab", () => {
const fullRefreshClient = settingsClient({
hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })],
});
const trustClient = {
const trustClient = withSettingsRequest({
trustHook: vi.fn().mockResolvedValue({}),
};
});
const hookReloadClient = settingsClient({
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
@ -435,9 +435,9 @@ describe("settings tab", () => {
const staleRestore = deferred<{ thread: ThreadRecord }>();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
const restoreClient = {
const restoreClient = withSettingsRequest({
unarchiveThread: vi.fn(() => staleRestore.promise),
};
});
const newerClient = settingsClient();
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
@ -476,9 +476,9 @@ describe("settings tab", () => {
const notify = vi.fn();
const applyThreadCatalogEvent = vi.fn();
const initialClient = settingsClient();
const restoreClient = {
const restoreClient = withSettingsRequest({
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
};
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
@ -510,9 +510,9 @@ describe("settings tab", () => {
it("displays restored archived thread state after recording the active catalog event", async () => {
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
const initialClient = settingsClient();
const restoreClient = {
const restoreClient = withSettingsRequest({
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
};
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
@ -557,9 +557,9 @@ describe("settings tab", () => {
it("displays deleted archived thread status after recording the catalog event", async () => {
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
const initialClient = settingsClient();
const deleteClient = {
const deleteClient = withSettingsRequest({
deleteThread: vi.fn().mockResolvedValue({}),
};
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
@ -932,7 +932,7 @@ function hook(overrides: Partial<CatalogHookMetadata> = {}): CatalogHookMetadata
function settingsClient(
options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {},
) {
return {
return withSettingsRequest({
listModels: vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }),
listHooks: vi.fn().mockImplementation(() => {
if (options.hooksError) return Promise.reject(options.hooksError);
@ -950,10 +950,52 @@ function settingsClient(
listThreads: vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }),
setHookEnabled: vi.fn().mockResolvedValue({}),
trustHook: vi.fn().mockResolvedValue({}),
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }),
deleteThread: vi.fn().mockResolvedValue({}),
});
}
function withSettingsRequest<T extends Record<string, unknown>>(client: T): T & { request: ReturnType<typeof vi.fn> } {
return {
...client,
request: vi.fn((method: string, params: unknown) => {
switch (method) {
case "model/list":
return (client["listModels"] as (includeHidden: boolean) => Promise<unknown>)(
(params as { includeHidden?: boolean }).includeHidden ?? false,
);
case "hooks/list":
return (client["listHooks"] as (cwd: string) => Promise<unknown>)((params as { cwds: string[] }).cwds[0] ?? "");
case "thread/list":
return (client["listThreads"] as (cwd: string, options: { archived?: boolean }) => Promise<unknown>)(
(params as { cwd: string }).cwd,
params as { archived?: boolean },
);
case "config/batchWrite": {
const state = hookStateFromBatchWrite(params);
if (state?.enabled === true && state.trusted_hash && client["trustHook"]) {
return (client["trustHook"] as () => Promise<unknown>)();
}
if (client["setHookEnabled"]) return (client["setHookEnabled"] as () => Promise<unknown>)();
return Promise.resolve({});
}
case "thread/unarchive":
return (client["unarchiveThread"] as (threadId: string) => Promise<unknown>)((params as { threadId: string }).threadId);
case "thread/delete":
return (client["deleteThread"] as (threadId: string) => Promise<unknown>)((params as { threadId: string }).threadId);
default:
throw new Error(`Unexpected app-server request: ${method}`);
}
}),
};
}
function hookStateFromBatchWrite(params: unknown): { enabled?: boolean; trusted_hash?: string } | null {
const edit = (params as { edits?: { value?: Record<string, { enabled?: boolean; trusted_hash?: string }> }[] }).edits?.[0];
const value = edit?.value;
return value ? (Object.values(value)[0] ?? null) : null;
}
function newSettingsTab(
options: {
saveSettings?: () => Promise<void>;