Skip to content

Commit 36b124b

Browse files
test: add test for server notifications
1 parent 7be0910 commit 36b124b

7 files changed

Lines changed: 180 additions & 61 deletions

File tree

src/CodexACPAgent.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,16 @@ export class CodexACPAgent implements acp.Agent {
8080
return {};
8181
}
8282

83-
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
84-
const sessionState = this.sessions.get(params.sessionId);
83+
getSessionState(sessionId: string): SessionState {
84+
const sessionState = this.sessions.get(sessionId);
8585
if (!sessionState) {
86-
throw new Error(`Session ${params.sessionId} not found`);
86+
throw new Error(`Session ${sessionId} not found`);
8787
}
88+
return sessionState;
89+
}
90+
91+
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
92+
const sessionState = this.getSessionState(params.sessionId);
8893

8994
sessionState.pendingPrompt?.abort();
9095
sessionState.pendingPrompt = new AbortController();

src/CodexAppServerClient.ts

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@ export class CodexAppServerClient {
2626

2727
constructor(connection: MessageConnection) {
2828
this.connection = connection;
29-
this.onServerNotification((notification) => {
30-
for (const callback of this.transportEventHandlers) {
31-
callback({ eventType: "notification", ...notification});
29+
this.connection.onUnhandledNotification((data) => {
30+
const serverNotification = data as ServerNotification ?? null;
31+
if (serverNotification) {
32+
this.notify(serverNotification);
33+
}
34+
for (const callback of this.codexEventHandlers) {
35+
callback({ eventType: "notification", ...serverNotification});
3236
}
3337
});
3438
}
@@ -82,35 +86,23 @@ export class CodexAppServerClient {
8286
}
8387

8488
onServerNotification(callback: (event: ServerNotification) => void){
85-
this.connection.onUnhandledNotification((data) => {
86-
const serverNotification = data as ServerNotification ?? null;
87-
if (serverNotification) {
88-
callback(serverNotification)
89-
}
90-
});
89+
this.notificationHandlers.push(callback);
9190
}
9291

93-
onUnhandledNotification(callback: (data: NotificationMessage) => void){
94-
this.connection.onUnhandledNotification((data) => {
95-
const event = this.getEventMessage(data);
96-
if (!event) {
97-
callback(data)
98-
}
99-
});
92+
private codexEventHandlers: Array<(event: CodexConnectionEvent) => void> = [];
93+
onClientTransportEvent(callback: (event: CodexConnectionEvent) => void){
94+
this.codexEventHandlers.push(callback);
10095
}
10196

102-
private getEventMessage(data: NotificationMessage): EventMsg | null {
103-
const params = data.params;
104-
return (params as { msg?: EventMsg })?.msg ?? null;
105-
}
106-
107-
private transportEventHandlers: Array<(event: ClientTransportEvent) => void> = [];
108-
onClientTransportEvent(callback: (event: ClientTransportEvent) => void){
109-
this.transportEventHandlers.push(callback);
97+
private notificationHandlers: Array<(event: ServerNotification) => void> = [];
98+
private notify(notification: ServerNotification) {
99+
for (const notificationHandler of this.notificationHandlers) {
100+
notificationHandler(notification);
101+
}
110102
}
111103

112104
private async sendRequest<R>(request: CodexRequest): Promise<R> {
113-
for (const callback of this.transportEventHandlers) {
105+
for (const callback of this.codexEventHandlers) {
114106
callback({ eventType: "request", ...request});
115107
}
116108
let result: any;
@@ -120,14 +112,14 @@ export class CodexAppServerClient {
120112
else {
121113
await this.connection.sendRequest<R>(request.method);
122114
}
123-
for (const callback of this.transportEventHandlers) {
115+
for (const callback of this.codexEventHandlers) {
124116
callback({ eventType: "response", ...result});
125117
}
126118
return result;
127119
}
128120
}
129121

130-
export type ClientTransportEvent = { eventType: "request" } & CodexRequest | { eventType: "response" } & unknown | { eventType: "notification" } & ServerNotification;
122+
export type CodexConnectionEvent = { eventType: "request" } & CodexRequest | { eventType: "response" } & unknown | { eventType: "notification" } & ServerNotification;
131123

132124
type CodexRequest = DistributiveOmit<ClientRequest, "id">
133125

src/CodexEventHandler.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ThreadItem,
1414
TurnPlanUpdatedNotification
1515
} from "./app-server/v2";
16+
import {readFile} from "node:fs/promises";
1617

1718
export class CodexEventHandler {
1819

@@ -33,7 +34,6 @@ export class CodexEventHandler {
3334
}
3435

3536
private async createUpdateEvent(notification: ServerNotification): Promise<UpdateSessionEvent | null> {
36-
//TODO should take flow and return flow
3737
switch (notification.method) {
3838
case "item/agentMessage/delta":
3939
return await this.createTextEvent(notification.params);
@@ -144,12 +144,7 @@ export class CodexEventHandler {
144144
}
145145

146146
private async createPatchContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
147-
const textResponse = await this.connection.readTextFile({
148-
sessionId: this.sessionState.sessionId,
149-
path: change.path
150-
});
151-
152-
const oldContent = textResponse.content;
147+
const oldContent = await readFile(change.path, { encoding: "utf8" });
153148
const newContent = applyPatch(oldContent, change.diff);
154149
if (!newContent) {
155150
return null

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import {describe, expect, it, vi, beforeEach} from 'vitest';
1+
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest';
22
import type {CodexAuthRequest} from "../../CodexAuthMethod";
33
import {createTestFixture, type TestFixture} from "../acp-test-utils";
4+
import type {ServerNotification} from "../../app-server";
5+
import type {SessionState} from "../../CodexACPAgent";
46

57
describe('ACP server test', () => {
68

@@ -20,7 +22,7 @@ describe('ACP server test', () => {
2022
const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []});
2123
codexAcpAgent.prompt({ sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "Hi!"}] });
2224

23-
const transportDump = fixture.getTransportDump(ignoredFields);
25+
const transportDump = fixture.getCodexConnectionDump(ignoredFields);
2426
await expect(transportDump).toMatchFileSnapshot("data/start-conversation.json");
2527
});
2628

@@ -29,13 +31,13 @@ describe('ACP server test', () => {
2931

3032
await codexAcpAgent.initialize({protocolVersion: 1});
3133
await fixture.getCodexAcpClient().logout();
32-
fixture.clearTransportDump();
34+
fixture.clearCodexConnectionDump();
3335

3436
await expect(
3537
codexAcpAgent.newSession({cwd: "", mcpServers: []})
3638
).rejects.toThrow("Authentication required");
3739

38-
const transportDump = fixture.getTransportDump(ignoredFields);
40+
const transportDump = fixture.getCodexConnectionDump(ignoredFields);
3941
await expect(transportDump).toMatchFileSnapshot("data/auth-failed.json");
4042
});
4143

@@ -44,14 +46,56 @@ describe('ACP server test', () => {
4446

4547
await codexAcpAgent.initialize({protocolVersion: 1});
4648
await fixture.getCodexAcpClient().logout();
47-
fixture.clearTransportDump();
49+
fixture.clearCodexConnectionDump();
4850

4951
const authRequest: CodexAuthRequest = { methodId: "api-key", _meta: {apiKey: "TOKEN"} }
5052
await codexAcpAgent.authenticate(authRequest);
5153
const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []});
5254
expect(newSessionResponse.sessionId).toBeDefined()
5355

54-
const transportDump = fixture.getTransportDump(ignoredFields);
56+
const transportDump = fixture.getCodexConnectionDump(ignoredFields);
5557
await expect(transportDump).toMatchFileSnapshot("data/auth-with-key.json");
5658
});
59+
60+
function loadNotifications(){
61+
//TODO load from file
62+
const serverNotifications: ServerNotification[] = [
63+
{ method: "item/agentMessage/delta", params: { threadId: "string", turnId: "string", itemId: "string", delta: "He", }},
64+
{ method: "item/agentMessage/delta", params: { threadId: "string", turnId: "string", itemId: "string", delta: "ll", }},
65+
{ method: "item/agentMessage/delta", params: { threadId: "string", turnId: "string", itemId: "string", delta: "o!", }},
66+
];
67+
function onServerNotification(callback: (event: ServerNotification) => void){
68+
for (const notification of serverNotifications) {
69+
callback(notification);
70+
}
71+
}
72+
return onServerNotification;
73+
}
74+
75+
it('should map events from dump', async () => {
76+
fixture.getCodexAppServerClient().onServerNotification = loadNotifications();
77+
78+
const codexAcpAgent = fixture.getCodexAcpAgent();
79+
80+
fixture.getCodexAppServerClient().turnStart = vi.fn().mockResolvedValue(undefined);
81+
fixture.getCodexAppServerClient().awaitTurnCompleted = vi.fn().mockResolvedValue(undefined);
82+
fixture.getCodexAcpAgent().getSessionState = vi.fn().mockResolvedValue({ pendingPrompt: null, sessionId: "id" });
83+
84+
await codexAcpAgent.prompt({ sessionId: "id", prompt: [{type: "text", text: ""}] });
85+
86+
expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/output-acp-events.json");
87+
88+
}, 90_000);
89+
90+
//dev-time test
91+
it.skip('should convert session notification to acp events', async () => {
92+
fixture.onCodexConnectionEvent((event) => {
93+
console.log(JSON.stringify(event, null, 2));
94+
});
95+
const codexAcpAgent = fixture.getCodexAcpAgent();
96+
await codexAcpAgent.initialize({protocolVersion: 1});
97+
const newSessionResponse = await codexAcpAgent.newSession({cwd: "/home/alex/work/spring-petclinic/", mcpServers: []});
98+
fixture.clearCodexConnectionDump();
99+
await codexAcpAgent.prompt({ sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "Add method `minus` to Math Utils."}] });
100+
}, 90_000);
57101
});

src/__tests__/CodexACPAgent/data/input-server-events.json

Whitespace-only changes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"update": {
6+
"sessionUpdate": "agent_message_chunk",
7+
"content": {
8+
"type": "text",
9+
"text": "He"
10+
}
11+
}
12+
}
13+
]
14+
}
15+
{
16+
"method": "sessionUpdate",
17+
"args": [
18+
{
19+
"update": {
20+
"sessionUpdate": "agent_message_chunk",
21+
"content": {
22+
"type": "text",
23+
"text": "ll"
24+
}
25+
}
26+
}
27+
]
28+
}
29+
{
30+
"method": "sessionUpdate",
31+
"args": [
32+
{
33+
"update": {
34+
"sessionUpdate": "agent_message_chunk",
35+
"content": {
36+
"type": "text",
37+
"text": "o!"
38+
}
39+
}
40+
}
41+
]
42+
}

