kanna-code 0.1.4 → 0.2.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.
@@ -0,0 +1,1303 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { EventEmitter } from "node:events"
3
+ import { PassThrough } from "node:stream"
4
+ import { CodexAppServerManager } from "./codex-app-server"
5
+
6
+ class FakeCodexProcess extends EventEmitter {
7
+ readonly stdin = new PassThrough()
8
+ readonly stdout = new PassThrough()
9
+ readonly stderr = new PassThrough()
10
+ readonly messages: unknown[] = []
11
+ killed = false
12
+
13
+ constructor(
14
+ private readonly onMessage?: (message: any, process: FakeCodexProcess) => void
15
+ ) {
16
+ super()
17
+ let buffer = ""
18
+ this.stdin.on("data", (chunk) => {
19
+ buffer += chunk.toString()
20
+ const lines = buffer.split("\n")
21
+ buffer = lines.pop() ?? ""
22
+ for (const line of lines) {
23
+ if (!line.trim()) continue
24
+ const message = JSON.parse(line)
25
+ this.messages.push(message)
26
+ this.onMessage?.(message, this)
27
+ }
28
+ })
29
+ }
30
+
31
+ kill() {
32
+ this.killed = true
33
+ this.emit("close", 0)
34
+ }
35
+
36
+ writeServerMessage(message: unknown) {
37
+ this.stdout.write(`${JSON.stringify(message)}\n`)
38
+ }
39
+
40
+ writeStderr(message: string) {
41
+ this.stderr.write(`${message}\n`)
42
+ }
43
+
44
+ closeWithCode(code: number) {
45
+ this.emit("close", code)
46
+ }
47
+ }
48
+
49
+ async function collectStream(stream: AsyncIterable<any>) {
50
+ const items: any[] = []
51
+ for await (const item of stream) {
52
+ items.push(item)
53
+ }
54
+ return items
55
+ }
56
+
57
+ describe("CodexAppServerManager", () => {
58
+ test("initializes app-server and starts a fresh thread", async () => {
59
+ const process = new FakeCodexProcess((message, child) => {
60
+ if (message.method === "initialize") {
61
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
62
+ } else if (message.method === "thread/start") {
63
+ child.writeServerMessage({
64
+ id: message.id,
65
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
66
+ })
67
+ }
68
+ })
69
+
70
+ const manager = new CodexAppServerManager({
71
+ spawnProcess: () => process as never,
72
+ })
73
+
74
+ await manager.startSession({
75
+ chatId: "chat-1",
76
+ cwd: "/tmp/project",
77
+ model: "gpt-5.4",
78
+ sessionToken: null,
79
+ })
80
+
81
+ expect(process.messages).toHaveLength(3)
82
+ expect((process.messages[0] as any).method).toBe("initialize")
83
+ expect((process.messages[1] as any).method).toBe("initialized")
84
+ expect((process.messages[2] as any).method).toBe("thread/start")
85
+ })
86
+
87
+ test("falls back to thread/start when thread/resume is recoverably missing", async () => {
88
+ const process = new FakeCodexProcess((message, child) => {
89
+ if (message.method === "initialize") {
90
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
91
+ } else if (message.method === "thread/resume") {
92
+ child.writeServerMessage({
93
+ id: message.id,
94
+ error: { message: "thread/resume failed: thread not found" },
95
+ })
96
+ } else if (message.method === "thread/start") {
97
+ child.writeServerMessage({
98
+ id: message.id,
99
+ result: { thread: { id: "thread-2" }, model: "gpt-5.4", reasoningEffort: "high" },
100
+ })
101
+ }
102
+ })
103
+
104
+ const manager = new CodexAppServerManager({
105
+ spawnProcess: () => process as never,
106
+ })
107
+
108
+ await manager.startSession({
109
+ chatId: "chat-1",
110
+ cwd: "/tmp/project",
111
+ model: "gpt-5.4",
112
+ sessionToken: "missing-thread",
113
+ })
114
+
115
+ expect(process.messages.map((message: any) => message.method)).toEqual([
116
+ "initialize",
117
+ "initialized",
118
+ "thread/resume",
119
+ "thread/start",
120
+ ])
121
+ })
122
+
123
+ test("maps fast mode and reasoning into app-server params", async () => {
124
+ const process = new FakeCodexProcess((message, child) => {
125
+ if (message.method === "initialize") {
126
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
127
+ } else if (message.method === "thread/start") {
128
+ child.writeServerMessage({
129
+ id: message.id,
130
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
131
+ })
132
+ } else if (message.method === "turn/start") {
133
+ child.writeServerMessage({
134
+ id: message.id,
135
+ result: { turn: { id: "turn-1", status: "completed", error: null } },
136
+ })
137
+ child.writeServerMessage({
138
+ method: "turn/completed",
139
+ params: {
140
+ threadId: "thread-1",
141
+ turn: { id: "turn-1", status: "completed", error: null },
142
+ },
143
+ })
144
+ }
145
+ })
146
+
147
+ const manager = new CodexAppServerManager({
148
+ spawnProcess: () => process as never,
149
+ })
150
+
151
+ await manager.startSession({
152
+ chatId: "chat-1",
153
+ cwd: "/tmp/project",
154
+ model: "gpt-5.4",
155
+ serviceTier: "fast",
156
+ sessionToken: null,
157
+ })
158
+
159
+ const turn = await manager.startTurn({
160
+ chatId: "chat-1",
161
+ model: "gpt-5.4",
162
+ effort: "xhigh",
163
+ serviceTier: "fast",
164
+ content: "Run pwd",
165
+ planMode: false,
166
+ onToolRequest: async () => ({}),
167
+ })
168
+
169
+ await collectStream(turn.stream)
170
+
171
+ const threadStart = process.messages.find((message: any) => message.method === "thread/start") as
172
+ | { method: "thread/start"; params: { serviceTier?: string } }
173
+ | undefined
174
+ const turnStart = process.messages.find((message: any) => message.method === "turn/start") as
175
+ | { method: "turn/start"; params: { effort?: string; serviceTier?: string; collaborationMode?: { settings?: { reasoning_effort?: string | null } } } }
176
+ | undefined
177
+
178
+ expect(threadStart?.params.serviceTier).toBe("fast")
179
+ expect(turnStart?.params.effort).toBe("xhigh")
180
+ expect(turnStart?.params.serviceTier).toBe("fast")
181
+ expect(turnStart?.params.collaborationMode?.settings?.reasoning_effort).toBeNull()
182
+ })
183
+
184
+ test("maps command execution and agent output into the shared transcript stream", async () => {
185
+ const process = new FakeCodexProcess((message, child) => {
186
+ if (message.method === "initialize") {
187
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
188
+ } else if (message.method === "thread/start") {
189
+ child.writeServerMessage({
190
+ id: message.id,
191
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
192
+ })
193
+ } else if (message.method === "turn/start") {
194
+ child.writeServerMessage({
195
+ id: message.id,
196
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
197
+ })
198
+ child.writeServerMessage({
199
+ method: "item/started",
200
+ params: {
201
+ threadId: "thread-1",
202
+ turnId: "turn-1",
203
+ item: {
204
+ type: "commandExecution",
205
+ id: "call-1",
206
+ command: "/bin/zsh -lc pwd",
207
+ status: "inProgress",
208
+ },
209
+ },
210
+ })
211
+ child.writeServerMessage({
212
+ method: "item/completed",
213
+ params: {
214
+ threadId: "thread-1",
215
+ turnId: "turn-1",
216
+ item: {
217
+ type: "commandExecution",
218
+ id: "call-1",
219
+ command: "/bin/zsh -lc pwd",
220
+ status: "completed",
221
+ aggregatedOutput: "/tmp/project\n",
222
+ exitCode: 0,
223
+ },
224
+ },
225
+ })
226
+ child.writeServerMessage({
227
+ method: "item/completed",
228
+ params: {
229
+ threadId: "thread-1",
230
+ turnId: "turn-1",
231
+ item: {
232
+ type: "agentMessage",
233
+ id: "msg-1",
234
+ text: "/tmp/project",
235
+ phase: "final_answer",
236
+ },
237
+ },
238
+ })
239
+ child.writeServerMessage({
240
+ method: "turn/completed",
241
+ params: {
242
+ threadId: "thread-1",
243
+ turn: { id: "turn-1", status: "completed", error: null },
244
+ },
245
+ })
246
+ }
247
+ })
248
+
249
+ const manager = new CodexAppServerManager({
250
+ spawnProcess: () => process as never,
251
+ })
252
+
253
+ await manager.startSession({
254
+ chatId: "chat-1",
255
+ cwd: "/tmp/project",
256
+ model: "gpt-5.4",
257
+ sessionToken: null,
258
+ })
259
+
260
+ const turn = await manager.startTurn({
261
+ chatId: "chat-1",
262
+ model: "gpt-5.4",
263
+ content: "Run pwd",
264
+ planMode: false,
265
+ onToolRequest: async () => ({}),
266
+ })
267
+
268
+ const events = await collectStream(turn.stream)
269
+ const transcriptKinds = events
270
+ .filter((event) => event.type === "transcript")
271
+ .map((event) => event.entry.kind)
272
+
273
+ expect(events[0]).toEqual({ type: "session_token", sessionToken: "thread-1" })
274
+ expect(transcriptKinds).toEqual(["system_init", "tool_call", "tool_result", "assistant_text", "result"])
275
+ })
276
+
277
+ test("emits only a compact boundary when Codex reports thread compaction", async () => {
278
+ const process = new FakeCodexProcess((message, child) => {
279
+ if (message.method === "initialize") {
280
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
281
+ } else if (message.method === "thread/start") {
282
+ child.writeServerMessage({
283
+ id: message.id,
284
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
285
+ })
286
+ } else if (message.method === "turn/start") {
287
+ child.writeServerMessage({
288
+ id: message.id,
289
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
290
+ })
291
+ child.writeServerMessage({
292
+ method: "thread/compacted",
293
+ params: {
294
+ threadId: "thread-1",
295
+ turnId: "turn-1",
296
+ },
297
+ })
298
+ child.writeServerMessage({
299
+ method: "turn/completed",
300
+ params: {
301
+ threadId: "thread-1",
302
+ turn: { id: "turn-1", status: "completed", error: null },
303
+ },
304
+ })
305
+ }
306
+ })
307
+
308
+ const manager = new CodexAppServerManager({
309
+ spawnProcess: () => process as never,
310
+ })
311
+
312
+ await manager.startSession({
313
+ chatId: "chat-1",
314
+ cwd: "/tmp/project",
315
+ model: "gpt-5.4",
316
+ sessionToken: null,
317
+ })
318
+
319
+ const turn = await manager.startTurn({
320
+ chatId: "chat-1",
321
+ model: "gpt-5.4",
322
+ content: "/compact",
323
+ planMode: false,
324
+ onToolRequest: async () => ({}),
325
+ })
326
+
327
+ const events = await collectStream(turn.stream)
328
+ const transcriptKinds = events
329
+ .filter((event) => event.type === "transcript")
330
+ .map((event) => event.entry.kind)
331
+
332
+ expect(transcriptKinds).toEqual(["system_init", "compact_boundary", "result"])
333
+ expect(transcriptKinds).not.toContain("context_cleared")
334
+ })
335
+
336
+ test("maps fileChange updates into edit_file tool calls", async () => {
337
+ const process = new FakeCodexProcess((message, child) => {
338
+ if (message.method === "initialize") {
339
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
340
+ } else if (message.method === "thread/start") {
341
+ child.writeServerMessage({
342
+ id: message.id,
343
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
344
+ })
345
+ } else if (message.method === "turn/start") {
346
+ child.writeServerMessage({
347
+ id: message.id,
348
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
349
+ })
350
+ child.writeServerMessage({
351
+ method: "item/started",
352
+ params: {
353
+ threadId: "thread-1",
354
+ turnId: "turn-1",
355
+ item: {
356
+ type: "fileChange",
357
+ id: "call-1",
358
+ changes: [
359
+ {
360
+ path: "/tmp/project/test.md",
361
+ kind: {
362
+ type: "update",
363
+ move_path: null,
364
+ },
365
+ diff: "@@ -1,2 +1,2 @@\n-old line\n+new line",
366
+ },
367
+ ],
368
+ status: "inProgress",
369
+ },
370
+ },
371
+ })
372
+ child.writeServerMessage({
373
+ method: "item/completed",
374
+ params: {
375
+ threadId: "thread-1",
376
+ turnId: "turn-1",
377
+ item: {
378
+ type: "fileChange",
379
+ id: "call-1",
380
+ changes: [
381
+ {
382
+ path: "/tmp/project/test.md",
383
+ kind: {
384
+ type: "update",
385
+ move_path: null,
386
+ },
387
+ diff: "@@ -1,2 +1,2 @@\n-old line\n+new line",
388
+ },
389
+ ],
390
+ status: "completed",
391
+ },
392
+ },
393
+ })
394
+ child.writeServerMessage({
395
+ method: "turn/completed",
396
+ params: {
397
+ threadId: "thread-1",
398
+ turn: { id: "turn-1", status: "completed", error: null },
399
+ },
400
+ })
401
+ }
402
+ })
403
+
404
+ const manager = new CodexAppServerManager({
405
+ spawnProcess: () => process as never,
406
+ })
407
+
408
+ await manager.startSession({
409
+ chatId: "chat-1",
410
+ cwd: "/tmp/project",
411
+ model: "gpt-5.4",
412
+ sessionToken: null,
413
+ })
414
+
415
+ const turn = await manager.startTurn({
416
+ chatId: "chat-1",
417
+ model: "gpt-5.4",
418
+ content: "edit a file",
419
+ planMode: false,
420
+ onToolRequest: async () => ({}),
421
+ })
422
+
423
+ const events = await collectStream(turn.stream)
424
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
425
+
426
+ expect(toolCall?.entry.kind).toBe("tool_call")
427
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
428
+ expect(toolCall.entry.tool.toolKind).toBe("edit_file")
429
+ expect(toolCall.entry.tool.toolName).toBe("Edit")
430
+ expect(toolCall.entry.tool.input).toEqual({
431
+ filePath: "/tmp/project/test.md",
432
+ oldString: "old line",
433
+ newString: "new line",
434
+ })
435
+ })
436
+
437
+ test("maps fileChange adds into write_file tool calls", async () => {
438
+ const process = new FakeCodexProcess((message, child) => {
439
+ if (message.method === "initialize") {
440
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
441
+ } else if (message.method === "thread/start") {
442
+ child.writeServerMessage({
443
+ id: message.id,
444
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
445
+ })
446
+ } else if (message.method === "turn/start") {
447
+ child.writeServerMessage({
448
+ id: message.id,
449
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
450
+ })
451
+ child.writeServerMessage({
452
+ method: "item/started",
453
+ params: {
454
+ threadId: "thread-1",
455
+ turnId: "turn-1",
456
+ item: {
457
+ type: "fileChange",
458
+ id: "call-1",
459
+ changes: [
460
+ {
461
+ path: "/tmp/project/test.md",
462
+ kind: {
463
+ type: "add",
464
+ move_path: null,
465
+ },
466
+ diff: "@@ -0,0 +1,2 @@\n+hello\n+world",
467
+ },
468
+ ],
469
+ status: "inProgress",
470
+ },
471
+ },
472
+ })
473
+ child.writeServerMessage({
474
+ method: "item/completed",
475
+ params: {
476
+ threadId: "thread-1",
477
+ turnId: "turn-1",
478
+ item: {
479
+ type: "fileChange",
480
+ id: "call-1",
481
+ changes: [
482
+ {
483
+ path: "/tmp/project/test.md",
484
+ kind: {
485
+ type: "add",
486
+ move_path: null,
487
+ },
488
+ diff: "@@ -0,0 +1,2 @@\n+hello\n+world",
489
+ },
490
+ ],
491
+ status: "completed",
492
+ },
493
+ },
494
+ })
495
+ child.writeServerMessage({
496
+ method: "turn/completed",
497
+ params: {
498
+ threadId: "thread-1",
499
+ turn: { id: "turn-1", status: "completed", error: null },
500
+ },
501
+ })
502
+ }
503
+ })
504
+
505
+ const manager = new CodexAppServerManager({
506
+ spawnProcess: () => process as never,
507
+ })
508
+
509
+ await manager.startSession({
510
+ chatId: "chat-1",
511
+ cwd: "/tmp/project",
512
+ model: "gpt-5.4",
513
+ sessionToken: null,
514
+ })
515
+
516
+ const turn = await manager.startTurn({
517
+ chatId: "chat-1",
518
+ model: "gpt-5.4",
519
+ content: "write a file",
520
+ planMode: false,
521
+ onToolRequest: async () => ({}),
522
+ })
523
+
524
+ const events = await collectStream(turn.stream)
525
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
526
+
527
+ expect(toolCall?.entry.kind).toBe("tool_call")
528
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
529
+ expect(toolCall.entry.tool.toolKind).toBe("write_file")
530
+ expect(toolCall.entry.tool.toolName).toBe("Write")
531
+ expect(toolCall.entry.tool.input).toEqual({
532
+ filePath: "/tmp/project/test.md",
533
+ content: "hello\nworld",
534
+ })
535
+ })
536
+
537
+ test("splits multi-change fileChange items into multiple tool calls and results", async () => {
538
+ const process = new FakeCodexProcess((message, child) => {
539
+ if (message.method === "initialize") {
540
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
541
+ } else if (message.method === "thread/start") {
542
+ child.writeServerMessage({
543
+ id: message.id,
544
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
545
+ })
546
+ } else if (message.method === "turn/start") {
547
+ child.writeServerMessage({
548
+ id: message.id,
549
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
550
+ })
551
+ child.writeServerMessage({
552
+ method: "item/completed",
553
+ params: {
554
+ threadId: "thread-1",
555
+ turnId: "turn-1",
556
+ item: {
557
+ type: "fileChange",
558
+ id: "call-1",
559
+ changes: [
560
+ {
561
+ path: "/tmp/project/one.md",
562
+ kind: {
563
+ type: "add",
564
+ move_path: null,
565
+ },
566
+ diff: "@@ -0,0 +1,2 @@\n+hello\n+world",
567
+ },
568
+ {
569
+ path: "/tmp/project/two.md",
570
+ kind: {
571
+ type: "update",
572
+ move_path: null,
573
+ },
574
+ diff: "@@ -1,2 +1,2 @@\n-old line\n+new line",
575
+ },
576
+ ],
577
+ status: "completed",
578
+ },
579
+ },
580
+ })
581
+ child.writeServerMessage({
582
+ method: "turn/completed",
583
+ params: {
584
+ threadId: "thread-1",
585
+ turn: { id: "turn-1", status: "completed", error: null },
586
+ },
587
+ })
588
+ }
589
+ })
590
+
591
+ const manager = new CodexAppServerManager({
592
+ spawnProcess: () => process as never,
593
+ })
594
+
595
+ await manager.startSession({
596
+ chatId: "chat-1",
597
+ cwd: "/tmp/project",
598
+ model: "gpt-5.4",
599
+ sessionToken: null,
600
+ })
601
+
602
+ const turn = await manager.startTurn({
603
+ chatId: "chat-1",
604
+ model: "gpt-5.4",
605
+ content: "change multiple files",
606
+ planMode: false,
607
+ onToolRequest: async () => ({}),
608
+ })
609
+
610
+ const events = await collectStream(turn.stream)
611
+ const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
612
+ const toolResults = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_result")
613
+
614
+ expect(toolCalls).toHaveLength(2)
615
+ expect(toolResults).toHaveLength(2)
616
+
617
+ expect(toolCalls[0]?.entry.kind).toBe("tool_call")
618
+ expect(toolCalls[1]?.entry.kind).toBe("tool_call")
619
+ if (toolCalls[0]?.entry.kind !== "tool_call" || toolCalls[1]?.entry.kind !== "tool_call") {
620
+ throw new Error("missing tool calls")
621
+ }
622
+
623
+ expect(toolCalls[0].entry.tool.toolKind).toBe("write_file")
624
+ expect(toolCalls[0].entry.tool.toolId).toBe("call-1:change:0")
625
+ expect(toolCalls[0].entry.tool.input).toEqual({
626
+ filePath: "/tmp/project/one.md",
627
+ content: "hello\nworld",
628
+ })
629
+
630
+ expect(toolCalls[1].entry.tool.toolKind).toBe("edit_file")
631
+ expect(toolCalls[1].entry.tool.toolId).toBe("call-1:change:1")
632
+ expect(toolCalls[1].entry.tool.input).toEqual({
633
+ filePath: "/tmp/project/two.md",
634
+ oldString: "old line",
635
+ newString: "new line",
636
+ })
637
+
638
+ expect(toolResults[0]?.entry.kind).toBe("tool_result")
639
+ expect(toolResults[1]?.entry.kind).toBe("tool_result")
640
+ if (toolResults[0]?.entry.kind !== "tool_result" || toolResults[1]?.entry.kind !== "tool_result") {
641
+ throw new Error("missing tool results")
642
+ }
643
+
644
+ expect(toolResults[0].entry.toolId).toBe("call-1:change:0")
645
+ expect(toolResults[1].entry.toolId).toBe("call-1:change:1")
646
+ })
647
+
648
+ test("maps plan updates into TodoWrite and synthesizes ExitPlanMode on successful plan turns", async () => {
649
+ const process = new FakeCodexProcess((message, child) => {
650
+ if (message.method === "initialize") {
651
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
652
+ } else if (message.method === "thread/start") {
653
+ child.writeServerMessage({
654
+ id: message.id,
655
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
656
+ })
657
+ } else if (message.method === "turn/start") {
658
+ child.writeServerMessage({
659
+ id: message.id,
660
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
661
+ })
662
+ child.writeServerMessage({
663
+ method: "turn/plan/updated",
664
+ params: {
665
+ threadId: "thread-1",
666
+ turnId: "turn-1",
667
+ explanation: "Plan the work",
668
+ plan: [
669
+ { step: "Inspect repo", status: "completed" },
670
+ { step: "Implement changes", status: "inProgress" },
671
+ ],
672
+ },
673
+ })
674
+ child.writeServerMessage({
675
+ method: "item/started",
676
+ params: {
677
+ threadId: "thread-1",
678
+ turnId: "turn-1",
679
+ item: {
680
+ type: "plan",
681
+ id: "plan-1",
682
+ text: "",
683
+ },
684
+ },
685
+ })
686
+ child.writeServerMessage({
687
+ method: "item/plan/delta",
688
+ params: {
689
+ threadId: "thread-1",
690
+ turnId: "turn-1",
691
+ itemId: "plan-1",
692
+ delta: "## Plan\n\n- [x] Inspect repo\n- [ ] Implement changes",
693
+ },
694
+ })
695
+ child.writeServerMessage({
696
+ method: "turn/completed",
697
+ params: {
698
+ threadId: "thread-1",
699
+ turn: { id: "turn-1", status: "completed", error: null },
700
+ },
701
+ })
702
+ }
703
+ })
704
+
705
+ const manager = new CodexAppServerManager({
706
+ spawnProcess: () => process as never,
707
+ })
708
+
709
+ await manager.startSession({
710
+ chatId: "chat-1",
711
+ cwd: "/tmp/project",
712
+ model: "gpt-5.4",
713
+ sessionToken: null,
714
+ })
715
+
716
+ const turn = await manager.startTurn({
717
+ chatId: "chat-1",
718
+ model: "gpt-5.4",
719
+ content: "make a plan",
720
+ planMode: true,
721
+ onToolRequest: async () => ({ confirmed: true }),
722
+ })
723
+
724
+ const events = await collectStream(turn.stream)
725
+ const toolCalls = events
726
+ .filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
727
+ .map((event) => event.entry.tool)
728
+
729
+ expect(toolCalls[0]?.toolKind).toBe("todo_write")
730
+ expect(toolCalls[1]?.toolKind).toBe("exit_plan_mode")
731
+ if (!toolCalls[1] || toolCalls[1].toolKind !== "exit_plan_mode") {
732
+ throw new Error("missing ExitPlanMode tool")
733
+ }
734
+ expect(toolCalls[1].input.summary).toBe("Plan the work")
735
+ expect(toolCalls[1].input.plan).toContain("## Plan")
736
+ })
737
+
738
+ test("maps collab agent tool calls into subagent_task", async () => {
739
+ const process = new FakeCodexProcess((message, child) => {
740
+ if (message.method === "initialize") {
741
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
742
+ } else if (message.method === "thread/start") {
743
+ child.writeServerMessage({
744
+ id: message.id,
745
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
746
+ })
747
+ } else if (message.method === "turn/start") {
748
+ child.writeServerMessage({
749
+ id: message.id,
750
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
751
+ })
752
+ child.writeServerMessage({
753
+ method: "item/completed",
754
+ params: {
755
+ threadId: "thread-1",
756
+ turnId: "turn-1",
757
+ item: {
758
+ type: "collabAgentToolCall",
759
+ id: "agent-1",
760
+ tool: "spawnAgent",
761
+ status: "completed",
762
+ senderThreadId: "thread-1",
763
+ receiverThreadIds: ["thread-2"],
764
+ prompt: "Inspect tests",
765
+ agentsStates: {
766
+ "thread-2": { status: "running", message: "Inspecting" },
767
+ },
768
+ },
769
+ },
770
+ })
771
+ child.writeServerMessage({
772
+ method: "turn/completed",
773
+ params: {
774
+ threadId: "thread-1",
775
+ turn: { id: "turn-1", status: "completed", error: null },
776
+ },
777
+ })
778
+ }
779
+ })
780
+
781
+ const manager = new CodexAppServerManager({
782
+ spawnProcess: () => process as never,
783
+ })
784
+
785
+ await manager.startSession({
786
+ chatId: "chat-1",
787
+ cwd: "/tmp/project",
788
+ model: "gpt-5.4",
789
+ sessionToken: null,
790
+ })
791
+
792
+ const turn = await manager.startTurn({
793
+ chatId: "chat-1",
794
+ model: "gpt-5.4",
795
+ content: "spawn an agent",
796
+ planMode: false,
797
+ onToolRequest: async () => ({}),
798
+ })
799
+
800
+ const events = await collectStream(turn.stream)
801
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
802
+
803
+ expect(toolCall?.entry.kind).toBe("tool_call")
804
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
805
+ expect(toolCall.entry.tool.toolKind).toBe("subagent_task")
806
+ expect(toolCall.entry.tool.input).toEqual({ subagentType: "spawnAgent" })
807
+ })
808
+
809
+ test("uses the completed webSearch query when the started item is empty", async () => {
810
+ const process = new FakeCodexProcess((message, child) => {
811
+ if (message.method === "initialize") {
812
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
813
+ } else if (message.method === "thread/start") {
814
+ child.writeServerMessage({
815
+ id: message.id,
816
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
817
+ })
818
+ } else if (message.method === "turn/start") {
819
+ child.writeServerMessage({
820
+ id: message.id,
821
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
822
+ })
823
+ child.writeServerMessage({
824
+ method: "item/started",
825
+ params: {
826
+ threadId: "thread-1",
827
+ turnId: "turn-1",
828
+ item: {
829
+ type: "webSearch",
830
+ id: "ws-1",
831
+ query: "",
832
+ },
833
+ },
834
+ })
835
+ child.writeServerMessage({
836
+ method: "item/completed",
837
+ params: {
838
+ threadId: "thread-1",
839
+ turnId: "turn-1",
840
+ item: {
841
+ type: "webSearch",
842
+ id: "ws-1",
843
+ query: "jake mor",
844
+ action: {
845
+ type: "search",
846
+ query: "jake mor",
847
+ queries: ["jake mor"],
848
+ },
849
+ },
850
+ },
851
+ })
852
+ child.writeServerMessage({
853
+ method: "turn/completed",
854
+ params: {
855
+ threadId: "thread-1",
856
+ turn: { id: "turn-1", status: "completed", error: null },
857
+ },
858
+ })
859
+ }
860
+ })
861
+
862
+ const manager = new CodexAppServerManager({
863
+ spawnProcess: () => process as never,
864
+ })
865
+
866
+ await manager.startSession({
867
+ chatId: "chat-1",
868
+ cwd: "/tmp/project",
869
+ model: "gpt-5.4",
870
+ sessionToken: null,
871
+ })
872
+
873
+ const turn = await manager.startTurn({
874
+ chatId: "chat-1",
875
+ model: "gpt-5.4",
876
+ content: "search",
877
+ planMode: false,
878
+ onToolRequest: async () => ({}),
879
+ })
880
+
881
+ const events = await collectStream(turn.stream)
882
+ const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
883
+
884
+ expect(toolCalls).toHaveLength(1)
885
+ const toolCall = toolCalls[0]
886
+ if (toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
887
+ expect(toolCall.entry.tool.toolKind).toBe("web_search")
888
+ expect(toolCall.entry.tool.input).toEqual({ query: "jake mor" })
889
+ })
890
+
891
+ test("responds to unsupported dynamic tool requests with a generic tool error", async () => {
892
+ const process = new FakeCodexProcess((message, child) => {
893
+ if (message.method === "initialize") {
894
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
895
+ } else if (message.method === "thread/start") {
896
+ child.writeServerMessage({
897
+ id: message.id,
898
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
899
+ })
900
+ } else if (message.method === "turn/start") {
901
+ child.writeServerMessage({
902
+ id: message.id,
903
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
904
+ })
905
+ child.writeServerMessage({
906
+ id: "dyn-1",
907
+ method: "item/tool/call",
908
+ params: {
909
+ threadId: "thread-1",
910
+ turnId: "turn-1",
911
+ callId: "call-1",
912
+ tool: "custom_tool",
913
+ arguments: { value: 1 },
914
+ },
915
+ })
916
+ child.writeServerMessage({
917
+ method: "turn/completed",
918
+ params: {
919
+ threadId: "thread-1",
920
+ turn: { id: "turn-1", status: "completed", error: null },
921
+ },
922
+ })
923
+ }
924
+ })
925
+
926
+ const manager = new CodexAppServerManager({
927
+ spawnProcess: () => process as never,
928
+ })
929
+
930
+ await manager.startSession({
931
+ chatId: "chat-1",
932
+ cwd: "/tmp/project",
933
+ model: "gpt-5.4",
934
+ sessionToken: null,
935
+ })
936
+
937
+ const turn = await manager.startTurn({
938
+ chatId: "chat-1",
939
+ model: "gpt-5.4",
940
+ content: "call tool",
941
+ planMode: false,
942
+ onToolRequest: async () => ({}),
943
+ })
944
+
945
+ const events = await collectStream(turn.stream)
946
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
947
+ const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
948
+ const response = process.messages.find((message: any) => message.id === "dyn-1")
949
+
950
+ expect(toolCall?.entry.kind).toBe("tool_call")
951
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
952
+ expect(toolCall.entry.tool.toolKind).toBe("unknown_tool")
953
+ expect(toolCall.entry.tool.toolName).toBe("custom_tool")
954
+ expect(toolResult?.entry.kind).toBe("tool_result")
955
+ expect(response).toEqual({
956
+ id: "dyn-1",
957
+ result: {
958
+ contentItems: [{ type: "inputText", text: "Unsupported dynamic tool call: custom_tool" }],
959
+ success: false,
960
+ },
961
+ })
962
+ })
963
+
964
+ test("answers requestUserInput requests with the official JSON-RPC result payload", async () => {
965
+ const process = new FakeCodexProcess((message, child) => {
966
+ if (message.method === "initialize") {
967
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
968
+ } else if (message.method === "thread/start") {
969
+ child.writeServerMessage({
970
+ id: message.id,
971
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
972
+ })
973
+ } else if (message.method === "turn/start") {
974
+ child.writeServerMessage({
975
+ id: message.id,
976
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
977
+ })
978
+ child.writeServerMessage({
979
+ id: "req-1",
980
+ method: "item/tool/requestUserInput",
981
+ params: {
982
+ threadId: "thread-1",
983
+ turnId: "turn-1",
984
+ itemId: "ask-1",
985
+ questions: [
986
+ {
987
+ id: "runtime",
988
+ header: "Runtime",
989
+ question: "Which runtime?",
990
+ isOther: false,
991
+ isSecret: false,
992
+ options: null,
993
+ },
994
+ ],
995
+ },
996
+ })
997
+ child.writeServerMessage({
998
+ method: "turn/completed",
999
+ params: {
1000
+ threadId: "thread-1",
1001
+ turn: { id: "turn-1", status: "completed", error: null },
1002
+ },
1003
+ })
1004
+ }
1005
+ })
1006
+
1007
+ const manager = new CodexAppServerManager({
1008
+ spawnProcess: () => process as never,
1009
+ })
1010
+
1011
+ await manager.startSession({
1012
+ chatId: "chat-1",
1013
+ cwd: "/tmp/project",
1014
+ model: "gpt-5.4",
1015
+ sessionToken: null,
1016
+ })
1017
+
1018
+ const turn = await manager.startTurn({
1019
+ chatId: "chat-1",
1020
+ model: "gpt-5.4",
1021
+ content: "ask me",
1022
+ planMode: false,
1023
+ onToolRequest: async () => ({
1024
+ questions: [{
1025
+ id: "runtime",
1026
+ question: "Which runtime?",
1027
+ }],
1028
+ answers: {
1029
+ runtime: "bun",
1030
+ },
1031
+ }),
1032
+ })
1033
+
1034
+ const events = await collectStream(turn.stream)
1035
+ const askEntry = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
1036
+ expect(askEntry?.entry.tool.toolKind).toBe("ask_user_question")
1037
+
1038
+ const response = process.messages.find((message: any) => message.id === "req-1")
1039
+ expect(response).toEqual({
1040
+ id: "req-1",
1041
+ result: {
1042
+ answers: {
1043
+ runtime: {
1044
+ answers: ["bun"],
1045
+ },
1046
+ },
1047
+ },
1048
+ })
1049
+ })
1050
+
1051
+ test("falls back to question text when requestUserInput answers are keyed by prompt text", async () => {
1052
+ const process = new FakeCodexProcess((message, child) => {
1053
+ if (message.method === "initialize") {
1054
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1055
+ } else if (message.method === "thread/start") {
1056
+ child.writeServerMessage({
1057
+ id: message.id,
1058
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1059
+ })
1060
+ } else if (message.method === "turn/start") {
1061
+ child.writeServerMessage({
1062
+ id: message.id,
1063
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
1064
+ })
1065
+ child.writeServerMessage({
1066
+ id: "req-1",
1067
+ method: "item/tool/requestUserInput",
1068
+ params: {
1069
+ threadId: "thread-1",
1070
+ turnId: "turn-1",
1071
+ itemId: "ask-1",
1072
+ questions: [
1073
+ {
1074
+ id: "favorite_color",
1075
+ header: "Color",
1076
+ question: "What is your favorite color right now?",
1077
+ isOther: true,
1078
+ isSecret: false,
1079
+ options: [
1080
+ { label: "Red", description: null },
1081
+ { label: "Blue", description: null },
1082
+ ],
1083
+ },
1084
+ ],
1085
+ },
1086
+ })
1087
+ child.writeServerMessage({
1088
+ method: "turn/completed",
1089
+ params: {
1090
+ threadId: "thread-1",
1091
+ turn: { id: "turn-1", status: "completed", error: null },
1092
+ },
1093
+ })
1094
+ }
1095
+ })
1096
+
1097
+ const manager = new CodexAppServerManager({
1098
+ spawnProcess: () => process as never,
1099
+ })
1100
+
1101
+ await manager.startSession({
1102
+ chatId: "chat-1",
1103
+ cwd: "/tmp/project",
1104
+ model: "gpt-5.4",
1105
+ sessionToken: null,
1106
+ })
1107
+
1108
+ const turn = await manager.startTurn({
1109
+ chatId: "chat-1",
1110
+ model: "gpt-5.4",
1111
+ content: "ask me",
1112
+ planMode: false,
1113
+ onToolRequest: async () => ({
1114
+ questions: [{
1115
+ id: "favorite_color",
1116
+ question: "What is your favorite color right now?",
1117
+ }],
1118
+ answers: {
1119
+ "What is your favorite color right now?": "Red",
1120
+ },
1121
+ }),
1122
+ })
1123
+
1124
+ await collectStream(turn.stream)
1125
+
1126
+ const response = process.messages.find((message: any) => message.id === "req-1")
1127
+ expect(response).toEqual({
1128
+ id: "req-1",
1129
+ result: {
1130
+ answers: {
1131
+ favorite_color: {
1132
+ answers: ["Red"],
1133
+ },
1134
+ },
1135
+ },
1136
+ })
1137
+ })
1138
+
1139
+ test("sends approval decisions back to the app-server", async () => {
1140
+ const process = new FakeCodexProcess((message, child) => {
1141
+ if (message.method === "initialize") {
1142
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1143
+ } else if (message.method === "thread/start") {
1144
+ child.writeServerMessage({
1145
+ id: message.id,
1146
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1147
+ })
1148
+ } else if (message.method === "turn/start") {
1149
+ child.writeServerMessage({
1150
+ id: message.id,
1151
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
1152
+ })
1153
+ child.writeServerMessage({
1154
+ id: "approval-1",
1155
+ method: "item/commandExecution/requestApproval",
1156
+ params: {
1157
+ threadId: "thread-1",
1158
+ turnId: "turn-1",
1159
+ itemId: "call-1",
1160
+ command: "rm -rf .",
1161
+ cwd: "/tmp/project",
1162
+ },
1163
+ })
1164
+ child.writeServerMessage({
1165
+ method: "turn/completed",
1166
+ params: {
1167
+ threadId: "thread-1",
1168
+ turn: { id: "turn-1", status: "completed", error: null },
1169
+ },
1170
+ })
1171
+ }
1172
+ })
1173
+
1174
+ const manager = new CodexAppServerManager({
1175
+ spawnProcess: () => process as never,
1176
+ })
1177
+
1178
+ await manager.startSession({
1179
+ chatId: "chat-1",
1180
+ cwd: "/tmp/project",
1181
+ model: "gpt-5.4",
1182
+ sessionToken: null,
1183
+ })
1184
+
1185
+ const turn = await manager.startTurn({
1186
+ chatId: "chat-1",
1187
+ model: "gpt-5.4",
1188
+ content: "approve something",
1189
+ planMode: false,
1190
+ onToolRequest: async () => ({}),
1191
+ onApprovalRequest: async () => "accept",
1192
+ })
1193
+
1194
+ await collectStream(turn.stream)
1195
+
1196
+ const response = process.messages.find((message: any) => message.id === "approval-1")
1197
+ expect(response).toEqual({
1198
+ id: "approval-1",
1199
+ result: {
1200
+ decision: "accept",
1201
+ },
1202
+ })
1203
+ })
1204
+
1205
+ test("interrupt sends turn/interrupt for the active turn", async () => {
1206
+ const process = new FakeCodexProcess((message, child) => {
1207
+ if (message.method === "initialize") {
1208
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1209
+ } else if (message.method === "thread/start") {
1210
+ child.writeServerMessage({
1211
+ id: message.id,
1212
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1213
+ })
1214
+ } else if (message.method === "turn/start") {
1215
+ child.writeServerMessage({
1216
+ id: message.id,
1217
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
1218
+ })
1219
+ } else if (message.method === "turn/interrupt") {
1220
+ child.writeServerMessage({ id: message.id, result: {} })
1221
+ }
1222
+ })
1223
+
1224
+ const manager = new CodexAppServerManager({
1225
+ spawnProcess: () => process as never,
1226
+ })
1227
+
1228
+ await manager.startSession({
1229
+ chatId: "chat-1",
1230
+ cwd: "/tmp/project",
1231
+ model: "gpt-5.4",
1232
+ sessionToken: null,
1233
+ })
1234
+
1235
+ const turn = await manager.startTurn({
1236
+ chatId: "chat-1",
1237
+ model: "gpt-5.4",
1238
+ content: "wait",
1239
+ planMode: false,
1240
+ onToolRequest: async () => ({}),
1241
+ })
1242
+
1243
+ await turn.interrupt()
1244
+
1245
+ const interruptRequest = process.messages.find((message: any) => message.method === "turn/interrupt") as
1246
+ | { id: string; method: "turn/interrupt"; params: { threadId: string; turnId: string } }
1247
+ | undefined
1248
+ expect(interruptRequest).toBeDefined()
1249
+ if (!interruptRequest) throw new Error("missing interrupt request")
1250
+ expect(interruptRequest).toEqual({
1251
+ id: interruptRequest.id,
1252
+ method: "turn/interrupt",
1253
+ params: {
1254
+ threadId: "thread-1",
1255
+ turnId: "turn-1",
1256
+ },
1257
+ })
1258
+ })
1259
+
1260
+ test("emits an error result when the app-server exits mid-turn", async () => {
1261
+ const process = new FakeCodexProcess((message, child) => {
1262
+ if (message.method === "initialize") {
1263
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
1264
+ } else if (message.method === "thread/start") {
1265
+ child.writeServerMessage({
1266
+ id: message.id,
1267
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
1268
+ })
1269
+ } else if (message.method === "turn/start") {
1270
+ child.writeServerMessage({
1271
+ id: message.id,
1272
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
1273
+ })
1274
+ child.writeStderr("fatal: app-server crashed")
1275
+ child.closeWithCode(1)
1276
+ }
1277
+ })
1278
+
1279
+ const manager = new CodexAppServerManager({
1280
+ spawnProcess: () => process as never,
1281
+ })
1282
+
1283
+ await manager.startSession({
1284
+ chatId: "chat-1",
1285
+ cwd: "/tmp/project",
1286
+ model: "gpt-5.4",
1287
+ sessionToken: null,
1288
+ })
1289
+
1290
+ const turn = await manager.startTurn({
1291
+ chatId: "chat-1",
1292
+ model: "gpt-5.4",
1293
+ content: "crash",
1294
+ planMode: false,
1295
+ onToolRequest: async () => ({}),
1296
+ })
1297
+
1298
+ const events = await collectStream(turn.stream)
1299
+ const resultEvent = events.find((event) => event.type === "transcript" && event.entry.kind === "result")
1300
+ expect(resultEvent?.entry.subtype).toBe("error")
1301
+ expect(resultEvent?.entry.result).toContain("fatal: app-server crashed")
1302
+ })
1303
+ })