Skip to content

Commit c8fd166

Browse files
authored
fix: migrate to ACP SDK 2.0 (spacering-net#649)
## Summary Migrate codex-acp from ACP SDK 1.5 to 2.0 while preserving the adapter's existing ACP v1, ACP v2, and AIR feature behavior. - Opt into the full unstable v1 and v2 SDK entrypoints consistently across implementation, tests, and examples; the SDK's default entrypoints are now stable-feature-only. - Replace removed legacy connection APIs in wire tests and the spawned-agent fixture with app builders and peer-context request/notify calls. - Use the versioned v1 unstable JSON schema export for scenario validation. - Remove the v2 omitted-params stream workaround. Verify the SDK handles omitted/null optional request params for both protocol versions while rejecting malformed params and missing required fields, including v2 batches. - Adapt v2 payload construction to the stricter discriminated-union types without changing existing snapshot outputs. - Keep newly added standard subagent/directed-message variants outside the adapter's emitted-update type until their dedicated migration, rather than adding unreachable runtime error cases. Existing AIR subagent updates remain unchanged. - Migrate the real-Codex test runner to the current connection/auth/event APIs and point the npm command at its tracked `.agents` path. Most changed files contain only SDK import-path changes. No existing snapshots were updated. ## Scope This is the SDK migration only. Foreground-state/resume synchronization, structured v2 failure reporting, initial slash commands, standard system prompts, and standard subagent sessions remain separate work packages. Existing AIR behavior is preserved; the bundled Codex runtime version is unchanged. ## Validation - `npm run typecheck` — passed, including examples. - `npm test` — 1,790 passed; 36 skipped. - `npm run build` — passed. - `npm run test:integration` — 4 passed against the real Codex app-server with a local mock model. - Standalone TypeScript check of the real-Codex runner — passed. - Real-Codex smoke test from an empty scratch directory — streamed `sdk-upgrade-ok`, with no file/tool access requested. - `git diff --check` — passed. The full live-model E2E suite was not run. npm audit reports one pre-existing high-severity advisory in the development-only MCP test dependency; this PR does not change that dependency.
1 parent a62b568 commit c8fd166

130 files changed

Lines changed: 378 additions & 366 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/run-codex/scripts/run-codex-test.ts‎

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {CodexAcpClient} from "../../../../src/CodexAcpClient";
1414
import {type CodexConnectionEvent, CodexAppServerClient} from "../../../../src/CodexAppServerClient";
1515
import {startCodexConnection} from "../../../../src/CodexJsonRpcConnection";
1616
import {CodexAcpServer} from "../../../../src/CodexAcpServer";
17-
import type {AgentSideConnection} from "@agentclientprotocol/sdk";
17+
import type {AcpClientConnection} from "../../../../src/ACPSessionConnection";
18+
import {RequestError} from "@agentclientprotocol/sdk/unstable";
1819

1920
// Parse command line arguments
2021
function parseArgs(): { prompt: string; cwd: string; output: string; json: boolean } {
@@ -64,15 +65,19 @@ Examples:
6465

6566
type MethodCallEvent = { method: string; args: unknown[] };
6667

67-
function createMockAcpConnection(events: MethodCallEvent[]): AgentSideConnection {
68-
return new Proxy({} as AgentSideConnection, {
69-
get(_, prop) {
70-
return (...args: unknown[]) => {
71-
events.push({ method: String(prop), args });
72-
return Promise.resolve({ mock: "Mocked return" });
73-
};
74-
}
75-
});
68+
function createMockAcpConnection(
69+
events: MethodCallEvent[],
70+
onNotification: (method: string, params: unknown) => void,
71+
): AcpClientConnection {
72+
return {
73+
notify: async (method: string, params?: unknown) => {
74+
events.push({method, args: [params]});
75+
onNotification(method, params);
76+
},
77+
request: async (method: string) => {
78+
throw RequestError.methodNotFound(method);
79+
},
80+
};
7681
}
7782

7883
async function main() {
@@ -108,39 +113,34 @@ async function main() {
108113
if (json) {
109114
console.log(JSON.stringify(event));
110115
} else {
111-
console.log(`[CODEX] ${event.type}:`, JSON.stringify(event.data, null, 2));
116+
console.log(`[CODEX] ${event.eventType}:`, JSON.stringify(event, null, 2));
112117
}
113118
}
114119
});
115120

116-
const acpConnection = createMockAcpConnection(acpEvents);
117-
const codexAcpClient = new CodexAcpClient(codexAppServerClient);
118-
const codexAcpAgent = new CodexAcpServer(acpConnection, codexAcpClient, undefined, () => codexConnection.process.exitCode);
119-
120-
// Track ACP events
121-
const originalSessionUpdate = acpConnection.sessionUpdate;
122-
(acpConnection as any).sessionUpdate = async (params: unknown) => {
123-
acpEvents.push({ method: "sessionUpdate", args: [params] });
121+
const acpConnection = createMockAcpConnection(acpEvents, (method, params) => {
124122
if (output === "all" || output === "acp") {
125123
if (json) {
126-
console.log(JSON.stringify({ method: "sessionUpdate", params }));
124+
console.log(JSON.stringify({method, params}));
127125
} else {
128-
console.log(`[ACP] sessionUpdate:`, JSON.stringify(params, null, 2));
126+
console.log(`[ACP] ${method}:`, JSON.stringify(params, null, 2));
129127
}
130128
}
131-
return originalSessionUpdate?.call(acpConnection, params);
132-
};
129+
});
130+
const codexAcpClient = new CodexAcpClient(codexAppServerClient);
131+
const codexAcpAgent = new CodexAcpServer(acpConnection, codexAcpClient, undefined, () => codexConnection.process.exitCode);
133132

134133
try {
135134
// Initialize
136135
console.log("\n--- Initializing ---\n");
137136
await codexAcpAgent.initialize({ protocolVersion: 1 });
138137

139138
// Check auth
140-
const authRequired = await codexAcpClient.authRequired();
141-
if (authRequired) {
139+
const authRequirement = await codexAcpClient.readAuthRequirement();
140+
if (authRequirement.required) {
142141
console.error("Error: Authentication required. Please login to Codex first.");
143-
process.exit(1);
142+
process.exitCode = 1;
143+
return;
144144
}
145145

146146
// Create session
@@ -168,20 +168,20 @@ async function main() {
168168
console.log(`Codex Events: ${codexEvents.length}`);
169169
console.log(`ACP Events: ${acpEvents.length}`);
170170

171-
if (promptResponse._meta?.quota?.token_count) {
172-
const tc = promptResponse._meta.quota.token_count as any;
171+
const usage = promptResponse.usage;
172+
if (usage) {
173173
console.log(`\nToken Usage:`);
174-
console.log(` Input Tokens: ${tc.inputTokens}`);
175-
console.log(` Cached Input: ${tc.cachedInputTokens}`);
176-
console.log(` Output Tokens: ${tc.outputTokens}`);
177-
console.log(` Reasoning Tokens: ${tc.reasoningOutputTokens}`);
178-
console.log(` Total Tokens: ${tc.totalTokens}`);
174+
console.log(` Input Tokens: ${usage.inputTokens}`);
175+
console.log(` Cached Input: ${usage.cachedReadTokens ?? 0}`);
176+
console.log(` Output Tokens: ${usage.outputTokens}`);
177+
console.log(` Reasoning Tokens: ${usage.thoughtTokens ?? 0}`);
178+
console.log(` Total Tokens: ${usage.totalTokens}`);
179179
}
180180

181181
// Event type breakdown
182182
const eventTypes = new Map<string, number>();
183183
for (const event of codexEvents) {
184-
const key = `${event.type}:${(event.data as any)?.method || "unknown"}`;
184+
const key = `${event.eventType}:${"method" in event ? event.method : "response"}`;
185185
eventTypes.set(key, (eventTypes.get(key) || 0) + 1);
186186
}
187187
console.log(`\nEvent Types:`);
@@ -196,9 +196,10 @@ async function main() {
196196
console.log(JSON.stringify(promptResponse, null, 2));
197197
}
198198

199+
await codexAcpAgent.closeSession({sessionId: sessionResponse.sessionId});
199200
} catch (err) {
200201
console.error("Error:", err);
201-
process.exit(1);
202+
process.exitCode = 1;
202203
} finally {
203204
codexConnection.connection.end();
204205
codexConnection.process.kill();

‎examples/simple-client.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ import {
5353
client as acpClient,
5454
methods,
5555
ndJsonStream,
56-
} from "@agentclientprotocol/sdk";
57-
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
56+
} from "@agentclientprotocol/sdk/unstable";
57+
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk/unstable";
5858

5959
// ---------------------------------------------------------------- steering
6060

‎examples/steering.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
* NO_COLOR disable ANSI colors
3030
*/
3131

32-
import * as acp from "@agentclientprotocol/sdk";
32+
import * as acp from "@agentclientprotocol/sdk/unstable";
3333
import {type ChildProcess, spawn} from "node:child_process";
3434
import {fileURLToPath} from "node:url";
3535
import {mkdtemp, rm, writeFile} from "node:fs/promises";

‎package-lock.json‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e",
4444
"test:watch": "vitest",
4545
"typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json",
46-
"codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts"
46+
"codex-test": "tsx .agents/skills/run-codex/scripts/run-codex-test.ts"
4747
},
4848
"homepage": "https://github.com/agentclientprotocol/codex-acp#readme",
4949
"bugs": {
@@ -68,7 +68,7 @@
6868
"vitest": "^5.0.1"
6969
},
7070
"dependencies": {
71-
"@agentclientprotocol/sdk": "~1.5.0",
71+
"@agentclientprotocol/sdk": "~2.0.0",
7272
"@openai/codex": "^0.160.1",
7373
"cross-spawn": "^7.0.6",
7474
"diff": "^9.0.0",

‎src/ACPSessionConnection.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import * as acp from "@agentclientprotocol/sdk";
2-
import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2";
1+
import * as acp from "@agentclientprotocol/sdk/unstable";
2+
import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
33
import {
4+
type AcpSessionNotification,
45
type AcpSessionUpdate,
56
asSdkSessionNotification,
67
} from "./AcpSessionExtensions";
@@ -84,9 +85,21 @@ export class AcpV2Connection {
8485
* is no v1 shape for them, so this bypasses `toV2SessionUpdates` entirely.
8586
*/
8687
async startReplayMessage(sessionId: string, kind: ReplayMessageKind, messageId: string): Promise<void> {
88+
let update: acpV2.SessionUpdate;
89+
switch (kind) {
90+
case "user_message":
91+
update = {sessionUpdate: "user_message", messageId, content: []};
92+
break;
93+
case "agent_message":
94+
update = {sessionUpdate: "agent_message", messageId, content: []};
95+
break;
96+
case "agent_thought":
97+
update = {sessionUpdate: "agent_thought", messageId, content: []};
98+
break;
99+
}
87100
await this.client.notify(acpV2.methods.client.session.update, {
88101
sessionId,
89-
update: {sessionUpdate: kind, messageId, content: []},
102+
update,
90103
});
91104
}
92105

@@ -164,7 +177,7 @@ export class AcpV2Connection {
164177
return this.client.notify(method as `_${string}`, params);
165178
}
166179
if (method === acp.methods.client.session.update) {
167-
const notification = params as acp.SessionNotification;
180+
const notification = params as AcpSessionNotification;
168181
return this.updateSession(notification.sessionId, notification.update);
169182
}
170183
if (method === acp.methods.client.elicitation.complete) {

‎src/AcpAgentRouter.ts‎

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import * as acp from "@agentclientprotocol/sdk";
2-
import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2";
3-
import type {AgentConnectOptions, AgentConnector, AnyWireMessage} from "@agentclientprotocol/sdk/experimental/v2";
1+
import * as acp from "@agentclientprotocol/sdk/unstable";
2+
import * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
43
import {z} from "zod";
54
import type {CodexAcpServer} from "./CodexAcpServer";
65
import {type AcpClientConnection, AcpV2Connection} from "./ACPSessionConnection";
@@ -60,48 +59,6 @@ const hooksTrustParamsParser = z.object({
6059
hooks: z.array(z.object({key: z.string().min(1), currentHash: z.string().min(1)})).min(1),
6160
});
6261

63-
// v2 request methods whose params are all-optional, per the v2 schema: `session/list` and
64-
// `auth/logout` have no required fields. SDK 1.5.0's built-in v2 parsers for these are plain
65-
// `z.object`s that reject an omitted `params` with -32602, even though JSON-RPC and the v2
66-
// schema both allow omitting it. There is no registration-layer workaround for built-in v2
67-
// methods (the SDK throws if you pass a parser for one), so this wraps the v2 connector's
68-
// inbound stream to fill in `params: {}` before the request reaches the SDK. `params: null`
69-
// is left untouched. v1 has the same SDK bug but is intentionally left as-is.
70-
const METHODS_WITH_OPTIONAL_PARAMS = new Set<string>([
71-
acpV2.methods.agent.session.list,
72-
acpV2.methods.agent.auth.logout,
73-
]);
74-
75-
function fillOmittedParams(item: AnyWireMessage): AnyWireMessage {
76-
if (Array.isArray(item)) {
77-
return item.map(fillOmittedParams) as unknown as AnyWireMessage;
78-
}
79-
if (
80-
typeof item === "object" && item !== null &&
81-
"method" in item && METHODS_WITH_OPTIONAL_PARAMS.has((item as {method: unknown}).method as string) &&
82-
!("params" in item)
83-
) {
84-
return {...item, params: {}} as AnyWireMessage;
85-
}
86-
return item;
87-
}
88-
89-
/** Wraps a v2 `AgentConnector` to work around the SDK's rejection of omitted `params`. */
90-
function withOmittedParamsWorkaround(agent: AgentConnector): AgentConnector {
91-
return {
92-
connect(stream, options?: AgentConnectOptions) {
93-
const normalized = stream.readable.pipeThrough(
94-
new TransformStream<AnyWireMessage, AnyWireMessage>({
95-
transform(item, controller) {
96-
controller.enqueue(fillOmittedParams(item));
97-
},
98-
})
99-
);
100-
return agent.connect({readable: normalized, writable: stream.writable}, options);
101-
},
102-
};
103-
}
104-
10562
/**
10663
* Builds the ACP router. It picks the v1 or v2 handler chain from the first `initialize`
10764
* request, and only the selected chain's `onConnect` runs for the connection.
@@ -188,5 +145,5 @@ export function createAcpAgentRouter(
188145
.onRequest(SESSION_LIST_SUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.subscribeList(ctx.params))
189146
.onRequest(SESSION_LIST_UNSUBSCRIBE_METHOD, sessionListSubscriptionParamsParser, (ctx) => getAgent().sessionIndex.unsubscribeList(ctx.params));
190147

191-
return acpV2.agentProtocolRouter().withV1(v1Agent).withV2(withOmittedParamsWorkaround(v2Agent));
148+
return acpV2.agentProtocolRouter().withV1(v1Agent).withV2(v2Agent);
192149
}

‎src/AcpExtensions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
NewSessionResponse,
66
ResumeSessionResponse,
77
SessionId,
8-
} from "@agentclientprotocol/sdk";
8+
} from "@agentclientprotocol/sdk/unstable";
99
import {
1010
GOAL_CONTROL_METHOD,
1111
LEGACY_GOAL_CONTROL_METHOD,

‎src/AcpSessionExtensions.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import type {SessionNotification} from "@agentclientprotocol/sdk";
1+
import type {SessionNotification} from "@agentclientprotocol/sdk/unstable";
22
import type {AsyncTaskUpdate} from "./async-tasks/AcpAsyncTasks";
33
import type {SubagentSpawnedUpdate, SubagentStateUpdate} from "./subagents/AcpSubagents";
44

5-
/** Session updates that are not available in the published ACP SDK yet. */
5+
/** Updates the adapter emits, including its existing AIR subagent contract. */
66
export type AcpSessionUpdate =
7-
| SessionNotification["update"]
7+
| Exclude<SessionNotification["update"], {
8+
sessionUpdate: "subagent_update" | "session_message" | "session_message_chunk";
9+
}>
810
| SubagentSpawnedUpdate
911
| SubagentStateUpdate
1012
| AsyncTaskUpdate;
1113

12-
type AcpSessionNotification = Omit<SessionNotification, "update"> & {
14+
export type AcpSessionNotification = Omit<SessionNotification, "update"> & {
1315
update: AcpSessionUpdate;
1416
};
1517

‎src/AcpV2ClientCapabilities.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type * as acp from "@agentclientprotocol/sdk";
2-
import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2";
1+
import type * as acp from "@agentclientprotocol/sdk/unstable";
2+
import type * as acpV2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
33

44
/**
55
* Maps v2 client capabilities onto the v1 shape the capability readers use, so each reader

0 commit comments

Comments
 (0)