src/__tests__/acp-test-utils.ts

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,54 @@
11
import {CodexAcpClient} from '../CodexAcpClient';
2-
import {type ClientTransportEvent, CodexAppServerClient} from '../CodexAppServerClient';
2+
import {type CodexConnectionEvent, CodexAppServerClient} from '../CodexAppServerClient';
33
import {startCodexConnection} from "../CodexJsonRpcConnection";
44
import {CodexACPAgent} from "../CodexACPAgent";
5+
import type {AgentSideConnection} from "@agentclientprotocol/sdk";
6+
7+
export type MethodCallEvent = { method: string; args: any[] };
8+
9+
function createSmartMock<T extends object>(onCall: (event: MethodCallEvent) => void) {
10+
return new Proxy({} as T, {
11+
get(_, prop) {
12+
return (...args: any[]) => {
13+
onCall({ method: String(prop), args });
14+
return { mock: "Mocked return" };
15+
};
16+
}
17+
});
18+
}
519

620
export interface TestFixture {
721
getCodexAppServerClient(): CodexAppServerClient,
822
getCodexAcpClient(): CodexAcpClient,
923
getCodexAcpAgent(): CodexACPAgent,
10-
getTransportEvents(): ClientTransportEvent[],
11-
getTransportDump(ignoredFields: string[]): string,
12-
clearTransportDump(): void
24+
25+
onCodexConnectionEvent(handler: (event: CodexConnectionEvent) => void): void,
26+
getCodexConnectionDump(ignoredFields: string[]): string,
27+
clearCodexConnectionDump(): void,
28+
29+
onAcpConnectionEvent(handler: (event: MethodCallEvent) => void): void,
30+
getAcpConnectionDump(ignoredFields: string[]): string,
31+
clearAcpConnectionDump(): void,
1332
}
1433

