mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Move app-server method semantics into services
This commit is contained in:
parent
bf1e96d19e
commit
470c00a1f1
39 changed files with 1050 additions and 1296 deletions
|
|
@ -1,16 +1,8 @@
|
|||
import { CLIENT_VERSION } from "../../constants";
|
||||
import type { CodexInput } from "../../domain/chat/input";
|
||||
import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings";
|
||||
import type { ThreadGoalUpdate } from "../../domain/threads/goal";
|
||||
import type { CollaborationMode } from "../../generated/app-server/CollaborationMode";
|
||||
import type { InitializeResponse } from "../../generated/app-server/InitializeResponse";
|
||||
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
|
||||
import type { RequestId } from "../../generated/app-server/RequestId";
|
||||
import type { ServerNotification } from "../../generated/app-server/ServerNotification";
|
||||
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
||||
import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue";
|
||||
import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer";
|
||||
import type { AppsListParams } from "../../generated/app-server/v2/AppsListParams";
|
||||
import type { AppsListResponse } from "../../generated/app-server/v2/AppsListResponse";
|
||||
import type { CollaborationModeListResponse } from "../../generated/app-server/v2/CollaborationModeListResponse";
|
||||
import type { ConfigReadResponse } from "../../generated/app-server/v2/ConfigReadResponse";
|
||||
|
|
@ -18,16 +10,12 @@ import type { ConfigWriteResponse } from "../../generated/app-server/v2/ConfigWr
|
|||
import type { FsReadFileResponse } from "../../generated/app-server/v2/FsReadFileResponse";
|
||||
import type { GetAccountRateLimitsResponse } from "../../generated/app-server/v2/GetAccountRateLimitsResponse";
|
||||
import type { HooksListResponse } from "../../generated/app-server/v2/HooksListResponse";
|
||||
import type { ListMcpServerStatusParams } from "../../generated/app-server/v2/ListMcpServerStatusParams";
|
||||
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
|
||||
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
|
||||
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
|
||||
import type { PluginInstalledParams } from "../../generated/app-server/v2/PluginInstalledParams";
|
||||
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
|
||||
import type { PluginReadParams } from "../../generated/app-server/v2/PluginReadParams";
|
||||
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
|
||||
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
|
||||
import type { SortDirection } from "../../generated/app-server/v2/SortDirection";
|
||||
import type { ThreadArchiveResponse } from "../../generated/app-server/v2/ThreadArchiveResponse";
|
||||
import type { ThreadCompactStartResponse } from "../../generated/app-server/v2/ThreadCompactStartResponse";
|
||||
import type { ThreadDeleteResponse } from "../../generated/app-server/v2/ThreadDeleteResponse";
|
||||
|
|
@ -46,14 +34,8 @@ import type { ThreadStartResponse } from "../../generated/app-server/v2/ThreadSt
|
|||
import type { ThreadTurnsListResponse } from "../../generated/app-server/v2/ThreadTurnsListResponse";
|
||||
import type { ThreadUnarchiveResponse } from "../../generated/app-server/v2/ThreadUnarchiveResponse";
|
||||
import type { TurnInterruptResponse } from "../../generated/app-server/v2/TurnInterruptResponse";
|
||||
import type { TurnItemsView } from "../../generated/app-server/v2/TurnItemsView";
|
||||
import type { TurnStartResponse } from "../../generated/app-server/v2/TurnStartResponse";
|
||||
import type { TurnSteerResponse } from "../../generated/app-server/v2/TurnSteerResponse";
|
||||
import type { UserInput } from "../../generated/app-server/v2/UserInput";
|
||||
import type { AppServerHookOperation } from "../protocol/catalog";
|
||||
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
||||
import { appServerThreadGoalUpdate } from "../protocol/thread-goal";
|
||||
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
|
||||
import { JsonRpcClient } from "./json-rpc-client";
|
||||
import type { ClientRequestMethod, ClientRequestParams, RpcOutboundMessage } from "./rpc-messages";
|
||||
import { type AppServerTransport, type AppServerTransportHandlers, StdioAppServerTransport } from "./transport";
|
||||
|
|
@ -74,53 +56,7 @@ export interface AppServerServerRequestResponder {
|
|||
|
||||
export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport;
|
||||
|
||||
interface AppServerTurnRuntimeOverrides {
|
||||
serviceTier?: RuntimeServiceTierRequest;
|
||||
collaborationMode?: CollaborationMode;
|
||||
model?: string | null;
|
||||
effort?: ReasoningEffort | null;
|
||||
approvalsReviewer?: ApprovalsReviewer | null;
|
||||
}
|
||||
|
||||
type AppServerTurnRuntimeParams = Pick<
|
||||
ClientRequestParams<"turn/start">,
|
||||
"serviceTier" | "collaborationMode" | "model" | "effort" | "approvalsReviewer"
|
||||
>;
|
||||
|
||||
export interface AppServerStartThreadOptions {
|
||||
cwd: string;
|
||||
serviceTier?: RuntimeServiceTierRequest;
|
||||
}
|
||||
|
||||
export interface AppServerThreadListOptions {
|
||||
archived?: boolean;
|
||||
cursor?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export interface AppServerStartEphemeralThreadOptions {
|
||||
cwd: string;
|
||||
serviceName: string;
|
||||
developerInstructions: string;
|
||||
}
|
||||
|
||||
export interface AppServerStartTurnOptions {
|
||||
threadId: string;
|
||||
cwd: string;
|
||||
input: string | CodexInput;
|
||||
clientUserMessageId?: string | null;
|
||||
runtime?: AppServerTurnRuntimeOverrides;
|
||||
}
|
||||
|
||||
export interface AppServerStartStructuredTurnOptions {
|
||||
threadId: string;
|
||||
cwd: string;
|
||||
text: string;
|
||||
outputSchema: JsonValue;
|
||||
runtime?: AppServerTurnRuntimeOverrides;
|
||||
}
|
||||
|
||||
interface ClientResponseByMethod {
|
||||
export interface ClientResponseByMethod {
|
||||
initialize: InitializeResponse;
|
||||
"config/batchWrite": ConfigWriteResponse;
|
||||
"config/read": ConfigReadResponse;
|
||||
|
|
@ -157,33 +93,13 @@ interface ClientResponseByMethod {
|
|||
"fs/readFile": FsReadFileResponse;
|
||||
}
|
||||
|
||||
type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
||||
export type TypedClientRequestMethod = Extract<ClientRequestMethod, keyof ClientResponseByMethod>;
|
||||
|
||||
type AppServerClientLifecycleState =
|
||||
| { kind: "disconnected" }
|
||||
| { kind: "starting"; transport: AppServerTransport }
|
||||
| { kind: "initialized"; transport: AppServerTransport; initializeResponse: InitializeResponse };
|
||||
|
||||
function toUserInput(input: string | CodexInput): UserInput[] {
|
||||
if (typeof input !== "string") return toAppServerUserInput(input);
|
||||
return toAppServerUserInput([{ type: "text", text: input }]);
|
||||
}
|
||||
|
||||
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
|
||||
if (typeof input === "string") return undefined;
|
||||
return additionalContextFromCodexInput(input);
|
||||
}
|
||||
|
||||
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
|
||||
const params: AppServerTurnRuntimeParams = {};
|
||||
if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier;
|
||||
if (runtime?.collaborationMode !== undefined) params.collaborationMode = runtime.collaborationMode;
|
||||
if (runtime?.model !== undefined) params.model = runtime.model;
|
||||
if (runtime?.effort !== undefined) params.effort = runtime.effort;
|
||||
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
||||
return params;
|
||||
}
|
||||
|
||||
export class AppServerClient {
|
||||
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
|
||||
private readonly rpc: JsonRpcClient;
|
||||
|
|
@ -304,257 +220,6 @@ export class AppServerClient {
|
|||
return this.lifecycle.initializeResponse;
|
||||
}
|
||||
|
||||
readEffectiveConfig(cwd: string): Promise<ConfigReadResponse> {
|
||||
return this.request("config/read", { cwd, includeLayers: true });
|
||||
}
|
||||
|
||||
listHooks(cwd: string): Promise<HooksListResponse> {
|
||||
return this.request("hooks/list", { cwds: [cwd] });
|
||||
}
|
||||
|
||||
trustHook(hook: AppServerHookOperation): Promise<ConfigWriteResponse> {
|
||||
return this.writeHookState(hook.key, {
|
||||
enabled: true,
|
||||
trusted_hash: hook.currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
setHookEnabled(hook: AppServerHookOperation, enabled: boolean): Promise<ConfigWriteResponse> {
|
||||
const state: Record<string, JsonValue> = hook.trustStatus === "trusted" ? { enabled, trusted_hash: hook.currentHash } : { enabled };
|
||||
return this.writeHookState(hook.key, state);
|
||||
}
|
||||
|
||||
startThread(options: AppServerStartThreadOptions): Promise<ThreadStartResponse> {
|
||||
const { cwd, serviceTier } = options;
|
||||
return this.request("thread/start", {
|
||||
cwd,
|
||||
serviceName: "codex-panel",
|
||||
...(serviceTier !== undefined ? { serviceTier } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<ThreadStartResponse> {
|
||||
const { cwd, serviceName, developerInstructions } = options;
|
||||
return this.request("thread/start", {
|
||||
cwd,
|
||||
serviceName,
|
||||
developerInstructions,
|
||||
ephemeral: true,
|
||||
sandbox: "read-only",
|
||||
approvalPolicy: "never",
|
||||
multiAgentMode: "none",
|
||||
environments: [],
|
||||
});
|
||||
}
|
||||
|
||||
resumeThread(threadId: string, cwd: string): Promise<ThreadResumeResponse> {
|
||||
return this.request("thread/resume", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
|
||||
});
|
||||
}
|
||||
|
||||
forkThread(threadId: string, cwd: string): Promise<ThreadForkResponse> {
|
||||
return this.request("thread/fork", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
});
|
||||
}
|
||||
|
||||
listThreads(cwd: string, options: AppServerThreadListOptions = {}): Promise<ThreadListResponse> {
|
||||
return this.request("thread/list", {
|
||||
cwd,
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
archived: options.archived ?? false,
|
||||
sortKey: "recency_at",
|
||||
sortDirection: "desc",
|
||||
});
|
||||
}
|
||||
|
||||
archiveThread(threadId: string): Promise<ThreadArchiveResponse> {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
deleteThread(threadId: string, options: { timeoutMs?: number } = {}): Promise<ThreadDeleteResponse> {
|
||||
return this.request("thread/delete", { threadId }, options);
|
||||
}
|
||||
|
||||
readThread(threadId: string, includeTurns = true): Promise<ThreadReadResponse> {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
readFile(path: string, options: { timeoutMs?: number } = {}): Promise<FsReadFileResponse> {
|
||||
return this.request("fs/readFile", { path }, options);
|
||||
}
|
||||
|
||||
unarchiveThread(threadId: string): Promise<ThreadUnarchiveResponse> {
|
||||
return this.request("thread/unarchive", { threadId });
|
||||
}
|
||||
|
||||
rollbackThread(threadId: string, numTurns = 1): Promise<ThreadRollbackResponse> {
|
||||
return this.request("thread/rollback", { threadId, numTurns });
|
||||
}
|
||||
|
||||
setThreadName(threadId: string, name: string): Promise<ThreadSetNameResponse> {
|
||||
return this.request("thread/name/set", { threadId, name });
|
||||
}
|
||||
|
||||
getThreadGoal(threadId: string): Promise<ThreadGoalGetResponse> {
|
||||
return this.request("thread/goal/get", { threadId });
|
||||
}
|
||||
|
||||
setThreadGoal(threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoalSetResponse> {
|
||||
return this.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) });
|
||||
}
|
||||
|
||||
clearThreadGoal(threadId: string): Promise<ThreadGoalClearResponse> {
|
||||
return this.request("thread/goal/clear", { threadId });
|
||||
}
|
||||
|
||||
injectThreadItems(threadId: string, items: ClientRequestParams<"thread/inject_items">["items"]): Promise<ThreadInjectItemsResponse> {
|
||||
return this.request("thread/inject_items", { threadId, items });
|
||||
}
|
||||
|
||||
updateThreadSettings(threadId: string, settings: RuntimeSettingsPatch): Promise<ThreadSettingsUpdateResponse> {
|
||||
return this.request("thread/settings/update", { threadId, ...appServerRuntimeSettingsPatch(settings) });
|
||||
}
|
||||
|
||||
threadTurnsList(
|
||||
threadId: string,
|
||||
cursor: string | null = null,
|
||||
limit = 20,
|
||||
sortDirection: SortDirection = "desc",
|
||||
itemsView: TurnItemsView = "full",
|
||||
): Promise<ThreadTurnsListResponse> {
|
||||
return this.request("thread/turns/list", {
|
||||
threadId,
|
||||
cursor,
|
||||
limit,
|
||||
sortDirection,
|
||||
itemsView,
|
||||
});
|
||||
}
|
||||
|
||||
listSkills(cwd: string, forceReload = false): Promise<SkillsListResponse> {
|
||||
return this.request("skills/list", {
|
||||
cwds: [cwd],
|
||||
forceReload,
|
||||
});
|
||||
}
|
||||
|
||||
listApps(params: AppsListParams = { limit: 100 }): Promise<AppsListResponse> {
|
||||
return this.request("app/list", params);
|
||||
}
|
||||
|
||||
listInstalledPlugins(cwd: string): Promise<PluginInstalledResponse> {
|
||||
const params: PluginInstalledParams = { cwds: [cwd] };
|
||||
return this.request("plugin/installed", params);
|
||||
}
|
||||
|
||||
readPlugin(params: PluginReadParams): Promise<PluginReadResponse> {
|
||||
return this.request("plugin/read", params);
|
||||
}
|
||||
|
||||
listModels(includeHidden = false): Promise<ModelListResponse> {
|
||||
return this.request("model/list", {
|
||||
includeHidden,
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
|
||||
readAccountRateLimits(): Promise<GetAccountRateLimitsResponse> {
|
||||
return this.request("account/rateLimits/read", undefined);
|
||||
}
|
||||
|
||||
listMcpServerStatus(
|
||||
params: ListMcpServerStatusParams = { detail: "toolsAndAuthOnly", limit: 100 },
|
||||
): Promise<ListMcpServerStatusResponse> {
|
||||
return this.request("mcpServerStatus/list", params);
|
||||
}
|
||||
|
||||
listCollaborationModes(): Promise<CollaborationModeListResponse> {
|
||||
return this.request("collaborationMode/list", {});
|
||||
}
|
||||
|
||||
readModelProviderCapabilities(): Promise<ModelProviderCapabilitiesReadResponse> {
|
||||
return this.request("modelProvider/capabilities/read", {});
|
||||
}
|
||||
|
||||
compactThread(threadId: string): Promise<ThreadCompactStartResponse> {
|
||||
return this.request("thread/compact/start", { threadId });
|
||||
}
|
||||
|
||||
startTurn(options: AppServerStartTurnOptions): Promise<TurnStartResponse> {
|
||||
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
||||
const additionalContext = toAdditionalContext(input);
|
||||
const params: ClientRequestParams<"turn/start"> = {
|
||||
threadId,
|
||||
cwd,
|
||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
||||
...appServerTurnRuntimeParams(runtime),
|
||||
input: toUserInput(input),
|
||||
};
|
||||
return this.request("turn/start", params);
|
||||
}
|
||||
|
||||
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
|
||||
const { threadId, cwd, text, outputSchema, runtime } = options;
|
||||
const params: ClientRequestParams<"turn/start"> = {
|
||||
threadId,
|
||||
cwd,
|
||||
input: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
outputSchema,
|
||||
...appServerTurnRuntimeParams(runtime),
|
||||
};
|
||||
return this.request("turn/start", params);
|
||||
}
|
||||
|
||||
steerTurn(
|
||||
threadId: string,
|
||||
expectedTurnId: string,
|
||||
input: string | CodexInput,
|
||||
clientUserMessageId?: string | null,
|
||||
): Promise<TurnSteerResponse> {
|
||||
const additionalContext = toAdditionalContext(input);
|
||||
return this.request("turn/steer", {
|
||||
threadId,
|
||||
expectedTurnId,
|
||||
input: toUserInput(input),
|
||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
interruptTurn(threadId: string, turnId: string): Promise<TurnInterruptResponse> {
|
||||
return this.request("turn/interrupt", { threadId, turnId });
|
||||
}
|
||||
|
||||
private writeHookState(key: string, state: Record<string, JsonValue>): Promise<ConfigWriteResponse> {
|
||||
return this.request("config/batchWrite", {
|
||||
edits: [
|
||||
{
|
||||
keyPath: "hooks.state",
|
||||
value: {
|
||||
[key]: state,
|
||||
},
|
||||
mergeStrategy: "upsert",
|
||||
},
|
||||
],
|
||||
reloadUserConfig: true,
|
||||
});
|
||||
}
|
||||
|
||||
respondToServerRequest(requestId: RequestId, result: unknown): void {
|
||||
this.rpc.respond(requestId, result);
|
||||
}
|
||||
|
|
@ -563,7 +228,7 @@ export class AppServerClient {
|
|||
this.rpc.reject(requestId, code, message);
|
||||
}
|
||||
|
||||
private request<M extends TypedClientRequestMethod>(
|
||||
request<M extends TypedClientRequestMethod>(
|
||||
method: M,
|
||||
params: ClientRequestParams<M>,
|
||||
options: { timeoutMs?: number } = {},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { AppServerClient } from "../connection/client";
|
|||
import type { AppServerClientAccessOptions } from "../connection/client-access";
|
||||
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
|
||||
import { listModelMetadata } from "../services/catalog";
|
||||
import { readEffectiveConfig } from "../services/config";
|
||||
import { listThreads } from "../services/threads";
|
||||
import {
|
||||
type AppServerQueryContext,
|
||||
|
|
@ -268,7 +269,7 @@ export class AppServerQueryCache {
|
|||
queryKey: appServerMetadataQueryKey(refreshContext),
|
||||
queryFn: async (): Promise<SharedServerMetadata> => {
|
||||
return this.runWithClient(refreshContext, async (client) => {
|
||||
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await client.readEffectiveConfig(refreshContext.vaultPath));
|
||||
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, refreshContext.vaultPath));
|
||||
const [models, skills, rateLimit] = await Promise.all([
|
||||
this.readModelMetadataProbe(refreshContext, client),
|
||||
readSkillMetadataProbe(client, refreshContext.vaultPath, options.forceSkills ?? false),
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||
import {
|
||||
type AppServerHookOperation,
|
||||
appServerHookOperationFromHookItem,
|
||||
type CatalogModel,
|
||||
hookItemsFromCatalogHooks,
|
||||
modelMetadataFromCatalogModels,
|
||||
skillMetadataFromCatalogSkills,
|
||||
} from "../protocol/catalog";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
|
||||
export interface HookCatalog {
|
||||
hooks: HookItem[];
|
||||
|
|
@ -15,20 +16,26 @@ export interface HookCatalog {
|
|||
}
|
||||
|
||||
export interface ModelMetadataClient {
|
||||
listModels(includeHidden: boolean): Promise<{ data: readonly CatalogModel[] }>;
|
||||
request: AppServerRequestClient["request"];
|
||||
}
|
||||
|
||||
export async function listModelMetadata(client: ModelMetadataClient, options: { includeHidden?: boolean } = {}): Promise<ModelMetadata[]> {
|
||||
const response = await client.listModels(options.includeHidden ?? false);
|
||||
const response = await client.request("model/list", {
|
||||
includeHidden: options.includeHidden ?? false,
|
||||
limit: 100,
|
||||
});
|
||||
return modelMetadataFromCatalogModels(response.data);
|
||||
}
|
||||
|
||||
export async function listSkillCatalog(
|
||||
client: AppServerClient,
|
||||
client: AppServerRequestClient,
|
||||
cwd: string,
|
||||
options: { forceReload?: boolean; enabledOnly?: boolean } = {},
|
||||
): Promise<{ skills: SkillMetadata[]; totalCount: number }> {
|
||||
const response = options.forceReload === undefined ? await client.listSkills(cwd) : await client.listSkills(cwd, options.forceReload);
|
||||
const response = await client.request("skills/list", {
|
||||
cwds: [cwd],
|
||||
forceReload: options.forceReload ?? false,
|
||||
});
|
||||
const skills = response.data.flatMap((entry) => entry.skills);
|
||||
return {
|
||||
skills: skillMetadataFromCatalogSkills(options.enabledOnly === false ? skills : skills.filter((skill) => skill.enabled)),
|
||||
|
|
@ -36,8 +43,8 @@ export async function listSkillCatalog(
|
|||
};
|
||||
}
|
||||
|
||||
export async function listHookCatalog(client: AppServerClient, cwd: string): Promise<HookCatalog> {
|
||||
const response = await client.listHooks(cwd);
|
||||
export async function listHookCatalog(client: AppServerRequestClient, cwd: string): Promise<HookCatalog> {
|
||||
const response = await client.request("hooks/list", { cwds: [cwd] });
|
||||
const entry = response.data.find((item) => item.cwd === cwd);
|
||||
if (!entry) return { hooks: [], warnings: [], errors: [] };
|
||||
return {
|
||||
|
|
@ -47,10 +54,37 @@ export async function listHookCatalog(client: AppServerClient, cwd: string): Pro
|
|||
};
|
||||
}
|
||||
|
||||
export async function trustHookItem(client: AppServerClient, hook: HookItem): Promise<void> {
|
||||
await client.trustHook(appServerHookOperationFromHookItem(hook));
|
||||
export async function trustHookItem(client: AppServerRequestClient, hook: HookItem): Promise<void> {
|
||||
const operation = appServerHookOperationFromHookItem(hook);
|
||||
await writeHookState(client, operation.key, {
|
||||
enabled: true,
|
||||
trusted_hash: operation.currentHash,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setHookItemEnabled(client: AppServerClient, hook: HookItem, enabled: boolean): Promise<void> {
|
||||
await client.setHookEnabled(appServerHookOperationFromHookItem(hook), enabled);
|
||||
export async function setHookItemEnabled(client: AppServerRequestClient, hook: HookItem, enabled: boolean): Promise<void> {
|
||||
const operation = appServerHookOperationFromHookItem(hook);
|
||||
const state: HookConfigState = operation.trustStatus === "trusted" ? { enabled, trusted_hash: operation.currentHash } : { enabled };
|
||||
await writeHookState(client, operation.key, state);
|
||||
}
|
||||
|
||||
type HookConfigState = Record<string, string | boolean | null>;
|
||||
type ConfigBatchWriteParams = ClientRequestParams<"config/batchWrite">;
|
||||
|
||||
function writeHookState(client: AppServerRequestClient, key: AppServerHookOperation["key"], state: HookConfigState): Promise<unknown> {
|
||||
const params: ConfigBatchWriteParams = {
|
||||
edits: [
|
||||
{
|
||||
keyPath: "hooks.state",
|
||||
value: {
|
||||
[key]: state,
|
||||
},
|
||||
mergeStrategy: "upsert",
|
||||
},
|
||||
],
|
||||
reloadUserConfig: true,
|
||||
};
|
||||
return client.request("config/batchWrite", {
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
6
src/app-server/services/config.ts
Normal file
6
src/app-server/services/config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ClientResponseByMethod } from "../connection/client";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
|
||||
export function readEffectiveConfig(client: AppServerRequestClient, cwd: string): Promise<ClientResponseByMethod["config/read"]> {
|
||||
return client.request("config/read", { cwd, includeLayers: true });
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { listenAbortSignal } from "../../shared/lifecycle/abort-signal";
|
||||
import {
|
||||
AppServerClient,
|
||||
type AppServerClientHandlers,
|
||||
type AppServerStartEphemeralThreadOptions,
|
||||
type AppServerStartStructuredTurnOptions,
|
||||
} from "../connection/client";
|
||||
import { AppServerClient, type AppServerClientHandlers } from "../connection/client";
|
||||
import type { AppServerClientRequestPolicy } from "../connection/client-access";
|
||||
import type { ServerNotification } from "../connection/rpc-messages";
|
||||
import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn";
|
||||
import type { ModelMetadataClient } from "./catalog";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
import { deleteThread, startEphemeralThread } from "./threads";
|
||||
import { type AppServerStartStructuredTurnOptions, startStructuredTurn } from "./turns";
|
||||
|
||||
export type StructuredTurnOutputSchema = AppServerStartStructuredTurnOptions["outputSchema"];
|
||||
|
||||
|
|
@ -31,11 +29,9 @@ const DEFAULT_EPHEMERAL_STRUCTURED_TURN_TIMERS: EphemeralStructuredTurnTimers =
|
|||
};
|
||||
|
||||
export interface EphemeralStructuredTurnClient {
|
||||
request: AppServerRequestClient["request"];
|
||||
connect(): Promise<unknown>;
|
||||
disconnect(): void;
|
||||
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<{ thread: { id: string } }>;
|
||||
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<{ turn: TurnRecord }>;
|
||||
deleteThread(threadId: string, options?: { timeoutMs?: number }): Promise<unknown>;
|
||||
}
|
||||
|
||||
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
||||
|
|
@ -122,7 +118,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
await runAbortable(client.connect());
|
||||
const runtime = options.resolveRuntime ? await runAbortable(options.resolveRuntime(client)) : (options.runtime ?? {});
|
||||
const threadResponse = await runAbortable(
|
||||
client.startEphemeralThread({
|
||||
startEphemeralThread(client, {
|
||||
cwd: options.cwd,
|
||||
serviceName: options.serviceName,
|
||||
developerInstructions: options.developerInstructions,
|
||||
|
|
@ -137,7 +133,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
}),
|
||||
};
|
||||
const turnResponse = await runAbortable(
|
||||
client.startStructuredTurn({
|
||||
startStructuredTurn(client, {
|
||||
threadId,
|
||||
cwd: options.cwd,
|
||||
text: options.prompt,
|
||||
|
|
@ -265,7 +261,7 @@ function turnWithCollectedItems(turn: TurnRecord, completedItems: readonly TurnI
|
|||
async function deleteEphemeralStructuredTurnThread(client: EphemeralStructuredTurnClient, threadId: string | null): Promise<void> {
|
||||
if (!threadId) return;
|
||||
try {
|
||||
await client.deleteThread(threadId, { timeoutMs: EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS });
|
||||
await deleteThread(client, threadId, { timeoutMs: EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS });
|
||||
} catch {
|
||||
// Ephemeral helpers must not fail visible workflows because cleanup raced app-server shutdown.
|
||||
}
|
||||
|
|
|
|||
10
src/app-server/services/request-client.ts
Normal file
10
src/app-server/services/request-client.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ClientResponseByMethod, TypedClientRequestMethod } from "../connection/client";
|
||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||
|
||||
export interface AppServerRequestClient {
|
||||
request<M extends TypedClientRequestMethod>(
|
||||
method: M,
|
||||
params: ClientRequestParams<M>,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<ClientResponseByMethod[M]>;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "./thread-archive-markdown";
|
||||
import { readThreadForArchiveExport } from "./threads";
|
||||
import { archiveThread, readThreadForArchiveExport } from "./threads";
|
||||
|
||||
export interface ArchiveThreadOptions {
|
||||
settings: ArchiveExportSettings;
|
||||
|
|
@ -16,7 +16,7 @@ export interface ArchiveThreadResult {
|
|||
}
|
||||
|
||||
export async function archiveThreadOnAppServer(
|
||||
client: AppServerClient,
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
options: ArchiveThreadOptions,
|
||||
): Promise<ArchiveThreadResult> {
|
||||
|
|
@ -30,6 +30,6 @@ export async function archiveThreadOnAppServer(
|
|||
exportedPath = result.path;
|
||||
}
|
||||
|
||||
await client.archiveThread(threadId);
|
||||
await archiveThread(client, threadId);
|
||||
return { exportedPath };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { normalizeReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import type { ApprovalsReviewer, ServiceTier } from "../../domain/runtime/policy";
|
||||
import { parseServiceTier } from "../../domain/runtime/policy";
|
||||
import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings";
|
||||
import type { ThreadActivationSnapshot } from "../../domain/threads/activation";
|
||||
import type { ArchiveThreadInput } from "../../domain/threads/archive-markdown";
|
||||
import type { ThreadGoal, ThreadGoalUpdate } from "../../domain/threads/goal";
|
||||
|
|
@ -8,22 +9,25 @@ import type { HistoricalTurn } from "../../domain/threads/history";
|
|||
import type { Thread } from "../../domain/threads/model";
|
||||
import { REFERENCED_THREAD_TURN_LIMIT } from "../../domain/threads/reference";
|
||||
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { ClientResponseByMethod } from "../connection/client";
|
||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||
import { type ThreadRecord, threadFromThreadRecord, threadsFromThreadRecords } from "../protocol/thread";
|
||||
import { appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal";
|
||||
import { appServerThreadGoalUpdate, appServerThreadGoalUserHistoryItem, threadGoalFromAppServerGoal } from "../protocol/thread-goal";
|
||||
import { appServerRuntimeSettingsPatch } from "../protocol/thread-settings";
|
||||
import {
|
||||
chronologicalConversationSummariesFromTurnRecords,
|
||||
completedConversationSummariesFromTurnRecords,
|
||||
transcriptEntriesFromTurnRecords,
|
||||
} from "../protocol/turn";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
|
||||
const THREAD_LIST_PAGE_LIMIT = 100;
|
||||
|
||||
export type ThreadTurnSortDirection = "asc" | "desc";
|
||||
export type ThreadConversationSummaryClient = Pick<AppServerClient, "threadTurnsList">;
|
||||
export type ThreadForkClient = Pick<AppServerClient, "forkThread">;
|
||||
export type ThreadRollbackClient = Pick<AppServerClient, "rollbackThread">;
|
||||
export type ThreadCompactionClient = Pick<AppServerClient, "compactThread">;
|
||||
export type ThreadConversationSummaryClient = AppServerRequestClient;
|
||||
export type ThreadForkClient = AppServerRequestClient;
|
||||
export type ThreadRollbackClient = AppServerRequestClient;
|
||||
export type ThreadCompactionClient = AppServerRequestClient;
|
||||
|
||||
interface ThreadConversationSummaryPage {
|
||||
summaries: ThreadConversationSummary[];
|
||||
|
|
@ -39,14 +43,73 @@ interface ThreadActivationResponse {
|
|||
reasoningEffort: string | null;
|
||||
}
|
||||
|
||||
export async function listThreads(client: AppServerClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
|
||||
export interface AppServerStartThreadOptions {
|
||||
cwd: string;
|
||||
serviceTier?: RuntimeServiceTierRequest;
|
||||
}
|
||||
|
||||
export interface AppServerStartEphemeralThreadOptions {
|
||||
cwd: string;
|
||||
serviceName: string;
|
||||
developerInstructions: string;
|
||||
}
|
||||
|
||||
interface AppServerThreadListOptions {
|
||||
archived?: boolean;
|
||||
cursor?: string | null;
|
||||
limit?: number | null;
|
||||
}
|
||||
|
||||
export function startThread(
|
||||
client: AppServerRequestClient,
|
||||
options: AppServerStartThreadOptions,
|
||||
): Promise<ClientResponseByMethod["thread/start"]> {
|
||||
const { cwd, serviceTier } = options;
|
||||
return client.request("thread/start", {
|
||||
cwd,
|
||||
serviceName: "codex-panel",
|
||||
...(serviceTier !== undefined ? { serviceTier } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function startEphemeralThread(
|
||||
client: AppServerRequestClient,
|
||||
options: AppServerStartEphemeralThreadOptions,
|
||||
): Promise<ClientResponseByMethod["thread/start"]> {
|
||||
const { cwd, serviceName, developerInstructions } = options;
|
||||
return client.request("thread/start", {
|
||||
cwd,
|
||||
serviceName,
|
||||
developerInstructions,
|
||||
ephemeral: true,
|
||||
sandbox: "read-only",
|
||||
approvalPolicy: "never",
|
||||
multiAgentMode: "none",
|
||||
environments: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function resumeThread(
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
cwd: string,
|
||||
): Promise<ClientResponseByMethod["thread/resume"]> {
|
||||
return client.request("thread/resume", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listThreads(client: AppServerRequestClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
|
||||
const archived = options.archived ?? false;
|
||||
const records: ThreadRecord[] = [];
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
for (;;) {
|
||||
const response = await client.listThreads(cwd, {
|
||||
const response = await listThreadPage(client, cwd, {
|
||||
archived,
|
||||
cursor,
|
||||
limit: THREAD_LIST_PAGE_LIMIT,
|
||||
|
|
@ -68,8 +131,8 @@ export function threadFromAppServerRecord(thread: ThreadRecord, options: { archi
|
|||
return threadFromThreadRecord(thread, options);
|
||||
}
|
||||
|
||||
export async function readThreadForArchiveExport(client: AppServerClient, threadId: string): Promise<ArchiveThreadInput> {
|
||||
const response = await client.readThread(threadId, true);
|
||||
export async function readThreadForArchiveExport(client: AppServerRequestClient, threadId: string): Promise<ArchiveThreadInput> {
|
||||
const response = await client.request("thread/read", { threadId, includeTurns: true });
|
||||
return {
|
||||
...threadFromThreadRecord(response.thread, { archived: true }),
|
||||
transcriptEntries: transcriptEntriesFromTurnRecords(response.thread.turns),
|
||||
|
|
@ -83,7 +146,7 @@ export async function readCompletedConversationSummariesPage(
|
|||
limit: number,
|
||||
sortDirection: ThreadTurnSortDirection = "asc",
|
||||
): Promise<ThreadConversationSummaryPage> {
|
||||
const response = await client.threadTurnsList(threadId, cursor, limit, sortDirection);
|
||||
const response = await listThreadTurns(client, threadId, cursor, limit, sortDirection);
|
||||
return {
|
||||
summaries: completedConversationSummariesFromTurnRecords(response.data),
|
||||
nextCursor: response.nextCursor,
|
||||
|
|
@ -95,7 +158,7 @@ export async function readReferencedThreadConversationSummaries(
|
|||
threadId: string,
|
||||
limit = REFERENCED_THREAD_TURN_LIMIT,
|
||||
): Promise<ThreadConversationSummary[]> {
|
||||
const response = await client.threadTurnsList(threadId, null, limit);
|
||||
const response = await listThreadTurns(client, threadId, null, limit);
|
||||
return chronologicalConversationSummariesFromTurnRecords(response.data);
|
||||
}
|
||||
|
||||
|
|
@ -121,21 +184,33 @@ function threadRollbackSnapshotFromAppServerResponse(response: ThreadRollbackRes
|
|||
}
|
||||
|
||||
export async function rollbackThread(client: ThreadRollbackClient, threadId: string, numTurns?: number): Promise<ThreadRollbackSnapshot> {
|
||||
const response = numTurns === undefined ? await client.rollbackThread(threadId) : await client.rollbackThread(threadId, numTurns);
|
||||
const response = await client.request("thread/rollback", { threadId, numTurns: numTurns ?? 1 });
|
||||
return threadRollbackSnapshotFromAppServerResponse(response);
|
||||
}
|
||||
|
||||
export async function forkThread(client: ThreadForkClient, threadId: string, cwd: string): Promise<Thread> {
|
||||
const response = await client.forkThread(threadId, cwd);
|
||||
const response = await client.request("thread/fork", {
|
||||
threadId,
|
||||
cwd,
|
||||
excludeTurns: true,
|
||||
});
|
||||
return threadFromThreadRecord(response.thread);
|
||||
}
|
||||
|
||||
export async function compactThread(client: ThreadCompactionClient, threadId: string): Promise<void> {
|
||||
await client.compactThread(threadId);
|
||||
await client.request("thread/compact/start", { threadId });
|
||||
}
|
||||
|
||||
export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise<Thread> {
|
||||
const response = await client.unarchiveThread(threadId);
|
||||
export async function archiveThread(client: AppServerRequestClient, threadId: string): Promise<void> {
|
||||
await client.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
export async function deleteThread(client: AppServerRequestClient, threadId: string, options: { timeoutMs?: number } = {}): Promise<void> {
|
||||
await client.request("thread/delete", { threadId }, options);
|
||||
}
|
||||
|
||||
export async function restoreArchivedThread(client: AppServerRequestClient, threadId: string): Promise<Thread> {
|
||||
const response = await client.request("thread/unarchive", { threadId });
|
||||
return threadFromThreadRecord(response.thread);
|
||||
}
|
||||
|
||||
|
|
@ -150,16 +225,72 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
|
|||
};
|
||||
}
|
||||
|
||||
export async function readThreadGoal(client: AppServerClient, threadId: string): Promise<ThreadGoal | null> {
|
||||
const response = await client.getThreadGoal(threadId);
|
||||
export async function readThreadGoal(client: AppServerRequestClient, threadId: string): Promise<ThreadGoal | null> {
|
||||
const response = await client.request("thread/goal/get", { threadId });
|
||||
return threadGoalFromAppServerGoal(response.goal);
|
||||
}
|
||||
|
||||
export async function setThreadGoal(client: AppServerClient, threadId: string, params: ThreadGoalUpdate): Promise<ThreadGoal | null> {
|
||||
const response = await client.setThreadGoal(threadId, params);
|
||||
export async function setThreadGoal(
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
params: ThreadGoalUpdate,
|
||||
): Promise<ThreadGoal | null> {
|
||||
const response = await client.request("thread/goal/set", { threadId, ...appServerThreadGoalUpdate(params) });
|
||||
return threadGoalFromAppServerGoal(response.goal);
|
||||
}
|
||||
|
||||
export async function recordThreadGoalUserMessage(client: AppServerClient, threadId: string, objective: string): Promise<void> {
|
||||
await client.injectThreadItems(threadId, [appServerThreadGoalUserHistoryItem(objective)]);
|
||||
export async function clearThreadGoal(client: AppServerRequestClient, threadId: string): Promise<void> {
|
||||
await client.request("thread/goal/clear", { threadId });
|
||||
}
|
||||
|
||||
export async function recordThreadGoalUserMessage(client: AppServerRequestClient, threadId: string, objective: string): Promise<void> {
|
||||
await client.request("thread/inject_items", { threadId, items: [appServerThreadGoalUserHistoryItem(objective)] });
|
||||
}
|
||||
|
||||
export async function renameThread(client: AppServerRequestClient, threadId: string, name: string): Promise<void> {
|
||||
await client.request("thread/name/set", { threadId, name });
|
||||
}
|
||||
|
||||
export async function updateThreadSettings(
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
settings: RuntimeSettingsPatch,
|
||||
): Promise<void> {
|
||||
await client.request("thread/settings/update", { threadId, ...appServerRuntimeSettingsPatch(settings) });
|
||||
}
|
||||
|
||||
export function readThreadRolloutFile(
|
||||
client: AppServerRequestClient,
|
||||
path: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<ClientResponseByMethod["fs/readFile"]> {
|
||||
return client.request("fs/readFile", { path }, options);
|
||||
}
|
||||
|
||||
export function listThreadTurns(
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
cursor: string | null = null,
|
||||
limit = 20,
|
||||
sortDirection: ClientRequestParams<"thread/turns/list">["sortDirection"] = "desc",
|
||||
itemsView: ClientRequestParams<"thread/turns/list">["itemsView"] = "full",
|
||||
) {
|
||||
return client.request("thread/turns/list", {
|
||||
threadId,
|
||||
cursor,
|
||||
limit,
|
||||
sortDirection,
|
||||
itemsView,
|
||||
});
|
||||
}
|
||||
|
||||
function listThreadPage(client: AppServerRequestClient, cwd: string, options: AppServerThreadListOptions) {
|
||||
return client.request("thread/list", {
|
||||
cwd,
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(options.limit === undefined ? {} : { limit: options.limit }),
|
||||
archived: options.archived ?? false,
|
||||
sortKey: "recency_at",
|
||||
sortDirection: "desc",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import {
|
|||
mcpServerStatusSummariesFromStatuses,
|
||||
} from "../../domain/server/diagnostics";
|
||||
import type { ToolInventoryMarketplaceError, ToolInventoryPlugin, ToolInventorySnapshot } from "../../domain/server/tool-inventory";
|
||||
import type { AppServerClient } from "../connection/client";
|
||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||
import { toolInventoryPluginsFromInstalledResponse } from "../protocol/tool-inventory";
|
||||
import { listSkillCatalog } from "./catalog";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
|
||||
const PLUGIN_DETAILS_CONCURRENCY = 4;
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ export interface ReadToolInventoryResult {
|
|||
}
|
||||
|
||||
export async function readToolInventory(
|
||||
client: AppServerClient,
|
||||
client: AppServerRequestClient,
|
||||
cwd: string,
|
||||
options: ReadToolInventoryOptions = {},
|
||||
): Promise<ReadToolInventoryResult> {
|
||||
|
|
@ -74,7 +75,7 @@ function readCachedSkills(
|
|||
}
|
||||
|
||||
async function readPlugins(
|
||||
client: AppServerClient,
|
||||
client: AppServerRequestClient,
|
||||
cwd: string,
|
||||
checkedAt: number,
|
||||
): Promise<{
|
||||
|
|
@ -84,7 +85,7 @@ async function readPlugins(
|
|||
probe: DiagnosticProbeResult;
|
||||
}> {
|
||||
try {
|
||||
const response = await client.listInstalledPlugins(cwd);
|
||||
const response = await client.request("plugin/installed", { cwds: [cwd] });
|
||||
const { plugins, marketplaceErrors } = toolInventoryPluginsFromInstalledResponse(response);
|
||||
const pluginsWithDetails = await mapLimit(plugins, PLUGIN_DETAILS_CONCURRENCY, (plugin) => readPluginDetails(client, plugin));
|
||||
return {
|
||||
|
|
@ -121,10 +122,10 @@ async function mapLimit<T, R>(items: readonly T[], concurrency: number, fn: (ite
|
|||
return results;
|
||||
}
|
||||
|
||||
async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
|
||||
async function readPluginDetails(client: AppServerRequestClient, plugin: ToolInventoryPlugin): Promise<ToolInventoryPlugin> {
|
||||
if (!isUsablePlugin(plugin)) return plugin;
|
||||
try {
|
||||
const response = await client.readPlugin(pluginReadParams(plugin));
|
||||
const response = await client.request("plugin/read", pluginReadParams(plugin));
|
||||
return {
|
||||
...plugin,
|
||||
details: {
|
||||
|
|
@ -140,7 +141,7 @@ async function readPluginDetails(client: AppServerClient, plugin: ToolInventoryP
|
|||
}
|
||||
}
|
||||
|
||||
function pluginReadParams(plugin: ToolInventoryPlugin): Parameters<AppServerClient["readPlugin"]>[0] {
|
||||
function pluginReadParams(plugin: ToolInventoryPlugin): ClientRequestParams<"plugin/read"> {
|
||||
if (plugin.marketplacePath) {
|
||||
return {
|
||||
pluginName: plugin.name,
|
||||
|
|
@ -158,12 +159,12 @@ function isUsablePlugin(plugin: ToolInventoryPlugin): boolean {
|
|||
}
|
||||
|
||||
async function readMcpServers(
|
||||
client: AppServerClient,
|
||||
client: AppServerRequestClient,
|
||||
threadId: string | null,
|
||||
checkedAt: number,
|
||||
): Promise<{ items: McpServerStatusSummary[] | null; error: string | null; probe: DiagnosticProbeResult }> {
|
||||
try {
|
||||
const response = await client.listMcpServerStatus({
|
||||
const response = await client.request("mcpServerStatus/list", {
|
||||
detail: "toolsAndAuthOnly",
|
||||
limit: 100,
|
||||
...(threadId ? { threadId } : {}),
|
||||
|
|
@ -189,7 +190,7 @@ function mcpSummary(servers: readonly McpServerStatusSummary[]): string {
|
|||
}
|
||||
|
||||
async function readSkills(
|
||||
client: AppServerClient,
|
||||
client: AppServerRequestClient,
|
||||
cwd: string,
|
||||
checkedAt: number,
|
||||
): Promise<{ items: ToolInventorySnapshot["skills"]; error: string | null; probe: DiagnosticProbeResult }> {
|
||||
|
|
|
|||
107
src/app-server/services/turns.ts
Normal file
107
src/app-server/services/turns.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { CodexInput } from "../../domain/chat/input";
|
||||
import type { ClientResponseByMethod } from "../connection/client";
|
||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
|
||||
import type { AppServerRequestClient } from "./request-client";
|
||||
|
||||
type AppServerTurnRuntimeOverrides = Partial<AppServerTurnRuntimeParams>;
|
||||
|
||||
type AppServerTurnRuntimeParams = Pick<
|
||||
ClientRequestParams<"turn/start">,
|
||||
"serviceTier" | "collaborationMode" | "model" | "effort" | "approvalsReviewer"
|
||||
>;
|
||||
|
||||
export interface AppServerStartTurnOptions {
|
||||
threadId: string;
|
||||
cwd: string;
|
||||
input: string | CodexInput;
|
||||
clientUserMessageId?: string | null;
|
||||
runtime?: AppServerTurnRuntimeOverrides;
|
||||
}
|
||||
|
||||
export interface AppServerStartStructuredTurnOptions {
|
||||
threadId: string;
|
||||
cwd: string;
|
||||
text: string;
|
||||
outputSchema: NonNullable<ClientRequestParams<"turn/start">["outputSchema"]>;
|
||||
runtime?: AppServerTurnRuntimeOverrides;
|
||||
}
|
||||
|
||||
export function startTurn(
|
||||
client: AppServerRequestClient,
|
||||
options: AppServerStartTurnOptions,
|
||||
): Promise<ClientResponseByMethod["turn/start"]> {
|
||||
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
|
||||
const additionalContext = toAdditionalContext(input);
|
||||
const params: ClientRequestParams<"turn/start"> = {
|
||||
threadId,
|
||||
cwd,
|
||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
||||
...appServerTurnRuntimeParams(runtime),
|
||||
input: toUserInput(input),
|
||||
};
|
||||
return client.request("turn/start", params);
|
||||
}
|
||||
|
||||
export function startStructuredTurn(
|
||||
client: AppServerRequestClient,
|
||||
options: AppServerStartStructuredTurnOptions,
|
||||
): Promise<ClientResponseByMethod["turn/start"]> {
|
||||
const { threadId, cwd, text, outputSchema, runtime } = options;
|
||||
const params: ClientRequestParams<"turn/start"> = {
|
||||
threadId,
|
||||
cwd,
|
||||
input: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
outputSchema,
|
||||
...appServerTurnRuntimeParams(runtime),
|
||||
};
|
||||
return client.request("turn/start", params);
|
||||
}
|
||||
|
||||
export function steerTurn(
|
||||
client: AppServerRequestClient,
|
||||
threadId: string,
|
||||
expectedTurnId: string,
|
||||
input: string | CodexInput,
|
||||
clientUserMessageId?: string | null,
|
||||
): Promise<unknown> {
|
||||
const additionalContext = toAdditionalContext(input);
|
||||
return client.request("turn/steer", {
|
||||
threadId,
|
||||
expectedTurnId,
|
||||
input: toUserInput(input),
|
||||
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
|
||||
...(additionalContext !== undefined ? { additionalContext } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function interruptTurn(client: AppServerRequestClient, threadId: string, turnId: string): Promise<unknown> {
|
||||
return client.request("turn/interrupt", { threadId, turnId });
|
||||
}
|
||||
|
||||
function toUserInput(input: string | CodexInput): ClientRequestParams<"turn/start">["input"] {
|
||||
if (typeof input !== "string") return toAppServerUserInput(input);
|
||||
return toAppServerUserInput([{ type: "text", text: input }]);
|
||||
}
|
||||
|
||||
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
|
||||
if (typeof input === "string") return undefined;
|
||||
return additionalContextFromCodexInput(input);
|
||||
}
|
||||
|
||||
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
|
||||
const params: AppServerTurnRuntimeParams = {};
|
||||
if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier;
|
||||
if (runtime?.collaborationMode !== undefined) params.collaborationMode = runtime.collaborationMode;
|
||||
if (runtime?.model !== undefined) params.model = runtime.model;
|
||||
if (runtime?.effort !== undefined) params.effort = runtime.effort;
|
||||
if (runtime?.approvalsReviewer !== undefined) params.approvalsReviewer = runtime.approvalsReviewer;
|
||||
return params;
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { AppServerClient } from "../../../../app-server/connection/client";
|
||||
import { threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads";
|
||||
import type { AppServerRequestClient } from "../../../../app-server/services/request-client";
|
||||
import { listThreadTurns, resumeThread, threadActivationSnapshotFromAppServerResponse } from "../../../../app-server/services/threads";
|
||||
import type { ThreadTurnsPage } from "../../../../domain/threads/history";
|
||||
import type { ThreadHistoryPage, ThreadResumeSnapshot } from "../../application/threads/thread-loading-transport";
|
||||
import { messageStreamItemsFromTurns } from "../mappers/message-stream/turn-items";
|
||||
|
||||
type ChatThreadHistoryClient = Pick<AppServerClient, "threadTurnsList">;
|
||||
type ChatThreadResumeClient = Pick<AppServerClient, "resumeThread">;
|
||||
type ChatThreadHistoryClient = AppServerRequestClient;
|
||||
type ChatThreadResumeClient = AppServerRequestClient;
|
||||
|
||||
interface AppServerThreadTurnsPage {
|
||||
readonly data: ThreadTurnsPage["turns"];
|
||||
|
|
@ -18,7 +18,7 @@ export async function readChatThreadHistoryPage(
|
|||
cursor: string | null,
|
||||
limit = 20,
|
||||
): Promise<ThreadHistoryPage> {
|
||||
return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await client.threadTurnsList(threadId, cursor, limit)));
|
||||
return chatThreadHistoryPageFromTurnsPage(threadTurnsPageFromAppServerPage(await listThreadTurns(client, threadId, cursor, limit)));
|
||||
}
|
||||
|
||||
function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistoryPage {
|
||||
|
|
@ -30,7 +30,7 @@ function chatThreadHistoryPageFromTurnsPage(page: ThreadTurnsPage): ThreadHistor
|
|||
}
|
||||
|
||||
export async function resumeChatThread(client: ChatThreadResumeClient, threadId: string, cwd: string): Promise<ThreadResumeSnapshot> {
|
||||
const response = await client.resumeThread(threadId, cwd);
|
||||
const response = await resumeThread(client, threadId, cwd);
|
||||
return {
|
||||
activation: threadActivationSnapshotFromAppServerResponse(response),
|
||||
rolloutPath: response.thread.path,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ?? "";
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import manifest from "../../manifest.json";
|
||||
import type {
|
||||
AppServerServerRequestResponder,
|
||||
AppServerStartStructuredTurnOptions,
|
||||
AppServerStartTurnOptions,
|
||||
} from "../../src/app-server/connection/client";
|
||||
import type { AppServerServerRequestResponder } from "../../src/app-server/connection/client";
|
||||
import { AppServerClient } from "../../src/app-server/connection/client";
|
||||
import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages";
|
||||
import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport";
|
||||
|
|
@ -94,6 +90,14 @@ async function expectRequest(
|
|||
await request;
|
||||
}
|
||||
|
||||
function listModels(client: AppServerClient): Promise<unknown> {
|
||||
return client.request("model/list", { includeHidden: false, limit: 100 });
|
||||
}
|
||||
|
||||
function readFile(client: AppServerClient, path: string, options: { timeoutMs?: number } = {}): Promise<unknown> {
|
||||
return client.request("fs/readFile", { path }, options);
|
||||
}
|
||||
|
||||
describe("AppServerClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", {
|
||||
|
|
@ -176,16 +180,19 @@ describe("AppServerClient", () => {
|
|||
expect(latestSent(getTransport())).toEqual({ id: 99, error: { code: -32601, message: "Request not handled." } });
|
||||
});
|
||||
|
||||
it("injects raw items into a thread", async () => {
|
||||
it("sends typed client requests", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const request = client.injectThreadItems("thread-1", [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Ship this" }],
|
||||
},
|
||||
]);
|
||||
const request = client.request("thread/inject_items", {
|
||||
threadId: "thread-1",
|
||||
items: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Ship this" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expectRequest(
|
||||
transport,
|
||||
|
|
@ -221,7 +228,7 @@ describe("AppServerClient", () => {
|
|||
|
||||
it("clears initialized state and rejects pending requests on disconnect", async () => {
|
||||
const { client } = await connectedClient();
|
||||
const listing = client.listModels();
|
||||
const listing = listModels(client);
|
||||
|
||||
client.disconnect();
|
||||
|
||||
|
|
@ -279,7 +286,7 @@ describe("AppServerClient", () => {
|
|||
const connecting = client.connect();
|
||||
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
|
||||
await connecting;
|
||||
const listing = client.listModels();
|
||||
const listing = listModels(client);
|
||||
|
||||
transport.emitError(new Error("transport failed"));
|
||||
|
||||
|
|
@ -472,89 +479,6 @@ describe("AppServerClient", () => {
|
|||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends typed turn steering requests", async () => {
|
||||
let transport: FakeTransport;
|
||||
const getTransport = () => transport;
|
||||
const client = new AppServerClient(
|
||||
"/bin/codex",
|
||||
"/vault",
|
||||
{
|
||||
onNotification: () => undefined,
|
||||
onServerRequest: () => undefined,
|
||||
onLog: () => undefined,
|
||||
onExit: () => undefined,
|
||||
},
|
||||
500,
|
||||
(handlers) => {
|
||||
transport = new FakeTransport(handlers);
|
||||
return transport;
|
||||
},
|
||||
);
|
||||
|
||||
const connecting = client.connect();
|
||||
getTransport().emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
|
||||
await connecting;
|
||||
|
||||
const steering = client.steerTurn("thread-1", "turn-1", "Please adjust course.", "local-steer-1");
|
||||
expect(getTransport().sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
clientUserMessageId: "local-steer-1",
|
||||
input: [{ type: "text", text: "Please adjust course.", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
getTransport().emitLine({ id: 2, result: { turnId: "turn-1" } });
|
||||
await expect(steering).resolves.toEqual({ turnId: "turn-1" });
|
||||
});
|
||||
|
||||
it("reads files through app-server fs requests", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const reading = client.readFile("/tmp/rollout.jsonl");
|
||||
|
||||
await expectRequest(
|
||||
transport,
|
||||
reading,
|
||||
{ method: "fs/readFile", params: { path: "/tmp/rollout.jsonl" } },
|
||||
{ dataBase64: btoa("hello") },
|
||||
);
|
||||
await expect(reading).resolves.toEqual({ dataBase64: btoa("hello") });
|
||||
});
|
||||
|
||||
it("sends thread goal management requests", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const reading = client.getThreadGoal("thread");
|
||||
await expectRequest(transport, reading, { method: "thread/goal/get", params: { threadId: "thread" } }, { goal: null });
|
||||
await expect(reading).resolves.toEqual({ goal: null });
|
||||
|
||||
const setting = client.setThreadGoal("thread", { objective: "Finish", status: "active", tokenBudget: 1000 });
|
||||
const goal = {
|
||||
threadId: "thread",
|
||||
objective: "Finish",
|
||||
status: "active",
|
||||
tokenBudget: 1000,
|
||||
tokensUsed: 0,
|
||||
timeUsedSeconds: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
await expectRequest(
|
||||
transport,
|
||||
setting,
|
||||
{ method: "thread/goal/set", params: { threadId: "thread", objective: "Finish", status: "active", tokenBudget: 1000 } },
|
||||
{ goal },
|
||||
);
|
||||
await expect(setting).resolves.toEqual({ goal });
|
||||
|
||||
const clearing = client.clearThreadGoal("thread");
|
||||
await expectRequest(transport, clearing, { method: "thread/goal/clear", params: { threadId: "thread" } }, { cleared: true });
|
||||
await expect(clearing).resolves.toEqual({ cleared: true });
|
||||
});
|
||||
|
||||
it("suppresses late responses after per-request timeouts", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("window", {
|
||||
|
|
@ -582,7 +506,7 @@ describe("AppServerClient", () => {
|
|||
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
|
||||
await connecting;
|
||||
|
||||
const reading = client.readFile("/tmp/slow.jsonl", { timeoutMs: 10 });
|
||||
const reading = readFile(client, "/tmp/slow.jsonl", { timeoutMs: 10 });
|
||||
const rejection = expect(reading).rejects.toThrow("Codex app-server request timed out: fs/readFile");
|
||||
const sent = latestSent(transport);
|
||||
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
|
||||
|
|
@ -623,7 +547,7 @@ describe("AppServerClient", () => {
|
|||
|
||||
const timedOutRequests: { id: number; rejection: Promise<void> }[] = [];
|
||||
for (let index = 0; index < 257; index += 1) {
|
||||
const promise = client.readFile(`/tmp/slow-${String(index)}.jsonl`, { timeoutMs: 10 });
|
||||
const promise = readFile(client, `/tmp/slow-${String(index)}.jsonl`, { timeoutMs: 10 });
|
||||
const rejection = expect(promise).rejects.toThrow("Codex app-server request timed out");
|
||||
const sent = latestSent(transport);
|
||||
if (!("id" in sent) || typeof sent.id !== "number") throw new Error("Expected an app-server request id.");
|
||||
|
|
@ -645,408 +569,10 @@ describe("AppServerClient", () => {
|
|||
expect(logs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("sends golden thread and turn request payloads", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const startingThread = client.startThread({ cwd: "/vault", serviceTier: "fast" });
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "thread/start",
|
||||
params: {
|
||||
cwd: "/vault",
|
||||
serviceName: "codex-panel",
|
||||
serviceTier: "fast",
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, serviceTier: "fast" } });
|
||||
await startingThread;
|
||||
|
||||
const startingTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "hello",
|
||||
clientUserMessageId: "local-user-1",
|
||||
runtime: { serviceTier: null },
|
||||
});
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
id: 3,
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
clientUserMessageId: "local-user-1",
|
||||
serviceTier: null,
|
||||
input: [{ type: "text", text: "hello", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { turn: { id: "turn-1" } } });
|
||||
await startingTurn;
|
||||
});
|
||||
|
||||
it("sends structured user input for turns and steering", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
const input = [
|
||||
{ type: "text" as const, text: "Read [[Alpha]].", text_elements: [] },
|
||||
{ type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" },
|
||||
{
|
||||
type: "additionalContext" as const,
|
||||
key: "codex_panel_wikilinks",
|
||||
kind: "untrusted" as const,
|
||||
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md",
|
||||
},
|
||||
];
|
||||
const serializedInput = [
|
||||
{ type: "text" as const, text: "Read [[Alpha]].", text_elements: [] },
|
||||
{ type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" },
|
||||
];
|
||||
const additionalContext = {
|
||||
codex_panel_wikilinks: {
|
||||
kind: "untrusted",
|
||||
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md",
|
||||
},
|
||||
};
|
||||
|
||||
const startingTurn = client.startTurn({ threadId: "thread-1", cwd: "/vault", input });
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: serializedInput,
|
||||
additionalContext,
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { turn: { id: "turn-1" } } });
|
||||
await startingTurn;
|
||||
|
||||
const steering = client.steerTurn("thread-1", "turn-1", input);
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: serializedInput,
|
||||
additionalContext,
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { turnId: "turn-1" } });
|
||||
await steering;
|
||||
});
|
||||
|
||||
it("sends explicit null service tier when fast mode is disabled", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const startingThread = client.startThread({ cwd: "/vault", serviceTier: null });
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "thread/start",
|
||||
params: { cwd: "/vault", serviceTier: null },
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, serviceTier: null } });
|
||||
await startingThread;
|
||||
});
|
||||
|
||||
it("sends collaboration mode only for Plan turns", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const defaultTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "default",
|
||||
runtime: { serviceTier: null },
|
||||
});
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: [{ type: "text", text: "default", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
expect((transport.sent[2] as { params?: Record<string, unknown> }).params?.["collaborationMode"]).toBeUndefined();
|
||||
transport.emitLine({ id: 2, result: { turn: { id: "turn-default" } } });
|
||||
await defaultTurn;
|
||||
|
||||
const planTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "plan",
|
||||
runtime: {
|
||||
serviceTier: null,
|
||||
collaborationMode: {
|
||||
mode: "plan",
|
||||
settings: {
|
||||
model: "gpt-5.5",
|
||||
reasoning_effort: "medium",
|
||||
developer_instructions: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: [{ type: "text", text: "plan", text_elements: [] }],
|
||||
collaborationMode: {
|
||||
mode: "plan",
|
||||
settings: {
|
||||
model: "gpt-5.5",
|
||||
reasoning_effort: "medium",
|
||||
developer_instructions: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { turn: { id: "turn-plan" } } });
|
||||
await planTurn;
|
||||
});
|
||||
|
||||
it("sends model and effort turn overrides when provided", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const startingTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "override",
|
||||
runtime: { serviceTier: null, model: "gpt-5.5", effort: "high" },
|
||||
});
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
model: "gpt-5.5",
|
||||
effort: "high",
|
||||
input: [{ type: "text", text: "override", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { turn: { id: "turn-override" } } });
|
||||
await startingTurn;
|
||||
|
||||
const resetTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "reset",
|
||||
runtime: { serviceTier: null, model: null, effort: null, approvalsReviewer: null },
|
||||
});
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
effort: null,
|
||||
approvalsReviewer: null,
|
||||
input: [{ type: "text", text: "reset", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { turn: { id: "turn-reset" } } });
|
||||
await resetTurn;
|
||||
});
|
||||
|
||||
it("omits undefined runtime turn overrides while preserving explicit null resets", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const runtimeWithUndefined = {
|
||||
serviceTier: null,
|
||||
collaborationMode: undefined,
|
||||
model: undefined,
|
||||
effort: undefined,
|
||||
approvalsReviewer: undefined,
|
||||
} as unknown as NonNullable<AppServerStartTurnOptions["runtime"]>;
|
||||
const turn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "runtime boundary",
|
||||
runtime: runtimeWithUndefined,
|
||||
});
|
||||
const params = (transport.sent[2] as { params?: Record<string, unknown> }).params ?? {};
|
||||
expect(params).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
serviceTier: null,
|
||||
input: [{ type: "text", text: "runtime boundary", text_elements: [] }],
|
||||
});
|
||||
expect(params).not.toHaveProperty("collaborationMode");
|
||||
expect(params).not.toHaveProperty("model");
|
||||
expect(params).not.toHaveProperty("effort");
|
||||
expect(params).not.toHaveProperty("approvalsReviewer");
|
||||
transport.emitLine({ id: 2, result: { turn: { id: "turn-runtime-boundary" } } });
|
||||
await turn;
|
||||
|
||||
const structuredRuntimeWithUndefined = {
|
||||
serviceTier: undefined,
|
||||
collaborationMode: undefined,
|
||||
model: undefined,
|
||||
effort: undefined,
|
||||
approvalsReviewer: undefined,
|
||||
} as unknown as NonNullable<AppServerStartStructuredTurnOptions["runtime"]>;
|
||||
const structuredTurn = client.startStructuredTurn({
|
||||
threadId: "structured-thread",
|
||||
cwd: "/vault",
|
||||
text: "structured runtime boundary",
|
||||
outputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
runtime: structuredRuntimeWithUndefined,
|
||||
});
|
||||
const structuredParams = (transport.sent[3] as { params?: Record<string, unknown> }).params ?? {};
|
||||
expect(structuredParams).toMatchObject({
|
||||
threadId: "structured-thread",
|
||||
cwd: "/vault",
|
||||
input: [{ type: "text", text: "structured runtime boundary", text_elements: [] }],
|
||||
outputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
});
|
||||
expect(structuredParams).not.toHaveProperty("serviceTier");
|
||||
expect(structuredParams).not.toHaveProperty("collaborationMode");
|
||||
expect(structuredParams).not.toHaveProperty("model");
|
||||
expect(structuredParams).not.toHaveProperty("effort");
|
||||
expect(structuredParams).not.toHaveProperty("approvalsReviewer");
|
||||
transport.emitLine({ id: 3, result: { turn: { id: "turn-structured-runtime-boundary" } } });
|
||||
await structuredTurn;
|
||||
});
|
||||
|
||||
it("sends approval reviewer turn overrides when provided", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const autoReviewTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "review this",
|
||||
runtime: { serviceTier: null, approvalsReviewer: "auto_review" },
|
||||
});
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
approvalsReviewer: "auto_review",
|
||||
input: [{ type: "text", text: "review this", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { turn: { id: "turn-auto-review" } } });
|
||||
await autoReviewTurn;
|
||||
|
||||
const userReviewTurn = client.startTurn({
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
input: "ask me",
|
||||
runtime: { serviceTier: null, approvalsReviewer: "user" },
|
||||
});
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
approvalsReviewer: "user",
|
||||
input: [{ type: "text", text: "ask me", text_elements: [] }],
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { turn: { id: "turn-user-review" } } });
|
||||
await userReviewTurn;
|
||||
});
|
||||
|
||||
it("sends thread settings updates for subsequent turns", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const update = client.updateThreadSettings("thread-1", {
|
||||
model: "gpt-5.5",
|
||||
effort: "high",
|
||||
serviceTier: "fast",
|
||||
approvalsReviewer: "auto_review",
|
||||
collaborationMode: {
|
||||
mode: "plan",
|
||||
settings: {
|
||||
model: "gpt-5.5",
|
||||
reasoningEffort: "high",
|
||||
developerInstructions: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
method: "thread/settings/update",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
model: "gpt-5.5",
|
||||
effort: "high",
|
||||
serviceTier: "fast",
|
||||
approvalsReviewer: "auto_review",
|
||||
collaborationMode: {
|
||||
mode: "plan",
|
||||
settings: {
|
||||
model: "gpt-5.5",
|
||||
reasoning_effort: "high",
|
||||
developer_instructions: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: {} });
|
||||
await update;
|
||||
});
|
||||
|
||||
it("sends inventory and diagnostic requests with panel-specific query bounds", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.readEffectiveConfig("/vault"),
|
||||
{ method: "config/read", params: { cwd: "/vault", includeLayers: true } },
|
||||
{ config: {}, origins: {}, layers: [] },
|
||||
);
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.listModels(),
|
||||
{ method: "model/list", params: { includeHidden: false, limit: 100 } },
|
||||
{ data: [], nextCursor: null },
|
||||
);
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.listThreads("/vault", { archived: true }),
|
||||
{ method: "thread/list", params: { cwd: "/vault", archived: true, sortKey: "recency_at", sortDirection: "desc" } },
|
||||
{ data: [], nextCursor: null },
|
||||
);
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.listThreads("/vault", { archived: false, cursor: "cursor-1", limit: 100 }),
|
||||
{
|
||||
method: "thread/list",
|
||||
params: { cwd: "/vault", cursor: "cursor-1", limit: 100, archived: false, sortKey: "recency_at", sortDirection: "desc" },
|
||||
},
|
||||
{ data: [], nextCursor: null },
|
||||
);
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.listSkills("/vault"),
|
||||
{ method: "skills/list", params: { cwds: ["/vault"], forceReload: false } },
|
||||
{ data: [] },
|
||||
);
|
||||
expect((latestSent(transport) as { params?: Record<string, unknown> }).params?.["perCwdExtraUserRoots"]).toBeUndefined();
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.listSkills("/vault", true),
|
||||
{ method: "skills/list", params: { cwds: ["/vault"], forceReload: true } },
|
||||
{ data: [] },
|
||||
);
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.listMcpServerStatus(),
|
||||
{ method: "mcpServerStatus/list", params: { detail: "toolsAndAuthOnly", limit: 100 } },
|
||||
{ data: [], nextCursor: null },
|
||||
);
|
||||
await expectRequest(transport, client.listCollaborationModes(), { method: "collaborationMode/list", params: {} }, { data: [] });
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.readModelProviderCapabilities(),
|
||||
{ method: "modelProvider/capabilities/read", params: {} },
|
||||
{ namespaceTools: true, imageGeneration: false, webSearch: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves app-server RPC error codes", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const listing = client.listModels();
|
||||
const listing = listModels(client);
|
||||
transport.emitLine({ id: 2, error: { code: -32601, message: "Method not found" } });
|
||||
|
||||
await expect(listing).rejects.toMatchObject({
|
||||
|
|
@ -1056,152 +582,4 @@ describe("AppServerClient", () => {
|
|||
message: "Method not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes and forks threads without loading full turn history", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const resuming = client.resumeThread("thread-1", "/vault");
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "thread/resume",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
cwd: "/vault",
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", title: null }, cwd: "/vault" } });
|
||||
await resuming;
|
||||
|
||||
const forking = client.forkThread("thread-1", "/vault");
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
id: 3,
|
||||
method: "thread/fork",
|
||||
params: { threadId: "thread-1", cwd: "/vault", excludeTurns: true },
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { thread: { id: "thread-2", title: null }, cwd: "/vault" } });
|
||||
await forking;
|
||||
});
|
||||
|
||||
it("loads turn history pages in the requested direction with full items", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const turns = client.threadTurnsList("thread-1", "cursor-1", 10);
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "thread/turns/list",
|
||||
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { data: [], nextCursor: null } });
|
||||
await turns;
|
||||
|
||||
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
|
||||
expect(transport.sent[3]).toMatchObject({
|
||||
id: 3,
|
||||
method: "thread/turns/list",
|
||||
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
|
||||
});
|
||||
transport.emitLine({ id: 3, result: { data: [], nextCursor: null } });
|
||||
await firstTurn;
|
||||
});
|
||||
|
||||
it("rolls back exactly one latest turn in thread history", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const rollback = client.rollbackThread("thread-1");
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "thread/rollback",
|
||||
params: { threadId: "thread-1", numTurns: 1 },
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { thread: { id: "thread-1", turns: [] } } });
|
||||
await rollback;
|
||||
});
|
||||
|
||||
it("starts title generation in a read-only ephemeral thread", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const namingThread = client.startEphemeralThread({
|
||||
cwd: "/vault",
|
||||
serviceName: "naming",
|
||||
developerInstructions: "Return a title.",
|
||||
});
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "thread/start",
|
||||
params: {
|
||||
cwd: "/vault",
|
||||
serviceName: "naming",
|
||||
developerInstructions: "Return a title.",
|
||||
ephemeral: true,
|
||||
sandbox: "read-only",
|
||||
approvalPolicy: "never",
|
||||
multiAgentMode: "none",
|
||||
environments: [],
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { thread: { id: "naming-thread" } } });
|
||||
await namingThread;
|
||||
});
|
||||
|
||||
it("requests structured title output with optional naming runtime overrides", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
const structuredTurn = client.startStructuredTurn({
|
||||
threadId: "naming-thread",
|
||||
cwd: "/vault",
|
||||
text: "title please",
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" } },
|
||||
required: ["title"],
|
||||
},
|
||||
runtime: {
|
||||
serviceTier: null,
|
||||
collaborationMode: {
|
||||
mode: "plan",
|
||||
settings: { model: "gpt-5.4-mini", reasoning_effort: "minimal", developer_instructions: null },
|
||||
},
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "minimal",
|
||||
approvalsReviewer: null,
|
||||
},
|
||||
});
|
||||
expect(transport.sent[2]).toMatchObject({
|
||||
id: 2,
|
||||
method: "turn/start",
|
||||
params: {
|
||||
threadId: "naming-thread",
|
||||
cwd: "/vault",
|
||||
serviceTier: null,
|
||||
collaborationMode: {
|
||||
mode: "plan",
|
||||
settings: { model: "gpt-5.4-mini", reasoning_effort: "minimal", developer_instructions: null },
|
||||
},
|
||||
model: "gpt-5.4-mini",
|
||||
effort: "minimal",
|
||||
approvalsReviewer: null,
|
||||
input: [{ type: "text", text: "title please", text_elements: [] }],
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: { title: { type: "string" } },
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
});
|
||||
transport.emitLine({ id: 2, result: { turn: { id: "naming-turn" } } });
|
||||
await structuredTurn;
|
||||
});
|
||||
|
||||
it("saves the user-confirmed thread title through app-server metadata", async () => {
|
||||
const { client, transport } = await connectedClient();
|
||||
|
||||
await expectRequest(
|
||||
transport,
|
||||
client.setThreadName("thread-1", "Codex Panel自動命名"),
|
||||
{ method: "thread/name/set", params: { threadId: "thread-1", name: "Codex Panel自動命名" } },
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AppServerClientHandlers,
|
||||
AppServerStartEphemeralThreadOptions,
|
||||
AppServerStartStructuredTurnOptions,
|
||||
} from "../../src/app-server/connection/client";
|
||||
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../src/app-server/connection/client";
|
||||
import type { ClientRequestParams } from "../../src/app-server/connection/rpc-messages";
|
||||
import type { TurnItem, TurnRecord } from "../../src/app-server/protocol/turn";
|
||||
import {
|
||||
type EphemeralStructuredTurnClient,
|
||||
type EphemeralStructuredTurnClientFactory,
|
||||
runEphemeralStructuredTurn,
|
||||
} from "../../src/app-server/services/ephemeral-structured-turn";
|
||||
import type { AppServerStartEphemeralThreadOptions } from "../../src/app-server/services/threads";
|
||||
import type { AppServerStartStructuredTurnOptions } from "../../src/app-server/services/turns";
|
||||
import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse";
|
||||
import type { RequestId } from "../../src/generated/app-server/RequestId";
|
||||
import type { ServerNotification } from "../../src/generated/app-server/ServerNotification";
|
||||
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
|
||||
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
|
||||
import type { Thread as AppServerThread } from "../../src/generated/app-server/v2/Thread";
|
||||
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
|
||||
|
||||
type TurnStartResponse = Awaited<ReturnType<EphemeralStructuredTurnClient["startStructuredTurn"]>>;
|
||||
interface TurnStartResponse {
|
||||
turn: TurnRecord;
|
||||
}
|
||||
|
||||
describe("runEphemeralStructuredTurn", () => {
|
||||
it("fills completed turn items from item completion notifications", async () => {
|
||||
|
|
@ -156,7 +156,7 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
it("rejects server requests with the configured message", async () => {
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.connectImpl = async () => {
|
||||
fake.request(serverRequest(123));
|
||||
fake.emitServerRequest(serverRequest(123));
|
||||
return { codexHome: "/tmp/codex" } as InitializeResponse;
|
||||
};
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
|
|
@ -186,13 +186,13 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
resolveRuntime: async (runtimeClient) => {
|
||||
callOrder.push("resolve-runtime");
|
||||
expect(runtimeClient).toBe(expectPresent(client.current));
|
||||
await runtimeClient.listModels(false);
|
||||
await runtimeClient.request("model/list", { includeHidden: false, limit: 100 });
|
||||
return { model: "gpt-5.1", effort: "low" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual(["resolve-runtime", "start-thread"]);
|
||||
expect(expectPresent(client.current).listModels).toHaveBeenCalledWith(false);
|
||||
expect(expectPresent(client.current).modelListRequests).toEqual([{ includeHidden: false, limit: 100 }]);
|
||||
expect(expectPresent(client.current).startStructuredTurnOptions).toEqual({
|
||||
threadId: "thread",
|
||||
cwd: "/vault",
|
||||
|
|
@ -316,9 +316,9 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
|
|||
startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
|
||||
startEphemeralThreadOptions: AppServerStartEphemeralThreadOptions | null = null;
|
||||
startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
|
||||
readonly listModels = vi.fn(async (): Promise<ModelListResponse> => ({ data: [], nextCursor: null }));
|
||||
readonly modelListRequests: ClientRequestParams<"model/list">[] = [];
|
||||
readonly rejectServerRequest = vi.fn();
|
||||
readonly deleteThread = vi.fn(async () => undefined);
|
||||
readonly deleteThread = vi.fn(async (_threadId: string, _options?: { timeoutMs?: number }) => undefined);
|
||||
readonly disconnect = vi.fn();
|
||||
readonly structuredTurnStarted: Promise<void>;
|
||||
private resolveStructuredTurnStarted!: () => void;
|
||||
|
|
@ -333,22 +333,39 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
|
|||
return this.connectImpl ? this.connectImpl() : ({ codexHome: "/tmp/codex" } as InitializeResponse);
|
||||
}
|
||||
|
||||
async startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<ThreadStartResponse> {
|
||||
this.startEphemeralThreadOptions = options;
|
||||
return this.startEphemeralThreadImpl ? this.startEphemeralThreadImpl() : threadStartResponse("thread");
|
||||
}
|
||||
|
||||
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
|
||||
this.startStructuredTurnOptions = options;
|
||||
this.resolveStructuredTurnStarted();
|
||||
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
|
||||
async request<M extends TypedClientRequestMethod>(
|
||||
method: M,
|
||||
params: ClientRequestParams<M>,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<ClientResponseByMethod[M]> {
|
||||
switch (method) {
|
||||
case "model/list":
|
||||
this.modelListRequests.push(params as ClientRequestParams<"model/list">);
|
||||
return { data: [], nextCursor: null } as unknown as ClientResponseByMethod[M];
|
||||
case "thread/start":
|
||||
this.startEphemeralThreadOptions = ephemeralThreadOptionsFromParams(params as ClientRequestParams<"thread/start">);
|
||||
return (this.startEphemeralThreadImpl
|
||||
? await this.startEphemeralThreadImpl()
|
||||
: threadStartResponse("thread")) as unknown as ClientResponseByMethod[M];
|
||||
case "turn/start":
|
||||
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
|
||||
this.resolveStructuredTurnStarted();
|
||||
return (this.startStructuredTurnImpl
|
||||
? await this.startStructuredTurnImpl()
|
||||
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
|
||||
case "thread/delete":
|
||||
await this.deleteThread((params as ClientRequestParams<"thread/delete">).threadId, options);
|
||||
return {} as unknown as ClientResponseByMethod[M];
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
emit(notification: ServerNotification): void {
|
||||
this.handlers.onNotification(notification);
|
||||
}
|
||||
|
||||
request(request: ServerRequest): void {
|
||||
emitServerRequest(request: ServerRequest): void {
|
||||
this.handlers.onServerRequest(request, {
|
||||
respond: () => undefined,
|
||||
reject: (code, message) => {
|
||||
|
|
@ -358,6 +375,39 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
|
|||
}
|
||||
}
|
||||
|
||||
function ephemeralThreadOptionsFromParams(params: ClientRequestParams<"thread/start">): AppServerStartEphemeralThreadOptions {
|
||||
if (!params.cwd || !params.serviceName || !params.developerInstructions) throw new Error("Expected ephemeral thread params.");
|
||||
return {
|
||||
cwd: params.cwd,
|
||||
serviceName: params.serviceName,
|
||||
developerInstructions: params.developerInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
|
||||
const textItem = params.input[0];
|
||||
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
|
||||
const runtime = structuredTurnRuntimeFromParams(params);
|
||||
return {
|
||||
threadId: params.threadId,
|
||||
cwd: params.cwd,
|
||||
text: textItem.text,
|
||||
outputSchema: params.outputSchema,
|
||||
...(runtime !== undefined ? { runtime } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function structuredTurnRuntimeFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions["runtime"] {
|
||||
const runtime = {
|
||||
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
|
||||
...(params.collaborationMode !== undefined && params.collaborationMode !== null ? { collaborationMode: params.collaborationMode } : {}),
|
||||
...(params.model !== undefined ? { model: params.model } : {}),
|
||||
...(params.effort !== undefined ? { effort: params.effort } : {}),
|
||||
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
|
||||
};
|
||||
return Object.keys(runtime).length > 0 ? runtime : undefined;
|
||||
}
|
||||
|
||||
function timerHarness(): {
|
||||
setTimeout: ReturnType<typeof vi.fn<(callback: () => void, delayMs: number) => number>>;
|
||||
clearTimeout: ReturnType<typeof vi.fn<(timer: number) => void>>;
|
||||
|
|
|
|||
|
|
@ -285,10 +285,13 @@ function cacheWithThreads(
|
|||
clientRunner: {
|
||||
runWithClient: async (context, operation) => {
|
||||
return operation({
|
||||
listThreads: async (_cwd: string, options: { archived?: boolean }) => ({
|
||||
data: await fetchThreads(context, options.archived ?? false),
|
||||
nextCursor: null,
|
||||
}),
|
||||
request: async (method: string, params: { archived?: boolean }) => {
|
||||
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return {
|
||||
data: await fetchThreads(context, params.archived ?? false),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
} as never);
|
||||
},
|
||||
},
|
||||
|
|
@ -296,13 +299,44 @@ function cacheWithThreads(
|
|||
}
|
||||
|
||||
function cacheWithClient(client: Record<string, unknown>): AppServerQueryCache {
|
||||
const requestClient = "request" in client ? client : clientWithRequest(client);
|
||||
return new AppServerQueryCache({
|
||||
clientRunner: {
|
||||
runWithClient: async (_context, operation) => operation(client as never),
|
||||
runWithClient: async (_context, operation) => operation(requestClient as never),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clientWithRequest(client: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...client,
|
||||
request: async (method: string, params: unknown) => {
|
||||
switch (method) {
|
||||
case "thread/list":
|
||||
return (client["listThreads"] as (cwd: string, options: { archived?: boolean }) => Promise<unknown>)(
|
||||
(params as { cwd: string }).cwd,
|
||||
params as { archived?: boolean },
|
||||
);
|
||||
case "config/read":
|
||||
return (client["readEffectiveConfig"] as (cwd: string) => Promise<unknown>)((params as { cwd: string }).cwd);
|
||||
case "model/list":
|
||||
return (client["listModels"] as (includeHidden: boolean) => Promise<unknown>)(
|
||||
(params as { includeHidden?: boolean }).includeHidden ?? false,
|
||||
);
|
||||
case "skills/list":
|
||||
return (client["listSkills"] as (cwd: string, forceReload?: boolean) => Promise<unknown>)(
|
||||
(params as { cwds: string[] }).cwds[0] ?? "",
|
||||
(params as { forceReload?: boolean }).forceReload,
|
||||
);
|
||||
case "account/rateLimits/read":
|
||||
return (client["readAccountRateLimits"] as () => Promise<unknown>)();
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function metadata(
|
||||
overrides: {
|
||||
availableModels?: readonly ModelMetadata[];
|
||||
|
|
|
|||
|
|
@ -57,9 +57,16 @@ function fakeClient(): AppServerClient & {
|
|||
readThread: ReturnType<typeof vi.fn>;
|
||||
archiveThread: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const readThread = vi.fn().mockResolvedValue({ thread: archivedThread() });
|
||||
const archiveThread = vi.fn().mockResolvedValue({});
|
||||
return {
|
||||
readThread: vi.fn().mockResolvedValue({ thread: archivedThread() }),
|
||||
archiveThread: vi.fn().mockResolvedValue({}),
|
||||
readThread,
|
||||
archiveThread,
|
||||
request: vi.fn((method: string, params: { threadId: string; includeTurns?: boolean }) => {
|
||||
if (method === "thread/read") return readThread(params.threadId, params.includeTurns);
|
||||
if (method === "thread/archive") return archiveThread(params.threadId);
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}),
|
||||
} as unknown as AppServerClient & {
|
||||
readThread: ReturnType<typeof vi.fn>;
|
||||
archiveThread: ReturnType<typeof vi.fn>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AppServerClient, AppServerClientHandlers, AppServerStartStructuredTurnOptions } from "../../src/app-server/connection/client";
|
||||
import type { RequestId, ServerNotification } from "../../src/app-server/connection/rpc-messages";
|
||||
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../src/app-server/connection/client";
|
||||
import type { ClientRequestParams, RequestId, ServerNotification } from "../../src/app-server/connection/rpc-messages";
|
||||
import type { TurnItem, TurnRecord } from "../../src/app-server/protocol/turn";
|
||||
import type {
|
||||
EphemeralStructuredTurnClient,
|
||||
EphemeralStructuredTurnClientFactory,
|
||||
} from "../../src/app-server/services/ephemeral-structured-turn";
|
||||
import { generateThreadTitleWithCodex } from "../../src/app-server/services/thread-title-generation";
|
||||
import type { AppServerStartStructuredTurnOptions } from "../../src/app-server/services/turns";
|
||||
import type { ServerInitialization } from "../../src/domain/server/initialization";
|
||||
import {
|
||||
findThreadTitleContext,
|
||||
|
|
@ -18,12 +19,14 @@ import {
|
|||
threadTitleFromGeneratedText,
|
||||
threadTitlePrompt,
|
||||
} from "../../src/domain/threads/title-generation-model";
|
||||
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
|
||||
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
|
||||
|
||||
type InitializeResponse = ServerInitialization;
|
||||
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
|
||||
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
|
||||
type Turn = TurnRecord;
|
||||
type TurnStartResponse = Awaited<ReturnType<EphemeralStructuredTurnClient["startStructuredTurn"]>>;
|
||||
interface TurnStartResponse {
|
||||
turn: TurnRecord;
|
||||
}
|
||||
|
||||
describe("thread title", () => {
|
||||
it("builds title context from a conversation summary", () => {
|
||||
|
|
@ -244,19 +247,30 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
|
|||
|
||||
async deleteThread(): Promise<void> {}
|
||||
|
||||
async listModels(): Promise<ModelListResponse> {
|
||||
return { data: this.modelList, nextCursor: null };
|
||||
}
|
||||
|
||||
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {}
|
||||
|
||||
async startEphemeralThread(): Promise<ThreadStartResponse> {
|
||||
return threadStartResponse("thread");
|
||||
}
|
||||
|
||||
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
|
||||
this.startStructuredTurnOptions = options;
|
||||
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
|
||||
async request<M extends TypedClientRequestMethod>(
|
||||
method: M,
|
||||
params: ClientRequestParams<M>,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<ClientResponseByMethod[M]> {
|
||||
void options;
|
||||
switch (method) {
|
||||
case "model/list":
|
||||
return { data: this.modelList, nextCursor: null } as unknown as ClientResponseByMethod[M];
|
||||
case "thread/start":
|
||||
return threadStartResponse("thread") as unknown as ClientResponseByMethod[M];
|
||||
case "turn/start":
|
||||
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
|
||||
return (this.startStructuredTurnImpl
|
||||
? await this.startStructuredTurnImpl()
|
||||
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
|
||||
case "thread/delete":
|
||||
await this.deleteThread();
|
||||
return {} as unknown as ClientResponseByMethod[M];
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
emit(notification: ServerNotification): void {
|
||||
|
|
@ -264,6 +278,26 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
|
|||
}
|
||||
}
|
||||
|
||||
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
|
||||
const textItem = params.input[0];
|
||||
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
|
||||
return {
|
||||
threadId: params.threadId,
|
||||
cwd: params.cwd,
|
||||
text: textItem.text,
|
||||
outputSchema: params.outputSchema,
|
||||
runtime: {
|
||||
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
|
||||
...(params.collaborationMode !== undefined && params.collaborationMode !== null
|
||||
? { collaborationMode: params.collaborationMode }
|
||||
: {}),
|
||||
...(params.model !== undefined ? { model: params.model } : {}),
|
||||
...(params.effort !== undefined ? { effort: params.effort } : {}),
|
||||
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function threadStartResponse(threadId: string): ThreadStartResponse {
|
||||
return {
|
||||
thread: threadFixture(threadId),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../src/app-server/connection/client";
|
||||
import type { AppServerRequestClient } from "../../src/app-server/services/request-client";
|
||||
import { readToolInventory } from "../../src/app-server/services/tool-inventory";
|
||||
import type { PluginReadParams } from "../../src/generated/app-server/v2/PluginReadParams";
|
||||
|
||||
describe("tool inventory", () => {
|
||||
it("reads plugin details with exactly one marketplace locator", async () => {
|
||||
const readPlugin = vi.fn(async (params: Parameters<AppServerClient["readPlugin"]>[0]) => ({
|
||||
const readPlugin = vi.fn(async (params: PluginReadParams) => ({
|
||||
plugin: {
|
||||
skills: params.pluginName === "local-plugin" ? [{ name: "local-skill" }] : [],
|
||||
hooks: [],
|
||||
|
|
@ -14,8 +15,7 @@ describe("tool inventory", () => {
|
|||
mcpServers: params.pluginName === "remote-plugin" ? ["remote-server"] : [],
|
||||
},
|
||||
}));
|
||||
const client = {
|
||||
listApps: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
const client = toolInventoryClient({
|
||||
listInstalledPlugins: vi.fn().mockResolvedValue({
|
||||
marketplaces: [
|
||||
{
|
||||
|
|
@ -54,9 +54,7 @@ describe("tool inventory", () => {
|
|||
marketplaceLoadErrors: [],
|
||||
}),
|
||||
readPlugin,
|
||||
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
|
||||
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
|
||||
const result = await readToolInventory(client, "/vault");
|
||||
|
||||
|
|
@ -78,14 +76,11 @@ describe("tool inventory", () => {
|
|||
});
|
||||
|
||||
it("skips app catalog loading during diagnostics", async () => {
|
||||
const listApps = vi.fn().mockResolvedValue({ data: [], nextCursor: null });
|
||||
const client = toolInventoryClient({
|
||||
listApps,
|
||||
});
|
||||
const client = toolInventoryClient();
|
||||
|
||||
const result = await readToolInventory(client, "/vault");
|
||||
|
||||
expect(listApps).not.toHaveBeenCalled();
|
||||
expect(client.request).not.toHaveBeenCalledWith("app/list", expect.anything());
|
||||
expect(result.probes.some((probe) => probe.method === "app/list")).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -93,7 +88,7 @@ describe("tool inventory", () => {
|
|||
let activeReads = 0;
|
||||
let maxActiveReads = 0;
|
||||
const plugins = Array.from({ length: 10 }, (_, index) => installedPlugin(`plugin-${String(index)}`));
|
||||
const readPlugin = vi.fn(async (params: Parameters<AppServerClient["readPlugin"]>[0]) => {
|
||||
const readPlugin = vi.fn(async (params: PluginReadParams) => {
|
||||
activeReads += 1;
|
||||
maxActiveReads = Math.max(maxActiveReads, activeReads);
|
||||
await Promise.resolve();
|
||||
|
|
@ -126,27 +121,38 @@ describe("tool inventory", () => {
|
|||
|
||||
function toolInventoryClient(
|
||||
overrides: Partial<{
|
||||
listApps: ReturnType<typeof vi.fn>;
|
||||
listInstalledPlugins: ReturnType<typeof vi.fn>;
|
||||
readPlugin: ReturnType<typeof vi.fn>;
|
||||
}> = {},
|
||||
): AppServerClient {
|
||||
): AppServerRequestClient & { request: ReturnType<typeof vi.fn> } {
|
||||
const listInstalledPlugins =
|
||||
overrides.listInstalledPlugins ??
|
||||
vi.fn().mockResolvedValue({
|
||||
marketplaces: [],
|
||||
marketplaceLoadErrors: [],
|
||||
});
|
||||
const readPlugin =
|
||||
overrides.readPlugin ??
|
||||
vi.fn().mockResolvedValue({
|
||||
plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] },
|
||||
});
|
||||
const request = vi.fn(async (method: string, params: unknown) => {
|
||||
switch (method) {
|
||||
case "plugin/installed":
|
||||
return (listInstalledPlugins as unknown as (params: unknown) => Promise<unknown>)(params);
|
||||
case "plugin/read":
|
||||
return (readPlugin as unknown as (params: unknown) => Promise<unknown>)(params);
|
||||
case "mcpServerStatus/list":
|
||||
return { data: [] };
|
||||
case "skills/list":
|
||||
return { data: [{ cwd: "/vault", skills: [] }] };
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
});
|
||||
return {
|
||||
listApps: overrides.listApps ?? vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
listInstalledPlugins:
|
||||
overrides.listInstalledPlugins ??
|
||||
vi.fn().mockResolvedValue({
|
||||
marketplaces: [],
|
||||
marketplaceLoadErrors: [],
|
||||
}),
|
||||
readPlugin:
|
||||
overrides.readPlugin ??
|
||||
vi.fn().mockResolvedValue({
|
||||
plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] },
|
||||
}),
|
||||
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
|
||||
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
|
||||
} as unknown as AppServerClient;
|
||||
request,
|
||||
} as unknown as AppServerRequestClient & { request: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function installedPlugin(name: string): {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../../../src/app-server/connection/client";
|
||||
import type { AppServerClient, ClientResponseByMethod } from "../../../../../src/app-server/connection/client";
|
||||
import type { ClientRequestParams } from "../../../../../src/app-server/connection/rpc-messages";
|
||||
import {
|
||||
type CatalogModel,
|
||||
type CatalogSkillMetadata,
|
||||
|
|
@ -26,7 +27,7 @@ import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/ap
|
|||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import { chatStateFixture, chatStateWith } from "../../support/state";
|
||||
|
||||
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startThread"]>>;
|
||||
type ThreadStartResponse = ClientResponseByMethod["thread/start"];
|
||||
|
||||
describe("chat app-server actions", () => {
|
||||
it("publishes newly started threads before the first turn completes", async () => {
|
||||
|
|
@ -39,8 +40,8 @@ describe("chat app-server actions", () => {
|
|||
const existingThread = threadFromThreadRecord(existing);
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const syncThreadGoal = vi.fn();
|
||||
const client = {
|
||||
startThread: vi.fn().mockResolvedValue({
|
||||
const client = startThreadClient(
|
||||
vi.fn().mockResolvedValue({
|
||||
thread: started,
|
||||
cwd: "/vault",
|
||||
model: "gpt-5",
|
||||
|
|
@ -48,7 +49,7 @@ describe("chat app-server actions", () => {
|
|||
approvalsReviewer: null,
|
||||
reasoningEffort: null,
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
);
|
||||
|
||||
const controller = createChatServerThreadActions({
|
||||
stateStore,
|
||||
|
|
@ -82,9 +83,7 @@ describe("chat app-server actions", () => {
|
|||
approvalsReviewer: null,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
const client = {
|
||||
startThread,
|
||||
} as unknown as AppServerClient;
|
||||
const client = startThreadClient(startThread);
|
||||
|
||||
const controller = createChatServerThreadActions({
|
||||
stateStore,
|
||||
|
|
@ -110,8 +109,8 @@ describe("chat app-server actions", () => {
|
|||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const started = threadFixture("started");
|
||||
const syncThreadGoal = vi.fn();
|
||||
const client = {
|
||||
startThread: vi.fn().mockResolvedValue({
|
||||
const client = startThreadClient(
|
||||
vi.fn().mockResolvedValue({
|
||||
thread: started,
|
||||
cwd: "/vault",
|
||||
model: "gpt-5",
|
||||
|
|
@ -119,7 +118,7 @@ describe("chat app-server actions", () => {
|
|||
approvalsReviewer: null,
|
||||
reasoningEffort: null,
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
);
|
||||
|
||||
const controller = createChatServerThreadActions({
|
||||
stateStore,
|
||||
|
|
@ -148,9 +147,7 @@ describe("chat app-server actions", () => {
|
|||
approvalsReviewer: null,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
const client = {
|
||||
startThread,
|
||||
} as unknown as AppServerClient;
|
||||
const client = startThreadClient(startThread);
|
||||
|
||||
const controller = createChatServerThreadActions({
|
||||
stateStore,
|
||||
|
|
@ -170,8 +167,8 @@ describe("chat app-server actions", () => {
|
|||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const started = threadFixture("started", { preview: "server preview" });
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const client = {
|
||||
startThread: vi.fn().mockResolvedValue({
|
||||
const client = startThreadClient(
|
||||
vi.fn().mockResolvedValue({
|
||||
thread: started,
|
||||
cwd: "/vault",
|
||||
model: "gpt-5",
|
||||
|
|
@ -179,7 +176,7 @@ describe("chat app-server actions", () => {
|
|||
approvalsReviewer: null,
|
||||
reasoningEffort: null,
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
);
|
||||
|
||||
const controller = createChatServerThreadActions({
|
||||
stateStore,
|
||||
|
|
@ -197,10 +194,8 @@ describe("chat app-server actions", () => {
|
|||
|
||||
it("does not apply newly started threads after the client changes", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const start = deferred<Awaited<ReturnType<AppServerClient["startThread"]>>>();
|
||||
const firstClient = {
|
||||
startThread: vi.fn().mockReturnValue(start.promise),
|
||||
} as unknown as AppServerClient;
|
||||
const start = deferred<ThreadStartResponse>();
|
||||
const firstClient = startThreadClient(vi.fn().mockReturnValue(start.promise));
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
|
|
@ -257,9 +252,9 @@ describe("chat app-server actions", () => {
|
|||
),
|
||||
});
|
||||
const refreshAppServerMetadata = vi.fn<() => Promise<SharedServerMetadata | null>>().mockResolvedValue(refreshedMetadata);
|
||||
const client = {
|
||||
const client = legacyClient({
|
||||
listMcpServerStatus,
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const metadataCache = metadataCacheHost({ current: null });
|
||||
|
||||
const metadata = createChatServerMetadataActions({
|
||||
|
|
@ -329,12 +324,12 @@ describe("chat app-server actions", () => {
|
|||
const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] });
|
||||
const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null });
|
||||
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [] });
|
||||
const client = {
|
||||
const client = legacyClient({
|
||||
listModels,
|
||||
listSkills,
|
||||
readAccountRateLimits,
|
||||
listMcpServerStatus,
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const diagnostics = createChatServerDiagnosticsActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
|
|
@ -359,12 +354,12 @@ describe("chat app-server actions", () => {
|
|||
const listModels = vi.fn().mockResolvedValue({ data: [modelFixture("gpt-direct")] });
|
||||
const listSkills = vi.fn().mockResolvedValue({ data: [{ skills: [skillFixture("direct-skill")] }] });
|
||||
const readAccountRateLimits = vi.fn().mockResolvedValue({ rateLimits: rateLimitFixture(), rateLimitsByLimitId: null });
|
||||
const client = {
|
||||
const client = legacyClient({
|
||||
listModels,
|
||||
listSkills,
|
||||
readAccountRateLimits,
|
||||
listMcpServerStatus: vi.fn().mockResolvedValue({ data: [] }),
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const diagnostics = createChatServerDiagnosticsActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
|
|
@ -375,7 +370,7 @@ describe("chat app-server actions", () => {
|
|||
await diagnostics.refreshServerDiagnostics({ forceResourceProbes: true });
|
||||
|
||||
expect(listModels).toHaveBeenCalledWith(false);
|
||||
expect(listSkills).toHaveBeenCalledWith("/vault");
|
||||
expect(listSkills).toHaveBeenCalledWith("/vault", false);
|
||||
expect(readAccountRateLimits).toHaveBeenCalledOnce();
|
||||
expect(stateStore.getState().connection.serverDiagnostics.probes["model/list"]).toMatchObject({
|
||||
status: "ok",
|
||||
|
|
@ -387,9 +382,9 @@ describe("chat app-server actions", () => {
|
|||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const mcpStatusRefresh = deferred<{ data: ReturnType<typeof mcpServerStatus>[] }>();
|
||||
const listMcpServerStatus = vi.fn().mockReturnValue(mcpStatusRefresh.promise);
|
||||
const firstClient = {
|
||||
const firstClient = legacyClient({
|
||||
listMcpServerStatus,
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const updateAppServerMetadata = vi.fn(() => null);
|
||||
|
|
@ -477,7 +472,7 @@ describe("chat app-server actions", () => {
|
|||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const skillRefresh = deferred<{ data: { skills: CatalogSkillMetadata[] }[] }>();
|
||||
const listSkills = vi.fn().mockReturnValue(skillRefresh.promise);
|
||||
const firstClient = { listSkills } as unknown as AppServerClient;
|
||||
const firstClient = legacyClient({ listSkills });
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const updateAppServerMetadata = vi.fn(() => null);
|
||||
|
|
@ -506,9 +501,9 @@ describe("chat app-server actions", () => {
|
|||
const stateStore = createChatStateStore(state);
|
||||
const rateLimit = rateLimitFixture({ primary: { usedPercent: 64, windowDurationMins: 300, resetsAt: null } });
|
||||
const cachedMetadata = { current: serverMetadataFixture() as SharedServerMetadata | null };
|
||||
const client = {
|
||||
const client = legacyClient({
|
||||
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: rateLimit, rateLimitsByLimitId: null }),
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const controller = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
|
|
@ -531,9 +526,9 @@ describe("chat app-server actions", () => {
|
|||
});
|
||||
state = chatStateWith(state, { connection: { rateLimit: previousRateLimit } });
|
||||
const stateStore = createChatStateStore(state);
|
||||
const client = {
|
||||
const client = legacyClient({
|
||||
readAccountRateLimits: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const controller = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
|
|
@ -551,9 +546,9 @@ describe("chat app-server actions", () => {
|
|||
it("does not apply or publish sparse rate limit refreshes after the client changes", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const rateLimitRefresh = deferred<{ rateLimits: RateLimitSnapshot; rateLimitsByLimitId: null }>();
|
||||
const firstClient = {
|
||||
const firstClient = legacyClient({
|
||||
readAccountRateLimits: vi.fn().mockReturnValue(rateLimitRefresh.promise),
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const updateAppServerMetadata = vi.fn(() => null);
|
||||
|
|
@ -584,12 +579,12 @@ describe("chat app-server actions", () => {
|
|||
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
|
||||
const stateStore = createChatStateStore(state);
|
||||
const listMcpServerStatus = vi.fn().mockResolvedValue({ data: [mcpServerStatus()] });
|
||||
const client = {
|
||||
const client = legacyClient({
|
||||
listApps: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
listInstalledPlugins: vi.fn().mockResolvedValue({ marketplaces: [], marketplaceLoadErrors: [] }),
|
||||
listMcpServerStatus,
|
||||
listSkills: vi.fn().mockResolvedValue({ data: [{ cwd: "/vault", skills: [] }] }),
|
||||
} as unknown as AppServerClient;
|
||||
});
|
||||
const metadataCache = metadataCacheHost({ current: serverMetadataFixture() });
|
||||
const metadata = createChatServerMetadataActions({
|
||||
stateStore,
|
||||
|
|
@ -627,6 +622,58 @@ describe("chat app-server actions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function legacyClient(methods: Record<string, unknown>): AppServerClient {
|
||||
return {
|
||||
...methods,
|
||||
request: vi.fn((method: string, params: unknown) => {
|
||||
switch (method) {
|
||||
case "model/list":
|
||||
return (methods["listModels"] as (includeHidden: boolean) => Promise<unknown>)(
|
||||
(params as { includeHidden?: boolean }).includeHidden ?? false,
|
||||
);
|
||||
case "skills/list":
|
||||
if (!methods["listSkills"]) return Promise.resolve({ data: [{ cwd: (params as { cwds: string[] }).cwds[0] ?? "", skills: [] }] });
|
||||
return (methods["listSkills"] as (cwd: string, forceReload?: boolean) => Promise<unknown>)(
|
||||
(params as { cwds: string[] }).cwds[0] ?? "",
|
||||
(params as { forceReload?: boolean }).forceReload,
|
||||
);
|
||||
case "account/rateLimits/read":
|
||||
return (methods["readAccountRateLimits"] as () => Promise<unknown>)();
|
||||
case "mcpServerStatus/list":
|
||||
return (methods["listMcpServerStatus"] as (params: unknown) => Promise<unknown>)(params);
|
||||
case "plugin/installed":
|
||||
if (!methods["listInstalledPlugins"]) return Promise.resolve({ marketplaces: [], marketplaceLoadErrors: [] });
|
||||
return (methods["listInstalledPlugins"] as (cwd: string) => Promise<unknown>)((params as { cwds: string[] }).cwds[0] ?? "");
|
||||
case "plugin/read":
|
||||
if (!methods["readPlugin"]) {
|
||||
return Promise.resolve({ plugin: { skills: [], hooks: [], apps: [], appTemplates: [], mcpServers: [] } });
|
||||
}
|
||||
return (methods["readPlugin"] as (params: unknown) => Promise<unknown>)(params);
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
||||
function startThreadClient(startThread: ReturnType<typeof vi.fn>): AppServerClient {
|
||||
return {
|
||||
request: vi.fn((method: string, params: ClientRequestParams<"thread/start">) => {
|
||||
if (method !== "thread/start") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
if (!params.cwd) throw new Error("Expected thread/start cwd.");
|
||||
return (
|
||||
startThread as unknown as (options: {
|
||||
cwd: string;
|
||||
serviceTier?: ClientRequestParams<"thread/start">["serviceTier"];
|
||||
}) => Promise<ThreadStartResponse>
|
||||
)({
|
||||
cwd: params.cwd,
|
||||
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
|
||||
});
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
||||
function threadFixture(id: string, overrides: Partial<ThreadStartResponse["thread"]> = {}): ThreadStartResponse["thread"] {
|
||||
return {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../../src/app-server/connection/client";
|
||||
import type { AppServerClient, ClientResponseByMethod } from "../../../../src/app-server/connection/client";
|
||||
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
|
||||
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
|
||||
import type { CodexInput } from "../../../../src/domain/chat/input";
|
||||
|
|
@ -19,8 +19,8 @@ const textInput = (text: string): CodexInput => [{ type: "text", text }];
|
|||
|
||||
describe("chat app-server transports", () => {
|
||||
it("starts turns with the session vault path and returns chat-owned turn ids", async () => {
|
||||
const startTurn = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });
|
||||
const client = { startTurn } as unknown as AppServerClient;
|
||||
const request = vi.fn().mockResolvedValue({ turn: { id: "turn-1" } });
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const transport = createChatTurnTransport({
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -35,17 +35,17 @@ describe("chat app-server transports", () => {
|
|||
}),
|
||||
).resolves.toEqual({ turnId: "turn-1" });
|
||||
|
||||
expect(startTurn).toHaveBeenCalledWith({
|
||||
expect(request).toHaveBeenCalledWith("turn/start", {
|
||||
threadId: "thread",
|
||||
cwd: "/vault",
|
||||
input: textInput("hello"),
|
||||
input: [{ type: "text", text: "hello", text_elements: [] }],
|
||||
clientUserMessageId: "local-user",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale turn transport responses after the current client changes", async () => {
|
||||
const start = deferred<{ turn: { id: string } }>();
|
||||
const firstClient = { startTurn: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient;
|
||||
const firstClient = { request: vi.fn().mockReturnValue(start.promise) } as unknown as AppServerClient;
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const transport = createChatTurnTransport({
|
||||
|
|
@ -62,8 +62,8 @@ describe("chat app-server transports", () => {
|
|||
});
|
||||
|
||||
it("compacts threads through a connected app-server client", async () => {
|
||||
const compactThread = vi.fn().mockResolvedValue({});
|
||||
const client = { compactThread } as unknown as AppServerClient;
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const transport = createChatThreadMutationTransport({
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -72,12 +72,12 @@ describe("chat app-server transports", () => {
|
|||
|
||||
await expect(transport.compactThread("thread")).resolves.toBe(true);
|
||||
|
||||
expect(compactThread).toHaveBeenCalledWith("thread");
|
||||
expect(request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread" });
|
||||
});
|
||||
|
||||
it("forks threads with the session vault path and returns panel threads", async () => {
|
||||
const forkThread = vi.fn().mockResolvedValue({ thread: threadRecord("forked") });
|
||||
const client = { forkThread } as unknown as AppServerClient;
|
||||
const request = vi.fn().mockResolvedValue({ thread: threadRecord("forked") });
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const transport = createChatThreadMutationTransport({
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -86,13 +86,13 @@ describe("chat app-server transports", () => {
|
|||
|
||||
const thread = await transport.forkThread("source");
|
||||
|
||||
expect(forkThread).toHaveBeenCalledWith("source", "/vault");
|
||||
expect(request).toHaveBeenCalledWith("thread/fork", { threadId: "source", cwd: "/vault", excludeTurns: true });
|
||||
expect(thread).toMatchObject({ id: "forked", preview: "Preview", archived: false });
|
||||
});
|
||||
|
||||
it("drops stale fork transport responses after the current client changes", async () => {
|
||||
const fork = deferred<{ thread: ThreadRecord }>();
|
||||
const firstClient = { forkThread: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient;
|
||||
const firstClient = { request: vi.fn().mockReturnValue(fork.promise) } as unknown as AppServerClient;
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const transport = createChatThreadMutationTransport({
|
||||
|
|
@ -109,8 +109,8 @@ describe("chat app-server transports", () => {
|
|||
});
|
||||
|
||||
it("projects rollback turns into message stream items", async () => {
|
||||
const rollbackThread = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) });
|
||||
const client = { rollbackThread } as unknown as AppServerClient;
|
||||
const request = vi.fn().mockResolvedValue({ thread: threadRecord("thread", [turn([userMessage("u1", "prompt")])]) });
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const transport = createChatThreadMutationTransport({
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -119,25 +119,31 @@ describe("chat app-server transports", () => {
|
|||
|
||||
const snapshot = await transport.rollbackThread("thread");
|
||||
|
||||
expect(rollbackThread).toHaveBeenCalledWith("thread");
|
||||
expect(request).toHaveBeenCalledWith("thread/rollback", { threadId: "thread", numTurns: 1 });
|
||||
expect(snapshot?.thread.id).toBe("thread");
|
||||
expect(snapshot?.cwd).toBe("/vault");
|
||||
expect(snapshot?.items).toEqual([expect.objectContaining({ kind: "message", role: "user", text: "prompt" })]);
|
||||
});
|
||||
|
||||
it("reads thread history pages as message stream items", async () => {
|
||||
const threadTurnsList = vi.fn().mockResolvedValue({
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
data: [turn([userMessage("u1", "prompt"), agentMessage("a1", "answer")])],
|
||||
nextCursor: "older",
|
||||
});
|
||||
const client = { threadTurnsList } as unknown as AppServerClient;
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const transport = createChatThreadHistoryTransport({
|
||||
currentClient: () => client,
|
||||
});
|
||||
|
||||
const page = await transport.readHistoryPage("thread", "cursor", 20);
|
||||
|
||||
expect(threadTurnsList).toHaveBeenCalledWith("thread", "cursor", 20);
|
||||
expect(request).toHaveBeenCalledWith("thread/turns/list", {
|
||||
threadId: "thread",
|
||||
cursor: "cursor",
|
||||
limit: 20,
|
||||
sortDirection: "desc",
|
||||
itemsView: "full",
|
||||
});
|
||||
expect(page?.nextCursor).toBe("older");
|
||||
expect(page?.hadTurns).toBe(true);
|
||||
expect(page?.items).toEqual([
|
||||
|
|
@ -148,7 +154,7 @@ describe("chat app-server transports", () => {
|
|||
|
||||
it("drops stale history transport responses after the current client changes", async () => {
|
||||
const history = deferred<{ data: TurnRecord[]; nextCursor: string | null }>();
|
||||
const firstClient = { threadTurnsList: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient;
|
||||
const firstClient = { request: vi.fn().mockReturnValue(history.promise) } as unknown as AppServerClient;
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const transport = createChatThreadHistoryTransport({
|
||||
|
|
@ -163,7 +169,7 @@ describe("chat app-server transports", () => {
|
|||
});
|
||||
|
||||
it("resumes threads with the session vault path and projects initial history", async () => {
|
||||
const resumeThread = vi.fn().mockResolvedValue({
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
thread: { ...threadRecord("thread"), path: "/tmp/rollout.jsonl" },
|
||||
cwd: "/vault",
|
||||
model: "gpt-test",
|
||||
|
|
@ -175,7 +181,7 @@ describe("chat app-server transports", () => {
|
|||
nextCursor: "older",
|
||||
},
|
||||
});
|
||||
const client = { resumeThread } as unknown as AppServerClient;
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const transport = createChatThreadResumeTransport({
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
|
|
@ -184,7 +190,12 @@ describe("chat app-server transports", () => {
|
|||
|
||||
const snapshot = await transport.resumeThread("thread");
|
||||
|
||||
expect(resumeThread).toHaveBeenCalledWith("thread", "/vault");
|
||||
expect(request).toHaveBeenCalledWith("thread/resume", {
|
||||
threadId: "thread",
|
||||
cwd: "/vault",
|
||||
excludeTurns: true,
|
||||
initialTurnsPage: { limit: 20, sortDirection: "desc", itemsView: "full" },
|
||||
});
|
||||
expect(snapshot?.activation.thread.id).toBe("thread");
|
||||
expect(snapshot?.activation.cwd).toBe("/vault");
|
||||
expect(snapshot?.rolloutPath).toBe("/tmp/rollout.jsonl");
|
||||
|
|
@ -197,7 +208,7 @@ describe("chat app-server transports", () => {
|
|||
|
||||
it("drops stale resume transport responses after the current client changes", async () => {
|
||||
const resume = deferred<AppServerThreadResumeResponse>();
|
||||
const firstClient = { resumeThread: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient;
|
||||
const firstClient = { request: vi.fn().mockReturnValue(resume.promise) } as unknown as AppServerClient;
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const transport = createChatThreadResumeTransport({
|
||||
|
|
@ -224,7 +235,7 @@ describe("chat app-server transports", () => {
|
|||
});
|
||||
|
||||
it("distinguishes absent goals from unavailable goal clients", async () => {
|
||||
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
|
||||
const client = { request: vi.fn().mockResolvedValue({ goal: null }) } as unknown as AppServerClient;
|
||||
const transport = createChatThreadGoalTransport({
|
||||
currentClient: () => client,
|
||||
connectedClient: vi.fn().mockResolvedValue(client),
|
||||
|
|
@ -244,7 +255,8 @@ describe("chat app-server transports", () => {
|
|||
|
||||
it("drops stale runtime settings updates after the current client changes", async () => {
|
||||
const update = deferred<void>();
|
||||
const firstClient = { updateThreadSettings: vi.fn().mockReturnValue(update.promise) } as unknown as AppServerClient;
|
||||
const request = vi.fn().mockReturnValue(update.promise);
|
||||
const firstClient = { request } as unknown as AppServerClient;
|
||||
const secondClient = {} as unknown as AppServerClient;
|
||||
let currentClient = firstClient;
|
||||
const transport = createChatRuntimeSettingsTransport({
|
||||
|
|
@ -256,15 +268,15 @@ describe("chat app-server transports", () => {
|
|||
update.resolve(undefined);
|
||||
|
||||
await expect(updating).resolves.toBe(false);
|
||||
expect(firstClient.updateThreadSettings).toHaveBeenCalledWith("thread", { model: "gpt-5.5" });
|
||||
expect(request).toHaveBeenCalledWith("thread/settings/update", { threadId: "thread", model: "gpt-5.5" });
|
||||
});
|
||||
|
||||
it("resolves referenced thread input at the app-server boundary", async () => {
|
||||
const threadTurnsList = vi.fn().mockResolvedValue({
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
data: [turn([userMessage("u1", "元の依頼"), agentMessage("a1", "回答")])],
|
||||
nextCursor: null,
|
||||
});
|
||||
const client = { threadTurnsList } as unknown as AppServerClient;
|
||||
const client = { request } as unknown as AppServerClient;
|
||||
const setStatus = vi.fn();
|
||||
const resolver = createThreadReferenceResolver({
|
||||
currentClient: () => client,
|
||||
|
|
@ -278,7 +290,13 @@ describe("chat app-server transports", () => {
|
|||
"summarize",
|
||||
);
|
||||
|
||||
expect(threadTurnsList).toHaveBeenCalledWith("019abcde-0000-7000-8000-000000000001", null, 20);
|
||||
expect(request).toHaveBeenCalledWith("thread/turns/list", {
|
||||
threadId: "019abcde-0000-7000-8000-000000000001",
|
||||
cursor: null,
|
||||
limit: 20,
|
||||
sortDirection: "desc",
|
||||
itemsView: "full",
|
||||
});
|
||||
expect(result?.input[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Reference thread history:"),
|
||||
|
|
@ -288,7 +306,7 @@ describe("chat app-server transports", () => {
|
|||
});
|
||||
});
|
||||
|
||||
type AppServerThreadResumeResponse = Awaited<ReturnType<AppServerClient["resumeThread"]>>;
|
||||
type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"];
|
||||
|
||||
function threadRecord(id: string, turns: readonly TurnRecord[] = [], overrides: Partial<ThreadRecord> = {}): ThreadRecord {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ function coordinatorFixture(
|
|||
completedTurnTitleContext: (turnId: string, completedSummary) => titleService.completedTurnContext(turnId, completedSummary),
|
||||
generateTitleFromContext: (context) => titleService.generate(context),
|
||||
renameGeneratedTitle: async (threadId: string, value: string, options: { shouldPublish: () => boolean }) => {
|
||||
await currentClient().setThreadName(threadId, value);
|
||||
await currentClient().request("thread/name/set", { threadId, name: value });
|
||||
if (options.shouldPublish()) notifyThreadRenamed(threadId, value);
|
||||
return true;
|
||||
},
|
||||
|
|
@ -161,8 +161,12 @@ function coordinatorFixture(
|
|||
}
|
||||
|
||||
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
|
||||
const setThreadName = options.setThreadName ?? vi.fn().mockResolvedValue({});
|
||||
return {
|
||||
setThreadName: options.setThreadName ?? vi.fn().mockResolvedValue({}),
|
||||
request: vi.fn((method: string, params: { threadId: string; name: string }) => {
|
||||
if (method !== "thread/name/set") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return (setThreadName as unknown as (threadId: string, name: string) => Promise<unknown>)(params.threadId, params.name);
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ function actionsFixture(
|
|||
renameThread: async (threadId: string, value: string) => {
|
||||
const name = normalizeExplicitThreadName(value);
|
||||
if (!name) return false;
|
||||
await currentClient().setThreadName(threadId, name);
|
||||
await currentClient().request("thread/name/set", { threadId, name });
|
||||
stateStore.dispatch({
|
||||
type: "thread-list/applied",
|
||||
threads: stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)),
|
||||
|
|
@ -189,11 +189,19 @@ function actionsFixture(
|
|||
}
|
||||
|
||||
function fakeClient(options: { setThreadName?: ReturnType<typeof vi.fn> } = {}): AppServerClient {
|
||||
const setThreadName = options.setThreadName ?? vi.fn().mockResolvedValue({});
|
||||
return {
|
||||
setThreadName: options.setThreadName ?? vi.fn().mockResolvedValue({}),
|
||||
threadTurnsList: vi.fn().mockResolvedValue({
|
||||
data: [turnFixture([userMessage("user", "Please name this."), assistantMessage("assistant", "Done.")])],
|
||||
nextCursor: null,
|
||||
request: vi.fn((method: string, params: { threadId: string; name: string }) => {
|
||||
if (method === "thread/name/set") {
|
||||
return (setThreadName as unknown as (threadId: string, name: string) => Promise<unknown>)(params.threadId, params.name);
|
||||
}
|
||||
if (method === "thread/turns/list") {
|
||||
return Promise.resolve({
|
||||
data: [turnFixture([userMessage("user", "Please name this."), assistantMessage("assistant", "Done.")])],
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}),
|
||||
} as unknown as AppServerClient;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -721,14 +721,15 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
});
|
||||
|
||||
function connectedClient(overrides: Partial<ReturnType<typeof baseClient>> = {}): ReturnType<typeof baseClient> {
|
||||
return {
|
||||
...baseClient(),
|
||||
...overrides,
|
||||
};
|
||||
return withClientRequest({ ...baseClientMethods(), ...overrides }) as ReturnType<typeof baseClient>;
|
||||
}
|
||||
|
||||
function baseClient() {
|
||||
return {
|
||||
return withClientRequest(baseClientMethods());
|
||||
}
|
||||
|
||||
function baseClientMethods() {
|
||||
const client = {
|
||||
readEffectiveConfig: vi.fn().mockResolvedValue({}),
|
||||
listModels: vi.fn().mockResolvedValue({ data: [] }),
|
||||
listSkills: vi.fn().mockResolvedValue({ data: [] }),
|
||||
|
|
@ -747,6 +748,84 @@ function baseClient() {
|
|||
readThread: vi.fn().mockResolvedValue({ thread: threadFixture("thread-1") }),
|
||||
archiveThread: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
function withClientRequest<T extends Record<string, unknown>>(client: T): T & { request: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
...client,
|
||||
request: vi.fn((method: string, params: Record<string, unknown>) => {
|
||||
switch (method) {
|
||||
case "config/read":
|
||||
return (client["readEffectiveConfig"] as (cwd: unknown) => Promise<unknown>)(params["cwd"]);
|
||||
case "model/list":
|
||||
return (client["listModels"] as (includeHidden: unknown) => Promise<unknown>)(params["includeHidden"] ?? false);
|
||||
case "skills/list":
|
||||
return (client["listSkills"] as (cwd: string) => Promise<unknown>)((params["cwds"] as string[])[0] ?? "");
|
||||
case "account/rateLimits/read":
|
||||
return (client["readAccountRateLimits"] as () => Promise<unknown>)();
|
||||
case "thread/list":
|
||||
return (client["listThreads"] as (cwd: unknown, params: unknown) => Promise<unknown>)(params["cwd"], params);
|
||||
case "thread/start":
|
||||
return (client["startThread"] as (options: unknown) => Promise<unknown>)({
|
||||
cwd: params["cwd"],
|
||||
serviceTier: params["serviceTier"],
|
||||
});
|
||||
case "thread/resume":
|
||||
return (client["resumeThread"] as (threadId: unknown, cwd: unknown) => Promise<unknown>)(params["threadId"], params["cwd"]);
|
||||
case "thread/turns/list":
|
||||
if (params["sortDirection"] === "desc") {
|
||||
return (client["threadTurnsList"] as (threadId: unknown, cursor: unknown, limit: unknown) => Promise<unknown>)(
|
||||
params["threadId"],
|
||||
params["cursor"],
|
||||
params["limit"],
|
||||
);
|
||||
}
|
||||
return (
|
||||
client["threadTurnsList"] as (threadId: unknown, cursor: unknown, limit: unknown, sortDirection?: unknown) => Promise<unknown>
|
||||
)(params["threadId"], params["cursor"], params["limit"], params["sortDirection"]);
|
||||
case "turn/start":
|
||||
return (client["startTurn"] as (options: unknown) => Promise<unknown>)({
|
||||
threadId: params["threadId"],
|
||||
cwd: params["cwd"],
|
||||
input: codexInputFromRequestInput(params["input"] as { type: string; text?: string }[]),
|
||||
clientUserMessageId: params["clientUserMessageId"],
|
||||
});
|
||||
case "thread/fork":
|
||||
return (client["forkThread"] as (threadId: unknown, cwd: unknown) => Promise<unknown>)(params["threadId"], params["cwd"]);
|
||||
case "thread/rollback":
|
||||
return (client["rollbackThread"] as (threadId: unknown) => Promise<unknown>)(params["threadId"]);
|
||||
case "thread/name/set":
|
||||
return (client["setThreadName"] as (threadId: unknown, name: unknown) => Promise<unknown>)(params["threadId"], params["name"]);
|
||||
case "thread/goal/get":
|
||||
return (client["getThreadGoal"] as (threadId: unknown) => Promise<unknown>)(params["threadId"]);
|
||||
case "thread/goal/set":
|
||||
return (client["setThreadGoal"] as (threadId: unknown, goal: unknown) => Promise<unknown>)(params["threadId"], {
|
||||
objective: params["objective"],
|
||||
status: params["status"],
|
||||
tokenBudget: params["tokenBudget"],
|
||||
});
|
||||
case "thread/inject_items":
|
||||
return (client["injectThreadItems"] as (threadId: unknown, items: unknown) => Promise<unknown>)(
|
||||
params["threadId"],
|
||||
params["items"],
|
||||
);
|
||||
case "thread/read":
|
||||
return (client["readThread"] as (threadId: unknown, includeTurns: unknown) => Promise<unknown>)(
|
||||
params["threadId"],
|
||||
params["includeTurns"],
|
||||
);
|
||||
case "thread/archive":
|
||||
return (client["archiveThread"] as (threadId: unknown) => Promise<unknown>)(params["threadId"]);
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function codexInputFromRequestInput(input: { type: string; text?: string }[]): { type: "text"; text: string }[] {
|
||||
return input.flatMap((item) => (item.type === "text" ? [{ type: "text", text: item.text ?? "" }] : []));
|
||||
}
|
||||
|
||||
function goalFixture(threadId: string) {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AppServerClient,
|
||||
AppServerClientHandlers,
|
||||
AppServerStartStructuredTurnOptions,
|
||||
} from "../../../src/app-server/connection/client";
|
||||
import type { RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
|
||||
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../../src/app-server/connection/client";
|
||||
import type { ClientRequestParams, RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
|
||||
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
|
||||
import type { AppServerStartStructuredTurnOptions } from "../../../src/app-server/services/turns";
|
||||
import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata";
|
||||
import type { ServerInitialization } from "../../../src/domain/server/initialization";
|
||||
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
|
||||
|
|
@ -24,16 +21,18 @@ import { positionSelectionRewritePopover } from "../../../src/features/selection
|
|||
import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt";
|
||||
import * as selectionRewriteRunner from "../../../src/features/selection-rewrite/runner";
|
||||
import { runSelectionRewrite } from "../../../src/features/selection-rewrite/runner";
|
||||
import type { ModelListResponse } from "../../../src/generated/app-server/v2/ModelListResponse";
|
||||
import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse";
|
||||
import { deferred } from "../../support/async";
|
||||
import { installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
type InitializeResponse = ServerInitialization;
|
||||
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
|
||||
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
|
||||
type Turn = TurnRecord;
|
||||
interface TurnStartResponse {
|
||||
turn: TurnRecord;
|
||||
}
|
||||
type SelectionRewriteClientFactory = NonNullable<Parameters<typeof runSelectionRewrite>[0]["clientFactory"]>;
|
||||
type SelectionRewriteClient = ReturnType<SelectionRewriteClientFactory>;
|
||||
type TurnStartResponse = Awaited<ReturnType<SelectionRewriteClient["startStructuredTurn"]>>;
|
||||
|
||||
installObsidianDomShims();
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
|
@ -814,20 +813,31 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
|
|||
|
||||
async deleteThread(): Promise<void> {}
|
||||
|
||||
async listModels(): Promise<ModelListResponse> {
|
||||
return this.modelList;
|
||||
}
|
||||
|
||||
rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {}
|
||||
|
||||
async startEphemeralThread(): Promise<ThreadStartResponse> {
|
||||
return threadStartResponse("thread");
|
||||
}
|
||||
|
||||
async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
|
||||
this.startStructuredTurnOptions = options;
|
||||
this.resolveStructuredTurnStarted();
|
||||
return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
|
||||
async request<M extends TypedClientRequestMethod>(
|
||||
method: M,
|
||||
params: ClientRequestParams<M>,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<ClientResponseByMethod[M]> {
|
||||
void options;
|
||||
switch (method) {
|
||||
case "model/list":
|
||||
return this.modelList as unknown as ClientResponseByMethod[M];
|
||||
case "thread/start":
|
||||
return threadStartResponse("thread") as unknown as ClientResponseByMethod[M];
|
||||
case "turn/start":
|
||||
this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
|
||||
this.resolveStructuredTurnStarted();
|
||||
return (this.startStructuredTurnImpl
|
||||
? await this.startStructuredTurnImpl()
|
||||
: { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
|
||||
case "thread/delete":
|
||||
await this.deleteThread();
|
||||
return {} as unknown as ClientResponseByMethod[M];
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
emit(notification: ServerNotification): void {
|
||||
|
|
@ -835,6 +845,26 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
|
|||
}
|
||||
}
|
||||
|
||||
function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
|
||||
const textItem = params.input[0];
|
||||
if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
|
||||
return {
|
||||
threadId: params.threadId,
|
||||
cwd: params.cwd,
|
||||
text: textItem.text,
|
||||
outputSchema: params.outputSchema,
|
||||
runtime: {
|
||||
...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
|
||||
...(params.collaborationMode !== undefined && params.collaborationMode !== null
|
||||
? { collaborationMode: params.collaborationMode }
|
||||
: {}),
|
||||
...(params.model !== undefined ? { model: params.model } : {}),
|
||||
...(params.effort !== undefined ? { effort: params.effort } : {}),
|
||||
...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function threadStartResponse(threadId: string): ThreadStartResponse {
|
||||
return {
|
||||
thread: thread(threadId),
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ describe("CodexThreadsView", () => {
|
|||
});
|
||||
|
||||
function clientFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
const client = {
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [] }),
|
||||
archiveThread: vi.fn().mockResolvedValue({}),
|
||||
setThreadName: vi.fn().mockResolvedValue({}),
|
||||
|
|
@ -511,6 +511,28 @@ function clientFixture(overrides: Record<string, unknown> = {}): Record<string,
|
|||
rejectServerRequest: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...client,
|
||||
request: vi.fn((method: string, params: Record<string, unknown>) => {
|
||||
switch (method) {
|
||||
case "thread/list":
|
||||
return (client.listThreads as (cwd: string, options: unknown) => Promise<unknown>)(params["cwd"] as string, params);
|
||||
case "thread/archive":
|
||||
return (client.archiveThread as (threadId: string) => Promise<unknown>)(params["threadId"] as string);
|
||||
case "thread/name/set":
|
||||
return (client.setThreadName as (threadId: string, name: string) => Promise<unknown>)(
|
||||
params["threadId"] as string,
|
||||
params["name"] as string,
|
||||
);
|
||||
case "thread/turns/list":
|
||||
return (
|
||||
client.threadTurnsList as (threadId: string, cursor: string | null, limit: number, sortDirection?: string) => Promise<unknown>
|
||||
)(params["threadId"] as string, params["cursor"] as string | null, params["limit"] as number, params["sortDirection"] as string);
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function threadsHost(overrides: Record<string, unknown> = {}) {
|
||||
|
|
|
|||
|
|
@ -126,10 +126,18 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
|
|||
type MockClient = ReturnType<typeof clientMock>;
|
||||
|
||||
function clientMock() {
|
||||
return {
|
||||
const client = {
|
||||
setThreadName: vi.fn().mockResolvedValue({}),
|
||||
archiveThread: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
return {
|
||||
...client,
|
||||
request: vi.fn((method: string, params: { threadId: string; name?: string }) => {
|
||||
if (method === "thread/name/set") return client.setThreadName(params.threadId, params.name);
|
||||
if (method === "thread/archive") return client.archiveThread(params.threadId);
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function archiveDestinationMock(): ArchiveExportDestination {
|
||||
|
|
|
|||
|
|
@ -674,7 +674,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
const connectedView = connectedLeaf.view as CodexChatView;
|
||||
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
|
||||
const runWithAppServerClient = vi.spyOn(connectedView.surface, "runWithAppServerClient").mockResolvedValue("chat-result");
|
||||
const shortLivedClient = { readEffectiveConfig: vi.fn().mockResolvedValue({}) };
|
||||
const shortLivedClient = { request: vi.fn().mockResolvedValue({}) };
|
||||
withShortLivedAppServerClientMock.mockImplementation(
|
||||
(_codexPath: string, _vaultPath: string, operation: (client: typeof shortLivedClient) => Promise<unknown>) =>
|
||||
operation(shortLivedClient),
|
||||
|
|
@ -682,7 +682,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
const plugin = await pluginWithLeaves([connectedLeaf]);
|
||||
plugin.settings.codexPath = "codex";
|
||||
|
||||
const result = await plugin.runtime.withClient((client) => client.readEffectiveConfig("/vault") as Promise<unknown>, {
|
||||
const result = await plugin.runtime.withClient((client) => client.request("config/read", { cwd: "/vault", includeLayers: true }), {
|
||||
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
|
||||
});
|
||||
|
||||
|
|
@ -691,7 +691,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => {
|
|||
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith("codex", "/vault", expect.any(Function), {
|
||||
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
|
||||
});
|
||||
expect(shortLivedClient.readEffectiveConfig).toHaveBeenCalledWith("/vault");
|
||||
expect(shortLivedClient.request).toHaveBeenCalledWith("config/read", { cwd: "/vault", includeLayers: true });
|
||||
});
|
||||
|
||||
it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => {
|
||||
|
|
@ -849,10 +849,13 @@ function chatHostFixture(): CodexChatHost {
|
|||
|
||||
function threadListClient(fetchThreads: () => Promise<readonly Thread[]>): never {
|
||||
return {
|
||||
listThreads: async () => ({
|
||||
data: await fetchThreads(),
|
||||
nextCursor: null,
|
||||
}),
|
||||
request: async (method: string) => {
|
||||
if (method !== "thread/list") throw new Error(`Unexpected app-server request: ${method}`);
|
||||
return {
|
||||
data: await fetchThreads(),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -348,9 +348,9 @@ describe("settings tab", () => {
|
|||
const initialClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" })],
|
||||
});
|
||||
const trustClient = {
|
||||
const trustClient = withSettingsRequest({
|
||||
trustHook: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
const staleClient = settingsClient();
|
||||
staleClient.listHooks.mockReturnValue(staleHooks.promise);
|
||||
const newerClient = settingsClient({
|
||||
|
|
@ -393,9 +393,9 @@ describe("settings tab", () => {
|
|||
const fullRefreshClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })],
|
||||
});
|
||||
const trustClient = {
|
||||
const trustClient = withSettingsRequest({
|
||||
trustHook: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
const hookReloadClient = settingsClient({
|
||||
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
|
||||
});
|
||||
|
|
@ -435,9 +435,9 @@ describe("settings tab", () => {
|
|||
const staleRestore = deferred<{ thread: ThreadRecord }>();
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = {
|
||||
const restoreClient = withSettingsRequest({
|
||||
unarchiveThread: vi.fn(() => staleRestore.promise),
|
||||
};
|
||||
});
|
||||
const newerClient = settingsClient();
|
||||
withShortLivedAppServerClientMock
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
|
|
@ -476,9 +476,9 @@ describe("settings tab", () => {
|
|||
const notify = vi.fn();
|
||||
const applyThreadCatalogEvent = vi.fn();
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = {
|
||||
const restoreClient = withSettingsRequest({
|
||||
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
|
||||
};
|
||||
});
|
||||
withShortLivedAppServerClientMock
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(initialClient),
|
||||
|
|
@ -510,9 +510,9 @@ describe("settings tab", () => {
|
|||
it("displays restored archived thread state after recording the active catalog event", async () => {
|
||||
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
|
||||
const initialClient = settingsClient();
|
||||
const restoreClient = {
|
||||
const restoreClient = withSettingsRequest({
|
||||
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
|
||||
};
|
||||
});
|
||||
withShortLivedAppServerClientMock
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(initialClient),
|
||||
|
|
@ -557,9 +557,9 @@ describe("settings tab", () => {
|
|||
it("displays deleted archived thread status after recording the catalog event", async () => {
|
||||
const snapshots: SettingsDynamicSectionsSnapshot[] = [];
|
||||
const initialClient = settingsClient();
|
||||
const deleteClient = {
|
||||
const deleteClient = withSettingsRequest({
|
||||
deleteThread: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
withShortLivedAppServerClientMock
|
||||
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
|
||||
operation(initialClient),
|
||||
|
|
@ -932,7 +932,7 @@ function hook(overrides: Partial<CatalogHookMetadata> = {}): CatalogHookMetadata
|
|||
function settingsClient(
|
||||
options: { models?: CatalogModel[]; hooks?: CatalogHookMetadata[]; hooksError?: Error; threads?: ThreadRecord[] } = {},
|
||||
) {
|
||||
return {
|
||||
return withSettingsRequest({
|
||||
listModels: vi.fn().mockResolvedValue({ data: options.models ?? [model("gpt-5.4")] }),
|
||||
listHooks: vi.fn().mockImplementation(() => {
|
||||
if (options.hooksError) return Promise.reject(options.hooksError);
|
||||
|
|
@ -950,10 +950,52 @@ function settingsClient(
|
|||
listThreads: vi.fn().mockResolvedValue({ data: options.threads ?? [appServerThread({ preview: "Archived" })] }),
|
||||
setHookEnabled: vi.fn().mockResolvedValue({}),
|
||||
trustHook: vi.fn().mockResolvedValue({}),
|
||||
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ preview: "Restored" }) }),
|
||||
deleteThread: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
}
|
||||
|
||||
function withSettingsRequest<T extends Record<string, unknown>>(client: T): T & { request: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
...client,
|
||||
request: vi.fn((method: string, params: unknown) => {
|
||||
switch (method) {
|
||||
case "model/list":
|
||||
return (client["listModels"] as (includeHidden: boolean) => Promise<unknown>)(
|
||||
(params as { includeHidden?: boolean }).includeHidden ?? false,
|
||||
);
|
||||
case "hooks/list":
|
||||
return (client["listHooks"] as (cwd: string) => Promise<unknown>)((params as { cwds: string[] }).cwds[0] ?? "");
|
||||
case "thread/list":
|
||||
return (client["listThreads"] as (cwd: string, options: { archived?: boolean }) => Promise<unknown>)(
|
||||
(params as { cwd: string }).cwd,
|
||||
params as { archived?: boolean },
|
||||
);
|
||||
case "config/batchWrite": {
|
||||
const state = hookStateFromBatchWrite(params);
|
||||
if (state?.enabled === true && state.trusted_hash && client["trustHook"]) {
|
||||
return (client["trustHook"] as () => Promise<unknown>)();
|
||||
}
|
||||
if (client["setHookEnabled"]) return (client["setHookEnabled"] as () => Promise<unknown>)();
|
||||
return Promise.resolve({});
|
||||
}
|
||||
case "thread/unarchive":
|
||||
return (client["unarchiveThread"] as (threadId: string) => Promise<unknown>)((params as { threadId: string }).threadId);
|
||||
case "thread/delete":
|
||||
return (client["deleteThread"] as (threadId: string) => Promise<unknown>)((params as { threadId: string }).threadId);
|
||||
default:
|
||||
throw new Error(`Unexpected app-server request: ${method}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function hookStateFromBatchWrite(params: unknown): { enabled?: boolean; trusted_hash?: string } | null {
|
||||
const edit = (params as { edits?: { value?: Record<string, { enabled?: boolean; trusted_hash?: string }> }[] }).edits?.[0];
|
||||
const value = edit?.value;
|
||||
return value ? (Object.values(value)[0] ?? null) : null;
|
||||
}
|
||||
|
||||
function newSettingsTab(
|
||||
options: {
|
||||
saveSettings?: () => Promise<void>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue