Skip to content
Source
Sign inSign up

client.test.ts

247 lines8.9 KB

1import * as Deferred from "effect/Deferred";
2import * as Exit from "effect/Exit";
3import * as Layer from "effect/Layer";
4import * as Logger from "effect/Logger";
5import * as Path from "effect/Path";
6import * as Effect from "effect/Effect";
7import * as Queue from "effect/Queue";
8import * as Ref from "effect/Ref";
9import * as References from "effect/References";
10import * as Scope from "effect/Scope";
11import { ChildProcess, ChildProcessSpawner } from "effect/process";
12​
13import * as NodeServices from "@effect/platform-node/NodeServices";
14import { assert, expect, it } from "@effect/vitest";
15​
16import * as CodexClient from "./client.ts";
17import { makeInMemoryStdio } from "./_internal/stdio.ts";
18import * as CodexError from "./errors.ts";
19​
20const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
21 path.join(import.meta.dirname, "../test/fixtures/codex-app-server-mock-peer.ts"),
22);
23const mockPeerArgs = (path: string) => [path];
24​
25it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {
26 const makeHandle = (env?: Record<string, string>) =>
27 Effect.gen(function* () {
28 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
29 const path = yield* Path.Path;
30 const peerCwd = path.join(import.meta.dirname, "..");
31 const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), {
32 cwd: peerCwd,
33 ...(env ? { env: { ...process.env, ...env } } : {}),
34 });
35 return yield* spawner.spawn(command);
36 });
37​
38 it.effect("initializes, handles typed server requests, and reads account and skills data", () =>
39 Effect.gen(function* () {
40 const userInputRequests = yield* Ref.make<Array<unknown>>([]);
41 const messageDeltas = yield* Ref.make<Array<unknown>>([]);
42 const handle = yield* makeHandle();
43 const scope = yield* Scope.make();
44 const layerClient = CodexClient.layerChildProcess(handle);
45 const context = yield* Layer.buildWithScope(layerClient, scope);
46​
47 const result = yield* Effect.gen(function* () {
48 const client = yield* CodexClient.CodexAppServerClient;
49​
50 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
51 Ref.update(userInputRequests, (current) => [...current, payload]).pipe(
52 Effect.as({
53 answers: {
54 approved: {
55 answers: ["yes"],
56 },
57 },
58 }),
59 ),
60 );
61​
62 yield* client.handleServerNotification("item/agentMessage/delta", (payload) =>
63 Ref.update(messageDeltas, (current) => [...current, payload]),
64 );
65​
66 const initialized = yield* client.request("initialize", {
67 clientInfo: {
68 name: "effect-codex-app-server-test",
69 title: "Effect Codex App Server Test",
70 version: "0.0.0",
71 },
72 capabilities: {
73 experimentalApi: true,
74 optOutNotificationMethods: null,
75 },
76 });
77 assert.equal(initialized.userAgent, "mock-codex-app-server");
78​
79 yield* client.notify("initialized", undefined);
80​
81 const account = yield* client.request("account/read", {});
82 assert.equal(account.requiresOpenaiAuth, false);
83 assert.deepEqual(account.account, {
84 type: "chatgpt",
85 email: "mock@example.com",
86 planType: "plus",
87 });
88​
89 const path = yield* Path.Path;
90 const peerCwd = path.join(import.meta.dirname, "..");
91 const skills = yield* client.request("skills/list", { cwds: [peerCwd] });
92 assert.equal(skills.data.length, 1);
93 assert.equal(skills.data[0]?.cwd, peerCwd);
94​
95 return {
96 account,
97 skills,
98 };
99 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
100​
101 assert.equal(result.skills.data[0]?.skills.length, 0);
102 assert.deepEqual(yield* Ref.get(userInputRequests), [
103 {
104 isBlocking: true,
105 itemId: "item-approval-1",
106 threadId: "thread-1",
107 turnId: "turn-1",
108 questions: [
109 {
110 id: "approved",
111 header: "Approve",
112 question: "Continue with the mock skills request?",
113 options: [
114 {
115 label: "yes",
116 description: "Approve the request",
117 },
118 ],
119 },
120 ],
121 },
122 ]);
123 assert.deepEqual(yield* Ref.get(messageDeltas), [
124 {
125 delta: "Mock server is ready.",
126 itemId: "item-1",
127 threadId: "thread-1",
128 turnId: "turn-1",
129 },
130 ]);
131 }),
132 );
133 it.effect("drains child stderr so large diagnostics cannot block protocol responses", () =>
134 Effect.gen(function* () {
135 const handle = yield* makeHandle({
136 CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024),
137 });
138 const scope = yield* Scope.make();
139 const layerClient = CodexClient.layerChildProcess(handle);
140 const context = yield* Layer.buildWithScope(layerClient, scope);
141​
142 const initialized = yield* Effect.gen(function* () {
143 const client = yield* CodexClient.CodexAppServerClient;
144 return yield* client.request("initialize", {
145 clientInfo: {
146 name: "effect-codex-app-server-test",
147 title: "Effect Codex App Server Test",
148 version: "0.0.0",
149 },
150 capabilities: {
151 experimentalApi: true,
152 optOutNotificationMethods: null,
153 },
154 });
155 }).pipe(
156 Effect.timeout("5 seconds"),
157 Effect.provide(context),
158 Effect.ensuring(Scope.close(scope, Exit.void)),
159 );
160​
161 assert.equal(initialized.userAgent, "mock-codex-app-server");
162 }),
163 );
164​
165 it.effect("warns once about a notification that fails to decode and keeps dispatching", () =>
166 Effect.gen(function* () {
167 const secret = "codex-notification-secret-sentinel";
168 const { stdio, input } = yield* makeInMemoryStdio();
169 const warnings: Array<unknown> = [];
170 const client = yield* CodexClient.make(stdio).pipe(
171 Effect.provide(
172 Logger.layer([
173 Logger.make(({ logLevel, fiber }) => {
174 if (logLevel === "Warn")
175 warnings.push(fiber.getRef(References.CurrentLogAnnotations));
176 }),
177 ]),
178 ),
179 );
180 const delivered = yield* Deferred.make<unknown>();
181 yield* client.handleServerNotification("item/agentMessage/delta", (payload) =>
182 Deferred.succeed(delivered, payload).pipe(Effect.asVoid),
183 );
184​
185 const send = (params: unknown) =>
186 Queue.offer(
187 input,
188 new TextEncoder().encode(
189 `${JSON.stringify({ method: "item/agentMessage/delta", params })}\n`,
190 ),
191 );
192 const malformed = { itemId: "item-1", threadId: "thread-1", turnId: secret };
193 yield* send(malformed);
194 yield* send(malformed);
195 yield* send({ delta: "ok", itemId: "item-1", threadId: "thread-1", turnId: "turn-1" });
196​
197 assert.deepEqual(yield* Deferred.await(delivered), {
198 delta: "ok",
199 itemId: "item-1",
200 threadId: "thread-1",
201 turnId: "turn-1",
202 });
203 assert.lengthOf(warnings, 1);
204 expect(warnings[0]).toMatchObject({
205 operation: "decode-notification-payload",
206 method: "item/agentMessage/delta",
207 issueKinds: expect.arrayContaining(["MissingKey"]),
208 });
209 assert.notInclude(JSON.stringify(warnings), secret);
210 }),
211 );
212});
213​
214it.live("reports the stderr error when the process exits before initialize", () =>
215 Effect.gen(function* () {
216 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
217 const handle = yield* spawner.spawn(
218 ChildProcess.make(process.execPath, [
219 "-e",
220 'throw new Error("Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex: npm install -g @openai/codex@latest")',
221 ]),
222 );
223 const scope = yield* Scope.make();
224 const context = yield* Layer.buildWithScope(CodexClient.layerChildProcess(handle), scope);
225​
226 const error = yield* Effect.gen(function* () {
227 const client = yield* CodexClient.CodexAppServerClient;
228 return yield* client.request("initialize", {
229 clientInfo: {
230 name: "effect-codex-app-server-test",
231 title: "Effect Codex App Server Test",
232 version: "0.0.0",
233 },
234 capabilities: {
235 experimentalApi: true,
236 optOutNotificationMethods: null,
237 },
238 });
239 }).pipe(Effect.flip, Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
240​
241 assert.instanceOf(error, CodexError.CodexAppServerProcessExitedError);
242 assert.equal(
243 error.message,
244 "Codex App Server process exited with code 1: Error: Missing optional dependency @openai/codex-darwin-arm64. Reinstall Codex: npm install -g @openai/codex@latest",
245 );
246 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
247);