diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index b210e23b..96850c00 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -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; +export type TypedClientRequestMethod = Extract; 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 { - return this.request("config/read", { cwd, includeLayers: true }); - } - - listHooks(cwd: string): Promise { - return this.request("hooks/list", { cwds: [cwd] }); - } - - trustHook(hook: AppServerHookOperation): Promise { - return this.writeHookState(hook.key, { - enabled: true, - trusted_hash: hook.currentHash, - }); - } - - setHookEnabled(hook: AppServerHookOperation, enabled: boolean): Promise { - const state: Record = hook.trustStatus === "trusted" ? { enabled, trusted_hash: hook.currentHash } : { enabled }; - return this.writeHookState(hook.key, state); - } - - startThread(options: AppServerStartThreadOptions): Promise { - const { cwd, serviceTier } = options; - return this.request("thread/start", { - cwd, - serviceName: "codex-panel", - ...(serviceTier !== undefined ? { serviceTier } : {}), - }); - } - - startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise { - 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 { - return this.request("thread/resume", { - threadId, - cwd, - excludeTurns: true, - initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" }, - }); - } - - forkThread(threadId: string, cwd: string): Promise { - return this.request("thread/fork", { - threadId, - cwd, - excludeTurns: true, - }); - } - - listThreads(cwd: string, options: AppServerThreadListOptions = {}): Promise { - 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 { - return this.request("thread/archive", { threadId }); - } - - deleteThread(threadId: string, options: { timeoutMs?: number } = {}): Promise { - return this.request("thread/delete", { threadId }, options); - } - - readThread(threadId: string, includeTurns = true): Promise { - return this.request("thread/read", { threadId, includeTurns }); - } - - readFile(path: string, options: { timeoutMs?: number } = {}): Promise { - return this.request("fs/readFile", { path }, options); - } - - unarchiveThread(threadId: string): Promise { - return this.request("thread/unarchive", { threadId }); - } - - rollbackThread(threadId: string, numTurns = 1): Promise { - return this.request("thread/rollback", { threadId, numTurns }); - } - - setThreadName(threadId: string, name: string): Promise { - return this.request("thread/name/set", { threadId, name }); - } - - getThreadGoal(threadId: string): Promise { - return this.request("thread/goal/get", { threadId }); - } - - setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise { - return this.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) }); - } - - clearThreadGoal(threadId: string): Promise { - return this.request("thread/goal/clear", { threadId }); - } - - injectThreadItems(threadId: string, items: ClientRequestParams<"thread/inject_items">["items"]): Promise { - return this.request("thread/inject_items", { threadId, items }); - } - - updateThreadSettings(threadId: string, settings: RuntimeSettingsPatch): Promise { - 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 { - return this.request("thread/turns/list", { - threadId, - cursor, - limit, - sortDirection, - itemsView, - }); - } - - listSkills(cwd: string, forceReload = false): Promise { - return this.request("skills/list", { - cwds: [cwd], - forceReload, - }); - } - - listApps(params: AppsListParams = { limit: 100 }): Promise { - return this.request("app/list", params); - } - - listInstalledPlugins(cwd: string): Promise { - const params: PluginInstalledParams = { cwds: [cwd] }; - return this.request("plugin/installed", params); - } - - readPlugin(params: PluginReadParams): Promise { - return this.request("plugin/read", params); - } - - listModels(includeHidden = false): Promise { - return this.request("model/list", { - includeHidden, - limit: 100, - }); - } - - readAccountRateLimits(): Promise { - return this.request("account/rateLimits/read", undefined); - } - - listMcpServerStatus( - params: ListMcpServerStatusParams = { detail: "toolsAndAuthOnly", limit: 100 }, - ): Promise { - return this.request("mcpServerStatus/list", params); - } - - listCollaborationModes(): Promise { - return this.request("collaborationMode/list", {}); - } - - readModelProviderCapabilities(): Promise { - return this.request("modelProvider/capabilities/read", {}); - } - - compactThread(threadId: string): Promise { - return this.request("thread/compact/start", { threadId }); - } - - startTurn(options: AppServerStartTurnOptions): Promise { - 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 { - 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 { - 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 { - return this.request("turn/interrupt", { threadId, turnId }); - } - - private writeHookState(key: string, state: Record): Promise { - 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( + request( method: M, params: ClientRequestParams, options: { timeoutMs?: number } = {}, diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index ac8edce8..5e74664b 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -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 => { 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), diff --git a/src/app-server/query/metadata-probes.ts b/src/app-server/query/metadata-probes.ts index f9ff3117..09dd68a9 100644 --- a/src/app-server/query/metadata-probes.ts +++ b/src/app-server/query/metadata-probes.ts @@ -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()), diff --git a/src/app-server/services/catalog.ts b/src/app-server/services/catalog.ts index 218a2b77..1fe53aef 100644 --- a/src/app-server/services/catalog.ts +++ b/src/app-server/services/catalog.ts @@ -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 { - 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 { - const response = await client.listHooks(cwd); +export async function listHookCatalog(client: AppServerRequestClient, cwd: string): Promise { + 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 { - await client.trustHook(appServerHookOperationFromHookItem(hook)); +export async function trustHookItem(client: AppServerRequestClient, hook: HookItem): Promise { + 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 { - await client.setHookEnabled(appServerHookOperationFromHookItem(hook), enabled); +export async function setHookItemEnabled(client: AppServerRequestClient, hook: HookItem, enabled: boolean): Promise { + 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; +type ConfigBatchWriteParams = ClientRequestParams<"config/batchWrite">; + +function writeHookState(client: AppServerRequestClient, key: AppServerHookOperation["key"], state: HookConfigState): Promise { + const params: ConfigBatchWriteParams = { + edits: [ + { + keyPath: "hooks.state", + value: { + [key]: state, + }, + mergeStrategy: "upsert", + }, + ], + reloadUserConfig: true, + }; + return client.request("config/batchWrite", { + ...params, + }); } diff --git a/src/app-server/services/config.ts b/src/app-server/services/config.ts new file mode 100644 index 00000000..a702d784 --- /dev/null +++ b/src/app-server/services/config.ts @@ -0,0 +1,6 @@ +import type { ClientResponseByMethod } from "../connection/client"; +import type { AppServerRequestClient } from "./request-client"; + +export function readEffectiveConfig(client: AppServerRequestClient, cwd: string): Promise { + return client.request("config/read", { cwd, includeLayers: true }); +} diff --git a/src/app-server/services/ephemeral-structured-turn.ts b/src/app-server/services/ephemeral-structured-turn.ts index dece04d9..70ff39cc 100644 --- a/src/app-server/services/ephemeral-structured-turn.ts +++ b/src/app-server/services/ephemeral-structured-turn.ts @@ -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; disconnect(): void; - startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<{ thread: { id: string } }>; - startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<{ turn: TurnRecord }>; - deleteThread(threadId: string, options?: { timeoutMs?: number }): Promise; } 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 { 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. } diff --git a/src/app-server/services/request-client.ts b/src/app-server/services/request-client.ts new file mode 100644 index 00000000..8e6b381d --- /dev/null +++ b/src/app-server/services/request-client.ts @@ -0,0 +1,10 @@ +import type { ClientResponseByMethod, TypedClientRequestMethod } from "../connection/client"; +import type { ClientRequestParams } from "../connection/rpc-messages"; + +export interface AppServerRequestClient { + request( + method: M, + params: ClientRequestParams, + options?: { timeoutMs?: number }, + ): Promise; +} diff --git a/src/app-server/services/thread-archive.ts b/src/app-server/services/thread-archive.ts index 007e2000..0002eb86 100644 --- a/src/app-server/services/thread-archive.ts +++ b/src/app-server/services/thread-archive.ts @@ -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 { @@ -30,6 +30,6 @@ export async function archiveThreadOnAppServer( exportedPath = result.path; } - await client.archiveThread(threadId); + await archiveThread(client, threadId); return { exportedPath }; } diff --git a/src/app-server/services/threads.ts b/src/app-server/services/threads.ts index de9c3886..43057ee4 100644 --- a/src/app-server/services/threads.ts +++ b/src/app-server/services/threads.ts @@ -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; -export type ThreadForkClient = Pick; -export type ThreadRollbackClient = Pick; -export type ThreadCompactionClient = Pick; +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 { +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 { + const { cwd, serviceTier } = options; + return client.request("thread/start", { + cwd, + serviceName: "codex-panel", + ...(serviceTier !== undefined ? { serviceTier } : {}), + }); +} + +export function startEphemeralThread( + client: AppServerRequestClient, + options: AppServerStartEphemeralThreadOptions, +): Promise { + 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 { + 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 { const archived = options.archived ?? false; const records: ThreadRecord[] = []; const seenCursors = new Set(); 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 { - const response = await client.readThread(threadId, true); +export async function readThreadForArchiveExport(client: AppServerRequestClient, threadId: string): Promise { + 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 { - 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 { - 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 { - 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 { - 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 { - await client.compactThread(threadId); + await client.request("thread/compact/start", { threadId }); } -export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise { - const response = await client.unarchiveThread(threadId); +export async function archiveThread(client: AppServerRequestClient, threadId: string): Promise { + await client.request("thread/archive", { threadId }); +} + +export async function deleteThread(client: AppServerRequestClient, threadId: string, options: { timeoutMs?: number } = {}): Promise { + await client.request("thread/delete", { threadId }, options); +} + +export async function restoreArchivedThread(client: AppServerRequestClient, threadId: string): Promise { + 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 { - const response = await client.getThreadGoal(threadId); +export async function readThreadGoal(client: AppServerRequestClient, threadId: string): Promise { + const response = await client.request("thread/goal/get", { threadId }); return threadGoalFromAppServerGoal(response.goal); } -export async function setThreadGoal(client: AppServerClient, threadId: string, params: ThreadGoalUpdate): Promise { - const response = await client.setThreadGoal(threadId, params); +export async function setThreadGoal( + client: AppServerRequestClient, + threadId: string, + params: ThreadGoalUpdate, +): Promise { + 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 { - await client.injectThreadItems(threadId, [appServerThreadGoalUserHistoryItem(objective)]); +export async function clearThreadGoal(client: AppServerRequestClient, threadId: string): Promise { + await client.request("thread/goal/clear", { threadId }); +} + +export async function recordThreadGoalUserMessage(client: AppServerRequestClient, threadId: string, objective: string): Promise { + await client.request("thread/inject_items", { threadId, items: [appServerThreadGoalUserHistoryItem(objective)] }); +} + +export async function renameThread(client: AppServerRequestClient, threadId: string, name: string): Promise { + await client.request("thread/name/set", { threadId, name }); +} + +export async function updateThreadSettings( + client: AppServerRequestClient, + threadId: string, + settings: RuntimeSettingsPatch, +): Promise { + await client.request("thread/settings/update", { threadId, ...appServerRuntimeSettingsPatch(settings) }); +} + +export function readThreadRolloutFile( + client: AppServerRequestClient, + path: string, + options: { timeoutMs?: number } = {}, +): Promise { + 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", + }); } diff --git a/src/app-server/services/tool-inventory.ts b/src/app-server/services/tool-inventory.ts index 24dec155..755f1239 100644 --- a/src/app-server/services/tool-inventory.ts +++ b/src/app-server/services/tool-inventory.ts @@ -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 { @@ -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(items: readonly T[], concurrency: number, fn: (ite return results; } -async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryPlugin): Promise { +async function readPluginDetails(client: AppServerRequestClient, plugin: ToolInventoryPlugin): Promise { 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[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 }> { diff --git a/src/app-server/services/turns.ts b/src/app-server/services/turns.ts new file mode 100644 index 00000000..03b3aea7 --- /dev/null +++ b/src/app-server/services/turns.ts @@ -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; + +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["outputSchema"]>; + runtime?: AppServerTurnRuntimeOverrides; +} + +export function startTurn( + client: AppServerRequestClient, + options: AppServerStartTurnOptions, +): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/features/chat/app-server/actions/diagnostics.ts b/src/features/chat/app-server/actions/diagnostics.ts index 094d28df..8801e9a6 100644 --- a/src/features/chat/app-server/actions/diagnostics.ts +++ b/src/features/chat/app-server/actions/diagnostics.ts @@ -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), ); diff --git a/src/features/chat/app-server/actions/threads.ts b/src/features/chat/app-server/actions/threads.ts index daacc850..73b162f2 100644 --- a/src/features/chat/app-server/actions/threads.ts +++ b/src/features/chat/app-server/actions/threads.ts @@ -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(); diff --git a/src/features/chat/app-server/goals/transport.ts b/src/features/chat/app-server/goals/transport.ts index 1919f5bc..0dff6efb 100644 --- a/src/features/chat/app-server/goals/transport.ts +++ b/src/features/chat/app-server/goals/transport.ts @@ -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; diff --git a/src/features/chat/app-server/runtime/thread-settings-transport.ts b/src/features/chat/app-server/runtime/thread-settings-transport.ts index 5f7a3ec2..115f97a5 100644 --- a/src/features/chat/app-server/runtime/thread-settings-transport.ts +++ b/src/features/chat/app-server/runtime/thread-settings-transport.ts @@ -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; diff --git a/src/features/chat/app-server/threads/projection.ts b/src/features/chat/app-server/threads/projection.ts index 85b322c9..2eceb0bd 100644 --- a/src/features/chat/app-server/threads/projection.ts +++ b/src/features/chat/app-server/threads/projection.ts @@ -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; -type ChatThreadResumeClient = Pick; +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 { - 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 { - const response = await client.resumeThread(threadId, cwd); + const response = await resumeThread(client, threadId, cwd); return { activation: threadActivationSnapshotFromAppServerResponse(response), rolloutPath: response.thread.path, diff --git a/src/features/chat/app-server/turns/transport.ts b/src/features/chat/app-server/turns/transport.ts index 3de61457..e365c052 100644 --- a/src/features/chat/app-server/turns/transport.ts +++ b/src/features/chat/app-server/turns/transport.ts @@ -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; diff --git a/src/features/chat/host/thread-bundle.ts b/src/features/chat/host/thread-bundle.ts index 03991ce4..583539a3 100644 --- a/src/features/chat/host/thread-bundle.ts +++ b/src/features/chat/host/thread-bundle.ts @@ -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 ?? ""; }), }, diff --git a/src/features/threads/workflows/thread-operations.ts b/src/features/threads/workflows/thread-operations.ts index f42ea690..89964a27 100644 --- a/src/features/threads/workflows/thread-operations.ts +++ b/src/features/threads/workflows/thread-operations.ts @@ -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 }); } diff --git a/src/settings/app-server/dynamic-data.ts b/src/settings/app-server/dynamic-data.ts index 69b1d91d..6ae79a4e 100644 --- a/src/settings/app-server/dynamic-data.ts +++ b/src/settings/app-server/dynamic-data.ts @@ -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 }); } diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index e93a8600..cd6f6749 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -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 { + return client.request("model/list", { includeHidden: false, limit: 100 }); +} + +function readFile(client: AppServerClient, path: string, options: { timeoutMs?: number } = {}): Promise { + 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 }); 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 }); - 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 }); 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 }[] = []; 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 }).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; - const turn = client.startTurn({ - threadId: "thread-1", - cwd: "/vault", - input: "runtime boundary", - runtime: runtimeWithUndefined, - }); - const params = (transport.sent[2] as { params?: Record }).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; - 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 }).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 }).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自動命名" } }, - {}, - ); - }); }); diff --git a/tests/app-server/catalog.test.ts b/tests/app-server/catalog.test.ts index 646e1dfa..a5fbd236 100644 --- a/tests/app-server/catalog.test.ts +++ b/tests/app-server/catalog.test.ts @@ -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: [], diff --git a/tests/app-server/ephemeral-structured-turn.test.ts b/tests/app-server/ephemeral-structured-turn.test.ts index ba5a5f6c..c4df5d9a 100644 --- a/tests/app-server/ephemeral-structured-turn.test.ts +++ b/tests/app-server/ephemeral-structured-turn.test.ts @@ -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>; +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) | null = null; startEphemeralThreadOptions: AppServerStartEphemeralThreadOptions | null = null; startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null; - readonly listModels = vi.fn(async (): Promise => ({ 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; 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 { - this.startEphemeralThreadOptions = options; - return this.startEphemeralThreadImpl ? this.startEphemeralThreadImpl() : threadStartResponse("thread"); - } - - async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise { - this.startStructuredTurnOptions = options; - this.resolveStructuredTurnStarted(); - return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) }; + async request( + method: M, + params: ClientRequestParams, + options: { timeoutMs?: number } = {}, + ): Promise { + 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 void, delayMs: number) => number>>; clearTimeout: ReturnType void>>; diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index a26ed57e..25c7044a 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -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): 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): Record { + return { + ...client, + request: async (method: string, params: unknown) => { + switch (method) { + case "thread/list": + return (client["listThreads"] as (cwd: string, options: { archived?: boolean }) => Promise)( + (params as { cwd: string }).cwd, + params as { archived?: boolean }, + ); + case "config/read": + return (client["readEffectiveConfig"] as (cwd: string) => Promise)((params as { cwd: string }).cwd); + case "model/list": + return (client["listModels"] as (includeHidden: boolean) => Promise)( + (params as { includeHidden?: boolean }).includeHidden ?? false, + ); + case "skills/list": + return (client["listSkills"] as (cwd: string, forceReload?: boolean) => Promise)( + (params as { cwds: string[] }).cwds[0] ?? "", + (params as { forceReload?: boolean }).forceReload, + ); + case "account/rateLimits/read": + return (client["readAccountRateLimits"] as () => Promise)(); + default: + throw new Error(`Unexpected app-server request: ${method}`); + } + }, + }; +} + function metadata( overrides: { availableModels?: readonly ModelMetadata[]; diff --git a/tests/app-server/thread-archive.test.ts b/tests/app-server/thread-archive.test.ts index 99ce3404..8abdf889 100644 --- a/tests/app-server/thread-archive.test.ts +++ b/tests/app-server/thread-archive.test.ts @@ -57,9 +57,16 @@ function fakeClient(): AppServerClient & { readThread: ReturnType; archiveThread: ReturnType; } { + 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; archiveThread: ReturnType; diff --git a/tests/app-server/thread-catalog.test.ts b/tests/app-server/thread-catalog.test.ts index 8dbb36da..8d79ad7c 100644 --- a/tests/app-server/thread-catalog.test.ts +++ b/tests/app-server/thread-catalog.test.ts @@ -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); }, }, diff --git a/tests/app-server/thread-title-generation.test.ts b/tests/app-server/thread-title-generation.test.ts index 3797f4cc..d8b898b5 100644 --- a/tests/app-server/thread-title-generation.test.ts +++ b/tests/app-server/thread-title-generation.test.ts @@ -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>; -type ThreadStartResponse = Awaited>; type Turn = TurnRecord; -type TurnStartResponse = Awaited>; +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 {} - async listModels(): Promise { - return { data: this.modelList, nextCursor: null }; - } - rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {} - async startEphemeralThread(): Promise { - return threadStartResponse("thread"); - } - - async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise { - this.startStructuredTurnOptions = options; - return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) }; + async request( + method: M, + params: ClientRequestParams, + options: { timeoutMs?: number } = {}, + ): Promise { + 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), diff --git a/tests/app-server/threads.test.ts b/tests/app-server/threads.test.ts index a2f02e6e..1b5481c9 100644 --- a/tests/app-server/threads.test.ts +++ b/tests/app-server/threads.test.ts @@ -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"); }); diff --git a/tests/app-server/tool-inventory.test.ts b/tests/app-server/tool-inventory.test.ts index 336b419a..69edcffc 100644 --- a/tests/app-server/tool-inventory.test.ts +++ b/tests/app-server/tool-inventory.test.ts @@ -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[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[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; listInstalledPlugins: ReturnType; readPlugin: ReturnType; }> = {}, -): AppServerClient { +): AppServerRequestClient & { request: ReturnType } { + 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)(params); + case "plugin/read": + return (readPlugin as unknown as (params: unknown) => Promise)(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 }; } function installedPlugin(name: string): { diff --git a/tests/features/chat/app-server/actions/actions.test.ts b/tests/features/chat/app-server/actions/actions.test.ts index eae57b68..5faaa422 100644 --- a/tests/features/chat/app-server/actions/actions.test.ts +++ b/tests/features/chat/app-server/actions/actions.test.ts @@ -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>; +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>>(); - const firstClient = { - startThread: vi.fn().mockReturnValue(start.promise), - } as unknown as AppServerClient; + const start = deferred(); + 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>().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[] }>(); 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): AppServerClient { + return { + ...methods, + request: vi.fn((method: string, params: unknown) => { + switch (method) { + case "model/list": + return (methods["listModels"] as (includeHidden: boolean) => Promise)( + (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)( + (params as { cwds: string[] }).cwds[0] ?? "", + (params as { forceReload?: boolean }).forceReload, + ); + case "account/rateLimits/read": + return (methods["readAccountRateLimits"] as () => Promise)(); + case "mcpServerStatus/list": + return (methods["listMcpServerStatus"] as (params: unknown) => Promise)(params); + case "plugin/installed": + if (!methods["listInstalledPlugins"]) return Promise.resolve({ marketplaces: [], marketplaceLoadErrors: [] }); + return (methods["listInstalledPlugins"] as (cwd: string) => Promise)((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)(params); + default: + throw new Error(`Unexpected app-server request: ${method}`); + } + }), + } as unknown as AppServerClient; +} + +function startThreadClient(startThread: ReturnType): 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 + )({ + cwd: params.cwd, + ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}), + }); + }), + } as unknown as AppServerClient; +} + function threadFixture(id: string, overrides: Partial = {}): ThreadStartResponse["thread"] { return { id, diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 2260e140..4632ee5f 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -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(); - 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(); - 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>; +type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"]; function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial = {}): ThreadRecord { return { diff --git a/tests/features/chat/application/threads/auto-title-coordinator.test.ts b/tests/features/chat/application/threads/auto-title-coordinator.test.ts index 828172f7..eb5ac5bf 100644 --- a/tests/features/chat/application/threads/auto-title-coordinator.test.ts +++ b/tests/features/chat/application/threads/auto-title-coordinator.test.ts @@ -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 } = {}): 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)(params.threadId, params.name); + }), } as unknown as AppServerClient; } diff --git a/tests/features/chat/application/threads/rename-editor-actions.test.ts b/tests/features/chat/application/threads/rename-editor-actions.test.ts index d63b0168..3faecfaf 100644 --- a/tests/features/chat/application/threads/rename-editor-actions.test.ts +++ b/tests/features/chat/application/threads/rename-editor-actions.test.ts @@ -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 } = {}): 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)(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; } diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index f07b49bd..bb71c46d 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -721,14 +721,15 @@ describe("CodexChatView connection lifecycle", () => { }); function connectedClient(overrides: Partial> = {}): ReturnType { - return { - ...baseClient(), - ...overrides, - }; + return withClientRequest({ ...baseClientMethods(), ...overrides }) as ReturnType; } 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>(client: T): T & { request: ReturnType } { + return { + ...client, + request: vi.fn((method: string, params: Record) => { + switch (method) { + case "config/read": + return (client["readEffectiveConfig"] as (cwd: unknown) => Promise)(params["cwd"]); + case "model/list": + return (client["listModels"] as (includeHidden: unknown) => Promise)(params["includeHidden"] ?? false); + case "skills/list": + return (client["listSkills"] as (cwd: string) => Promise)((params["cwds"] as string[])[0] ?? ""); + case "account/rateLimits/read": + return (client["readAccountRateLimits"] as () => Promise)(); + case "thread/list": + return (client["listThreads"] as (cwd: unknown, params: unknown) => Promise)(params["cwd"], params); + case "thread/start": + return (client["startThread"] as (options: unknown) => Promise)({ + cwd: params["cwd"], + serviceTier: params["serviceTier"], + }); + case "thread/resume": + return (client["resumeThread"] as (threadId: unknown, cwd: unknown) => Promise)(params["threadId"], params["cwd"]); + case "thread/turns/list": + if (params["sortDirection"] === "desc") { + return (client["threadTurnsList"] as (threadId: unknown, cursor: unknown, limit: unknown) => Promise)( + params["threadId"], + params["cursor"], + params["limit"], + ); + } + return ( + client["threadTurnsList"] as (threadId: unknown, cursor: unknown, limit: unknown, sortDirection?: unknown) => Promise + )(params["threadId"], params["cursor"], params["limit"], params["sortDirection"]); + case "turn/start": + return (client["startTurn"] as (options: unknown) => Promise)({ + 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)(params["threadId"], params["cwd"]); + case "thread/rollback": + return (client["rollbackThread"] as (threadId: unknown) => Promise)(params["threadId"]); + case "thread/name/set": + return (client["setThreadName"] as (threadId: unknown, name: unknown) => Promise)(params["threadId"], params["name"]); + case "thread/goal/get": + return (client["getThreadGoal"] as (threadId: unknown) => Promise)(params["threadId"]); + case "thread/goal/set": + return (client["setThreadGoal"] as (threadId: unknown, goal: unknown) => Promise)(params["threadId"], { + objective: params["objective"], + status: params["status"], + tokenBudget: params["tokenBudget"], + }); + case "thread/inject_items": + return (client["injectThreadItems"] as (threadId: unknown, items: unknown) => Promise)( + params["threadId"], + params["items"], + ); + case "thread/read": + return (client["readThread"] as (threadId: unknown, includeTurns: unknown) => Promise)( + params["threadId"], + params["includeTurns"], + ); + case "thread/archive": + return (client["archiveThread"] as (threadId: unknown) => Promise)(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) { diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 8b146778..4c7ec00f 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -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>; -type ThreadStartResponse = Awaited>; type Turn = TurnRecord; +interface TurnStartResponse { + turn: TurnRecord; +} type SelectionRewriteClientFactory = NonNullable[0]["clientFactory"]>; type SelectionRewriteClient = ReturnType; -type TurnStartResponse = Awaited>; 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 {} - async listModels(): Promise { - return this.modelList; - } - rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {} - async startEphemeralThread(): Promise { - return threadStartResponse("thread"); - } - - async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise { - this.startStructuredTurnOptions = options; - this.resolveStructuredTurnStarted(); - return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) }; + async request( + method: M, + params: ClientRequestParams, + options: { timeoutMs?: number } = {}, + ): Promise { + 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), diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 814ee92d..20c8e6f1 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -503,7 +503,7 @@ describe("CodexThreadsView", () => { }); function clientFixture(overrides: Record = {}): Record { - return { + const client = { listThreads: vi.fn().mockResolvedValue({ data: [] }), archiveThread: vi.fn().mockResolvedValue({}), setThreadName: vi.fn().mockResolvedValue({}), @@ -511,6 +511,28 @@ function clientFixture(overrides: Record = {}): Record) => { + switch (method) { + case "thread/list": + return (client.listThreads as (cwd: string, options: unknown) => Promise)(params["cwd"] as string, params); + case "thread/archive": + return (client.archiveThread as (threadId: string) => Promise)(params["threadId"] as string); + case "thread/name/set": + return (client.setThreadName as (threadId: string, name: string) => Promise)( + 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 + )(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 = {}) { diff --git a/tests/features/threads/workflows/thread-operations.test.ts b/tests/features/threads/workflows/thread-operations.test.ts index 5843be86..4eeb293a 100644 --- a/tests/features/threads/workflows/thread-operations.test.ts +++ b/tests/features/threads/workflows/thread-operations.test.ts @@ -126,10 +126,18 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl type MockClient = ReturnType; 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 { diff --git a/tests/main.test.ts b/tests/main.test.ts index 3aa7bb98..88404b3d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -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) => 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, { + 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): 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; } diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 94ac375e..6121bc17 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -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) => @@ -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) => 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) => 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) => operation(initialClient), @@ -932,7 +932,7 @@ function hook(overrides: Partial = {}): 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>(client: T): T & { request: ReturnType } { + return { + ...client, + request: vi.fn((method: string, params: unknown) => { + switch (method) { + case "model/list": + return (client["listModels"] as (includeHidden: boolean) => Promise)( + (params as { includeHidden?: boolean }).includeHidden ?? false, + ); + case "hooks/list": + return (client["listHooks"] as (cwd: string) => Promise)((params as { cwds: string[] }).cwds[0] ?? ""); + case "thread/list": + return (client["listThreads"] as (cwd: string, options: { archived?: boolean }) => Promise)( + (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)(); + } + if (client["setHookEnabled"]) return (client["setHookEnabled"] as () => Promise)(); + return Promise.resolve({}); + } + case "thread/unarchive": + return (client["unarchiveThread"] as (threadId: string) => Promise)((params as { threadId: string }).threadId); + case "thread/delete": + return (client["deleteThread"] as (threadId: string) => Promise)((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 }[] }).edits?.[0]; + const value = edit?.value; + return value ? (Object.values(value)[0] ?? null) : null; +} + function newSettingsTab( options: { saveSettings?: () => Promise;