Make app-server initialize profile explicit

This commit is contained in:
murashit 2026-07-07 22:06:19 +09:00
parent a9e68e159d
commit 42af40c299
13 changed files with 301 additions and 170 deletions

View file

@ -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.

View file

@ -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"}`);

View file

@ -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<InitializeCapabilities, "experimentalApi" | "requestAttestation">;
};
};
}
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,
},
};
}

View file

@ -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<AppServerTransport>();
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;

View file

@ -0,0 +1,13 @@
{
"codexAppServer": {
"typeGeneration": {
"experimental": true
},
"initialize": {
"capabilities": {
"experimentalApi": true,
"requestAttestation": false
}
}
}
}

View file

@ -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 {

View file

@ -1,5 +1,6 @@
import { AppServerClient } from "./client";
import type { AppServerClientAccessOptions } from "./client-access";
import { codexPanelAppServerInitializeParams } from "./client-profile";
export async function withShortLivedAppServerClient<T>(
codexPath: string,
@ -7,19 +8,24 @@ export async function withShortLivedAppServerClient<T>(
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions = {},
): Promise<T> {
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 {

View file

@ -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) => {

View file

@ -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 {

View file

@ -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<InitializeResponse> });
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<InitializeResponse> });
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<InitializeResponse> });
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<InitializeResponse> });
@ -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<InitializeResponse> });
@ -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<InitializeResponse> });
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<InitializeResponse> });
await connecting;

View file

@ -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;
},
});
}

View file

@ -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,
});
});
});

View file

@ -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([]);