Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions packages/acp/src/adapter-snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@

import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
import { readdir, readFile, rename as renamePath, rm as rmPath, writeFile } from "node:fs/promises";
import { exec, hostClaudeExecutable, useQuietProcessOutput } from "@executablemd/runtime";
import {
exec,
hostClaudeExecutable,
hostCodexExecutable,
useQuietProcessOutput,
} from "@executablemd/runtime";
import { createAgentRegistry } from "./acpx-runtime.ts";
import type { AcpAgentRegistry } from "./acpx-runtime.ts";
import type { AcpxProviderDependencies } from "./provider.ts";
Expand Down Expand Up @@ -246,8 +251,9 @@ function* contentDigest(directory: string): Operation<string> {
*
* A `file:` dependency rather than an unpacked tree, so the adapter's own
* runtime dependencies are resolved the way they are for anybody installing it
* from the registry. Claude uses the host CLI; its SDK library remains installed
* while platform executable optional dependencies are omitted.
* from the registry. Both adapters use the host CLI: Codex depends on no CLI
* package, and Claude's SDK library remains installed while platform
* executable optional dependencies are omitted.
*/
function* install(
directory: string,
Expand Down Expand Up @@ -734,15 +740,20 @@ export function embeddedAdapterDependencies(
? undefined
: { version: snapshot.version, digest: `sha256:${snapshot.sha256}` };
},
*agentProcessEnv(agentName, agentCommand) {
*agentProcessEnv(agentName, agentCommand): Operation<Record<string, string> | undefined> {
if (
agentName !== "claude" ||
!carriesEmbeddedAdapter(adapters, agentName) ||
agentCommand !== adapters.command(agentName)
) {
return undefined;
}
return { CLAUDE_CODE_EXECUTABLE: yield* hostClaudeExecutable() };
if (agentName === "claude") {
return { CLAUDE_CODE_EXECUTABLE: yield* hostClaudeExecutable() };
}
if (agentName === "codex") {
return { CODEX_PATH: yield* hostCodexExecutable() };
}
return undefined;
},
*prepareAgent(agentName: string): Operation<void> {
if (!carriesEmbeddedAdapter(adapters, agentName)) {
Expand Down
55 changes: 55 additions & 0 deletions packages/acp/tests/adapter-materialization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,3 +872,58 @@ describe("HC fresh Claude materialization", () => {
expect(names.filter((name) => name.startsWith("@anthropic-ai/claude-agent-sdk-"))).toEqual([]);
});
});

describe("H fresh Codex materialization", () => {
it("H5: the installed closure carries one adapter and no Codex CLI or platform package", function* () {
const root = yield* useRoot();
const adapters = createEmbeddedAdapters(root);
yield* adapters.materialize("codex");
const installed = join(root, digestOf("codex"));
const names: string[] = [];
function* walk(directory: string): Operation<void> {
for (const entry of yield* until(readdir(directory, { withFileTypes: true }))) {
if (entry.isDirectory()) {
yield* walk(join(directory, entry.name));
} else if (entry.name === "package.json") {
const value = JSON.parse(yield* until(readFile(join(directory, entry.name), "utf8")));
if (typeof value.name === "string" && typeof value.version === "string") {
names.push(value.name);
}
}
}
}
yield* walk(join(installed, "node_modules"));
expect(names.filter((name) => name === CODEX_PACKAGE)).toEqual([CODEX_PACKAGE]);
expect(names).toContain("@agentclientprotocol/sdk");
expect(names).toContain("vscode-jsonrpc");
expect(names.filter((name) => name.startsWith("@openai/"))).toEqual([]);
const bin = join(installed, "node_modules", ".bin");
const linked = (yield* exists(bin)) ? yield* until(readdir(bin)) : [];
expect(linked.filter((name) => /^codex(\.|$)/.test(name))).toEqual([]);

// npm's own account of the tree agrees with what is on disk.
const listed = spawnSync("npm", ["ls", "--all", "--json"], {
cwd: installed,
encoding: "utf8",
});
expect(listed.status).toBe(0);
const tree: string[] = [];
const visit = (node: unknown): void => {
if (typeof node !== "object" || node === null || !("dependencies" in node)) {
return;
}
const { dependencies } = node;
if (typeof dependencies !== "object" || dependencies === null) {
return;
}
for (const [name, child] of Object.entries(dependencies)) {
tree.push(name);
visit(child);
}
};
visit(JSON.parse(listed.stdout));
expect(tree).toContain(CODEX_PACKAGE);
expect(tree.filter((name) => name.startsWith("@openai/"))).toEqual([]);
expect(tree.every((name) => names.includes(name))).toBe(true);
});
});
144 changes: 141 additions & 3 deletions packages/acp/tests/adapter-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ import {
withResolvers,
} from "effection";
import type { Operation } from "effection";
import { rm, readTextFile } from "@effectionx/fs";
import { ensureDir, exists, rm, readTextFile, writeTextFile } from "@effectionx/fs";
import { Buffer } from "node:buffer";
import { spawn, execFile } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import process from "node:process";
import { createEmbeddedAdapters } from "../src/adapter-snapshots.ts";
Expand Down Expand Up @@ -67,14 +67,21 @@ interface Adapter {
* The command comes from the materializer, so what is spawned is exactly what a
* workflow run spawns — not a path this test assembled.
*/
function useAdapter(provider: string, environment: Record<string, string>): Operation<Adapter> {
function useAdapter(
provider: string,
environment: Record<string, string>,
prepare?: (entry: string) => Operation<void>,
): Operation<Adapter> {
return resource(function* (provide) {
const root = yield* until(mkdtemp(join(tmpdir(), "xmd ea ")));
yield* ensure(() => rm(root, { recursive: true, force: true }));

const adapters = createEmbeddedAdapters(join(root, "adapters"));
yield* adapters.materialize(provider);
const entry = adapters.executablePath(provider);
if (prepare !== undefined) {
yield* prepare(entry);
}

let child: ChildProcess | undefined;
const closed = withResolvers<{ code: number | null; signal: NodeJS.Signals | null }>();
Expand Down Expand Up @@ -248,6 +255,42 @@ function acceptances(adapter: Adapter): string[] {
}

const CODEX_ENV = { CODEX_PATH: FAKE_CODEX };

/** A private directory for one case's host logs, removed with the case. */
function* useCaseDirectory(): Operation<string> {
const directory = yield* until(mkdtemp(join(tmpdir(), "xmd ea host ")));
yield* ensure(() => rm(directory, { recursive: true, force: true }));
return directory;
}

/** Every request the fake App Server received, in arrival order. */
function* hostRequests(log: string): Operation<Array<{ method?: string; params?: unknown }>> {
if (!(yield* exists(log))) {
return [];
}
return (yield* readTextFile(log))
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line));
}

/** One advertised selector's current value and choices, as a client reads them. */
function selector(
response: Record<string, unknown>,
id: string,
): { currentValue: unknown; values: unknown[] } | undefined {
const options = response["configOptions"];
if (!Array.isArray(options)) {
return undefined;
}
const found = options.find(
(option): option is { currentValue: unknown; options: Array<{ value: unknown }> } =>
typeof option === "object" && option !== null && option.id === id,
);
return found === undefined
? undefined
: { currentValue: found.currentValue, values: found.options.map((choice) => choice.value) };
}
const CLAUDE_ENV = { CLAUDE_CODE_EXECUTABLE: FAKE_CLAUDE };

describe("Tier EA — the embedded adapters' prompt-response metadata", () => {
Expand Down Expand Up @@ -594,6 +637,101 @@ describe("Tier EA — the embedded adapters' prompt-response metadata", () => {
expect(metaOf(loaded, "agentSessionId")).not.toBe(opened);
});

it("H4: Codex forwards the selected host's catalog and applies its exact model and effort", function* () {
const log = join(yield* useCaseDirectory(), "host.log");
const adapter = yield* useAdapter("codex", {
...CODEX_ENV,
FAKE_CODEX_CATALOG: "sol",
FAKE_CODEX_LOG: log,
});
yield* initialize(adapter);
const opened = yield* adapter.request("session/new", { cwd: process.cwd(), mcpServers: [] });
const sessionId = opened["sessionId"];

// The host's own choices, in its order, before any turn exists.
expect(selector(opened, "model")).toEqual({
currentValue: "gpt-6.1-sol",
values: ["gpt-6.1-sol", "gpt-5.6-sol"],
});
expect(selector(opened, "reasoning_effort")).toEqual({
currentValue: "medium",
values: ["low", "medium", "high", "xhigh"],
});
expect((yield* hostRequests(log)).filter((entry) => entry.method === "turn/start")).toEqual([]);

const modelSet = yield* adapter.request("session/set_config_option", {
sessionId,
configId: "model",
value: "gpt-6.1-sol",
});
expect(selector(modelSet, "model")?.currentValue).toBe("gpt-6.1-sol");
const effortSet = yield* adapter.request("session/set_config_option", {
sessionId,
configId: "reasoning_effort",
value: "high",
});
expect(selector(effortSet, "reasoning_effort")?.currentValue).toBe("high");

yield* adapter.request("session/prompt", {
sessionId,
prompt: [{ type: "text", text: "hello" }],
});
const turns = (yield* hostRequests(log)).filter((entry) => entry.method === "turn/start");
expect(turns).toHaveLength(1);
expect(turns[0]?.params).toMatchObject({ model: "gpt-6.1-sol", effort: "high" });
});

it("H4: a host catalog without the model's effort selector offers none and starts no turn", function* () {
const log = join(yield* useCaseDirectory(), "host.log");
const adapter = yield* useAdapter("codex", {
...CODEX_ENV,
FAKE_CODEX_CATALOG: "no-selector",
FAKE_CODEX_LOG: log,
});
yield* initialize(adapter);
const opened = yield* adapter.request("session/new", { cwd: process.cwd(), mcpServers: [] });

// The configured model is still named, but nothing claims an effort for it.
expect(selector(opened, "model")?.currentValue).toBe("gpt-6.1-sol");
expect(selector(opened, "reasoning_effort")).toBeUndefined();
let refusal: string | undefined;
try {
yield* adapter.request("session/set_config_option", {
sessionId: opened["sessionId"],
configId: "reasoning_effort",
value: "high",
});
} catch (error) {
refusal = error instanceof Error ? error.message : String(error);
}
expect(refusal).toContain("session/set_config_option");
expect((yield* hostRequests(log)).filter((entry) => entry.method === "turn/start")).toEqual([]);
});

it("H2: a Codex adapter with no host CLI refuses without resolving a bundled agent", function* () {
const directory = yield* useCaseDirectory();
const sentinel = join(directory, "bundled.log");
const emptyPath = join(directory, "empty bin");
yield* ensureDir(emptyPath);
const adapter = yield* useAdapter(
"codex",
{ CODEX_PATH: "", PATH: emptyPath },
function* (entry) {
// Where the previous adapter resolved its bundled CLI from. Planting one
// makes any such resolution observable rather than merely failing.
const bundled = join(dirname(dirname(dirname(entry))), "..", "@openai", "codex", "bin");
yield* ensureDir(bundled);
yield* writeTextFile(
join(bundled, "codex.js"),
`require("node:fs").appendFileSync(${JSON.stringify(sentinel)}, "bundled\\n");\n`,
);
},
);
const outcome = yield* adapter.closed;
expect(outcome.code === 0 && outcome.signal === null).toBe(false);
expect(yield* exists(sentinel)).toBe(false);
});

it("HC EA11: Claude confirms each Query identity before returning it", function* () {
const adapter = yield* useAdapter("claude", CLAUDE_ENV);
yield* initialize(adapter);
Expand Down
Loading
Loading