1import * as Exit from "effect/Exit";
2import * as Layer from "effect/Layer";
3import * as Path from "effect/Path";
4import * as Effect from "effect/Effect";
5import * as Ref from "effect/Ref";
6import * as Scope from "effect/Scope";
7import { ChildProcess, ChildProcessSpawner } from "effect/process";
8
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import { assert, it } from "@effect/vitest";
11
12import * as CodexClient from "./client.ts";
13
14const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
15 path.join(import.meta.dirname, "../test/fixtures/codex-app-server-mock-peer.ts"),
16);
17const mockPeerArgs = (path: string) => [path];
18
19it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {
20 const makeHandle = (env?: Record<string, string>) =>
21 Effect.gen(function* () {
22 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
23 const path = yield* Path.Path;
24 const peerCwd = path.join(import.meta.dirname, "..");
25 const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), {
26 cwd: peerCwd,
27 ...(env ? { env: { ...process.env, ...env } } : {}),
28 });
29 return yield* spawner.spawn(command);
30 });
31
32 it.effect("initializes, handles typed server requests, and reads account and skills data", () =>
33 Effect.gen(function* () {
34 const userInputRequests = yield* Ref.make<Array<unknown>>([]);
35 const messageDeltas = yield* Ref.make<Array<unknown>>([]);
36 const handle = yield* makeHandle();
37 const scope = yield* Scope.make();
38 const layerClient = CodexClient.layerChildProcess(handle);
39 const context = yield* Layer.buildWithScope(layerClient, scope);
40
41 const result = yield* Effect.gen(function* () {
42 const client = yield* CodexClient.CodexAppServerClient;
43
44 yield* client.handleServerRequest("item/tool/requestUserInput", (payload) =>
45 Ref.update(userInputRequests, (current) => [...current, payload]).pipe(
46 Effect.as({
47 answers: {
48 approved: {
49 answers: ["yes"],
50 },
51 },
52 }),
53 ),
54 );
55
56 yield* client.handleServerNotification("item/agentMessage/delta", (payload) =>
57 Ref.update(messageDeltas, (current) => [...current, payload]),
58 );
59
60 const initialized = yield* client.request("initialize", {
61 clientInfo: {
62 name: "effect-codex-app-server-test",
63 title: "Effect Codex App Server Test",
64 version: "0.0.0",
65 },
66 capabilities: {
67 experimentalApi: true,
68 optOutNotificationMethods: null,
69 },
70 });
71 assert.equal(initialized.userAgent, "mock-codex-app-server");
72
73 yield* client.notify("initialized", undefined);
74
75 const account = yield* client.request("account/read", {});
76 assert.equal(account.requiresOpenaiAuth, false);
77 assert.deepEqual(account.account, {
78 type: "chatgpt",
79 email: "mock@example.com",
80 planType: "plus",
81 });
82
83 const path = yield* Path.Path;
84 const peerCwd = path.join(import.meta.dirname, "..");
85 const skills = yield* client.request("skills/list", { cwds: [peerCwd] });
86 assert.equal(skills.data.length, 1);
87 assert.equal(skills.data[0]?.cwd, peerCwd);
88
89 return {
90 account,
91 skills,
92 };
93 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
94
95 assert.equal(result.skills.data[0]?.skills.length, 0);
96 assert.deepEqual(yield* Ref.get(userInputRequests), [
97 {
98 isBlocking: true,
99 itemId: "item-approval-1",
100 threadId: "thread-1",
101 turnId: "turn-1",
102 questions: [
103 {
104 id: "approved",
105 header: "Approve",
106 question: "Continue with the mock skills request?",
107 options: [
108 {
109 label: "yes",
110 description: "Approve the request",
111 },
112 ],
113 },
114 ],
115 },
116 ]);
117 assert.deepEqual(yield* Ref.get(messageDeltas), [
118 {
119 delta: "Mock server is ready.",
120 itemId: "item-1",
121 threadId: "thread-1",
122 turnId: "turn-1",
123 },
124 ]);
125 }),
126 );
127 it.effect("drains child stderr so large diagnostics cannot block protocol responses", () =>
128 Effect.gen(function* () {
129 const handle = yield* makeHandle({
130 CODEX_APP_SERVER_TEST_STDERR_BYTES: String(512 * 1024),
131 });
132 const scope = yield* Scope.make();
133 const layerClient = CodexClient.layerChildProcess(handle);
134 const context = yield* Layer.buildWithScope(layerClient, scope);
135
136 const initialized = yield* Effect.gen(function* () {
137 const client = yield* CodexClient.CodexAppServerClient;
138 return yield* client.request("initialize", {
139 clientInfo: {
140 name: "effect-codex-app-server-test",
141 title: "Effect Codex App Server Test",
142 version: "0.0.0",
143 },
144 capabilities: {
145 experimentalApi: true,
146 optOutNotificationMethods: null,
147 },
148 });
149 }).pipe(
150 Effect.timeout("5 seconds"),
151 Effect.provide(context),
152 Effect.ensuring(Scope.close(scope, Exit.void)),
153 );
154
155 assert.equal(initialized.userAgent, "mock-codex-app-server");
156 }),
157 );
158});