@rudderhq/agent-runtime-codex-local 0.4.6-canary.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/server/app-server-chat.d.ts +34 -0
  2. package/dist/server/app-server-chat.d.ts.map +1 -0
  3. package/dist/server/app-server-chat.js +584 -0
  4. package/dist/server/app-server-chat.js.map +1 -0
  5. package/dist/server/app-server-chat.test.d.ts +2 -0
  6. package/dist/server/app-server-chat.test.d.ts.map +1 -0
  7. package/dist/server/app-server-chat.test.js +431 -0
  8. package/dist/server/app-server-chat.test.js.map +1 -0
  9. package/dist/server/app-server-client.d.ts +126 -0
  10. package/dist/server/app-server-client.d.ts.map +1 -0
  11. package/dist/server/app-server-client.js +603 -0
  12. package/dist/server/app-server-client.js.map +1 -0
  13. package/dist/server/app-server-client.test.d.ts +2 -0
  14. package/dist/server/app-server-client.test.d.ts.map +1 -0
  15. package/dist/server/app-server-client.test.js +656 -0
  16. package/dist/server/app-server-client.test.js.map +1 -0
  17. package/dist/server/codex-home.d.ts +2 -2
  18. package/dist/server/codex-home.d.ts.map +1 -1
  19. package/dist/server/codex-home.js +10 -6
  20. package/dist/server/codex-home.js.map +1 -1
  21. package/dist/server/execute.d.ts.map +1 -1
  22. package/dist/server/execute.js +187 -20
  23. package/dist/server/execute.js.map +1 -1
  24. package/dist/server/index.d.ts +1 -0
  25. package/dist/server/index.d.ts.map +1 -1
  26. package/dist/server/index.js +1 -0
  27. package/dist/server/index.js.map +1 -1
  28. package/dist/server/inline-visuals.d.ts +24 -0
  29. package/dist/server/inline-visuals.d.ts.map +1 -0
  30. package/dist/server/inline-visuals.js +174 -0
  31. package/dist/server/inline-visuals.js.map +1 -0
  32. package/dist/server/inline-visuals.test.d.ts +2 -0
  33. package/dist/server/inline-visuals.test.d.ts.map +1 -0
  34. package/dist/server/inline-visuals.test.js +202 -0
  35. package/dist/server/inline-visuals.test.js.map +1 -0
  36. package/dist/ui/parse-stdout.js +2 -2
  37. package/dist/ui/parse-stdout.js.map +1 -1
  38. package/package.json +3 -2
  39. package/skills/rudder-docs/SKILL.md +161 -0
  40. package/skills/rudder-docs/evals/trigger-evals.json +82 -0
  41. package/skills/{rudder → rudder-docs}/references/api-reference.md +17 -1
  42. package/skills/{rudder → rudder-docs}/references/cli-reference.md +52 -97
  43. package/skills/rudder-docs/references/control-plane-practices.md +231 -0
  44. package/skills/{rudder → rudder-docs}/references/organization-skills.md +15 -1
  45. package/skills/rudder-docs/references/source-map.md +138 -0
  46. package/skills/visualize/SKILL.md +132 -0
  47. package/skills/visualize/agents/openai.yaml +7 -0
  48. package/skills/visualize/assets/example-chart.html +89 -0
  49. package/skills/visualize/references/runtime-contract.md +114 -0
  50. package/skills/rudder/SKILL.md +0 -314