1534
export function createTestFixture(): TestFixture {
1635
const pathToCodex = "././node_modules/.bin/codex"
17-
const mockedAcpConnection = { } as any;
36+
const acpConnectionEvents: MethodCallEvent[] = []
37+
const acpEventHandlers: ((event: MethodCallEvent) => void)[] = [];
38+
const acpConnection = createSmartMock<AgentSideConnection>((event) => {
39+
acpConnectionEvents.push(event);
40+
acpEventHandlers.forEach(handler => handler(event));
41+
});
1842
const codexAppServerClient = new CodexAppServerClient(startCodexConnection(pathToCodex));
1943

2044
const codexAcpClient = new CodexAcpClient(codexAppServerClient);
21-
const codexAcpAgent = new CodexACPAgent(mockedAcpConnection, codexAcpClient);
45+
const codexAcpAgent = new CodexACPAgent(acpConnection, codexAcpClient);
2246

23-
const transportEvents: ClientTransportEvent[] = []
47+
const transportEvents: CodexConnectionEvent[] = []
48+
const codexEventHandlers: ((event: CodexConnectionEvent) => void)[] = [];
2449
codexAppServerClient.onClientTransportEvent((event) => {
2550
transportEvents.push(event);
51+
codexEventHandlers.forEach(handler => handler(event));
2652
});
2753

2854
return {
@@ -32,23 +58,38 @@ export function createTestFixture(): TestFixture {
3258
getCodexAcpClient(): CodexAcpClient {
3359
return codexAcpClient;
3460
},
35-
getTransportDump(ignoredFields: string[]): string {
36-
function stringify(obj: any, anonymizedFields: string[] = []) {
37-
function fieldAnonymizer(key: string, value: any): any {
38-
return anonymizedFields.includes(key) ? key : value;
39-
}
40-
return JSON.stringify(obj, fieldAnonymizer, 2);
41-
}
42-
return this.getTransportEvents().map(event => stringify(event, ignoredFields)).join("\n");
61+
getCodexConnectionDump(ignoredFields: string[]): string {
62+
return createArrayDump(transportEvents, ignoredFields);
4363
},
44-
getTransportEvents(): ClientTransportEvent[] {
45-
return transportEvents;
64+
onCodexConnectionEvent(handler: (event: CodexConnectionEvent) => void): void {
65+
codexEventHandlers.push(handler);
4666
},
4767
getCodexAppServerClient(): CodexAppServerClient {
4868
return codexAppServerClient;
4969
},
50-
clearTransportDump(): void {
70+
clearCodexConnectionDump(): void {
5171
transportEvents.splice(0, transportEvents.length);
72+
},
73+
onAcpConnectionEvent(handler: (event: MethodCallEvent) => void): void {
74+
acpEventHandlers.push(handler);
75+
},
76+
getAcpConnectionDump(ignoredFields: string[]): string {
77+
return createArrayDump(acpConnectionEvents, ignoredFields);
78+
},
79+
clearAcpConnectionDump(){
80+
acpConnectionEvents.splice(0, acpConnectionEvents.length);
5281
}
5382
};
83+
}
84+
85+
86+
function createObjectDump(obj: any, anonymizedFields: string[] = []) {
87+
function fieldAnonymizer(key: string, value: any): any {
88+
return anonymizedFields.includes(key) ? key : value;
89+
}
90+
return JSON.stringify(obj, fieldAnonymizer, 2);
91+
}
92+
93+
function createArrayDump(objects: any[], anonymizedFields: string[]): string {
94+
return objects.map(event => createObjectDump(event, anonymizedFields)).join("\n");
5495
}

0 commit comments

Comments
 (0)