From 42af40c299ab2ace64249bd9a6817ee5e2d6348d Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 7 Jul 2026 22:06:19 +0900 Subject: [PATCH] Make app-server initialize profile explicit --- docs/development.md | 2 +- scripts/api-baseline.mjs | 43 +++-- src/app-server/connection/client-profile.ts | 31 ++++ src/app-server/connection/client.ts | 43 +++-- src/app-server/connection/compatibility.json | 13 ++ .../connection/connection-manager.ts | 5 +- .../connection/short-lived-client.ts | 30 ++-- .../services/ephemeral-structured-turn.ts | 11 +- src/features/chat/host/session-graph.ts | 7 +- tests/app-server/app-server-client.test.ts | 170 +++++++++--------- tests/app-server/connection-manager.test.ts | 69 ++++--- tests/app-server/panel-client-profile.test.ts | 17 ++ tests/scripts/development-scripts.test.ts | 30 ++-- 13 files changed, 301 insertions(+), 170 deletions(-) create mode 100644 src/app-server/connection/client-profile.ts create mode 100644 src/app-server/connection/compatibility.json create mode 100644 tests/app-server/panel-client-profile.test.ts diff --git a/docs/development.md b/docs/development.md index 808f9e6f..dac7f7eb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,4 +82,4 @@ npm run api:baseline Obsidian runtime compatibility is declared through `manifest.json` and `versions.json`. The `obsidian` npm package provides compile-time TypeScript API definitions; it is not runtime validation for an Obsidian app-version matrix. Because the project does not run app-version smoke tests, keep the API type package in the same minor as `manifest.minAppVersion` and use the latest patch in that minor for local type checking. `npm run api:baseline` exits non-zero when the local environment or recorded baselines drift. Raise `manifest.minAppVersion` only when intentionally adopting a newer Obsidian app/API minor. -Codex app-server compatibility is managed by Codex CLI minor version. README records the tested Codex CLI patch version, and the baseline check verifies that the local `codex --version` is in the same minor before app-server binding or compatibility work. +Codex app-server compatibility is managed by Codex CLI minor version. README records the tested Codex CLI patch version, `src/app-server/connection/compatibility.json` records the machine-readable app-server capability baseline used by the panel client profile, and the baseline check verifies that the local `codex --version` is in the same minor before app-server binding or compatibility work. diff --git a/scripts/api-baseline.mjs b/scripts/api-baseline.mjs index 4fcc2de8..9303fa16 100644 --- a/scripts/api-baseline.mjs +++ b/scripts/api-baseline.mjs @@ -73,12 +73,14 @@ export async function createApiBaselineReport(options = {}) { const obsidianLockSemver = parseSemver(obsidianLockVersion); const obsidianMinSemver = parseSemver(obsidianMinVersion); + const appServerGenerationExperimentalDeclared = inputs.appServerCompatibilityJson.codexAppServer?.typeGeneration?.experimental === true; const appServerGenerationExperimental = inputs.appServerGenerateSource.includes("app-server") && inputs.appServerGenerateSource.includes("generate-ts") && inputs.appServerGenerateSource.includes("--experimental"); - const initializeExperimentalApi = /experimentalApi:\s*true/.test(inputs.clientSource); - const initializeRequestAttestationDisabled = /requestAttestation:\s*false/.test(inputs.clientSource); + const initializeCapabilities = inputs.appServerCompatibilityJson.codexAppServer?.initialize?.capabilities ?? {}; + const initializeExperimentalApi = initializeCapabilities.experimentalApi === true; + const initializeRequestAttestationDisabled = initializeCapabilities.requestAttestation === false; if (!codexReadmeSemver) { fail("README.md Compatibility table must define `codex.testedCliVersion` as X.Y.Z."); @@ -89,9 +91,19 @@ export async function createApiBaselineReport(options = {}) { if (codexReadmeSemver && codexLocalSemver && minorKey(codexReadmeSemver) !== minorKey(codexLocalSemver)) { fail(`local Codex CLI minor ${minorKey(codexLocalSemver)} does not match compatibility table minor ${minorKey(codexReadmeSemver)}.`); } + if (!appServerGenerationExperimentalDeclared) { + fail("src/app-server/connection/compatibility.json must declare codexAppServer.typeGeneration.experimental: true."); + } if (!appServerGenerationExperimental) fail("generate:app-server-types must use codex app-server generate-ts --experimental."); - if (!initializeExperimentalApi) fail("app-server initialize must declare experimentalApi: true."); - if (!initializeRequestAttestationDisabled) fail("app-server initialize must declare requestAttestation: false."); + if (appServerGenerationExperimentalDeclared !== appServerGenerationExperimental) { + fail("app-server type generation declaration must match scripts/generate-app-server-types.mjs."); + } + if (!initializeExperimentalApi) { + fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.experimentalApi: true."); + } + if (!initializeRequestAttestationDisabled) { + fail("src/app-server/connection/compatibility.json must declare codexAppServer.initialize.capabilities.requestAttestation: false."); + } if (!obsidianMinSemver) fail("manifest.json minAppVersion must be X.Y.Z."); if (obsidianMinSemver && obsidianMinSemver.patch !== 0) { @@ -132,6 +144,7 @@ export async function createApiBaselineReport(options = {}) { localCliVersion: codexLocalVersion, localCliMinor: minorKey(codexLocalSemver), localCliMatchesTestedMinor: codexReadmeSemver && codexLocalSemver ? minorKey(codexReadmeSemver) === minorKey(codexLocalSemver) : null, + appServerGenerationExperimentalDeclared, appServerGenerationExperimental, initializeExperimentalApi, initializeRequestAttestationDisabled, @@ -225,19 +238,20 @@ function displayValue(value) { } async function readBaselineInputs(cwd) { - const [packageJson, packageLockJson, manifestJson, versionsJson, readme, clientSource, appServerGenerateSource] = await Promise.all([ - readJson(cwd, "package.json"), - readJson(cwd, "package-lock.json"), - readJson(cwd, "manifest.json"), - readJson(cwd, "versions.json"), - readFile(path.join(cwd, "README.md"), "utf8"), - readFile(path.join(cwd, "src/app-server/connection/client.ts"), "utf8"), - readFile(path.join(cwd, "scripts/generate-app-server-types.mjs"), "utf8"), - ]); + const [packageJson, packageLockJson, manifestJson, versionsJson, readme, appServerCompatibilityJson, appServerGenerateSource] = + await Promise.all([ + readJson(cwd, "package.json"), + readJson(cwd, "package-lock.json"), + readJson(cwd, "manifest.json"), + readJson(cwd, "versions.json"), + readFile(path.join(cwd, "README.md"), "utf8"), + readJson(cwd, "src/app-server/connection/compatibility.json"), + readFile(path.join(cwd, "scripts/generate-app-server-types.mjs"), "utf8"), + ]); return { + appServerCompatibilityJson, appServerGenerateSource, - clientSource, manifestJson, packageJson, packageLockJson, @@ -263,6 +277,7 @@ function printReport(report) { console.log(` compatibility table minor: ${displayValue(report.codex.readmeTestedMinor)}`); console.log(` local codex CLI: ${displayValue(report.codex.localCliVersion)}`); console.log(` local codex minor: ${displayValue(report.codex.localCliMinor)}`); + console.log(` declared generate-ts --experimental: ${report.codex.appServerGenerationExperimentalDeclared ? "yes" : "no"}`); console.log(` generate-ts --experimental: ${report.codex.appServerGenerationExperimental ? "yes" : "no"}`); console.log(` initialize experimentalApi: ${report.codex.initializeExperimentalApi ? "yes" : "no"}`); console.log(` initialize requestAttestation disabled: ${report.codex.initializeRequestAttestationDisabled ? "yes" : "no"}`); diff --git a/src/app-server/connection/client-profile.ts b/src/app-server/connection/client-profile.ts new file mode 100644 index 00000000..c8d3ce84 --- /dev/null +++ b/src/app-server/connection/client-profile.ts @@ -0,0 +1,31 @@ +import { CLIENT_VERSION } from "../../constants"; +import type { InitializeCapabilities } from "../../generated/app-server/InitializeCapabilities"; +import type { InitializeParams } from "../../generated/app-server/InitializeParams"; +import compatibility from "./compatibility.json"; + +interface AppServerCompatibility { + codexAppServer: { + typeGeneration: { + experimental: boolean; + }; + initialize: { + capabilities: Pick; + }; + }; +} + +const appServerCompatibility = compatibility satisfies AppServerCompatibility; + +export function codexPanelAppServerInitializeParams(): InitializeParams { + return { + clientInfo: { + name: "obsidian_codex_panel", + title: "Codex Panel", + version: CLIENT_VERSION, + }, + capabilities: { + experimentalApi: appServerCompatibility.codexAppServer.initialize.capabilities.experimentalApi, + requestAttestation: appServerCompatibility.codexAppServer.initialize.capabilities.requestAttestation, + }, + }; +} diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 9ecb21f4..b0fb66a0 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -1,4 +1,4 @@ -import { CLIENT_VERSION } from "../../constants"; +import type { InitializeParams } from "../../generated/app-server/InitializeParams"; import type { InitializeResponse } from "../../generated/app-server/InitializeResponse"; import type { RequestId } from "../../generated/app-server/RequestId"; import type { ServerNotification } from "../../generated/app-server/ServerNotification"; @@ -57,6 +57,15 @@ export interface AppServerServerRequestResponder { export type AppServerTransportFactory = (handlers: AppServerTransportHandlers) => AppServerTransport; +export interface AppServerClientOptions { + codexPath: string; + cwd: string; + handlers: AppServerClientHandlers; + initializeParams: InitializeParams; + requestTimeoutMs?: number; + transportFactory?: AppServerTransportFactory; +} + export interface ClientResponseByMethod { initialize: InitializeResponse; "config/batchWrite": ConfigWriteResponse; @@ -106,14 +115,20 @@ export class AppServerClient { private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" }; private readonly rpc: JsonRpcClient; private readonly intentionallyStoppedTransports = new WeakSet(); + private readonly codexPath: string; + private readonly cwd: string; + private readonly handlers: AppServerClientHandlers; + private readonly initializeParams: InitializeParams; + private readonly requestTimeoutMs: number; + private readonly transportFactory: AppServerTransportFactory | undefined; - constructor( - private readonly codexPath: string, - private readonly cwd: string, - private readonly handlers: AppServerClientHandlers, - private readonly requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - private readonly transportFactory?: AppServerTransportFactory, - ) { + constructor(options: AppServerClientOptions) { + this.codexPath = options.codexPath; + this.cwd = options.cwd; + this.handlers = options.handlers; + this.initializeParams = options.initializeParams; + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.transportFactory = options.transportFactory; this.rpc = new JsonRpcClient({ requestTimeoutMs: this.requestTimeoutMs, send: (message) => { @@ -178,17 +193,7 @@ export class AppServerClient { this.lifecycle = { kind: "starting", transport }; try { transport.start(); - const init = await this.request("initialize", { - clientInfo: { - name: "obsidian_codex_panel", - title: "Codex Panel", - version: CLIENT_VERSION, - }, - capabilities: { - experimentalApi: true, - requestAttestation: false, - }, - }); + const init = await this.request("initialize", this.initializeParams); this.notify({ method: "initialized" }); this.lifecycle = { kind: "initialized", transport, initializeResponse: init }; return init; diff --git a/src/app-server/connection/compatibility.json b/src/app-server/connection/compatibility.json new file mode 100644 index 00000000..51fe03b7 --- /dev/null +++ b/src/app-server/connection/compatibility.json @@ -0,0 +1,13 @@ +{ + "codexAppServer": { + "typeGeneration": { + "experimental": true + }, + "initialize": { + "capabilities": { + "experimentalApi": true, + "requestAttestation": false + } + } + } +} diff --git a/src/app-server/connection/connection-manager.ts b/src/app-server/connection/connection-manager.ts index dd9c71a5..51e65207 100644 --- a/src/app-server/connection/connection-manager.ts +++ b/src/app-server/connection/connection-manager.ts @@ -1,4 +1,5 @@ import type { ServerInitialization } from "../../domain/server/initialization"; +import type { InitializeParams } from "../../generated/app-server/InitializeParams"; import type { ServerNotification } from "../../generated/app-server/ServerNotification"; import type { ServerRequest } from "../../generated/app-server/ServerRequest"; import { AppServerClient, type AppServerClientHandlers } from "./client"; @@ -43,7 +44,9 @@ export class ConnectionManager { constructor( private readonly codexPath: () => string, private readonly cwd: string, - private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers), + initializeParams: InitializeParams, + private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) => + new AppServerClient({ codexPath, cwd, handlers, initializeParams }), ) {} currentClient(): AppServerClient | null { diff --git a/src/app-server/connection/short-lived-client.ts b/src/app-server/connection/short-lived-client.ts index 025b0144..4e1661ad 100644 --- a/src/app-server/connection/short-lived-client.ts +++ b/src/app-server/connection/short-lived-client.ts @@ -1,5 +1,6 @@ import { AppServerClient } from "./client"; import type { AppServerClientAccessOptions } from "./client-access"; +import { codexPanelAppServerInitializeParams } from "./client-profile"; export async function withShortLivedAppServerClient( codexPath: string, @@ -7,19 +8,24 @@ export async function withShortLivedAppServerClient( operation: (client: AppServerClient) => Promise, options: AppServerClientAccessOptions = {}, ): Promise { - const client = new AppServerClient(codexPath, cwd, { - onNotification: () => undefined, - onServerRequest: (request, responder) => { - void request; - responder.reject( - -32601, - options.serverRequests?.kind === "reject" - ? options.serverRequests.message - : "This Codex Panel view does not handle server requests.", - ); + const client = new AppServerClient({ + codexPath, + cwd, + initializeParams: codexPanelAppServerInitializeParams(), + handlers: { + onNotification: () => undefined, + onServerRequest: (request, responder) => { + void request; + responder.reject( + -32601, + options.serverRequests?.kind === "reject" + ? options.serverRequests.message + : "This Codex Panel view does not handle server requests.", + ); + }, + onLog: () => undefined, + onExit: () => undefined, }, - onLog: () => undefined, - onExit: () => undefined, }); try { diff --git a/src/app-server/services/ephemeral-structured-turn.ts b/src/app-server/services/ephemeral-structured-turn.ts index d695bdc7..ea7c30d8 100644 --- a/src/app-server/services/ephemeral-structured-turn.ts +++ b/src/app-server/services/ephemeral-structured-turn.ts @@ -1,6 +1,7 @@ import { listenAbortSignal } from "../../shared/runtime/abort-signal"; import { AppServerClient, type AppServerClientHandlers } from "../connection/client"; import type { AppServerClientRequestPolicy } from "../connection/client-access"; +import { codexPanelAppServerInitializeParams } from "../connection/client-profile"; import type { ServerNotification } from "../connection/rpc-messages"; import { lastAgentMessageTextFromTurnRecord, type TurnItem, type TurnRecord } from "../protocol/turn"; import type { ModelMetadataClient } from "./catalog"; @@ -96,7 +97,15 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured }; }); - const clientFactory = options.clientFactory ?? ((codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers)); + const clientFactory = + options.clientFactory ?? + ((codexPath, cwd, handlers) => + new AppServerClient({ + codexPath, + cwd, + handlers, + initializeParams: codexPanelAppServerInitializeParams(), + })); let threadId: string | null = null; const client = clientFactory(options.codexPath, options.cwd, { onNotification: (notification) => { diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index c1d2548e..50f07536 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -1,3 +1,4 @@ +import { codexPanelAppServerInitializeParams } from "../../../app-server/connection/client-profile"; import { ConnectionManager } from "../../../app-server/connection/connection-manager"; import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries"; import { createChatAppServerGateway } from "../app-server/session-gateway"; @@ -272,7 +273,11 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch } function createConnectionManager(environment: ChatPanelEnvironment): ConnectionManager { - return new ConnectionManager(() => environment.plugin.settingsRef.settings.codexPath(), environment.plugin.settingsRef.vaultPath); + return new ConnectionManager( + () => environment.plugin.settingsRef.settings.codexPath(), + environment.plugin.settingsRef.vaultPath, + codexPanelAppServerInitializeParams(), + ); } function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalIdSource): ChatPanelSessionStatus { diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index cd6f6749..f36af7ea 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -1,12 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import manifest from "../../manifest.json"; -import type { AppServerServerRequestResponder } from "../../src/app-server/connection/client"; +import type { AppServerClientHandlers, 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"; +import type { InitializeParams } from "../../src/generated/app-server/InitializeParams"; import type { InitializeResponse } from "../../src/generated/app-server/InitializeResponse"; import type { ServerRequest } from "../../src/generated/app-server/ServerRequest"; +const TEST_INITIALIZE_PARAMS: InitializeParams = { + clientInfo: { + name: "test_client", + title: "Test Client", + version: "0.0.0", + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, +}; + class FakeTransport implements AppServerTransport { readonly sent: RpcOutboundMessage[] = []; running = false; @@ -45,27 +57,40 @@ class FakeTransport implements AppServerTransport { async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> { let transport!: FakeTransport; - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit: () => undefined, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; return { client, transport }; } +function createTestClient(options: { + handlers: AppServerClientHandlers; + requestTimeoutMs?: number; + transportFactory: (handlers: AppServerTransportHandlers) => AppServerTransport; + initializeParams?: InitializeParams; +}): AppServerClient { + return new AppServerClient({ + codexPath: "/bin/codex", + cwd: "/vault", + handlers: options.handlers, + initializeParams: options.initializeParams ?? TEST_INITIALIZE_PARAMS, + requestTimeoutMs: options.requestTimeoutMs ?? 500, + transportFactory: options.transportFactory, + }); +} + function expectTransport(transport: FakeTransport | undefined): FakeTransport { if (!transport) throw new Error("Expected app-server transport."); return transport; @@ -117,10 +142,19 @@ describe("AppServerClient", () => { const notifications: string[] = []; const serverRequests: ServerRequest[] = []; const serverRequestResponders: AppServerServerRequestResponder[] = []; - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + initializeParams: { + clientInfo: { + name: "custom_client", + title: "Custom Client", + version: "1.2.3", + }, + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + }, + handlers: { onNotification: (notification) => notifications.push(notification.method), onServerRequest: (request, responder) => { serverRequests.push(request); @@ -129,12 +163,11 @@ describe("AppServerClient", () => { onLog: () => undefined, onExit: () => undefined, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); expect(getTransport().sent[0]).toMatchObject({ @@ -142,9 +175,9 @@ describe("AppServerClient", () => { method: "initialize", params: { clientInfo: { - name: "obsidian_codex_panel", - title: "Codex Panel", - version: manifest.version, + name: "custom_client", + title: "Custom Client", + version: "1.2.3", }, capabilities: { experimentalApi: true, @@ -240,21 +273,18 @@ describe("AppServerClient", () => { it("does not notify external exit handlers for intentional disconnect exits", async () => { let transport!: FakeTransport; const onExit = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; @@ -268,21 +298,18 @@ describe("AppServerClient", () => { it("fails active transports on transport error without waiting for exit", async () => { let transport!: FakeTransport; const onExit = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; @@ -304,22 +331,19 @@ describe("AppServerClient", () => { it("cleans up the active transport when initialize fails", async () => { const transports: FakeTransport[] = []; const onExit = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { const transport = new FakeTransport(handlers); transports.push(transport); return transport; }, - ); + }); const connecting = client.connect(); const firstTransport = expectTransport(transports[0]); @@ -353,21 +377,18 @@ describe("AppServerClient", () => { }); let transport!: FakeTransport; const onExit = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); const rejection = expect(connecting).rejects.toThrow("Codex app-server request timed out: initialize"); @@ -384,21 +405,18 @@ describe("AppServerClient", () => { it("rejects connect without notifying external exit handlers when transport exits during initialize", async () => { let transport!: FakeTransport; const onExit = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); const rejection = expect(connecting).rejects.toThrow("Codex app-server exited: 1"); @@ -413,22 +431,19 @@ describe("AppServerClient", () => { const transports: FakeTransport[] = []; const onExit = vi.fn(); const onNotification = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { const transport = new FakeTransport(handlers); transports.push(transport); return transport; }, - ); + }); const firstConnect = client.connect(); transports[0]?.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); @@ -452,24 +467,21 @@ describe("AppServerClient", () => { it("ignores synchronous transport callbacks before the transport becomes active", async () => { let transport!: FakeTransport; const onExit = vi.fn(); - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: () => undefined, onExit, }, - 500, - (handlers) => { + transportFactory: (handlers) => { handlers.onLine(JSON.stringify({ method: "warning", params: { message: "early" } })); handlers.onError(new Error("early failure")); handlers.onExit(1, null); transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); @@ -487,21 +499,18 @@ describe("AppServerClient", () => { }); const logs: string[] = []; let transport!: FakeTransport; - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: (message) => logs.push(message), onExit: () => undefined, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; @@ -526,21 +535,18 @@ describe("AppServerClient", () => { }); const logs: string[] = []; let transport!: FakeTransport; - const client = new AppServerClient( - "/bin/codex", - "/vault", - { + const client = createTestClient({ + handlers: { onNotification: () => undefined, onServerRequest: () => undefined, onLog: (message) => logs.push(message), onExit: () => undefined, }, - 500, - (handlers) => { + transportFactory: (handlers) => { transport = new FakeTransport(handlers); return transport; }, - ); + }); const connecting = client.connect(); transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial }); await connecting; diff --git a/tests/app-server/connection-manager.test.ts b/tests/app-server/connection-manager.test.ts index e9275827..784d051b 100644 --- a/tests/app-server/connection-manager.test.ts +++ b/tests/app-server/connection-manager.test.ts @@ -2,12 +2,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AppServerClient } from "../../src/app-server/connection/client"; import { + type AppServerClientFactory, ConnectionManager, type ConnectionManagerHandlers, StaleConnectionError, } from "../../src/app-server/connection/connection-manager"; import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages"; import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport"; +import type { InitializeParams } from "../../src/generated/app-server/InitializeParams"; + +const TEST_INITIALIZE_PARAMS: InitializeParams = { + clientInfo: { + name: "test_client", + title: "Test Client", + version: "0.0.0", + }, + capabilities: { + experimentalApi: false, + requestAttestation: false, + }, +}; class SilentTransport implements AppServerTransport { readonly sent: RpcOutboundMessage[] = []; @@ -54,11 +68,8 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", - (codexPath, cwd, handlers) => - new AppServerClient(codexPath, cwd, handlers, 5, (transportHandlers) => { - transport = new SilentTransport(transportHandlers); - return transport; - }), + TEST_INITIALIZE_PARAMS, + testClientFactory({ requestTimeoutMs: 5, onTransport: (next) => (transport = next) }), ); await expect(manager.connect(silentConnectionHandlers())).rejects.toThrow("Codex app-server request timed out: initialize"); @@ -72,11 +83,8 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", - (codexPath, cwd, handlers) => - new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { - transport = new SilentTransport(transportHandlers); - return transport; - }), + TEST_INITIALIZE_PARAMS, + testClientFactory({ onTransport: (next) => (transport = next) }), ); const first = manager.connect(silentConnectionHandlers()); @@ -95,12 +103,8 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => codexPath, "/vault", - (clientCodexPath, cwd, handlers) => - new AppServerClient(clientCodexPath, cwd, handlers, 500, (transportHandlers) => { - const transport = new SilentTransport(transportHandlers); - transports.push(transport); - return transport; - }), + TEST_INITIALIZE_PARAMS, + testClientFactory({ onTransport: (transport) => transports.push(transport) }), ); const first = manager.connect(silentConnectionHandlers()); @@ -129,11 +133,8 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", - (codexPath, cwd, handlers) => - new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { - transport = new SilentTransport(transportHandlers); - return transport; - }), + TEST_INITIALIZE_PARAMS, + testClientFactory({ onTransport: (next) => (transport = next) }), ); const connecting = manager.connect({ ...silentConnectionHandlers(), onExit }); @@ -150,11 +151,8 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", - (codexPath, cwd, handlers) => - new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { - transport = new SilentTransport(transportHandlers); - return transport; - }), + TEST_INITIALIZE_PARAMS, + testClientFactory({ onTransport: (next) => (transport = next) }), ); const connecting = manager.connect({ ...silentConnectionHandlers(), onExit }); @@ -175,3 +173,22 @@ function silentConnectionHandlers(): ConnectionManagerHandlers { onExit: () => undefined, }; } + +function testClientFactory(options: { + requestTimeoutMs?: number; + onTransport: (transport: SilentTransport) => void; +}): AppServerClientFactory { + return (codexPath, cwd, handlers) => + new AppServerClient({ + codexPath, + cwd, + handlers, + initializeParams: TEST_INITIALIZE_PARAMS, + requestTimeoutMs: options.requestTimeoutMs ?? 500, + transportFactory: (transportHandlers) => { + const transport = new SilentTransport(transportHandlers); + options.onTransport(transport); + return transport; + }, + }); +} diff --git a/tests/app-server/panel-client-profile.test.ts b/tests/app-server/panel-client-profile.test.ts new file mode 100644 index 00000000..df7bfeb1 --- /dev/null +++ b/tests/app-server/panel-client-profile.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import manifest from "../../manifest.json"; +import { codexPanelAppServerInitializeParams } from "../../src/app-server/connection/client-profile"; +import compatibility from "../../src/app-server/connection/compatibility.json"; + +describe("codexPanelAppServerInitializeParams", () => { + it("builds the panel initialize profile from compatibility policy and manifest version", () => { + expect(codexPanelAppServerInitializeParams()).toEqual({ + clientInfo: { + name: "obsidian_codex_panel", + title: "Codex Panel", + version: manifest.version, + }, + capabilities: compatibility.codexAppServer.initialize.capabilities, + }); + }); +}); diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index 9dbe9aa2..f654173d 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -46,10 +46,10 @@ describe("development scripts", () => { ); }); - it("reads app-server initialize flags from the connection client in API baseline checks", async () => { + it("reads app-server compatibility policy from the declared baseline in API baseline checks", async () => { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, "scripts"), { recursive: true }); - await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true }); + await mkdir(path.join(cwd, "src", "app-server"), { recursive: true }); const { createApiBaselineReport } = await import(pathToFileURL(path.join(repoRoot, "scripts", "api-baseline.mjs")).href); await writeJson(path.join(cwd, "package.json"), { @@ -87,23 +87,27 @@ describe("development scripts", () => { path.join(cwd, "scripts", "generate-app-server-types.mjs"), 'run("codex", ["app-server", "generate-ts", "--experimental"]);\n', ); - await writeFile( - path.join(cwd, "src", "app-server", "connection", "client.ts"), - [ - 'const init = await this.request("initialize", {', - " capabilities: {", - " experimentalApi: true,", - " requestAttestation: false,", - " },", - "});", - ].join("\n"), - ); + await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true }); + await writeJson(path.join(cwd, "src", "app-server", "connection", "compatibility.json"), { + codexAppServer: { + typeGeneration: { + experimental: true, + }, + initialize: { + capabilities: { + experimentalApi: true, + requestAttestation: false, + }, + }, + }, + }); const report = await createApiBaselineReport({ cwd, readCodexVersion: () => "0.139.0", }); + expect(report.codex.appServerGenerationExperimentalDeclared).toBe(true); expect(report.codex.initializeExperimentalApi).toBe(true); expect(report.codex.initializeRequestAttestationDisabled).toBe(true); expect(report.failures).toEqual([]);