Skip to content

Commit b71a8ef

Browse files
refactor: extract acp -> app-server mapping into a separate layer
1 parent 1096e40 commit b71a8ef

4 files changed

Lines changed: 141 additions & 121 deletions

File tree

src/CodexACPAgent.ts

Lines changed: 14 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,37 @@
11
import * as acp from "@agentclientprotocol/sdk";
2-
import type {MessageConnection} from "vscode-jsonrpc/node";
3-
import {CodexClient} from "./CodexClient";
42
import {CodexEventHandler} from "./CodexEventHandler";
5-
import type {JsonValue} from "./app-server/serde_json/JsonValue";
6-
import {CodexAuthMethods, type CodexAuthRequest, isCodexAuthRequest} from "./CodexAuthMethod";
3+
import {CodexAuthMethods, type CodexAuthRequest} from "./CodexAuthMethod";
74
import {RequestError} from "@agentclientprotocol/sdk";
5+
import {CodexAcpClient} from "./CodexAcpClient";
86

97

108
export interface SessionState {
119
sessionId: string,
12-
seenReasoningDeltas: boolean;
1310
pendingPrompt: AbortController | null;
1411
}
1512

1613
export class CodexACPAgent implements acp.Agent {
17-
private readonly name: string = "codex-appserver-acp";
18-
private readonly version: string = "0.1.0";
19-
private readonly title: string = "Codex ACP";
20-
21-
private readonly codexClient: CodexClient;
14+
private readonly codexAcpClient: CodexAcpClient;
2215
private readonly connection: acp.AgentSideConnection;
23-
private readonly config: JsonObject | null;
24-
private readonly modelProvider: string | null;
2516
private readonly defaultAuthRequest: CodexAuthRequest | null;
2617

2718
private readonly sessions: Map<string, SessionState>;
2819

2920
constructor(
3021
connection: acp.AgentSideConnection,
31-
codexConnection: MessageConnection,
32-
codexConfig?: JsonObject,
33-
modelProvider?: string,
22+
codexAcpClient: CodexAcpClient,
3423
defaultAuthRequest?: CodexAuthRequest,
3524
) {
3625
this.sessions = new Map();
37-
this.codexClient = new CodexClient(codexConnection);
3826
this.connection = connection;
39-
this.config = codexConfig ?? null;
40-
this.modelProvider = modelProvider ?? null;
4127
this.defaultAuthRequest = defaultAuthRequest ?? null;
28+
this.codexAcpClient = codexAcpClient;
4229
}
4330

4431
async initialize(
4532
_params: acp.InitializeRequest,
4633
): Promise<acp.InitializeResponse> {
47-
await this.codexClient.initialize({
48-
clientInfo: {
49-
name: _params.clientInfo?.name ?? this.name,
50-
version: _params.clientInfo?.version ?? this.version,
51-
title: _params.clientInfo?.title ?? this.title,
52-
}
53-
});
34+
await this.codexAcpClient.initialize(_params);
5435
return {
5536
protocolVersion: acp.PROTOCOL_VERSION,
5637
agentCapabilities: {
@@ -63,53 +44,30 @@ export class CodexACPAgent implements acp.Agent {
6344
async newSession(
6445
_params: acp.NewSessionRequest,
6546
): Promise<acp.NewSessionResponse> {
66-
if (!await this.codexClient.loginStatus()) {
47+
if (await this.codexAcpClient.authRequired()) {
6748
if (this.defaultAuthRequest) {
6849
await this.authenticate(this.defaultAuthRequest)
6950
} else {
7051
throw RequestError.authRequired();
7152
}
7253
}
7354

74-
const threadStartResponse = await this.codexClient.threadStart({
75-
config: this.config,
76-
modelProvider: this.modelProvider,
77-
model: null,
78-
cwd: _params.cwd,
79-
approvalPolicy: "never",
80-
sandbox: null,
81-
baseInstructions: null,
82-
developerInstructions: null,
83-
})
84-
85-
const sessionId = threadStartResponse.thread.id;
55+
const sessionId = await this.codexAcpClient.newSession(_params);
8656
this.sessions.set(sessionId, {
8757
sessionId: sessionId,
88-
seenReasoningDeltas: false,
8958
pendingPrompt: null,
9059
});
9160

9261
return {
93-
sessionId,
62+
sessionId: sessionId,
9463
};
9564
}
9665

9766
async authenticate(
9867
_params: acp.AuthenticateRequest,
9968
): Promise<acp.AuthenticateResponse> {
100-
if (!isCodexAuthRequest(_params)) {
101-
throw RequestError.invalidRequest();
102-
}
103-
let authResult: Boolean;
104-
switch (_params.methodId) {
105-
case "api-key":
106-
authResult = await this.codexClient.loginWithApiKey(_params._meta.apiKey);
107-
break;
108-
case "chat-gpt":
109-
authResult = await this.codexClient.loginWithChatGpt();
110-
break;
111-
}
112-
if (!authResult) {
69+
const isAuthenticated = await this.codexAcpClient.authenticate(_params);
70+
if (!isAuthenticated) {
11371
throw RequestError.invalidParams();
11472
}
11573
return { };
@@ -131,12 +89,9 @@ export class CodexACPAgent implements acp.Agent {
13189
sessionState.pendingPrompt?.abort();
13290
sessionState.pendingPrompt = new AbortController();
13391

134-
const prompt = params.prompt.filter(b => b.type === "text")
135-
.map(b => b.text)
136-
.join(" ");
137-
13892
try {
139-
await this.processMessage(sessionState, prompt);
93+
const messageHandler = new CodexEventHandler(this.connection, sessionState);
94+
await this.codexAcpClient.sendPrompt(params, (event) => messageHandler.handleNotification(event));
14095
} catch (err) {
14196
if (sessionState.pendingPrompt.signal.aborted) {
14297
return {stopReason: "cancelled"};
@@ -152,35 +107,8 @@ export class CodexACPAgent implements acp.Agent {
152107
};
153108
}
154109

155-
private async processMessage(
156-
sessionState: SessionState,
157-
prompt: string
158-
): Promise<void> {
159-
const messageHandler = new CodexEventHandler(this.connection, sessionState);
160-
161-
this.codexClient.onServerNotification(notification => {
162-
messageHandler.handleNotification(notification);
163-
});
164-
165-
await this.codexClient.turnStart({
166-
threadId: sessionState.sessionId,
167-
input: [{type: "text", text: prompt}],
168-
approvalPolicy: null,
169-
sandboxPolicy: null,
170-
summary: null,
171-
cwd: null,
172-
effort: null,
173-
model: null,
174-
})
175-
176-
await this.codexClient.waitForCompletion()
177-
}
178-
179110
async cancel(params: acp.CancelNotification): Promise<void> {
180111
//TODO not supported yet
181-
await this.codexClient.close()
182112
this.sessions.get(params.sessionId)?.pendingPrompt?.abort();
183113
}
184-
}
185-
186-
export type JsonObject = { [key in string]?: JsonValue }
114+
}

src/CodexAcpClient.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {isCodexAuthRequest} from "./CodexAuthMethod";
2+
import * as acp from "@agentclientprotocol/sdk";
3+
import type {CodexAppServerClient} from "./CodexAppServerClient";
4+
import {RequestError} from "@agentclientprotocol/sdk";
5+
import open from "open";
6+
import type {ClientInfo, ServerNotification} from "./app-server";
7+
import type {JsonValue} from "./app-server/serde_json/JsonValue";
8+
9+
/**
10+
* API for accessing the Codex App Server using ACP requests.
11+
* Converts ACP requests into corresponding app-server operations.
12+
*/
13+
export class CodexAcpClient {
14+
15+
private readonly codexClient: CodexAppServerClient;
16+
private readonly config: JsonObject | null;
17+
private readonly modelProvider: string | null;
18+
19+
constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
20+
this.codexClient = codexClient;
21+
this.config = codexConfig ?? null;
22+
this.modelProvider = modelProvider ?? null;
23+
}
24+
25+
private readonly defaultClientInfo: ClientInfo = {
26+
name: "codex-acp", title: "Codex ACP", version: "0.0.5"
27+
};
28+
29+
async initialize(request: acp.InitializeRequest): Promise<void> {
30+
await this.codexClient.initialize({
31+
clientInfo: {
32+
name: request.clientInfo?.name ?? this.defaultClientInfo.name,
33+
version: request.clientInfo?.version ?? this.defaultClientInfo.version,
34+
title: request.clientInfo?.title ?? this.defaultClientInfo.title,
35+
}
36+
});
37+
}
38+
39+
async authenticate(authRequest: acp.AuthenticateRequest): Promise<Boolean> {
40+
if (!isCodexAuthRequest(authRequest)) {
41+
throw RequestError.invalidRequest();
42+
}
43+
switch (authRequest.methodId) {
44+
case "api-key":
45+
await this.codexClient.accountLogin({
46+
type: "apiKey",
47+
apiKey: authRequest._meta.apiKey
48+
});
49+
break;
50+
case "chat-gpt":
51+
const loginResponse = await this.codexClient.accountLogin({ type: "chatgpt" });
52+
if (loginResponse.type == "chatgpt") {
53+
await open(loginResponse.authUrl);
54+
}
55+
break;
56+
}
57+
const result = await this.codexClient.awaitLoginCompleted()
58+
return result.success;
59+
}
60+
61+
async authRequired(): Promise<Boolean> {
62+
const response = await this.codexClient.accountRead({refreshToken: false})
63+
return response.requiresOpenaiAuth || !response.account;
64+
}
65+
66+
/**
67+
* Returns a new session ID.
68+
*/
69+
async newSession(request: acp.NewSessionRequest): Promise<string> {
70+
const response = await this.codexClient.threadStart({
71+
config: this.config,
72+
modelProvider: this.modelProvider,
73+
model: null,
74+
cwd: request.cwd,
75+
approvalPolicy: "never",
76+
sandbox: null,
77+
baseInstructions: null,
78+
developerInstructions: null,
79+
});
80+
return response.thread.id;
81+
}
82+
83+
async sendPrompt(request: acp.PromptRequest, eventHandler: (result: ServerNotification) => void): Promise<void> {
84+
this.codexClient.onServerNotification(eventHandler);
85+
86+
const input = request.prompt.filter(b => b.type === "text")
87+
.map(b => b.text)
88+
.join(" ");
89+
90+
await this.codexClient.turnStart({
91+
threadId: request.sessionId,
92+
input: [{type: "text", text: input}],
93+
approvalPolicy: null,
94+
sandboxPolicy: null,
95+
summary: null,
96+
cwd: null,
97+
effort: null,
98+
model: null,
99+
});
100+
101+
await this.codexClient.awaitTurnCompleted();
102+
}
103+
104+
}
105+
106+
export type JsonObject = { [key in string]?: JsonValue }
Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,24 @@ import type {
44
EventMsg,
55
InitializeParams,
66
InitializeResponse,
7-
LoginChatGptResponse,
87
ServerNotification
98
} from "./app-server";
109
import type {
1110
AccountLoginCompletedNotification,
1211
GetAccountParams,
13-
GetAccountResponse,
12+
GetAccountResponse, LoginAccountParams, LoginAccountResponse,
1413
ThreadStartParams,
1514
ThreadStartResponse,
1615
TurnCompletedNotification,
1716
TurnStartParams,
1817
TurnStartResponse
1918
} from "./app-server/v2";
20-
import open from "open";
2119

22-
export class CodexClient {
20+
/**
21+
* A type-safe client over the Codex App Server's JSON-RPC API.
22+
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
23+
*/
24+
export class CodexAppServerClient {
2325
readonly connection: MessageConnection;
2426

2527
constructor(connection: MessageConnection) {
@@ -38,43 +40,23 @@ export class CodexClient {
3840
return await this.sendRequest({ method: "thread/start", params: params });
3941
}
4042

41-
async loginWithApiKey(apiKey: string): Promise<Boolean> {
42-
await this.sendRequest({
43-
method: "account/login/start",
44-
params: { type: "apiKey", apiKey: apiKey }
45-
});
46-
const result = await this.awaitLogin();
47-
return result.success
43+
async accountLogin(params: LoginAccountParams): Promise<LoginAccountResponse> {
44+
return await this.sendRequest({ method: "account/login/start", params: params });
4845
}
4946

50-
async loginWithChatGpt(): Promise<Boolean> {
51-
const response: LoginChatGptResponse = await this.sendRequest({
52-
method: "account/login/start",
53-
params: { type: "chatgpt" }
54-
});
55-
await open(response.authUrl);
56-
const result = await this.awaitLogin();
57-
return result.success
58-
59-
}
60-
private async awaitLogin(): Promise<AccountLoginCompletedNotification> {
47+
async awaitLoginCompleted(): Promise<AccountLoginCompletedNotification> {
6148
return await new Promise((resolve) => {
6249
this.connection.onNotification("account/login/completed", (event: AccountLoginCompletedNotification) => {
6350
resolve(event);
6451
});
6552
});
6653
}
6754

68-
private async accountRead(params: GetAccountParams): Promise<GetAccountResponse> {
55+
async accountRead(params: GetAccountParams): Promise<GetAccountResponse> {
6956
return await this.sendRequest({ method: "account/read", params: params });
7057
}
7158

72-
async loginStatus(): Promise<Boolean> {
73-
const response = await this.accountRead({refreshToken: false})
74-
return !response.requiresOpenaiAuth || response.account !== null;
75-
}
76-
77-
async waitForCompletion(): Promise<TurnCompletedNotification> {
59+
async awaitTurnCompleted(): Promise<TurnCompletedNotification> {
7860
return await new Promise((resolve) => {
7961
this.connection.onNotification("turn/completed", (event: TurnCompletedNotification) => {
8062
resolve(event);
@@ -105,13 +87,13 @@ export class CodexClient {
10587
return (params as { msg?: EventMsg })?.msg ?? null;
10688
}
10789

108-
async close(){
109-
this.connection.end();
110-
}
111-
11290
private async sendRequest<R>(request: CodexRequest): Promise<R> {
11391
return await this.connection.sendRequest(request.method, request.params);
11492
}
11593
}
11694

117-
type CodexRequest = Omit<ClientRequest, "id">;
95+
type CodexRequest = DistributiveOmit<ClientRequest, "id">
96+
97+
type DistributiveOmit<T, K extends keyof any> = T extends any
98+
? Omit<T, K>
99+
: never;

0 commit comments

Comments
 (0)