@@ -0,0 +1,656 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { PassThrough } from "node:stream";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { CodexAppServerClient, CodexAppServerClosedError, CodexAppServerProtocolError, CodexAppServerTimeoutError, createCodexAppServerStdioTransport, } from "./app-server-client.js";
5
+ class FakeTransport {
6
+ writes = [];
7
+ terminated = false;
8
+ writeError = null;
9
+ stdoutListeners = new Set();
10
+ exitListeners = new Set();
11
+ write(serializedMessage) {
12
+ if (this.writeError)
13
+ throw this.writeError;
14
+ this.writes.push(serializedMessage);
15
+ }
16
+ onStdout(listener) {
17
+ this.stdoutListeners.add(listener);
18
+ return () => this.stdoutListeners.delete(listener);
19
+ }
20
+ onExit(listener) {
21
+ this.exitListeners.add(listener);
22
+ return () => this.exitListeners.delete(listener);
23
+ }
24
+ terminate() {
25
+ this.terminated = true;
26
+ }
27
+ emitStdout(chunk) {
28
+ for (const listener of [...this.stdoutListeners])
29
+ listener(chunk);
30
+ }
31
+ emitMessage(message) {
32
+ this.emitStdout(`${JSON.stringify(message)}\n`);
33
+ }
34
+ emitExit(exit) {
35
+ for (const listener of [...this.exitListeners])
36
+ listener(exit);
37
+ }
38
+ messages() {
39
+ return this.writes.flatMap((write) => write
40
+ .split("\n")
41
+ .filter((line) => line.length > 0)
42
+ .map((line) => JSON.parse(line)));
43
+ }
44
+ }
45
+ const clients = [];
46
+ function createClient(transport, options = {}) {
47
+ const client = new CodexAppServerClient({
48
+ transport,
49
+ clientInfo: { name: "rudder-test", version: "0.0.0" },
50
+ onError: () => undefined,
51
+ ...options,
52
+ });
53
+ clients.push(client);
54
+ return client;
55
+ }
56
+ async function initializeClient(client, transport) {
57
+ const initialized = client.initialize();
58
+ expect(transport.messages()).toEqual([{
59
+ id: 1,
60
+ method: "initialize",
61
+ params: {
62
+ clientInfo: { name: "rudder-test", version: "0.0.0" },
63
+ capabilities: null,
64
+ },
65
+ }]);
66
+ transport.emitMessage({ id: 1, result: { serverInfo: { name: "codex" } } });
67
+ await initialized;
68
+ }
69
+ async function flushPromises() {
70
+ await Promise.resolve();
71
+ await Promise.resolve();
72
+ await Promise.resolve();
73
+ }
74
+ afterEach(() => {
75
+ for (const client of clients.splice(0))
76
+ client.dispose("test cleanup");
77
+ vi.useRealTimers();
78
+ vi.restoreAllMocks();
79
+ });
80
+ describe("CodexAppServerClient", () => {
81
+ it("turns an asynchronous child stdin error into a transport exit", () => {
82
+ const childEvents = new EventEmitter();
83
+ const stdin = new PassThrough();
84
+ const stdout = new PassThrough();
85
+ const child = Object.assign(childEvents, {
86
+ stdin,
87
+ stdout,
88
+ exitCode: null,
89
+ signalCode: null,
90
+ kill: vi.fn(() => true),
91
+ });
92
+ const transport = createCodexAppServerStdioTransport(child);
93
+ const exits = [];
94
+ const removeExitListener = transport.onExit((exit) => exits.push(exit));
95
+ const brokenPipe = Object.assign(new Error("broken pipe"), { code: "EPIPE" });
96
+ stdin.emit("error", brokenPipe);
97
+ expect(exits).toEqual([{ code: null, signal: null, error: brokenPipe }]);
98
+ removeExitListener();
99
+ expect(() => stdin.emit("error", new Error("late pipe error"))).not.toThrow();
100
+ });
101
+ it("initializes once and emits initialized only after the response", async () => {
102
+ const transport = new FakeTransport();
103
+ const client = createClient(transport, {
104
+ capabilities: { experimentalApi: true },
105
+ });
106
+ const first = client.initialize();
107
+ const second = client.initialize();
108
+ expect(second).toBe(first);
109
+ expect(transport.messages()).toEqual([{
110
+ id: 1,
111
+ method: "initialize",
112
+ params: {
113
+ clientInfo: { name: "rudder-test", version: "0.0.0" },
114
+ capabilities: { experimentalApi: true },
115
+ },
116
+ }]);
117
+ transport.emitMessage({ id: 1, result: { userAgent: "codex-test" } });
118
+ await Promise.all([first, second]);
119
+ await client.initialize();
120
+ expect(client.state).toBe("ready");
121
+ expect(transport.messages()).toEqual([
122
+ {
123
+ id: 1,
124
+ method: "initialize",
125
+ params: {
126
+ clientInfo: { name: "rudder-test", version: "0.0.0" },
127
+ capabilities: { experimentalApi: true },
128
+ },
129
+ },
130
+ { method: "initialized", params: {} },
131
+ ]);
132
+ });
133
+ it("decodes split UTF-8 and drains multiple JSONL frames from one chunk", async () => {
134
+ const transport = new FakeTransport();
135
+ const notifications = [];
136
+ const client = createClient(transport, {
137
+ onNotification: (notification) => {
138
+ notifications.push(notification);
139
+ },
140
+ });
141
+ const initialized = client.initialize();
142
+ const frames = Buffer.from([
143
+ JSON.stringify({ id: 1, result: { serverInfo: { name: "Codex" } } }),
144
+ JSON.stringify({ method: "item/agentMessage/delta", params: { delta: "调整方向" } }),
145
+ JSON.stringify({ method: "turn/started", params: { turnId: "turn-1" } }),
146
+ "",
147
+ ].join("\n"), "utf8");
148
+ const chineseStart = frames.indexOf(Buffer.from("调", "utf8"));
149
+ expect(chineseStart).toBeGreaterThan(0);
150
+ transport.emitStdout(frames.subarray(0, chineseStart + 1));
151
+ transport.emitStdout(frames.subarray(chineseStart + 1));
152
+ await initialized;
153
+ await flushPromises();
154
+ expect(notifications).toEqual([
155
+ { method: "item/agentMessage/delta", params: { delta: "调整方向" } },
156
+ { method: "turn/started", params: { turnId: "turn-1" } },
157
+ ]);
158
+ expect(transport.messages().at(-1)).toEqual({ method: "initialized", params: {} });
159
+ });
160
+ it("fails closed when chunk types change during an incomplete UTF-8 sequence", async () => {
161
+ const transport = new FakeTransport();
162
+ const notifications = [];
163
+ const client = createClient(transport, {
164
+ onNotification: (notification) => {
165
+ notifications.push(notification);
166
+ },
167
+ });
168
+ await initializeClient(client, transport);
169
+ const prefix = Buffer.from('{"method":"notice","params":{"text":"', "utf8");
170
+ const partialChinese = Buffer.from("调", "utf8").subarray(0, 1);
171
+ transport.emitStdout(Buffer.concat([prefix, partialChinese]));
172
+ transport.emitStdout('TAIL"}}\r\n\r\n');
173
+ await flushPromises();
174
+ expect(notifications).toEqual([]);
175
+ expect(client.state).toBe("failed");
176
+ expect(client.protocolCompatible).toBe(false);
177
+ expect(transport.terminated).toBe(true);
178
+ });
179
+ it("rejects an oversized unterminated frame", async () => {
180
+ const transport = new FakeTransport();
181
+ const client = createClient(transport, { maxFrameBytes: 16 });
182
+ const initialized = client.initialize();
183
+ transport.emitStdout('{"id":1,"result":{"tooLong":true}}');
184
+ await expect(initialized).rejects.toBeInstanceOf(CodexAppServerProtocolError);
185
+ expect(client.state).toBe("failed");
186
+ expect(transport.terminated).toBe(true);
187
+ });
188
+ it("drains a final JSONL remainder before process-exit cleanup", async () => {
189
+ const transport = new FakeTransport();
190
+ const notifications = [];
191
+ const client = createClient(transport, {
192
+ onNotification: (notification) => {
193
+ notifications.push(notification);
194
+ },
195
+ });
196
+ await initializeClient(client, transport);
197
+ transport.emitStdout('{"method":"turn/completed","params":{"status":"completed"}}');
198
+ transport.emitExit({ code: 0, signal: null });
199
+ await flushPromises();
200
+ expect(notifications).toEqual([
201
+ { method: "turn/completed", params: { status: "completed" } },
202
+ ]);
203
+ expect(client.state).toBe("closed");
204
+ });
205
+ it("publishes process exit only after the final async notification drains", async () => {
206
+ const transport = new FakeTransport();
207
+ const order = [];
208
+ let releaseNotification;
209
+ const notificationBarrier = new Promise((resolve) => {
210
+ releaseNotification = resolve;
211
+ });
212
+ const client = createClient(transport, {
213
+ notificationDrainTimeoutMs: 1_000,
214
+ onNotification: async ({ method }) => {
215
+ order.push(`notification-start:${method}`);
216
+ await notificationBarrier;
217
+ order.push(`notification-end:${method}`);
218
+ },
219
+ onError: () => {
220
+ order.push("exit-published");
221
+ },
222
+ });
223
+ await initializeClient(client, transport);
224
+ transport.emitStdout('{"method":"turn/completed","params":{"status":"completed"}}');
225
+ transport.emitExit({ code: 0, signal: null });
226
+ await flushPromises();
227
+ expect(client.state).toBe("closed");
228
+ expect(order).toEqual(["notification-start:turn/completed"]);
229
+ releaseNotification?.();
230
+ await vi.waitFor(() => {
231
+ expect(order).toEqual([
232
+ "notification-start:turn/completed",
233
+ "notification-end:turn/completed",
234
+ "exit-published",
235
+ ]);
236
+ });
237
+ });
238
+ it("aborts a final notification before publishing an exit after drain timeout", async () => {
239
+ vi.useFakeTimers();
240
+ const transport = new FakeTransport();
241
+ const order = [];
242
+ let notificationSignal;
243
+ let releaseNotification;
244
+ const notificationBarrier = new Promise((resolve) => {
245
+ releaseNotification = resolve;
246
+ });
247
+ const client = createClient(transport, {
248
+ notificationDrainTimeoutMs: 10,
249
+ onNotification: async ({ method }, { signal }) => {
250
+ notificationSignal = signal;
251
+ order.push(`notification-start:${method}`);
252
+ await notificationBarrier;
253
+ if (signal.aborted)
254
+ return;
255
+ order.push(`notification-end:${method}`);
256
+ },
257
+ onError: () => {
258
+ order.push("exit-published");
259
+ },
260
+ });
261
+ await initializeClient(client, transport);
262
+ transport.emitStdout('{"method":"turn/completed","params":{"status":"completed"}}');
263
+ transport.emitExit({ code: 0, signal: null });
264
+ await flushPromises();
265
+ await vi.advanceTimersByTimeAsync(10);
266
+ expect(notificationSignal?.aborted).toBe(true);
267
+ expect(order).toEqual([
268
+ "notification-start:turn/completed",
269
+ "exit-published",
270
+ ]);
271
+ releaseNotification?.();
272
+ await flushPromises();
273
+ expect(order).toEqual([
274
+ "notification-start:turn/completed",
275
+ "exit-published",
276
+ ]);
277
+ });
278
+ it("correlates concurrent requests with reverse-order responses and notifications", async () => {
279
+ const transport = new FakeTransport();
280
+ const notifications = [];
281
+ const client = createClient(transport, {
282
+ onNotification: ({ method }) => {
283
+ notifications.push(method);
284
+ },
285
+ });
286
+ await initializeClient(client, transport);
287
+ const threadRequest = client.request("thread/read", { threadId: "t-1" });
288
+ const turnRequest = client.request("turn/steer", { threadId: "t-1" });
289
+ expect(transport.messages().slice(-2)).toEqual([
290
+ { id: 2, method: "thread/read", params: { threadId: "t-1" } },
291
+ { id: 3, method: "turn/steer", params: { threadId: "t-1" } },
292
+ ]);
293
+ transport.emitStdout([
294
+ JSON.stringify({ method: "item/started", params: { itemId: "i-1" } }),
295
+ JSON.stringify({ id: 3, result: { turn: "steered" } }),
296
+ JSON.stringify({ id: 2, result: { thread: "loaded" } }),
297
+ "",
298
+ ].join("\n"));
299
+ await expect(turnRequest).resolves.toEqual({ turn: "steered" });
300
+ await expect(threadRequest).resolves.toEqual({ thread: "loaded" });
301
+ expect(notifications).toEqual(["item/started"]);
302
+ expect(client.pendingRequestCount).toBe(0);
303
+ });
304
+ it("serializes async notification callbacks in wire order", async () => {
305
+ const transport = new FakeTransport();
306
+ const order = [];
307
+ let releaseFirst;
308
+ const firstBarrier = new Promise((resolve) => {
309
+ releaseFirst = resolve;
310
+ });
311
+ const client = createClient(transport, {
312
+ onNotification: async ({ method }) => {
313
+ order.push(`start:${method}`);
314
+ if (method === "item/agentMessage/delta")
315
+ await firstBarrier;
316
+ order.push(`end:${method}`);
317
+ },
318
+ });
319
+ await initializeClient(client, transport);
320
+ transport.emitStdout([
321
+ JSON.stringify({ method: "item/agentMessage/delta", params: { delta: "before" } }),
322
+ JSON.stringify({ method: "turn/completed", params: { status: "completed" } }),
323
+ "",
324
+ ].join("\n"));
325
+ await flushPromises();
326
+ expect(order).toEqual(["start:item/agentMessage/delta"]);
327
+ releaseFirst?.();
328
+ await vi.waitFor(() => {
329
+ expect(order).toEqual([
330
+ "start:item/agentMessage/delta",
331
+ "end:item/agentMessage/delta",
332
+ "start:turn/completed",
333
+ "end:turn/completed",
334
+ ]);
335
+ });
336
+ });
337
+ it("aborts the active notification and skips queued notifications on client abort", async () => {
338
+ const transport = new FakeTransport();
339
+ const abortController = new AbortController();
340
+ const started = [];
341
+ const completed = [];
342
+ let activeSawAbort = false;
343
+ let releaseFirst;
344
+ const firstBarrier = new Promise((resolve) => {
345
+ releaseFirst = resolve;
346
+ });
347
+ const client = createClient(transport, {
348
+ abortSignal: abortController.signal,
349
+ onNotification: async ({ method }, { signal }) => {
350
+ started.push(method);
351
+ if (method === "item/agentMessage/delta")
352
+ await firstBarrier;
353
+ if (signal.aborted) {
354
+ activeSawAbort = true;
355
+ return;
356
+ }
357
+ completed.push(method);
358
+ },
359
+ });
360
+ await initializeClient(client, transport);
361
+ transport.emitStdout([
362
+ JSON.stringify({ method: "item/agentMessage/delta", params: { delta: "before" } }),
363
+ JSON.stringify({ method: "turn/completed", params: { status: "completed" } }),
364
+ "",
365
+ ].join("\n"));
366
+ await flushPromises();
367
+ abortController.abort(new Error("operator stop"));
368
+ releaseFirst?.();
369
+ await flushPromises();
370
+ expect(started).toEqual(["item/agentMessage/delta"]);
371
+ expect(completed).toEqual([]);
372
+ expect(activeSawAbort).toBe(true);
373
+ expect(client.state).toBe("closed");
374
+ });
375
+ it("responds exactly once to successful, throwing, and timed-out server handlers", async () => {
376
+ vi.useFakeTimers();
377
+ const transport = new FakeTransport();
378
+ let resolveSlow;
379
+ let slowSignal;
380
+ const client = createClient(transport, {
381
+ serverRequestTimeoutMs: 25,
382
+ serverRequestHandlers: {
383
+ success: () => ({ accepted: true }),
384
+ throws: () => {
385
+ throw new Error("handler failed");
386
+ },
387
+ slow: ({ signal }) => {
388
+ slowSignal = signal;
389
+ return new Promise((resolve) => {
390
+ resolveSlow = resolve;
391
+ });
392
+ },
393
+ },
394
+ });
395
+ await initializeClient(client, transport);
396
+ transport.emitStdout([
397
+ JSON.stringify({ id: "provider-1", method: "success" }),
398
+ JSON.stringify({ id: "provider-2", method: "throws" }),
399
+ JSON.stringify({ id: "provider-3", method: "slow" }),
400
+ "",
401
+ ].join("\n"));
402
+ await flushPromises();
403
+ expect(transport.messages().filter((message) => message.id === "provider-1")).toEqual([
404
+ { id: "provider-1", result: { accepted: true } },
405
+ ]);
406
+ expect(transport.messages().filter((message) => message.id === "provider-2")).toEqual([
407
+ { id: "provider-2", error: { code: -32603, message: "handler failed" } },
408
+ ]);
409
+ expect(transport.messages().filter((message) => message.id === "provider-3")).toEqual([]);
410
+ await vi.advanceTimersByTimeAsync(25);
411
+ expect(slowSignal?.aborted).toBe(true);
412
+ expect(transport.messages().filter((message) => message.id === "provider-3")).toEqual([
413
+ {
414
+ id: "provider-3",
415
+ error: { code: -32000, message: "Client handler timed out for slow" },
416
+ },
417
+ ]);
418
+ resolveSlow?.("late result");
419
+ await flushPromises();
420
+ expect(transport.messages().filter((message) => message.id === "provider-3")).toHaveLength(1);
421
+ expect(client.pendingServerRequestCount).toBe(0);
422
+ });
423
+ it("suppresses duplicate server request ids after the first response", async () => {
424
+ const transport = new FakeTransport();
425
+ const handler = vi.fn(() => ({ decision: "accept" }));
426
+ const client = createClient(transport, {
427
+ serverRequestHandlers: { approval: handler },
428
+ });
429
+ await initializeClient(client, transport);
430
+ transport.emitMessage({ id: 44, method: "approval", params: { command: "pwd" } });
431
+ await flushPromises();
432
+ transport.emitMessage({ id: 44, method: "approval", params: { command: "pwd" } });
433
+ await flushPromises();
434
+ expect(handler).toHaveBeenCalledTimes(1);
435
+ expect(transport.messages().filter((message) => message.id === 44)).toEqual([
436
+ { id: 44, result: { decision: "accept" } },
437
+ ]);
438
+ });
439
+ it("does not re-execute the first server request after more than 1024 later ids", async () => {
440
+ const transport = new FakeTransport();
441
+ const handler = vi.fn(({ id }) => ({ id }));
442
+ const client = createClient(transport, {
443
+ serverRequestHandlers: { approval: handler },
444
+ });
445
+ await initializeClient(client, transport);
446
+ for (let index = 0; index < 1_025; index += 1) {
447
+ transport.emitMessage({ id: `provider-${index}`, method: "approval" });
448
+ }
449
+ await vi.waitFor(() => {
450
+ expect(handler).toHaveBeenCalledTimes(1_025);
451
+ });
452
+ transport.emitMessage({ id: "provider-0", method: "approval" });
453
+ await flushPromises();
454
+ expect(handler).toHaveBeenCalledTimes(1_025);
455
+ expect(transport.messages().filter((message) => message.id === "provider-0")).toEqual([
456
+ { id: "provider-0", result: { id: "provider-0" } },
457
+ ]);
458
+ });
459
+ it("fails closed when the server responds with an unknown request id", async () => {
460
+ const transport = new FakeTransport();
461
+ const client = createClient(transport);
462
+ await initializeClient(client, transport);
463
+ const pending = client.request("turn/steer", { threadId: "t-1" });
464
+ transport.emitMessage({ id: 999, result: { turnId: "wrong" } });
465
+ await expect(pending).rejects.toBeInstanceOf(CodexAppServerProtocolError);
466
+ expect(client.state).toBe("failed");
467
+ expect(client.protocolCompatible).toBe(false);
468
+ expect(transport.terminated).toBe(true);
469
+ });
470
+ it("fails closed on a malformed RPC error response", async () => {
471
+ const transport = new FakeTransport();
472
+ const client = createClient(transport);
473
+ await initializeClient(client, transport);
474
+ const pending = client.request("turn/steer", { threadId: "t-1" });
475
+ transport.emitMessage({ id: 2, error: {} });
476
+ await expect(pending).rejects.toBeInstanceOf(CodexAppServerProtocolError);
477
+ expect(client.state).toBe("failed");
478
+ expect(client.protocolCompatible).toBe(false);
479
+ expect(transport.terminated).toBe(true);
480
+ });
481
+ it("fails closed when an outbound transport write throws", async () => {
482
+ const transport = new FakeTransport();
483
+ const client = createClient(transport);
484
+ await initializeClient(client, transport);
485
+ transport.writeError = new Error("broken pipe");
486
+ await expect(client.request("turn/steer", { threadId: "t-1" })).rejects.toThrow("broken pipe");
487
+ expect(client.state).toBe("failed");
488
+ expect(client.pendingRequestCount).toBe(0);
489
+ expect(transport.terminated).toBe(true);
490
+ });
491
+ it("ignores a known late response after its request timed out", async () => {
492
+ vi.useFakeTimers();
493
+ const transport = new FakeTransport();
494
+ const client = createClient(transport);
495
+ await initializeClient(client, transport);
496
+ const pending = client.request("thread/read", { threadId: "t-1" }, 10);
497
+ const timedOut = expect(pending).rejects.toBeInstanceOf(CodexAppServerTimeoutError);
498
+ await vi.advanceTimersByTimeAsync(10);
499
+ await timedOut;
500
+ transport.emitMessage({ id: 2, result: { threadId: "late" } });
501
+ expect(client.state).toBe("ready");
502
+ expect(client.protocolCompatible).toBe(true);
503
+ expect(transport.terminated).toBe(false);
504
+ });
505
+ it.each([
506
+ { id: null, method: "approval" },
507
+ { id: { nested: true }, method: "approval" },
508
+ { id: Number.MAX_SAFE_INTEGER + 1, method: "approval" },
509
+ { id: 12, method: "approval", result: {} },
510
+ { id: 12, result: {}, error: { code: -1, message: "both" } },
511
+ ])("fails closed on a malformed RPC envelope: $id", async (message) => {
512
+ const transport = new FakeTransport();
513
+ const client = createClient(transport, {
514
+ serverRequestHandlers: { approval: () => ({ accepted: true }) },
515
+ });
516
+ await initializeClient(client, transport);
517
+ transport.emitMessage(message);
518
+ expect(client.state).toBe("failed");
519
+ expect(client.protocolCompatible).toBe(false);
520
+ expect(transport.terminated).toBe(true);
521
+ });
522
+ it("fails closed on an unknown server method and rejects pending client work", async () => {
523
+ const transport = new FakeTransport();
524
+ const capabilityErrors = [];
525
+ const client = createClient(transport, {
526
+ onCapabilityGateClosed: (error) => capabilityErrors.push(error),
527
+ });
528
+ await initializeClient(client, transport);
529
+ const pending = client.request("thread/read", { threadId: "t-1" });
530
+ transport.emitMessage({ id: "future-1", method: "future/request", params: {} });
531
+ await expect(pending).rejects.toBeInstanceOf(CodexAppServerProtocolError);
532
+ expect(transport.messages().filter((message) => message.id === "future-1")).toEqual([
533
+ {
534
+ id: "future-1",
535
+ error: { code: -32601, message: "Client does not handle method \"future/request\"" },
536
+ },
537
+ ]);
538
+ expect(client.state).toBe("failed");
539
+ expect(client.protocolCompatible).toBe(false);
540
+ expect(client.capabilityGateError).toBe(capabilityErrors[0]);
541
+ expect(capabilityErrors).toHaveLength(1);
542
+ expect(client.pendingRequestCount).toBe(0);
543
+ expect(transport.terminated).toBe(true);
544
+ });
545
+ it("finishes fail-closed cleanup before running capability callbacks", async () => {
546
+ const transport = new FakeTransport();
547
+ const reportedErrors = [];
548
+ let stateDuringCallback;
549
+ let reentrantRequest;
550
+ let client;
551
+ client = createClient(transport, {
552
+ onCapabilityGateClosed: () => {
553
+ stateDuringCallback = client.state;
554
+ reentrantRequest = client.request("thread/read", { threadId: "reentrant" });
555
+ expect(() => client.notify("client/reentrant")).toThrow(CodexAppServerClosedError);
556
+ throw new Error("callback failed");
557
+ },
558
+ onError: (error) => reportedErrors.push(error),
559
+ });
560
+ await initializeClient(client, transport);
561
+ const pending = client.request("thread/read", { threadId: "pending" });
562
+ transport.emitMessage({ id: "future-2", method: "future/request" });
563
+ await expect(pending).rejects.toBeInstanceOf(CodexAppServerProtocolError);
564
+ await expect(reentrantRequest).rejects.toBeInstanceOf(CodexAppServerClosedError);
565
+ expect(stateDuringCallback).toBe("failed");
566
+ expect(transport.terminated).toBe(true);
567
+ expect(reportedErrors.some((error) => error.message === "callback failed")).toBe(true);
568
+ });
569
+ it("rejects outbound work and aborts inbound handlers when the process exits", async () => {
570
+ const transport = new FakeTransport();
571
+ let inboundSignal;
572
+ const client = createClient(transport, {
573
+ serverRequestHandlers: {
574
+ approval: ({ signal }) => {
575
+ inboundSignal = signal;
576
+ return new Promise(() => undefined);
577
+ },
578
+ },
579
+ });
580
+ await initializeClient(client, transport);
581
+ const outbound = client.request("thread/read", { threadId: "t-1" });
582
+ transport.emitMessage({ id: "approval-1", method: "approval" });
583
+ await flushPromises();
584
+ transport.emitExit({ code: 9, signal: null });
585
+ await expect(outbound).rejects.toBeInstanceOf(CodexAppServerClosedError);
586
+ expect(inboundSignal?.aborted).toBe(true);
587
+ expect(client.state).toBe("closed");
588
+ expect(client.pendingRequestCount).toBe(0);
589
+ expect(client.pendingServerRequestCount).toBe(0);
590
+ expect(transport.messages().filter((message) => message.id === "approval-1")).toEqual([]);
591
+ expect(transport.terminated).toBe(false);
592
+ });
593
+ it("terminates transport and clears both request directions when aborted", async () => {
594
+ const transport = new FakeTransport();
595
+ const abortController = new AbortController();
596
+ let inboundSignal;
597
+ const client = createClient(transport, {
598
+ abortSignal: abortController.signal,
599
+ serverRequestHandlers: {
600
+ approval: ({ signal }) => {
601
+ inboundSignal = signal;
602
+ return new Promise(() => undefined);
603
+ },
604
+ },
605
+ });
606
+ await initializeClient(client, transport);
607
+ const outbound = client.request("turn/start", { threadId: "t-1" });
608
+ transport.emitMessage({ id: "approval-2", method: "approval" });
609
+ await flushPromises();
610
+ abortController.abort(new Error("operator stop"));
611
+ await expect(outbound).rejects.toThrow("operator stop");
612
+ expect(inboundSignal?.aborted).toBe(true);
613
+ expect(transport.messages().filter((message) => message.id === "approval-2")).toEqual([
614
+ { id: "approval-2", error: { code: -32603, message: "operator stop" } },
615
+ ]);
616
+ expect(client.state).toBe("closed");
617
+ expect(client.pendingRequestCount).toBe(0);
618
+ expect(client.pendingServerRequestCount).toBe(0);
619
+ expect(transport.terminated).toBe(true);
620
+ });
621
+ it("blocks request and notification reentry from an inbound abort listener", async () => {
622
+ const transport = new FakeTransport();
623
+ const abortController = new AbortController();
624
+ let stateDuringAbort;
625
+ let reentrantRequest;
626
+ let notifyError;
627
+ let client;
628
+ client = createClient(transport, {
629
+ abortSignal: abortController.signal,
630
+ serverRequestHandlers: {
631
+ approval: ({ signal }) => new Promise(() => {
632
+ signal.addEventListener("abort", () => {
633
+ stateDuringAbort = client.state;
634
+ reentrantRequest = client.request("thread/read", { threadId: "reentrant" });
635
+ try {
636
+ client.notify("client/reentrant");
637
+ }
638
+ catch (error) {
639
+ notifyError = error;
640
+ }
641
+ }, { once: true });
642
+ }),
643
+ },
644
+ });
645
+ await initializeClient(client, transport);
646
+ transport.emitMessage({ id: "approval-reentrant", method: "approval" });
647
+ await flushPromises();
648
+ abortController.abort(new Error("operator stop"));
649
+ await expect(reentrantRequest).rejects.toBeInstanceOf(CodexAppServerClosedError);
650
+ expect(stateDuringAbort).toBe("closed");
651
+ expect(notifyError).toBeInstanceOf(CodexAppServerClosedError);
652
+ expect(transport.messages().some((message) => message.method === "client/reentrant")).toBe(false);
653
+ expect(transport.terminated).toBe(true);
654
+ });
655
+ });
656
+ //# sourceMappingURL=app-server-client.test.js.map