airaknit 1.1.2-rc.9
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.
- package/LICENSE +84 -0
- package/README.md +202 -0
- package/bin/airaknit +9 -0
- package/bin/airaknit-project +14 -0
- package/bin/kanna +9 -0
- package/dist/client/assets/CompactSummaryMessage-Yw0BDWEJ.js +1 -0
- package/dist/client/assets/ExitPlanModeMessage-DIdkQ4uF.js +1 -0
- package/dist/client/assets/LocalFilePreviewDialog-DQx2eiCc.js +3 -0
- package/dist/client/assets/LocalProjectsSection-C4xlWkgS.js +1 -0
- package/dist/client/assets/TextMessage-B5G39DEJ.js +1 -0
- package/dist/client/assets/UserMessage-CIkWk-0L.js +1 -0
- package/dist/client/assets/_basePickBy-CVrAFfnZ.js +1 -0
- package/dist/client/assets/_baseUniq-JL-aaF4P.js +1 -0
- package/dist/client/assets/arc-B07zg7ol.js +1 -0
- package/dist/client/assets/architecture-YZFGNWBL-PSLVJL3p.js +1 -0
- package/dist/client/assets/architectureDiagram-Q4EWVU46-DfWIF1G_.js +36 -0
- package/dist/client/assets/array-BGFCBI0e.js +1 -0
- package/dist/client/assets/blockDiagram-DXYQGD6D-CzwIeo_B.js +132 -0
- package/dist/client/assets/bundle-mjs-BDE2gWbQ.js +1 -0
- package/dist/client/assets/button-DO50qOGv.js +1 -0
- package/dist/client/assets/c4Diagram-AHTNJAMY-CR8DCQRE.js +10 -0
- package/dist/client/assets/channel-Dj-UUfaF.js +1 -0
- package/dist/client/assets/chunk-2KRD3SAO-dznP-cn8.js +1 -0
- package/dist/client/assets/chunk-336JU56O-Dqss5Vu6.js +2 -0
- package/dist/client/assets/chunk-426QAEUC-CEKis_0_.js +1 -0
- package/dist/client/assets/chunk-4BX2VUAB-BYOv0Gm1.js +1 -0
- package/dist/client/assets/chunk-4TB4RGXK-BxzubH5S.js +206 -0
- package/dist/client/assets/chunk-55IACEB6-BSlTj03a.js +1 -0
- package/dist/client/assets/chunk-5FUZZQ4R-9Au93Bi1.js +62 -0
- package/dist/client/assets/chunk-5PVQY5BW-BhksHFEZ.js +2 -0
- package/dist/client/assets/chunk-67CJDMHE-BFwhz-8t.js +1 -0
- package/dist/client/assets/chunk-7N4EOEYR-BDUPds87.js +1 -0
- package/dist/client/assets/chunk-AA7GKIK3-CEWTdyXO.js +1 -0
- package/dist/client/assets/chunk-BO2N2NFS-D0LvxnhU.js +103 -0
- package/dist/client/assets/chunk-BSJP7CBP-BNJnK6sq.js +1 -0
- package/dist/client/assets/chunk-Bj-mKKzh.js +1 -0
- package/dist/client/assets/chunk-CIAEETIT-CYhfoCeN.js +1 -0
- package/dist/client/assets/chunk-EDXVE4YY-C5ovJLc0.js +1 -0
- package/dist/client/assets/chunk-ENJZ2VHE-HOhYaeGr.js +10 -0
- package/dist/client/assets/chunk-FMBD7UC4-BLCiKcAQ.js +15 -0
- package/dist/client/assets/chunk-FOC6F5B3-B6GtY2ek.js +1 -0
- package/dist/client/assets/chunk-ICPOFSXX-DPoIZoC5.js +122 -0
- package/dist/client/assets/chunk-K5T4RW27-BsKN63rv.js +94 -0
- package/dist/client/assets/chunk-KGLVRYIC-BUGn9uuY.js +1 -0
- package/dist/client/assets/chunk-LIHQZDEY-DhaZyo03.js +1 -0
- package/dist/client/assets/chunk-ORNJ4GCN-DlpeeJyi.js +1 -0
- package/dist/client/assets/chunk-OYMX7WX6-Dc9q7aYA.js +231 -0
- package/dist/client/assets/chunk-QZHKN3VN-BEdrPoSb.js +1 -0
- package/dist/client/assets/chunk-U2HBQHQK-CIB3Bjjd.js +70 -0
- package/dist/client/assets/chunk-X2U36JSP-CtB-o8Yp.js +1 -0
- package/dist/client/assets/chunk-XPW4576I-C6iHhX_8.js +32 -0
- package/dist/client/assets/chunk-YZCP3GAM-CTmKr6ZH.js +1 -0
- package/dist/client/assets/chunk-ZZ45TVLE-BgU8A2RF.js +1 -0
- package/dist/client/assets/classDiagram-6PBFFD2Q-Bqk5e679.js +1 -0
- package/dist/client/assets/classDiagram-v2-HSJHXN6E-6pSaZOkC.js +1 -0
- package/dist/client/assets/client-BrKWI4CM.js +1 -0
- package/dist/client/assets/client-CGgNRU9w.js +1 -0
- package/dist/client/assets/client-DMSLRzg9.js +6 -0
- package/dist/client/assets/clone-DWcL7whJ.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-CrV5wsV_.js +1 -0
- package/dist/client/assets/cytoscape.esm--aLzKuep.js +321 -0
- package/dist/client/assets/dagre-CuRxWcrj.js +1 -0
- package/dist/client/assets/dagre-KV5264BT-BIDiVnkA.js +4 -0
- package/dist/client/assets/defaultLocale-CRZydyG6.js +1 -0
- package/dist/client/assets/diagram-5BDNPKRD-i1kjKRCB.js +10 -0
- package/dist/client/assets/diagram-G4DWMVQ6-9ZSLuhbl.js +24 -0
- package/dist/client/assets/diagram-MMDJMWI5-B4_CUjgv.js +43 -0
- package/dist/client/assets/diagram-TYMM5635-Ct5eTGS8.js +24 -0
- package/dist/client/assets/dist-CuB4kiSK.js +1 -0
- package/dist/client/assets/erDiagram-SMLLAGMA-Cy38ercc.js +85 -0
- package/dist/client/assets/flowDiagram-DWJPFMVM-CZKuYl0V.js +162 -0
- package/dist/client/assets/ganttDiagram-T4ZO3ILL-DLPjCh7a.js +292 -0
- package/dist/client/assets/gitGraph-7Q5UKJZL-DqbrtEp9.js +1 -0
- package/dist/client/assets/gitGraphDiagram-UUTBAWPF-BoRBkDhQ.js +106 -0
- package/dist/client/assets/graphlib-BcQ6qlQh.js +1 -0
- package/dist/client/assets/highlighted-body-OFNGDK62-BEpBVDTX.js +1 -0
- package/dist/client/assets/index-CetCiuqP.js +105 -0
- package/dist/client/assets/index-N29Mip7A.css +1 -0
- package/dist/client/assets/info-OMHHGYJF-D98DRBJX.js +1 -0
- package/dist/client/assets/infoDiagram-42DDH7IO-BAcdTWbt.js +2 -0
- package/dist/client/assets/init-B8gtcn7T.js +1 -0
- package/dist/client/assets/isArrayLikeObject-D8SJFmkN.js +1 -0
- package/dist/client/assets/isEmpty-BF3YX5Jk.js +1 -0
- package/dist/client/assets/ishikawaDiagram-UXIWVN3A-Ynu2VKdC.js +70 -0
- package/dist/client/assets/journeyDiagram-VCZTEJTY-BjfhQaN3.js +139 -0
- package/dist/client/assets/jsx-runtime-CyI9ICYU.js +1 -0
- package/dist/client/assets/kanban-definition-6JOO6SKY-JLXH9zUJ.js +89 -0
- package/dist/client/assets/katex-B94qP8b6.js +265 -0
- package/dist/client/assets/lib--QVjyxmL.js +29 -0
- package/dist/client/assets/lib-B6rgJiZ9.js +1 -0
- package/dist/client/assets/line-DCrYfLBn.js +1 -0
- package/dist/client/assets/linear-_4upLmeo.js +1 -0
- package/dist/client/assets/mermaid-GHXKKRXX-rwJHYUmW.js +1 -0
- package/dist/client/assets/mermaid-parser.core-KZinfW8o.js +4 -0
- package/dist/client/assets/mermaid.core-QqY9gSNe.js +11 -0
- package/dist/client/assets/mindmap-definition-QFDTVHPH-TWgHDAzp.js +96 -0
- package/dist/client/assets/ordinal-CCj7PWgZ.js +1 -0
- package/dist/client/assets/packet-4T2RLAQJ-DEvfkn3F.js +1 -0
- package/dist/client/assets/path-DZF-JdEe.js +1 -0
- package/dist/client/assets/pie-ZZUOXDRM-72e6WVjb.js +1 -0
- package/dist/client/assets/pieDiagram-DEJITSTG-Cl8PCsoj.js +30 -0
- package/dist/client/assets/preload-helper-rov5CBGT.js +1 -0
- package/dist/client/assets/pty-client-DZ27IS00.js +1 -0
- package/dist/client/assets/ptyInstancesStore-D9ag7SYd.js +1 -0
- package/dist/client/assets/quadrantDiagram-34T5L4WZ-CHyVGp9E.js +7 -0
- package/dist/client/assets/radar-PYXPWWZC-Cp7xd_EY.js +1 -0
- package/dist/client/assets/react-CClhXMB2.js +1 -0
- package/dist/client/assets/react-Dd6D81m0.js +1 -0
- package/dist/client/assets/react-dom--G6_6fQ_.js +1 -0
- package/dist/client/assets/requirementDiagram-MS252O5E-DaanG2iM.js +84 -0
- package/dist/client/assets/rough.esm-BsmKo2S5.js +1 -0
- package/dist/client/assets/sankeyDiagram-XADWPNL6-B_fhLY36.js +10 -0
- package/dist/client/assets/sequenceDiagram-FGHM5R23-C5FNrveI.js +157 -0
- package/dist/client/assets/src-DeTlMJAU.js +1 -0
- package/dist/client/assets/stateDiagram-FHFEXIEX-nTTcdjjQ.js +1 -0
- package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-Dw0632j_.js +1 -0
- package/dist/client/assets/timeline-definition-GMOUNBTQ-DkQV1yP8.js +120 -0
- package/dist/client/assets/treeView-SZITEDCU-ZLIgC7_K.js +1 -0
- package/dist/client/assets/treemap-W4RFUUIX-BqaMbB6N.js +1 -0
- package/dist/client/assets/uiIdentityOverlay-Ba7GNj7m.js +1 -0
- package/dist/client/assets/vennDiagram-DHZGUBPP-DbZ2xgs6.js +34 -0
- package/dist/client/assets/wardley-RL74JXVD-DXQS8zf4.js +1 -0
- package/dist/client/assets/wardleyDiagram-NUSXRM2D-BzCJ6MAu.js +20 -0
- package/dist/client/assets/xychartDiagram-5P7HB3ND-BSlFecop.js +7 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/favicon.svg +15 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/index.html +31 -0
- package/dist/client/manifest.webmanifest +12 -0
- package/dist/client/sw.js +32 -0
- package/package.json +122 -0
- package/src/nats/auth-callout/callout-config.test.ts +93 -0
- package/src/nats/auth-callout/callout-config.ts +109 -0
- package/src/nats/auth-callout/callout.integration.test.ts +332 -0
- package/src/nats/auth-callout/keys.ts +103 -0
- package/src/nats/auth-callout/responder.ts +241 -0
- package/src/nats/auth-callout/scope-policy.test.ts +159 -0
- package/src/nats/auth-callout/scope-policy.ts +210 -0
- package/src/nats/auth-callout/token.test.ts +163 -0
- package/src/nats/auth-callout/token.ts +157 -0
- package/src/nats/nats-daemon-callout.ts +194 -0
- package/src/nats/nats-daemon.test.ts +77 -0
- package/src/nats/nats-daemon.ts +50 -0
- package/src/nats/nats-token.test.ts +61 -0
- package/src/nats/nats-token.ts +59 -0
- package/src/runner/coordination-mcp-integration.test.ts +134 -0
- package/src/runner/nats-coordination-client.test.ts +49 -0
- package/src/runner/nats-coordination-client.ts +94 -0
- package/src/runner/runner-agent.test.ts +469 -0
- package/src/runner/runner-agent.ts +453 -0
- package/src/runner/runner-credential.test.ts +93 -0
- package/src/runner/runner-credential.ts +82 -0
- package/src/runner/runner-nats.test.ts +495 -0
- package/src/runner/runner-nats.ts +323 -0
- package/src/runner/runner-pair.test.ts +107 -0
- package/src/runner/runner-pair.ts +81 -0
- package/src/runner/runner.test.ts +135 -0
- package/src/runner/runner.ts +212 -0
- package/src/runner/turn-factories.test.ts +97 -0
- package/src/runner/turn-factories.ts +475 -0
- package/src/server/agent-config-journey.test.ts +106 -0
- package/src/server/agent.ts +8 -0
- package/src/server/auto-continue/auth-error-detector.ts +66 -0
- package/src/server/auto-continue/limit-detector.ts +194 -0
- package/src/server/bm25.test.ts +92 -0
- package/src/server/bm25.ts +101 -0
- package/src/server/chat-events-jetstream.test.ts +135 -0
- package/src/server/claude-harness.ts +360 -0
- package/src/server/claude-pty/agent-normalizers.ts +309 -0
- package/src/server/claude-pty/auth.test.ts +38 -0
- package/src/server/claude-pty/auth.ts +32 -0
- package/src/server/claude-pty/claude-session-registry.adapter.ts +81 -0
- package/src/server/claude-pty/claude-session-registry.test.ts +149 -0
- package/src/server/claude-pty/driver.test.ts +902 -0
- package/src/server/claude-pty/driver.ts +807 -0
- package/src/server/claude-pty/jsonl-path.adapter.ts +57 -0
- package/src/server/claude-pty/jsonl-path.test.ts +114 -0
- package/src/server/claude-pty/jsonl-to-event.test.ts +241 -0
- package/src/server/claude-pty/jsonl-to-event.ts +174 -0
- package/src/server/claude-pty/output-ring.test.ts +35 -0
- package/src/server/claude-pty/output-ring.ts +25 -0
- package/src/server/claude-pty/parity-matrix.test.ts +227 -0
- package/src/server/claude-pty/pid-registry.adapter.ts +135 -0
- package/src/server/claude-pty/pid-registry.test.ts +122 -0
- package/src/server/claude-pty/preflight/binary-fingerprint.adapter.ts +20 -0
- package/src/server/claude-pty/preflight/binary-fingerprint.test.ts +32 -0
- package/src/server/claude-pty/pty-instance-registry.test.ts +177 -0
- package/src/server/claude-pty/pty-instance-registry.ts +166 -0
- package/src/server/claude-pty/pty-memory-sampler.adapter.test.ts +103 -0
- package/src/server/claude-pty/pty-memory-sampler.adapter.ts +85 -0
- package/src/server/claude-pty/pty-process.adapter.ts +66 -0
- package/src/server/claude-pty/pty-process.test.ts +49 -0
- package/src/server/claude-pty/resolve-binary.adapter.ts +106 -0
- package/src/server/claude-pty/resolve-binary.test.ts +118 -0
- package/src/server/claude-pty/runtime-dir.adapter.ts +19 -0
- package/src/server/claude-pty/settings-writer.adapter.ts +27 -0
- package/src/server/claude-pty/settings-writer.test.ts +22 -0
- package/src/server/claude-pty/smoke-test-io.adapter.ts +28 -0
- package/src/server/claude-pty/smoke-test.test.ts +191 -0
- package/src/server/claude-pty/smoke-test.ts +185 -0
- package/src/server/claude-pty/subagent-orchestrator.ts +887 -0
- package/src/server/claude-pty/tool-callback.ts +274 -0
- package/src/server/claude-pty/tui-control.test.ts +272 -0
- package/src/server/claude-pty/tui-control.ts +182 -0
- package/src/server/claude-pty/tui-source.adapter.test.ts +360 -0
- package/src/server/claude-pty/tui-source.adapter.ts +343 -0
- package/src/server/claude-pty/tunnel-gateway.ts +12 -0
- package/src/server/claude-pty-mcp/canonical-args.ts +15 -0
- package/src/server/claude-pty-mcp/fs-stat.adapter.ts +8 -0
- package/src/server/claude-pty-mcp/history-primer.ts +90 -0
- package/src/server/claude-pty-mcp/http-server.adapter.ts +33 -0
- package/src/server/claude-pty-mcp/mcp-http.ts +177 -0
- package/src/server/claude-pty-mcp/mcp.ts +412 -0
- package/src/server/claude-pty-mcp/mention-parser.ts +25 -0
- package/src/server/claude-pty-mcp/paths.ts +24 -0
- package/src/server/claude-pty-mcp/permission-gate.ts +243 -0
- package/src/server/claude-pty-mcp/terminal-pid-registry.adapter.ts +107 -0
- package/src/server/claude-pty-mcp/tools/ask-user-question.test.ts +119 -0
- package/src/server/claude-pty-mcp/tools/ask-user-question.ts +61 -0
- package/src/server/claude-pty-mcp/tools/bash.adapter.ts +76 -0
- package/src/server/claude-pty-mcp/tools/bash.test.ts +56 -0
- package/src/server/claude-pty-mcp/tools/delegate-subagent.test.ts +155 -0
- package/src/server/claude-pty-mcp/tools/delegate-subagent.ts +111 -0
- package/src/server/claude-pty-mcp/tools/edit.adapter.ts +95 -0
- package/src/server/claude-pty-mcp/tools/edit.test.ts +93 -0
- package/src/server/claude-pty-mcp/tools/exit-plan-mode.test.ts +61 -0
- package/src/server/claude-pty-mcp/tools/exit-plan-mode.ts +50 -0
- package/src/server/claude-pty-mcp/tools/glob.adapter.ts +86 -0
- package/src/server/claude-pty-mcp/tools/glob.test.ts +61 -0
- package/src/server/claude-pty-mcp/tools/grep.adapter.ts +126 -0
- package/src/server/claude-pty-mcp/tools/grep.test.ts +62 -0
- package/src/server/claude-pty-mcp/tools/read.adapter.ts +58 -0
- package/src/server/claude-pty-mcp/tools/read.test.ts +62 -0
- package/src/server/claude-pty-mcp/tools/tool-callback-shim.ts +42 -0
- package/src/server/claude-pty-mcp/tools/webfetch.test.ts +81 -0
- package/src/server/claude-pty-mcp/tools/webfetch.ts +82 -0
- package/src/server/claude-pty-mcp/tools/websearch.test.ts +40 -0
- package/src/server/claude-pty-mcp/tools/websearch.ts +42 -0
- package/src/server/claude-pty-mcp/tools/write.adapter.ts +60 -0
- package/src/server/claude-pty-mcp/tools/write.test.ts +52 -0
- package/src/server/claude-pty-mcp/uploads.adapter.ts +98 -0
- package/src/server/claude-pty-mcp/uploads.ts +38 -0
- package/src/server/claude-turn.test.ts +176 -0
- package/src/server/cli-runtime.test.ts +456 -0
- package/src/server/cli-runtime.ts +374 -0
- package/src/server/cli-supervisor.ts +81 -0
- package/src/server/cli.ts +78 -0
- package/src/server/client-log-forwarder.test.ts +74 -0
- package/src/server/client-log-forwarder.ts +75 -0
- package/src/server/codex-app-server-protocol.ts +449 -0
- package/src/server/codex-app-server.test.ts +2990 -0
- package/src/server/codex-app-server.ts +1713 -0
- package/src/server/coordination-integration.test.ts +63 -0
- package/src/server/coordination-mcp.test.ts +149 -0
- package/src/server/coordination-mcp.ts +197 -0
- package/src/server/delegation-coordinator.test.ts +675 -0
- package/src/server/delegation-coordinator.ts +454 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +301 -0
- package/src/server/event-store-agent-config.test.ts +124 -0
- package/src/server/event-store-coordination.test.ts +149 -0
- package/src/server/event-store-profile.test.ts +132 -0
- package/src/server/event-store-repo.test.ts +154 -0
- package/src/server/event-store-runner-team.test.ts +104 -0
- package/src/server/event-store.test.ts +342 -0
- package/src/server/event-store.ts +2208 -0
- package/src/server/events.ts +379 -0
- package/src/server/extension-router.test.ts +183 -0
- package/src/server/extension-router.ts +114 -0
- package/src/server/extensions/agents/server.test.ts +191 -0
- package/src/server/extensions/agents/server.ts +108 -0
- package/src/server/extensions/c3/server.test.ts +284 -0
- package/src/server/extensions/c3/server.ts +212 -0
- package/src/server/extensions/code/server.test.ts +200 -0
- package/src/server/extensions/code/server.ts +150 -0
- package/src/server/extensions.config.ts +10 -0
- package/src/server/external-open.ts +69 -0
- package/src/server/generate-fork-context.ts +58 -0
- package/src/server/generate-merge-context.test.ts +290 -0
- package/src/server/generate-merge-context.ts +141 -0
- package/src/server/generate-title.ts +36 -0
- package/src/server/git-clone-policy.test.ts +138 -0
- package/src/server/git-clone-policy.ts +27 -0
- package/src/server/harness-types.ts +1 -0
- package/src/server/journey-verification.test.ts +640 -0
- package/src/server/journey-verification.ts +195 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/nats-auth.test.ts +92 -0
- package/src/server/nats-auth.ts +6 -0
- package/src/server/nats-bind-guard.test.ts +71 -0
- package/src/server/nats-bind-guard.ts +42 -0
- package/src/server/nats-bridge.test.ts +141 -0
- package/src/server/nats-bridge.ts +111 -0
- package/src/server/nats-connector.test.ts +56 -0
- package/src/server/nats-connector.ts +71 -0
- package/src/server/nats-daemon-manager.test.ts +76 -0
- package/src/server/nats-daemon-manager.ts +174 -0
- package/src/server/nats-publisher.test.ts +356 -0
- package/src/server/nats-publisher.ts +271 -0
- package/src/server/nats-responders.test.ts +1018 -0
- package/src/server/nats-responders.ts +925 -0
- package/src/server/nats-streams.test.ts +112 -0
- package/src/server/nats-streams.ts +129 -0
- package/src/server/oauth-pool/oauth-responders.ts +185 -0
- package/src/server/oauth-pool/oauth-settings-store.ts +173 -0
- package/src/server/oauth-pool/oauth-token-pool.ts +303 -0
- package/src/server/orchestration.test.ts +1063 -0
- package/src/server/orchestration.ts +716 -0
- package/src/server/pairing-endpoints.test.ts +171 -0
- package/src/server/pairing-store.test.ts +154 -0
- package/src/server/pairing-store.ts +124 -0
- package/src/server/paths.ts +27 -0
- package/src/server/pr3-liveness.test.ts +252 -0
- package/src/server/process-utils.ts +10 -0
- package/src/server/project-cli.ts +180 -0
- package/src/server/provider-catalog.test.ts +177 -0
- package/src/server/provider-catalog.ts +146 -0
- package/src/server/pty-responders.ts +345 -0
- package/src/server/push-notifications.test.ts +234 -0
- package/src/server/push-notifications.ts +188 -0
- package/src/server/quick-response.test.ts +196 -0
- package/src/server/quick-response.ts +154 -0
- package/src/server/read-models-agent-config.test.ts +59 -0
- package/src/server/read-models-coordination.test.ts +69 -0
- package/src/server/read-models.test.ts +332 -0
- package/src/server/read-models.ts +258 -0
- package/src/server/repo-journey.test.ts +96 -0
- package/src/server/repo-manager.test.ts +118 -0
- package/src/server/repo-manager.ts +97 -0
- package/src/server/repo-status.test.ts +54 -0
- package/src/server/repo-status.ts +82 -0
- package/src/server/restart.test.ts +27 -0
- package/src/server/restart.ts +30 -0
- package/src/server/runner-incompatible-gate.test.ts +383 -0
- package/src/server/runner-manager.test.ts +72 -0
- package/src/server/runner-manager.ts +312 -0
- package/src/server/runner-pairing-urls.test.ts +59 -0
- package/src/server/runner-pairing-urls.ts +67 -0
- package/src/server/runner-proxy.test.ts +456 -0
- package/src/server/runner-proxy.ts +494 -0
- package/src/server/runner-router.test.ts +429 -0
- package/src/server/runner-router.ts +212 -0
- package/src/server/runner-routing.test.ts +584 -0
- package/src/server/runtime-registry.test.ts +436 -0
- package/src/server/runtime-registry.ts +307 -0
- package/src/server/sandbox-health.test.ts +127 -0
- package/src/server/sandbox-health.ts +94 -0
- package/src/server/sandbox-journey.test.ts +232 -0
- package/src/server/sandbox-manager.test.ts +146 -0
- package/src/server/sandbox-manager.ts +159 -0
- package/src/server/server.test.ts +287 -0
- package/src/server/server.ts +1108 -0
- package/src/server/session-discovery.test.ts +129 -0
- package/src/server/session-discovery.ts +475 -0
- package/src/server/session-index.test.ts +362 -0
- package/src/server/session-index.ts +119 -0
- package/src/server/session-seed.ts +288 -0
- package/src/server/share.test.ts +108 -0
- package/src/server/share.ts +113 -0
- package/src/server/skill-discovery.test.ts +201 -0
- package/src/server/skill-discovery.ts +77 -0
- package/src/server/storage/test-helpers.ts +67 -0
- package/src/server/terminal-manager.test.ts +309 -0
- package/src/server/terminal-manager.ts +354 -0
- package/src/server/transcript-consumer.test.ts +339 -0
- package/src/server/transcript-consumer.ts +162 -0
- package/src/server/transcript-search.test.ts +193 -0
- package/src/server/transcript-search.ts +83 -0
- package/src/server/transcript-utils.ts +52 -0
- package/src/server/update-manager.test.ts +107 -0
- package/src/server/update-manager.ts +230 -0
- package/src/server/workflow-engine.test.ts +251 -0
- package/src/server/workflow-engine.ts +169 -0
- package/src/server/workflow-mcp.ts +49 -0
- package/src/server/workflow-store.test.ts +155 -0
- package/src/server/workflow-store.ts +139 -0
- package/src/server/workspace-agent-integration.test.ts +167 -0
- package/src/server/workspace-agent-routes.test.ts +127 -0
- package/src/server/workspace-agent-routes.ts +89 -0
- package/src/server/workspace-agent.test.ts +103 -0
- package/src/server/workspace-agent.ts +102 -0
- package/src/server/workspace-cli.test.ts +79 -0
- package/src/server/workspace-config-manager.test.ts +109 -0
- package/src/server/workspace-config-manager.ts +83 -0
- package/src/server/workspace-directory-policy.test.ts +109 -0
- package/src/server/workspace-directory-policy.ts +56 -0
- package/src/shared/agent-config-types.ts +25 -0
- package/src/shared/branding.test.ts +42 -0
- package/src/shared/branding.ts +54 -0
- package/src/shared/compression.test.ts +85 -0
- package/src/shared/compression.ts +42 -0
- package/src/shared/coordination-store.test.ts +24 -0
- package/src/shared/coordination-store.ts +26 -0
- package/src/shared/dev-ports.test.ts +84 -0
- package/src/shared/dev-ports.ts +100 -0
- package/src/shared/extension-types.ts +45 -0
- package/src/shared/fork-presets.ts +54 -0
- package/src/shared/harness-types.test.ts +15 -0
- package/src/shared/harness-types.ts +21 -0
- package/src/shared/log-sink.test.ts +112 -0
- package/src/shared/log-sink.ts +185 -0
- package/src/shared/mention-pattern.ts +7 -0
- package/src/shared/merge-presets.ts +41 -0
- package/src/shared/nats-subjects.test.ts +61 -0
- package/src/shared/nats-subjects.ts +131 -0
- package/src/shared/permission-policy.ts +136 -0
- package/src/shared/ports.ts +3 -0
- package/src/shared/preset-types.ts +15 -0
- package/src/shared/profile-types.ts +49 -0
- package/src/shared/projectFileUrl.ts +36 -0
- package/src/shared/protocol.ts +153 -0
- package/src/shared/pty-instance.ts +43 -0
- package/src/shared/puggy/diagnostics/result.ts +18 -0
- package/src/shared/puggy/expressions/evaluate.ts +292 -0
- package/src/shared/puggy/index.test.ts +101 -0
- package/src/shared/puggy/index.ts +69 -0
- package/src/shared/puggy/parser/ast.ts +110 -0
- package/src/shared/puggy/parser/parser.ts +624 -0
- package/src/shared/puggy/renderer/html.ts +447 -0
- package/src/shared/runner-protocol.test.ts +277 -0
- package/src/shared/runner-protocol.ts +210 -0
- package/src/shared/runner-team-types.ts +73 -0
- package/src/shared/runtime-types.ts +48 -0
- package/src/shared/sandbox-types.ts +53 -0
- package/src/shared/tailwind-build.test.ts +12 -0
- package/src/shared/tinkaria-system-prompt.ts +101 -0
- package/src/shared/tools.test.ts +335 -0
- package/src/shared/tools.ts +397 -0
- package/src/shared/transcript-entries.ts +27 -0
- package/src/shared/transcript-render.test.ts +225 -0
- package/src/shared/transcript-render.ts +467 -0
- package/src/shared/types.ts +1031 -0
- package/src/shared/vite-config.test.ts +47 -0
- package/src/shared/web-context.test.ts +110 -0
- package/src/shared/web-context.ts +76 -0
- package/src/shared/workflow-types.ts +51 -0
- package/src/shared/workspace-types.ts +100 -0
|
@@ -0,0 +1,2990 @@
|
|
|
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
|
+
function expectPresentContentSchemaValidationError(value: unknown) {
|
|
59
|
+
expect(value).toEqual({
|
|
60
|
+
error: {
|
|
61
|
+
source: "schema_validation",
|
|
62
|
+
schema: "present_content",
|
|
63
|
+
issues: expect.arrayContaining([
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
path: expect.any(Array),
|
|
66
|
+
code: expect.any(String),
|
|
67
|
+
message: expect.any(String),
|
|
68
|
+
}),
|
|
69
|
+
]),
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("initializes app-server and starts a fresh thread", async () => {
|
|
75
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
76
|
+
if (message.method === "initialize") {
|
|
77
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
78
|
+
} else if (message.method === "thread/start") {
|
|
79
|
+
child.writeServerMessage({
|
|
80
|
+
id: message.id,
|
|
81
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const manager = new CodexAppServerManager({
|
|
87
|
+
spawnProcess: () => process as never,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
await manager.startSession({
|
|
91
|
+
chatId: "chat-1",
|
|
92
|
+
cwd: "/tmp/project",
|
|
93
|
+
model: "gpt-5.4",
|
|
94
|
+
sessionToken: null,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(process.messages).toHaveLength(3)
|
|
98
|
+
expect((process.messages[0] as any).method).toBe("initialize")
|
|
99
|
+
expect((process.messages[1] as any).method).toBe("initialized")
|
|
100
|
+
expect((process.messages[2] as any).method).toBe("thread/start")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("falls back to thread/start when thread/resume is recoverably missing", async () => {
|
|
104
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
105
|
+
if (message.method === "initialize") {
|
|
106
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
107
|
+
} else if (message.method === "thread/resume") {
|
|
108
|
+
child.writeServerMessage({
|
|
109
|
+
id: message.id,
|
|
110
|
+
error: { message: "thread/resume failed: thread not found" },
|
|
111
|
+
})
|
|
112
|
+
} else if (message.method === "thread/start") {
|
|
113
|
+
child.writeServerMessage({
|
|
114
|
+
id: message.id,
|
|
115
|
+
result: { thread: { id: "thread-2" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const manager = new CodexAppServerManager({
|
|
121
|
+
spawnProcess: () => process as never,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await manager.startSession({
|
|
125
|
+
chatId: "chat-1",
|
|
126
|
+
cwd: "/tmp/project",
|
|
127
|
+
model: "gpt-5.4",
|
|
128
|
+
sessionToken: "missing-thread",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(process.messages.map((message: any) => message.method)).toEqual([
|
|
132
|
+
"initialize",
|
|
133
|
+
"initialized",
|
|
134
|
+
"thread/resume",
|
|
135
|
+
"thread/start",
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("falls back to thread/start when thread/resume reports no rollout found", async () => {
|
|
140
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
141
|
+
if (message.method === "initialize") {
|
|
142
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
143
|
+
} else if (message.method === "thread/resume") {
|
|
144
|
+
child.writeServerMessage({
|
|
145
|
+
id: message.id,
|
|
146
|
+
error: { message: "thread/resume failed: no rollout found for thread id stale-thread" },
|
|
147
|
+
})
|
|
148
|
+
} else if (message.method === "thread/start") {
|
|
149
|
+
child.writeServerMessage({
|
|
150
|
+
id: message.id,
|
|
151
|
+
result: { thread: { id: "thread-3" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const manager = new CodexAppServerManager({
|
|
157
|
+
spawnProcess: () => process as never,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await manager.startSession({
|
|
161
|
+
chatId: "chat-1",
|
|
162
|
+
cwd: "/tmp/project",
|
|
163
|
+
model: "gpt-5.4",
|
|
164
|
+
sessionToken: "stale-thread",
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
expect(process.messages.map((message: any) => message.method)).toEqual([
|
|
168
|
+
"initialize",
|
|
169
|
+
"initialized",
|
|
170
|
+
"thread/resume",
|
|
171
|
+
"thread/start",
|
|
172
|
+
])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("falls back to thread/start for any unrecognized thread/resume error", async () => {
|
|
176
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
177
|
+
if (message.method === "initialize") {
|
|
178
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
179
|
+
} else if (message.method === "thread/resume") {
|
|
180
|
+
child.writeServerMessage({
|
|
181
|
+
id: message.id,
|
|
182
|
+
error: { message: "thread/resume failed: thread is not rollable" },
|
|
183
|
+
})
|
|
184
|
+
} else if (message.method === "thread/start") {
|
|
185
|
+
child.writeServerMessage({
|
|
186
|
+
id: message.id,
|
|
187
|
+
result: { thread: { id: "thread-4" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const manager = new CodexAppServerManager({
|
|
193
|
+
spawnProcess: () => process as never,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
await manager.startSession({
|
|
197
|
+
chatId: "chat-1",
|
|
198
|
+
cwd: "/tmp/project",
|
|
199
|
+
model: "gpt-5.4",
|
|
200
|
+
sessionToken: "not-rollable-thread",
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
expect(process.messages.map((message: any) => message.method)).toEqual([
|
|
204
|
+
"initialize",
|
|
205
|
+
"initialized",
|
|
206
|
+
"thread/resume",
|
|
207
|
+
"thread/start",
|
|
208
|
+
])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test("maps fast mode and reasoning into app-server params", async () => {
|
|
212
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
213
|
+
if (message.method === "initialize") {
|
|
214
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
215
|
+
} else if (message.method === "thread/start") {
|
|
216
|
+
child.writeServerMessage({
|
|
217
|
+
id: message.id,
|
|
218
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
219
|
+
})
|
|
220
|
+
} else if (message.method === "turn/start") {
|
|
221
|
+
child.writeServerMessage({
|
|
222
|
+
id: message.id,
|
|
223
|
+
result: { turn: { id: "turn-1", status: "completed", error: null } },
|
|
224
|
+
})
|
|
225
|
+
child.writeServerMessage({
|
|
226
|
+
method: "turn/completed",
|
|
227
|
+
params: {
|
|
228
|
+
threadId: "thread-1",
|
|
229
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const manager = new CodexAppServerManager({
|
|
236
|
+
spawnProcess: () => process as never,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
await manager.startSession({
|
|
240
|
+
chatId: "chat-1",
|
|
241
|
+
cwd: "/tmp/project",
|
|
242
|
+
model: "gpt-5.4",
|
|
243
|
+
serviceTier: "fast",
|
|
244
|
+
sessionToken: null,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const turn = await manager.startTurn({
|
|
248
|
+
chatId: "chat-1",
|
|
249
|
+
model: "gpt-5.4",
|
|
250
|
+
effort: "xhigh",
|
|
251
|
+
serviceTier: "fast",
|
|
252
|
+
content: "Run pwd",
|
|
253
|
+
planMode: false,
|
|
254
|
+
onToolRequest: async () => ({}),
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
await collectStream(turn.stream)
|
|
258
|
+
|
|
259
|
+
const threadStart = process.messages.find((message: any) => message.method === "thread/start") as
|
|
260
|
+
| { method: "thread/start"; params: { serviceTier?: string } }
|
|
261
|
+
| undefined
|
|
262
|
+
const turnStart = process.messages.find((message: any) => message.method === "turn/start") as
|
|
263
|
+
| { method: "turn/start"; params: { effort?: string; serviceTier?: string; collaborationMode?: { settings?: { reasoning_effort?: string | null } } } }
|
|
264
|
+
| undefined
|
|
265
|
+
|
|
266
|
+
expect(threadStart?.params.serviceTier).toBe("fast")
|
|
267
|
+
expect(turnStart?.params.effort).toBe("xhigh")
|
|
268
|
+
expect(turnStart?.params.serviceTier).toBe("fast")
|
|
269
|
+
expect(turnStart?.params.collaborationMode?.settings?.reasoning_effort).toBeNull()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test("does not advertise removed dynamic content tools on turn start", async () => {
|
|
273
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
274
|
+
if (message.method === "initialize") {
|
|
275
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
276
|
+
} else if (message.method === "thread/start") {
|
|
277
|
+
child.writeServerMessage({
|
|
278
|
+
id: message.id,
|
|
279
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
280
|
+
})
|
|
281
|
+
} else if (message.method === "turn/start") {
|
|
282
|
+
expect(message.params).not.toHaveProperty("dynamicTools")
|
|
283
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).not.toContain("present_content")
|
|
284
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).toContain(
|
|
285
|
+
"Use structured rich transcript output when it improves clarity"
|
|
286
|
+
)
|
|
287
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).toContain(
|
|
288
|
+
"Prefer direct rich embeds or structured artifact cards over bare links"
|
|
289
|
+
)
|
|
290
|
+
child.writeServerMessage({
|
|
291
|
+
id: message.id,
|
|
292
|
+
result: { turn: { id: "turn-1", status: "completed", error: null } },
|
|
293
|
+
})
|
|
294
|
+
child.writeServerMessage({
|
|
295
|
+
method: "turn/completed",
|
|
296
|
+
params: {
|
|
297
|
+
threadId: "thread-1",
|
|
298
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const manager = new CodexAppServerManager({
|
|
305
|
+
spawnProcess: () => process as never,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await manager.startSession({
|
|
309
|
+
chatId: "chat-1",
|
|
310
|
+
cwd: "/tmp/project",
|
|
311
|
+
model: "gpt-5.4",
|
|
312
|
+
sessionToken: null,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const turn = await manager.startTurn({
|
|
316
|
+
chatId: "chat-1",
|
|
317
|
+
model: "gpt-5.4",
|
|
318
|
+
content: "show a card",
|
|
319
|
+
planMode: false,
|
|
320
|
+
onToolRequest: async () => ({}),
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
await collectStream(turn.stream)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test("does not advertise removed dynamic ask_user_question tool on turn start", async () => {
|
|
327
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
328
|
+
if (message.method === "initialize") {
|
|
329
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
330
|
+
} else if (message.method === "thread/start") {
|
|
331
|
+
child.writeServerMessage({
|
|
332
|
+
id: message.id,
|
|
333
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
334
|
+
})
|
|
335
|
+
} else if (message.method === "turn/start") {
|
|
336
|
+
expect(message.params).not.toHaveProperty("dynamicTools")
|
|
337
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).not.toContain("ask_user_question")
|
|
338
|
+
child.writeServerMessage({
|
|
339
|
+
id: message.id,
|
|
340
|
+
result: { turn: { id: "turn-1", status: "completed", error: null } },
|
|
341
|
+
})
|
|
342
|
+
child.writeServerMessage({
|
|
343
|
+
method: "turn/completed",
|
|
344
|
+
params: {
|
|
345
|
+
threadId: "thread-1",
|
|
346
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const manager = new CodexAppServerManager({
|
|
353
|
+
spawnProcess: () => process as never,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
await manager.startSession({
|
|
357
|
+
chatId: "chat-1",
|
|
358
|
+
cwd: "/tmp/project",
|
|
359
|
+
model: "gpt-5.4",
|
|
360
|
+
sessionToken: null,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const turn = await manager.startTurn({
|
|
364
|
+
chatId: "chat-1",
|
|
365
|
+
model: "gpt-5.4",
|
|
366
|
+
content: "ask me",
|
|
367
|
+
planMode: false,
|
|
368
|
+
onToolRequest: async () => ({}),
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
await collectStream(turn.stream)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test("uses codex-native collaboration mode instead of removed dynamicTools on turn start", async () => {
|
|
375
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
376
|
+
if (message.method === "initialize") {
|
|
377
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
378
|
+
} else if (message.method === "thread/start") {
|
|
379
|
+
child.writeServerMessage({
|
|
380
|
+
id: message.id,
|
|
381
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
382
|
+
})
|
|
383
|
+
} else if (message.method === "turn/start") {
|
|
384
|
+
expect(message.params).not.toHaveProperty("dynamicTools")
|
|
385
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).toContain("Codex-native subagent collaboration")
|
|
386
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).toContain("collabAgentToolCall")
|
|
387
|
+
expect(message.params.collaborationMode?.settings?.developer_instructions).not.toContain("spawn_agent creates")
|
|
388
|
+
child.writeServerMessage({
|
|
389
|
+
id: message.id,
|
|
390
|
+
result: { turn: { id: "turn-1", status: "completed", error: null } },
|
|
391
|
+
})
|
|
392
|
+
child.writeServerMessage({
|
|
393
|
+
method: "turn/completed",
|
|
394
|
+
params: {
|
|
395
|
+
threadId: "thread-1",
|
|
396
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
const manager = new CodexAppServerManager({
|
|
403
|
+
spawnProcess: () => process as never,
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
await manager.startSession({
|
|
407
|
+
chatId: "chat-1",
|
|
408
|
+
cwd: "/tmp/project",
|
|
409
|
+
model: "gpt-5.4",
|
|
410
|
+
sessionToken: null,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const turn = await manager.startTurn({
|
|
414
|
+
chatId: "chat-1",
|
|
415
|
+
model: "gpt-5.4",
|
|
416
|
+
content: "delegate work",
|
|
417
|
+
planMode: false,
|
|
418
|
+
orchestrator: {
|
|
419
|
+
async spawnAgent() { return { chatId: "child-1" } },
|
|
420
|
+
listAgents() { return { children: [] } },
|
|
421
|
+
async sendInput() {},
|
|
422
|
+
async waitForResult() { return { result: "done", isError: false } },
|
|
423
|
+
async closeAgent() {},
|
|
424
|
+
},
|
|
425
|
+
orchestrationChatId: "chat-1",
|
|
426
|
+
onToolRequest: async () => ({}),
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await collectStream(turn.stream)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test("generateStructured returns the final assistant JSON and stops the transient session", async () => {
|
|
433
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
434
|
+
if (message.method === "initialize") {
|
|
435
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
436
|
+
} else if (message.method === "thread/start") {
|
|
437
|
+
child.writeServerMessage({
|
|
438
|
+
id: message.id,
|
|
439
|
+
result: { thread: { id: "thread-structured" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
440
|
+
})
|
|
441
|
+
} else if (message.method === "turn/start") {
|
|
442
|
+
child.writeServerMessage({
|
|
443
|
+
id: message.id,
|
|
444
|
+
result: { turn: { id: "turn-structured", status: "completed", error: null } },
|
|
445
|
+
})
|
|
446
|
+
child.writeServerMessage({
|
|
447
|
+
method: "item/completed",
|
|
448
|
+
params: {
|
|
449
|
+
threadId: "thread-structured",
|
|
450
|
+
turnId: "turn-structured",
|
|
451
|
+
item: {
|
|
452
|
+
type: "agentMessage",
|
|
453
|
+
id: "msg-structured",
|
|
454
|
+
text: "{\"title\":\"Codex title\"}",
|
|
455
|
+
phase: "final_answer",
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
})
|
|
459
|
+
child.writeServerMessage({
|
|
460
|
+
method: "turn/completed",
|
|
461
|
+
params: {
|
|
462
|
+
threadId: "thread-structured",
|
|
463
|
+
turn: { id: "turn-structured", status: "completed", error: null },
|
|
464
|
+
},
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
const manager = new CodexAppServerManager({
|
|
470
|
+
spawnProcess: () => process as never,
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const result = await manager.generateStructured({
|
|
474
|
+
cwd: "/tmp/project",
|
|
475
|
+
prompt: "Return JSON",
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
expect(result).toBe("{\"title\":\"Codex title\"}")
|
|
479
|
+
expect(process.killed).toBe(true)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test("generateStructured does not advertise dynamic tools on turn start", async () => {
|
|
483
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
484
|
+
if (message.method === "initialize") {
|
|
485
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
486
|
+
} else if (message.method === "thread/start") {
|
|
487
|
+
child.writeServerMessage({
|
|
488
|
+
id: message.id,
|
|
489
|
+
result: { thread: { id: "thread-structured" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
490
|
+
})
|
|
491
|
+
} else if (message.method === "turn/start") {
|
|
492
|
+
expect(message.params.dynamicTools).toBeUndefined()
|
|
493
|
+
child.writeServerMessage({
|
|
494
|
+
id: message.id,
|
|
495
|
+
result: { turn: { id: "turn-structured", status: "completed", error: null } },
|
|
496
|
+
})
|
|
497
|
+
child.writeServerMessage({
|
|
498
|
+
method: "item/completed",
|
|
499
|
+
params: {
|
|
500
|
+
threadId: "thread-structured",
|
|
501
|
+
turnId: "turn-structured",
|
|
502
|
+
item: {
|
|
503
|
+
type: "agentMessage",
|
|
504
|
+
id: "msg-structured",
|
|
505
|
+
text: "{\"title\":\"Codex title\"}",
|
|
506
|
+
phase: "final_answer",
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
})
|
|
510
|
+
child.writeServerMessage({
|
|
511
|
+
method: "turn/completed",
|
|
512
|
+
params: {
|
|
513
|
+
threadId: "thread-structured",
|
|
514
|
+
turn: { id: "turn-structured", status: "completed", error: null },
|
|
515
|
+
},
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const manager = new CodexAppServerManager({
|
|
521
|
+
spawnProcess: () => process as never,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const result = await manager.generateStructured({
|
|
525
|
+
cwd: "/tmp/project",
|
|
526
|
+
prompt: "Return JSON",
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
expect(result).toBe("{\"title\":\"Codex title\"}")
|
|
530
|
+
expect(process.killed).toBe(true)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test("maps command execution and agent output into the shared transcript stream", async () => {
|
|
534
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
535
|
+
if (message.method === "initialize") {
|
|
536
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
537
|
+
} else if (message.method === "thread/start") {
|
|
538
|
+
child.writeServerMessage({
|
|
539
|
+
id: message.id,
|
|
540
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
541
|
+
})
|
|
542
|
+
} else if (message.method === "turn/start") {
|
|
543
|
+
child.writeServerMessage({
|
|
544
|
+
id: message.id,
|
|
545
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
546
|
+
})
|
|
547
|
+
child.writeServerMessage({
|
|
548
|
+
method: "item/started",
|
|
549
|
+
params: {
|
|
550
|
+
threadId: "thread-1",
|
|
551
|
+
turnId: "turn-1",
|
|
552
|
+
item: {
|
|
553
|
+
type: "commandExecution",
|
|
554
|
+
id: "call-1",
|
|
555
|
+
command: "/bin/zsh -lc pwd",
|
|
556
|
+
status: "inProgress",
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
})
|
|
560
|
+
child.writeServerMessage({
|
|
561
|
+
method: "item/completed",
|
|
562
|
+
params: {
|
|
563
|
+
threadId: "thread-1",
|
|
564
|
+
turnId: "turn-1",
|
|
565
|
+
item: {
|
|
566
|
+
type: "commandExecution",
|
|
567
|
+
id: "call-1",
|
|
568
|
+
command: "/bin/zsh -lc pwd",
|
|
569
|
+
status: "completed",
|
|
570
|
+
aggregatedOutput: "/tmp/project\n",
|
|
571
|
+
exitCode: 0,
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
})
|
|
575
|
+
child.writeServerMessage({
|
|
576
|
+
method: "item/completed",
|
|
577
|
+
params: {
|
|
578
|
+
threadId: "thread-1",
|
|
579
|
+
turnId: "turn-1",
|
|
580
|
+
item: {
|
|
581
|
+
type: "agentMessage",
|
|
582
|
+
id: "msg-1",
|
|
583
|
+
text: "/tmp/project",
|
|
584
|
+
phase: "final_answer",
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
})
|
|
588
|
+
child.writeServerMessage({
|
|
589
|
+
method: "turn/completed",
|
|
590
|
+
params: {
|
|
591
|
+
threadId: "thread-1",
|
|
592
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
593
|
+
},
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
const manager = new CodexAppServerManager({
|
|
599
|
+
spawnProcess: () => process as never,
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
await manager.startSession({
|
|
603
|
+
chatId: "chat-1",
|
|
604
|
+
cwd: "/tmp/project",
|
|
605
|
+
model: "gpt-5.4",
|
|
606
|
+
sessionToken: null,
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
const turn = await manager.startTurn({
|
|
610
|
+
chatId: "chat-1",
|
|
611
|
+
model: "gpt-5.4",
|
|
612
|
+
content: "Run pwd",
|
|
613
|
+
planMode: false,
|
|
614
|
+
onToolRequest: async () => ({}),
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
const events = await collectStream(turn.stream)
|
|
618
|
+
const transcriptKinds = events
|
|
619
|
+
.filter((event) => event.type === "transcript")
|
|
620
|
+
.map((event) => event.entry.kind)
|
|
621
|
+
|
|
622
|
+
expect(events[0]).toEqual({ type: "session_token", sessionToken: "thread-1" })
|
|
623
|
+
expect(transcriptKinds).toEqual(["system_init", "tool_call", "tool_result", "assistant_text", "result"])
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
test("emits only a compact boundary when Codex reports thread compaction", async () => {
|
|
627
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
628
|
+
if (message.method === "initialize") {
|
|
629
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
630
|
+
} else if (message.method === "thread/start") {
|
|
631
|
+
child.writeServerMessage({
|
|
632
|
+
id: message.id,
|
|
633
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
634
|
+
})
|
|
635
|
+
} else if (message.method === "turn/start") {
|
|
636
|
+
child.writeServerMessage({
|
|
637
|
+
id: message.id,
|
|
638
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
639
|
+
})
|
|
640
|
+
child.writeServerMessage({
|
|
641
|
+
method: "thread/compacted",
|
|
642
|
+
params: {
|
|
643
|
+
threadId: "thread-1",
|
|
644
|
+
turnId: "turn-1",
|
|
645
|
+
},
|
|
646
|
+
})
|
|
647
|
+
child.writeServerMessage({
|
|
648
|
+
method: "turn/completed",
|
|
649
|
+
params: {
|
|
650
|
+
threadId: "thread-1",
|
|
651
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
const manager = new CodexAppServerManager({
|
|
658
|
+
spawnProcess: () => process as never,
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
await manager.startSession({
|
|
662
|
+
chatId: "chat-1",
|
|
663
|
+
cwd: "/tmp/project",
|
|
664
|
+
model: "gpt-5.4",
|
|
665
|
+
sessionToken: null,
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
const turn = await manager.startTurn({
|
|
669
|
+
chatId: "chat-1",
|
|
670
|
+
model: "gpt-5.4",
|
|
671
|
+
content: "/compact",
|
|
672
|
+
planMode: false,
|
|
673
|
+
onToolRequest: async () => ({}),
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
const events = await collectStream(turn.stream)
|
|
677
|
+
const transcriptKinds = events
|
|
678
|
+
.filter((event) => event.type === "transcript")
|
|
679
|
+
.map((event) => event.entry.kind)
|
|
680
|
+
|
|
681
|
+
expect(transcriptKinds).toEqual(["system_init", "compact_boundary", "result"])
|
|
682
|
+
expect(transcriptKinds).not.toContain("context_cleared")
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
test("maps fileChange updates into edit_file tool calls", async () => {
|
|
686
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
687
|
+
if (message.method === "initialize") {
|
|
688
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
689
|
+
} else if (message.method === "thread/start") {
|
|
690
|
+
child.writeServerMessage({
|
|
691
|
+
id: message.id,
|
|
692
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
693
|
+
})
|
|
694
|
+
} else if (message.method === "turn/start") {
|
|
695
|
+
child.writeServerMessage({
|
|
696
|
+
id: message.id,
|
|
697
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
698
|
+
})
|
|
699
|
+
child.writeServerMessage({
|
|
700
|
+
method: "item/started",
|
|
701
|
+
params: {
|
|
702
|
+
threadId: "thread-1",
|
|
703
|
+
turnId: "turn-1",
|
|
704
|
+
item: {
|
|
705
|
+
type: "fileChange",
|
|
706
|
+
id: "call-1",
|
|
707
|
+
changes: [
|
|
708
|
+
{
|
|
709
|
+
path: "/tmp/project/test.md",
|
|
710
|
+
kind: {
|
|
711
|
+
type: "update",
|
|
712
|
+
move_path: null,
|
|
713
|
+
},
|
|
714
|
+
diff: "@@ -1,2 +1,2 @@\n-old line\n+new line",
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
status: "inProgress",
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
})
|
|
721
|
+
child.writeServerMessage({
|
|
722
|
+
method: "item/completed",
|
|
723
|
+
params: {
|
|
724
|
+
threadId: "thread-1",
|
|
725
|
+
turnId: "turn-1",
|
|
726
|
+
item: {
|
|
727
|
+
type: "fileChange",
|
|
728
|
+
id: "call-1",
|
|
729
|
+
changes: [
|
|
730
|
+
{
|
|
731
|
+
path: "/tmp/project/test.md",
|
|
732
|
+
kind: {
|
|
733
|
+
type: "update",
|
|
734
|
+
move_path: null,
|
|
735
|
+
},
|
|
736
|
+
diff: "@@ -1,2 +1,2 @@\n-old line\n+new line",
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
status: "completed",
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
})
|
|
743
|
+
child.writeServerMessage({
|
|
744
|
+
method: "turn/completed",
|
|
745
|
+
params: {
|
|
746
|
+
threadId: "thread-1",
|
|
747
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
748
|
+
},
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
const manager = new CodexAppServerManager({
|
|
754
|
+
spawnProcess: () => process as never,
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
await manager.startSession({
|
|
758
|
+
chatId: "chat-1",
|
|
759
|
+
cwd: "/tmp/project",
|
|
760
|
+
model: "gpt-5.4",
|
|
761
|
+
sessionToken: null,
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
const turn = await manager.startTurn({
|
|
765
|
+
chatId: "chat-1",
|
|
766
|
+
model: "gpt-5.4",
|
|
767
|
+
content: "edit a file",
|
|
768
|
+
planMode: false,
|
|
769
|
+
onToolRequest: async () => ({}),
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
const events = await collectStream(turn.stream)
|
|
773
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
774
|
+
|
|
775
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
776
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
777
|
+
expect(toolCall.entry.tool.toolKind).toBe("edit_file")
|
|
778
|
+
expect(toolCall.entry.tool.toolName).toBe("Edit")
|
|
779
|
+
expect(toolCall.entry.tool.input).toEqual({
|
|
780
|
+
filePath: "/tmp/project/test.md",
|
|
781
|
+
oldString: "old line",
|
|
782
|
+
newString: "new line",
|
|
783
|
+
})
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test("maps fileChange adds into write_file tool calls", async () => {
|
|
787
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
788
|
+
if (message.method === "initialize") {
|
|
789
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
790
|
+
} else if (message.method === "thread/start") {
|
|
791
|
+
child.writeServerMessage({
|
|
792
|
+
id: message.id,
|
|
793
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
794
|
+
})
|
|
795
|
+
} else if (message.method === "turn/start") {
|
|
796
|
+
child.writeServerMessage({
|
|
797
|
+
id: message.id,
|
|
798
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
799
|
+
})
|
|
800
|
+
child.writeServerMessage({
|
|
801
|
+
method: "item/started",
|
|
802
|
+
params: {
|
|
803
|
+
threadId: "thread-1",
|
|
804
|
+
turnId: "turn-1",
|
|
805
|
+
item: {
|
|
806
|
+
type: "fileChange",
|
|
807
|
+
id: "call-1",
|
|
808
|
+
changes: [
|
|
809
|
+
{
|
|
810
|
+
path: "/tmp/project/test.md",
|
|
811
|
+
kind: {
|
|
812
|
+
type: "add",
|
|
813
|
+
move_path: null,
|
|
814
|
+
},
|
|
815
|
+
diff: "@@ -0,0 +1,2 @@\n+hello\n+world",
|
|
816
|
+
},
|
|
817
|
+
],
|
|
818
|
+
status: "inProgress",
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
})
|
|
822
|
+
child.writeServerMessage({
|
|
823
|
+
method: "item/completed",
|
|
824
|
+
params: {
|
|
825
|
+
threadId: "thread-1",
|
|
826
|
+
turnId: "turn-1",
|
|
827
|
+
item: {
|
|
828
|
+
type: "fileChange",
|
|
829
|
+
id: "call-1",
|
|
830
|
+
changes: [
|
|
831
|
+
{
|
|
832
|
+
path: "/tmp/project/test.md",
|
|
833
|
+
kind: {
|
|
834
|
+
type: "add",
|
|
835
|
+
move_path: null,
|
|
836
|
+
},
|
|
837
|
+
diff: "@@ -0,0 +1,2 @@\n+hello\n+world",
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
status: "completed",
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
})
|
|
844
|
+
child.writeServerMessage({
|
|
845
|
+
method: "turn/completed",
|
|
846
|
+
params: {
|
|
847
|
+
threadId: "thread-1",
|
|
848
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
849
|
+
},
|
|
850
|
+
})
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
const manager = new CodexAppServerManager({
|
|
855
|
+
spawnProcess: () => process as never,
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
await manager.startSession({
|
|
859
|
+
chatId: "chat-1",
|
|
860
|
+
cwd: "/tmp/project",
|
|
861
|
+
model: "gpt-5.4",
|
|
862
|
+
sessionToken: null,
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
const turn = await manager.startTurn({
|
|
866
|
+
chatId: "chat-1",
|
|
867
|
+
model: "gpt-5.4",
|
|
868
|
+
content: "write a file",
|
|
869
|
+
planMode: false,
|
|
870
|
+
onToolRequest: async () => ({}),
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
const events = await collectStream(turn.stream)
|
|
874
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
875
|
+
|
|
876
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
877
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
878
|
+
expect(toolCall.entry.tool.toolKind).toBe("write_file")
|
|
879
|
+
expect(toolCall.entry.tool.toolName).toBe("Write")
|
|
880
|
+
expect(toolCall.entry.tool.input).toEqual({
|
|
881
|
+
filePath: "/tmp/project/test.md",
|
|
882
|
+
content: "hello\nworld",
|
|
883
|
+
})
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
test("splits multi-change fileChange items into multiple tool calls and results", async () => {
|
|
887
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
888
|
+
if (message.method === "initialize") {
|
|
889
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
890
|
+
} else if (message.method === "thread/start") {
|
|
891
|
+
child.writeServerMessage({
|
|
892
|
+
id: message.id,
|
|
893
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
894
|
+
})
|
|
895
|
+
} else if (message.method === "turn/start") {
|
|
896
|
+
child.writeServerMessage({
|
|
897
|
+
id: message.id,
|
|
898
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
899
|
+
})
|
|
900
|
+
child.writeServerMessage({
|
|
901
|
+
method: "item/completed",
|
|
902
|
+
params: {
|
|
903
|
+
threadId: "thread-1",
|
|
904
|
+
turnId: "turn-1",
|
|
905
|
+
item: {
|
|
906
|
+
type: "fileChange",
|
|
907
|
+
id: "call-1",
|
|
908
|
+
changes: [
|
|
909
|
+
{
|
|
910
|
+
path: "/tmp/project/one.md",
|
|
911
|
+
kind: {
|
|
912
|
+
type: "add",
|
|
913
|
+
move_path: null,
|
|
914
|
+
},
|
|
915
|
+
diff: "@@ -0,0 +1,2 @@\n+hello\n+world",
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
path: "/tmp/project/two.md",
|
|
919
|
+
kind: {
|
|
920
|
+
type: "update",
|
|
921
|
+
move_path: null,
|
|
922
|
+
},
|
|
923
|
+
diff: "@@ -1,2 +1,2 @@\n-old line\n+new line",
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
status: "completed",
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
})
|
|
930
|
+
child.writeServerMessage({
|
|
931
|
+
method: "turn/completed",
|
|
932
|
+
params: {
|
|
933
|
+
threadId: "thread-1",
|
|
934
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
935
|
+
},
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
const manager = new CodexAppServerManager({
|
|
941
|
+
spawnProcess: () => process as never,
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
await manager.startSession({
|
|
945
|
+
chatId: "chat-1",
|
|
946
|
+
cwd: "/tmp/project",
|
|
947
|
+
model: "gpt-5.4",
|
|
948
|
+
sessionToken: null,
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
const turn = await manager.startTurn({
|
|
952
|
+
chatId: "chat-1",
|
|
953
|
+
model: "gpt-5.4",
|
|
954
|
+
content: "change multiple files",
|
|
955
|
+
planMode: false,
|
|
956
|
+
onToolRequest: async () => ({}),
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
const events = await collectStream(turn.stream)
|
|
960
|
+
const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
961
|
+
const toolResults = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
962
|
+
|
|
963
|
+
expect(toolCalls).toHaveLength(2)
|
|
964
|
+
expect(toolResults).toHaveLength(2)
|
|
965
|
+
|
|
966
|
+
expect(toolCalls[0]?.entry.kind).toBe("tool_call")
|
|
967
|
+
expect(toolCalls[1]?.entry.kind).toBe("tool_call")
|
|
968
|
+
if (toolCalls[0]?.entry.kind !== "tool_call" || toolCalls[1]?.entry.kind !== "tool_call") {
|
|
969
|
+
throw new Error("missing tool calls")
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
expect(toolCalls[0].entry.tool.toolKind).toBe("write_file")
|
|
973
|
+
expect(toolCalls[0].entry.tool.toolId).toBe("call-1:change:0")
|
|
974
|
+
expect(toolCalls[0].entry.tool.input).toEqual({
|
|
975
|
+
filePath: "/tmp/project/one.md",
|
|
976
|
+
content: "hello\nworld",
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
expect(toolCalls[1].entry.tool.toolKind).toBe("edit_file")
|
|
980
|
+
expect(toolCalls[1].entry.tool.toolId).toBe("call-1:change:1")
|
|
981
|
+
expect(toolCalls[1].entry.tool.input).toEqual({
|
|
982
|
+
filePath: "/tmp/project/two.md",
|
|
983
|
+
oldString: "old line",
|
|
984
|
+
newString: "new line",
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
expect(toolResults[0]?.entry.kind).toBe("tool_result")
|
|
988
|
+
expect(toolResults[1]?.entry.kind).toBe("tool_result")
|
|
989
|
+
if (toolResults[0]?.entry.kind !== "tool_result" || toolResults[1]?.entry.kind !== "tool_result") {
|
|
990
|
+
throw new Error("missing tool results")
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
expect(toolResults[0].entry.toolId).toBe("call-1:change:0")
|
|
994
|
+
expect(toolResults[1].entry.toolId).toBe("call-1:change:1")
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
test("maps plan updates into TodoWrite and synthesizes ExitPlanMode on successful plan turns", async () => {
|
|
998
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
999
|
+
if (message.method === "initialize") {
|
|
1000
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1001
|
+
} else if (message.method === "thread/start") {
|
|
1002
|
+
child.writeServerMessage({
|
|
1003
|
+
id: message.id,
|
|
1004
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1005
|
+
})
|
|
1006
|
+
} else if (message.method === "turn/start") {
|
|
1007
|
+
child.writeServerMessage({
|
|
1008
|
+
id: message.id,
|
|
1009
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1010
|
+
})
|
|
1011
|
+
child.writeServerMessage({
|
|
1012
|
+
method: "turn/plan/updated",
|
|
1013
|
+
params: {
|
|
1014
|
+
threadId: "thread-1",
|
|
1015
|
+
turnId: "turn-1",
|
|
1016
|
+
explanation: "Plan the work",
|
|
1017
|
+
plan: [
|
|
1018
|
+
{ step: "Inspect repo", status: "completed" },
|
|
1019
|
+
{ step: "Implement changes", status: "inProgress" },
|
|
1020
|
+
],
|
|
1021
|
+
},
|
|
1022
|
+
})
|
|
1023
|
+
child.writeServerMessage({
|
|
1024
|
+
method: "item/started",
|
|
1025
|
+
params: {
|
|
1026
|
+
threadId: "thread-1",
|
|
1027
|
+
turnId: "turn-1",
|
|
1028
|
+
item: {
|
|
1029
|
+
type: "plan",
|
|
1030
|
+
id: "plan-1",
|
|
1031
|
+
text: "",
|
|
1032
|
+
},
|
|
1033
|
+
},
|
|
1034
|
+
})
|
|
1035
|
+
child.writeServerMessage({
|
|
1036
|
+
method: "item/plan/delta",
|
|
1037
|
+
params: {
|
|
1038
|
+
threadId: "thread-1",
|
|
1039
|
+
turnId: "turn-1",
|
|
1040
|
+
itemId: "plan-1",
|
|
1041
|
+
delta: "## Plan\n\n- [x] Inspect repo\n- [ ] Implement changes",
|
|
1042
|
+
},
|
|
1043
|
+
})
|
|
1044
|
+
child.writeServerMessage({
|
|
1045
|
+
method: "turn/completed",
|
|
1046
|
+
params: {
|
|
1047
|
+
threadId: "thread-1",
|
|
1048
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1049
|
+
},
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
const manager = new CodexAppServerManager({
|
|
1055
|
+
spawnProcess: () => process as never,
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
await manager.startSession({
|
|
1059
|
+
chatId: "chat-1",
|
|
1060
|
+
cwd: "/tmp/project",
|
|
1061
|
+
model: "gpt-5.4",
|
|
1062
|
+
sessionToken: null,
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
const turn = await manager.startTurn({
|
|
1066
|
+
chatId: "chat-1",
|
|
1067
|
+
model: "gpt-5.4",
|
|
1068
|
+
content: "make a plan",
|
|
1069
|
+
planMode: true,
|
|
1070
|
+
onToolRequest: async () => ({ confirmed: true }),
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
const events = await collectStream(turn.stream)
|
|
1074
|
+
const toolCalls = events
|
|
1075
|
+
.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1076
|
+
.map((event) => event.entry.tool)
|
|
1077
|
+
|
|
1078
|
+
expect(toolCalls[0]?.toolKind).toBe("todo_write")
|
|
1079
|
+
expect(toolCalls[1]?.toolKind).toBe("exit_plan_mode")
|
|
1080
|
+
if (!toolCalls[1] || toolCalls[1].toolKind !== "exit_plan_mode") {
|
|
1081
|
+
throw new Error("missing ExitPlanMode tool")
|
|
1082
|
+
}
|
|
1083
|
+
expect(toolCalls[1].input.summary).toBe("Plan the work")
|
|
1084
|
+
expect(toolCalls[1].input.plan).toContain("## Plan")
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
test("maps collab agent tool calls into subagent_task", async () => {
|
|
1088
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1089
|
+
if (message.method === "initialize") {
|
|
1090
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1091
|
+
} else if (message.method === "thread/start") {
|
|
1092
|
+
child.writeServerMessage({
|
|
1093
|
+
id: message.id,
|
|
1094
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1095
|
+
})
|
|
1096
|
+
} else if (message.method === "turn/start") {
|
|
1097
|
+
child.writeServerMessage({
|
|
1098
|
+
id: message.id,
|
|
1099
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1100
|
+
})
|
|
1101
|
+
child.writeServerMessage({
|
|
1102
|
+
method: "item/completed",
|
|
1103
|
+
params: {
|
|
1104
|
+
threadId: "thread-1",
|
|
1105
|
+
turnId: "turn-1",
|
|
1106
|
+
item: {
|
|
1107
|
+
type: "collabAgentToolCall",
|
|
1108
|
+
id: "agent-1",
|
|
1109
|
+
tool: "spawnAgent",
|
|
1110
|
+
status: "completed",
|
|
1111
|
+
senderThreadId: "thread-1",
|
|
1112
|
+
receiverThreadIds: ["thread-2"],
|
|
1113
|
+
prompt: "Inspect tests",
|
|
1114
|
+
agentsStates: {
|
|
1115
|
+
"thread-2": { status: "running", message: "Inspecting" },
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
})
|
|
1120
|
+
child.writeServerMessage({
|
|
1121
|
+
method: "turn/completed",
|
|
1122
|
+
params: {
|
|
1123
|
+
threadId: "thread-1",
|
|
1124
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1125
|
+
},
|
|
1126
|
+
})
|
|
1127
|
+
}
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
const manager = new CodexAppServerManager({
|
|
1131
|
+
spawnProcess: () => process as never,
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
await manager.startSession({
|
|
1135
|
+
chatId: "chat-1",
|
|
1136
|
+
cwd: "/tmp/project",
|
|
1137
|
+
model: "gpt-5.4",
|
|
1138
|
+
sessionToken: null,
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
const turn = await manager.startTurn({
|
|
1142
|
+
chatId: "chat-1",
|
|
1143
|
+
model: "gpt-5.4",
|
|
1144
|
+
content: "spawn an agent",
|
|
1145
|
+
planMode: false,
|
|
1146
|
+
onToolRequest: async () => ({}),
|
|
1147
|
+
})
|
|
1148
|
+
|
|
1149
|
+
const events = await collectStream(turn.stream)
|
|
1150
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1151
|
+
|
|
1152
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1153
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1154
|
+
expect(toolCall.entry.tool.toolKind).toBe("subagent_task")
|
|
1155
|
+
expect(toolCall.entry.tool.input).toEqual({ subagentType: "spawnAgent" })
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
test("marks failed collab agent tool calls as transcript errors", async () => {
|
|
1159
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1160
|
+
if (message.method === "initialize") {
|
|
1161
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1162
|
+
} else if (message.method === "thread/start") {
|
|
1163
|
+
child.writeServerMessage({
|
|
1164
|
+
id: message.id,
|
|
1165
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1166
|
+
})
|
|
1167
|
+
} else if (message.method === "turn/start") {
|
|
1168
|
+
child.writeServerMessage({
|
|
1169
|
+
id: message.id,
|
|
1170
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1171
|
+
})
|
|
1172
|
+
child.writeServerMessage({
|
|
1173
|
+
method: "item/completed",
|
|
1174
|
+
params: {
|
|
1175
|
+
threadId: "thread-1",
|
|
1176
|
+
turnId: "turn-1",
|
|
1177
|
+
item: {
|
|
1178
|
+
type: "collabAgentToolCall",
|
|
1179
|
+
id: "agent-failed-1",
|
|
1180
|
+
tool: "spawnAgent",
|
|
1181
|
+
status: "failed",
|
|
1182
|
+
senderThreadId: "thread-1",
|
|
1183
|
+
receiverThreadIds: [],
|
|
1184
|
+
prompt: "Inspect tests",
|
|
1185
|
+
agentsStates: {},
|
|
1186
|
+
error: { message: "spawn failed" },
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
})
|
|
1190
|
+
child.writeServerMessage({
|
|
1191
|
+
method: "turn/completed",
|
|
1192
|
+
params: {
|
|
1193
|
+
threadId: "thread-1",
|
|
1194
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1195
|
+
},
|
|
1196
|
+
})
|
|
1197
|
+
}
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
const manager = new CodexAppServerManager({
|
|
1201
|
+
spawnProcess: () => process as never,
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
await manager.startSession({
|
|
1205
|
+
chatId: "chat-1",
|
|
1206
|
+
cwd: "/tmp/project",
|
|
1207
|
+
model: "gpt-5.4",
|
|
1208
|
+
sessionToken: null,
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
const turn = await manager.startTurn({
|
|
1212
|
+
chatId: "chat-1",
|
|
1213
|
+
model: "gpt-5.4",
|
|
1214
|
+
content: "spawn an agent",
|
|
1215
|
+
planMode: false,
|
|
1216
|
+
onToolRequest: async () => ({}),
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
const events = await collectStream(turn.stream)
|
|
1220
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1221
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1222
|
+
|
|
1223
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1224
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1225
|
+
expect(toolCall.entry.tool.toolKind).toBe("subagent_task")
|
|
1226
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
1227
|
+
if (!toolResult || toolResult.entry.kind !== "tool_result") throw new Error("missing tool result")
|
|
1228
|
+
expect(toolResult.entry.isError).toBe(true)
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
test("uses the completed webSearch query when the started item is empty", async () => {
|
|
1232
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1233
|
+
if (message.method === "initialize") {
|
|
1234
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1235
|
+
} else if (message.method === "thread/start") {
|
|
1236
|
+
child.writeServerMessage({
|
|
1237
|
+
id: message.id,
|
|
1238
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1239
|
+
})
|
|
1240
|
+
} else if (message.method === "turn/start") {
|
|
1241
|
+
child.writeServerMessage({
|
|
1242
|
+
id: message.id,
|
|
1243
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1244
|
+
})
|
|
1245
|
+
child.writeServerMessage({
|
|
1246
|
+
method: "item/started",
|
|
1247
|
+
params: {
|
|
1248
|
+
threadId: "thread-1",
|
|
1249
|
+
turnId: "turn-1",
|
|
1250
|
+
item: {
|
|
1251
|
+
type: "webSearch",
|
|
1252
|
+
id: "ws-1",
|
|
1253
|
+
query: "",
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
1256
|
+
})
|
|
1257
|
+
child.writeServerMessage({
|
|
1258
|
+
method: "item/completed",
|
|
1259
|
+
params: {
|
|
1260
|
+
threadId: "thread-1",
|
|
1261
|
+
turnId: "turn-1",
|
|
1262
|
+
item: {
|
|
1263
|
+
type: "webSearch",
|
|
1264
|
+
id: "ws-1",
|
|
1265
|
+
query: "jake mor",
|
|
1266
|
+
action: {
|
|
1267
|
+
type: "search",
|
|
1268
|
+
query: "jake mor",
|
|
1269
|
+
queries: ["jake mor"],
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
},
|
|
1273
|
+
})
|
|
1274
|
+
child.writeServerMessage({
|
|
1275
|
+
method: "turn/completed",
|
|
1276
|
+
params: {
|
|
1277
|
+
threadId: "thread-1",
|
|
1278
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1279
|
+
},
|
|
1280
|
+
})
|
|
1281
|
+
}
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
const manager = new CodexAppServerManager({
|
|
1285
|
+
spawnProcess: () => process as never,
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
await manager.startSession({
|
|
1289
|
+
chatId: "chat-1",
|
|
1290
|
+
cwd: "/tmp/project",
|
|
1291
|
+
model: "gpt-5.4",
|
|
1292
|
+
sessionToken: null,
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1295
|
+
const turn = await manager.startTurn({
|
|
1296
|
+
chatId: "chat-1",
|
|
1297
|
+
model: "gpt-5.4",
|
|
1298
|
+
content: "search",
|
|
1299
|
+
planMode: false,
|
|
1300
|
+
onToolRequest: async () => ({}),
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
const events = await collectStream(turn.stream)
|
|
1304
|
+
const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1305
|
+
|
|
1306
|
+
expect(toolCalls).toHaveLength(1)
|
|
1307
|
+
const toolCall = toolCalls[0]
|
|
1308
|
+
if (toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1309
|
+
expect(toolCall.entry.tool.toolKind).toBe("web_search")
|
|
1310
|
+
expect(toolCall.entry.tool.input).toEqual({ query: "jake mor" })
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
test("responds to unsupported dynamic tool requests with a generic tool error", async () => {
|
|
1314
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1315
|
+
if (message.method === "initialize") {
|
|
1316
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1317
|
+
} else if (message.method === "thread/start") {
|
|
1318
|
+
child.writeServerMessage({
|
|
1319
|
+
id: message.id,
|
|
1320
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1321
|
+
})
|
|
1322
|
+
} else if (message.method === "turn/start") {
|
|
1323
|
+
child.writeServerMessage({
|
|
1324
|
+
id: message.id,
|
|
1325
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1326
|
+
})
|
|
1327
|
+
child.writeServerMessage({
|
|
1328
|
+
id: "dyn-1",
|
|
1329
|
+
method: "item/tool/call",
|
|
1330
|
+
params: {
|
|
1331
|
+
threadId: "thread-1",
|
|
1332
|
+
turnId: "turn-1",
|
|
1333
|
+
callId: "call-1",
|
|
1334
|
+
tool: "custom_tool",
|
|
1335
|
+
arguments: { value: 1 },
|
|
1336
|
+
},
|
|
1337
|
+
})
|
|
1338
|
+
child.writeServerMessage({
|
|
1339
|
+
method: "turn/completed",
|
|
1340
|
+
params: {
|
|
1341
|
+
threadId: "thread-1",
|
|
1342
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1343
|
+
},
|
|
1344
|
+
})
|
|
1345
|
+
}
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
const manager = new CodexAppServerManager({
|
|
1349
|
+
spawnProcess: () => process as never,
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
await manager.startSession({
|
|
1353
|
+
chatId: "chat-1",
|
|
1354
|
+
cwd: "/tmp/project",
|
|
1355
|
+
model: "gpt-5.4",
|
|
1356
|
+
sessionToken: null,
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
const turn = await manager.startTurn({
|
|
1360
|
+
chatId: "chat-1",
|
|
1361
|
+
model: "gpt-5.4",
|
|
1362
|
+
content: "call tool",
|
|
1363
|
+
planMode: false,
|
|
1364
|
+
onToolRequest: async () => ({}),
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
const events = await collectStream(turn.stream)
|
|
1368
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1369
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1370
|
+
const response = process.messages.find((message: any) => message.id === "dyn-1")
|
|
1371
|
+
|
|
1372
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1373
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1374
|
+
expect(toolCall.entry.tool.toolKind).toBe("unknown_tool")
|
|
1375
|
+
expect(toolCall.entry.tool.toolName).toBe("custom_tool")
|
|
1376
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
1377
|
+
expect(response).toEqual({
|
|
1378
|
+
id: "dyn-1",
|
|
1379
|
+
result: {
|
|
1380
|
+
contentItems: [{ type: "inputText", text: "Unsupported dynamic tool call: custom_tool" }],
|
|
1381
|
+
success: false,
|
|
1382
|
+
},
|
|
1383
|
+
})
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
test("records present_content dynamic tool calls as typed transcript entries", async () => {
|
|
1387
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1388
|
+
if (message.method === "initialize") {
|
|
1389
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1390
|
+
return
|
|
1391
|
+
}
|
|
1392
|
+
if (message.method === "thread/start") {
|
|
1393
|
+
child.writeServerMessage({
|
|
1394
|
+
id: message.id,
|
|
1395
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1396
|
+
})
|
|
1397
|
+
return
|
|
1398
|
+
}
|
|
1399
|
+
if (message.method === "turn/start") {
|
|
1400
|
+
child.writeServerMessage({
|
|
1401
|
+
id: message.id,
|
|
1402
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1403
|
+
})
|
|
1404
|
+
child.writeServerMessage({
|
|
1405
|
+
id: "dyn-2",
|
|
1406
|
+
method: "item/tool/call",
|
|
1407
|
+
params: {
|
|
1408
|
+
threadId: "thread-1",
|
|
1409
|
+
turnId: "turn-1",
|
|
1410
|
+
callId: "call-present-1",
|
|
1411
|
+
tool: "present_content",
|
|
1412
|
+
arguments: {
|
|
1413
|
+
title: "System Design",
|
|
1414
|
+
kind: "diagram",
|
|
1415
|
+
format: "mermaid",
|
|
1416
|
+
source: "graph TD\\nA-->B",
|
|
1417
|
+
summary: "Current state",
|
|
1418
|
+
collapsed: true,
|
|
1419
|
+
},
|
|
1420
|
+
},
|
|
1421
|
+
})
|
|
1422
|
+
child.writeServerMessage({
|
|
1423
|
+
method: "turn/completed",
|
|
1424
|
+
params: {
|
|
1425
|
+
threadId: "thread-1",
|
|
1426
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1427
|
+
},
|
|
1428
|
+
})
|
|
1429
|
+
}
|
|
1430
|
+
})
|
|
1431
|
+
|
|
1432
|
+
const manager = new CodexAppServerManager({
|
|
1433
|
+
spawnProcess: () => process as never,
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
await manager.startSession({
|
|
1437
|
+
chatId: "chat-1",
|
|
1438
|
+
cwd: "/tmp/project",
|
|
1439
|
+
model: "gpt-5.4",
|
|
1440
|
+
sessionToken: null,
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
const turn = await manager.startTurn({
|
|
1444
|
+
chatId: "chat-1",
|
|
1445
|
+
model: "gpt-5.4",
|
|
1446
|
+
content: "show me the system",
|
|
1447
|
+
planMode: false,
|
|
1448
|
+
onToolRequest: async () => ({}),
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
const events = await collectStream(turn.stream)
|
|
1452
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1453
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1454
|
+
const response = process.messages.find((message: any) => message.id === "dyn-2")
|
|
1455
|
+
|
|
1456
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1457
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1458
|
+
expect(toolCall.entry.tool.toolKind).toBe("present_content")
|
|
1459
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
1460
|
+
expect(toolResult?.entry.content).toEqual({
|
|
1461
|
+
accepted: true,
|
|
1462
|
+
title: "System Design",
|
|
1463
|
+
kind: "diagram",
|
|
1464
|
+
format: "mermaid",
|
|
1465
|
+
source: "graph TD\\nA-->B",
|
|
1466
|
+
summary: "Current state",
|
|
1467
|
+
collapsed: true,
|
|
1468
|
+
})
|
|
1469
|
+
expect(response).toEqual({
|
|
1470
|
+
id: "dyn-2",
|
|
1471
|
+
result: {
|
|
1472
|
+
contentItems: [{ type: "inputText", text: "presented" }],
|
|
1473
|
+
success: true,
|
|
1474
|
+
},
|
|
1475
|
+
})
|
|
1476
|
+
})
|
|
1477
|
+
|
|
1478
|
+
test("routes ask_user_question dynamic tool calls through pending user input", async () => {
|
|
1479
|
+
let toolRequest: unknown
|
|
1480
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1481
|
+
if (message.method === "initialize") {
|
|
1482
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1483
|
+
return
|
|
1484
|
+
}
|
|
1485
|
+
if (message.method === "thread/start") {
|
|
1486
|
+
child.writeServerMessage({
|
|
1487
|
+
id: message.id,
|
|
1488
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1489
|
+
})
|
|
1490
|
+
return
|
|
1491
|
+
}
|
|
1492
|
+
if (message.method === "turn/start") {
|
|
1493
|
+
child.writeServerMessage({
|
|
1494
|
+
id: message.id,
|
|
1495
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1496
|
+
})
|
|
1497
|
+
child.writeServerMessage({
|
|
1498
|
+
id: "dyn-ask-1",
|
|
1499
|
+
method: "item/tool/call",
|
|
1500
|
+
params: {
|
|
1501
|
+
threadId: "thread-1",
|
|
1502
|
+
turnId: "turn-1",
|
|
1503
|
+
callId: "call-ask-1",
|
|
1504
|
+
tool: "ask_user_question",
|
|
1505
|
+
arguments: {
|
|
1506
|
+
questions: [{
|
|
1507
|
+
id: "runtime",
|
|
1508
|
+
header: "Runtime",
|
|
1509
|
+
question: "Which runtime?",
|
|
1510
|
+
options: [
|
|
1511
|
+
{ label: "Bun", description: "Fast JS runtime" },
|
|
1512
|
+
{ label: "Node" },
|
|
1513
|
+
],
|
|
1514
|
+
}],
|
|
1515
|
+
},
|
|
1516
|
+
},
|
|
1517
|
+
})
|
|
1518
|
+
child.writeServerMessage({
|
|
1519
|
+
method: "turn/completed",
|
|
1520
|
+
params: {
|
|
1521
|
+
threadId: "thread-1",
|
|
1522
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1523
|
+
},
|
|
1524
|
+
})
|
|
1525
|
+
}
|
|
1526
|
+
})
|
|
1527
|
+
|
|
1528
|
+
const manager = new CodexAppServerManager({
|
|
1529
|
+
spawnProcess: () => process as never,
|
|
1530
|
+
})
|
|
1531
|
+
|
|
1532
|
+
await manager.startSession({
|
|
1533
|
+
chatId: "chat-1",
|
|
1534
|
+
cwd: "/tmp/project",
|
|
1535
|
+
model: "gpt-5.4",
|
|
1536
|
+
sessionToken: null,
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
const turn = await manager.startTurn({
|
|
1540
|
+
chatId: "chat-1",
|
|
1541
|
+
model: "gpt-5.4",
|
|
1542
|
+
content: "ask runtime",
|
|
1543
|
+
planMode: false,
|
|
1544
|
+
onToolRequest: async (request) => {
|
|
1545
|
+
if (request.tool.toolKind !== "ask_user_question") {
|
|
1546
|
+
throw new Error("unexpected tool request")
|
|
1547
|
+
}
|
|
1548
|
+
toolRequest = request
|
|
1549
|
+
return {
|
|
1550
|
+
questions: request.tool.input.questions,
|
|
1551
|
+
answers: {
|
|
1552
|
+
runtime: ["Bun"],
|
|
1553
|
+
},
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
})
|
|
1557
|
+
|
|
1558
|
+
const events = await collectStream(turn.stream)
|
|
1559
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1560
|
+
const toolResults = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1561
|
+
const response = process.messages.find((message: any) => message.id === "dyn-ask-1")
|
|
1562
|
+
|
|
1563
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1564
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1565
|
+
expect(toolCall.entry.tool.toolKind).toBe("ask_user_question")
|
|
1566
|
+
expect(toolCall.entry.tool.toolName).toBe("AskUserQuestion")
|
|
1567
|
+
expect(toolCall.entry.tool.toolId).toBe("call-ask-1")
|
|
1568
|
+
expect(toolCall.entry.tool.input.questions).toEqual([{
|
|
1569
|
+
id: "runtime",
|
|
1570
|
+
header: "Runtime",
|
|
1571
|
+
question: "Which runtime?",
|
|
1572
|
+
options: [
|
|
1573
|
+
{ label: "Bun", description: "Fast JS runtime" },
|
|
1574
|
+
{ label: "Node" },
|
|
1575
|
+
],
|
|
1576
|
+
}])
|
|
1577
|
+
expect(toolRequest).toEqual({
|
|
1578
|
+
tool: expect.objectContaining({
|
|
1579
|
+
toolKind: "ask_user_question",
|
|
1580
|
+
toolName: "AskUserQuestion",
|
|
1581
|
+
toolId: "call-ask-1",
|
|
1582
|
+
}),
|
|
1583
|
+
})
|
|
1584
|
+
expect(toolResults).toEqual([])
|
|
1585
|
+
expect(response).toEqual({
|
|
1586
|
+
id: "dyn-ask-1",
|
|
1587
|
+
result: {
|
|
1588
|
+
contentItems: [{ type: "inputText", text: JSON.stringify({ questions: toolCall.entry.tool.input.questions, answers: { runtime: ["Bun"] } }) }],
|
|
1589
|
+
success: true,
|
|
1590
|
+
},
|
|
1591
|
+
})
|
|
1592
|
+
})
|
|
1593
|
+
|
|
1594
|
+
test("auto-generates ask_user_question IDs and preserves multi-select", async () => {
|
|
1595
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1596
|
+
if (message.method === "initialize") {
|
|
1597
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1598
|
+
return
|
|
1599
|
+
}
|
|
1600
|
+
if (message.method === "thread/start") {
|
|
1601
|
+
child.writeServerMessage({
|
|
1602
|
+
id: message.id,
|
|
1603
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1604
|
+
})
|
|
1605
|
+
return
|
|
1606
|
+
}
|
|
1607
|
+
if (message.method === "turn/start") {
|
|
1608
|
+
child.writeServerMessage({
|
|
1609
|
+
id: message.id,
|
|
1610
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1611
|
+
})
|
|
1612
|
+
child.writeServerMessage({
|
|
1613
|
+
id: "dyn-ask-2",
|
|
1614
|
+
method: "item/tool/call",
|
|
1615
|
+
params: {
|
|
1616
|
+
threadId: "thread-1",
|
|
1617
|
+
turnId: "turn-1",
|
|
1618
|
+
callId: "call-ask-2",
|
|
1619
|
+
tool: "ask_user_question",
|
|
1620
|
+
arguments: {
|
|
1621
|
+
questions: [
|
|
1622
|
+
{ question: "First?" },
|
|
1623
|
+
{ question: "Pick many?", multiSelect: true, options: [{ label: "A" }, { label: "B" }] },
|
|
1624
|
+
],
|
|
1625
|
+
},
|
|
1626
|
+
},
|
|
1627
|
+
})
|
|
1628
|
+
child.writeServerMessage({
|
|
1629
|
+
method: "turn/completed",
|
|
1630
|
+
params: {
|
|
1631
|
+
threadId: "thread-1",
|
|
1632
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1633
|
+
},
|
|
1634
|
+
})
|
|
1635
|
+
}
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1638
|
+
const manager = new CodexAppServerManager({
|
|
1639
|
+
spawnProcess: () => process as never,
|
|
1640
|
+
})
|
|
1641
|
+
|
|
1642
|
+
await manager.startSession({
|
|
1643
|
+
chatId: "chat-1",
|
|
1644
|
+
cwd: "/tmp/project",
|
|
1645
|
+
model: "gpt-5.4",
|
|
1646
|
+
sessionToken: null,
|
|
1647
|
+
})
|
|
1648
|
+
|
|
1649
|
+
const turn = await manager.startTurn({
|
|
1650
|
+
chatId: "chat-1",
|
|
1651
|
+
model: "gpt-5.4",
|
|
1652
|
+
content: "ask choices",
|
|
1653
|
+
planMode: false,
|
|
1654
|
+
onToolRequest: async (request) => {
|
|
1655
|
+
if (request.tool.toolKind !== "ask_user_question") {
|
|
1656
|
+
throw new Error("unexpected tool request")
|
|
1657
|
+
}
|
|
1658
|
+
return {
|
|
1659
|
+
questions: request.tool.input.questions,
|
|
1660
|
+
answers: {
|
|
1661
|
+
"q-0": ["ok"],
|
|
1662
|
+
"q-1": ["A", "B"],
|
|
1663
|
+
},
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
})
|
|
1667
|
+
|
|
1668
|
+
const events = await collectStream(turn.stream)
|
|
1669
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1670
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1671
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1672
|
+
expect(toolCall.entry.tool.input.questions).toEqual([
|
|
1673
|
+
{ id: "q-0", question: "First?" },
|
|
1674
|
+
{ id: "q-1", question: "Pick many?", multiSelect: true, options: [{ label: "A" }, { label: "B" }] },
|
|
1675
|
+
])
|
|
1676
|
+
})
|
|
1677
|
+
|
|
1678
|
+
test("rejects invalid ask_user_question dynamic payloads without waiting for user input", async () => {
|
|
1679
|
+
let toolRequestCount = 0
|
|
1680
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1681
|
+
if (message.method === "initialize") {
|
|
1682
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1683
|
+
return
|
|
1684
|
+
}
|
|
1685
|
+
if (message.method === "thread/start") {
|
|
1686
|
+
child.writeServerMessage({
|
|
1687
|
+
id: message.id,
|
|
1688
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1689
|
+
})
|
|
1690
|
+
return
|
|
1691
|
+
}
|
|
1692
|
+
if (message.method === "turn/start") {
|
|
1693
|
+
child.writeServerMessage({
|
|
1694
|
+
id: message.id,
|
|
1695
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1696
|
+
})
|
|
1697
|
+
child.writeServerMessage({
|
|
1698
|
+
id: "dyn-ask-invalid",
|
|
1699
|
+
method: "item/tool/call",
|
|
1700
|
+
params: {
|
|
1701
|
+
threadId: "thread-1",
|
|
1702
|
+
turnId: "turn-1",
|
|
1703
|
+
callId: "call-ask-invalid",
|
|
1704
|
+
tool: "ask_user_question",
|
|
1705
|
+
arguments: { questions: [] },
|
|
1706
|
+
},
|
|
1707
|
+
})
|
|
1708
|
+
child.writeServerMessage({
|
|
1709
|
+
method: "turn/completed",
|
|
1710
|
+
params: {
|
|
1711
|
+
threadId: "thread-1",
|
|
1712
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1713
|
+
},
|
|
1714
|
+
})
|
|
1715
|
+
}
|
|
1716
|
+
})
|
|
1717
|
+
|
|
1718
|
+
const manager = new CodexAppServerManager({
|
|
1719
|
+
spawnProcess: () => process as never,
|
|
1720
|
+
})
|
|
1721
|
+
|
|
1722
|
+
await manager.startSession({
|
|
1723
|
+
chatId: "chat-1",
|
|
1724
|
+
cwd: "/tmp/project",
|
|
1725
|
+
model: "gpt-5.4",
|
|
1726
|
+
sessionToken: null,
|
|
1727
|
+
})
|
|
1728
|
+
|
|
1729
|
+
const turn = await manager.startTurn({
|
|
1730
|
+
chatId: "chat-1",
|
|
1731
|
+
model: "gpt-5.4",
|
|
1732
|
+
content: "ask invalid",
|
|
1733
|
+
planMode: false,
|
|
1734
|
+
onToolRequest: async () => {
|
|
1735
|
+
toolRequestCount += 1
|
|
1736
|
+
return {}
|
|
1737
|
+
},
|
|
1738
|
+
})
|
|
1739
|
+
|
|
1740
|
+
const events = await collectStream(turn.stream)
|
|
1741
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1742
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1743
|
+
const response = process.messages.find((message: any) => message.id === "dyn-ask-invalid")
|
|
1744
|
+
|
|
1745
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1746
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1747
|
+
expect(toolCall.entry.tool.toolKind).toBe("ask_user_question")
|
|
1748
|
+
expect(toolRequestCount).toBe(0)
|
|
1749
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
1750
|
+
if (!toolResult || toolResult.entry.kind !== "tool_result") throw new Error("missing tool result")
|
|
1751
|
+
expect(toolResult.entry.isError).toBe(true)
|
|
1752
|
+
expect(response).toEqual({
|
|
1753
|
+
id: "dyn-ask-invalid",
|
|
1754
|
+
result: {
|
|
1755
|
+
contentItems: [{ type: "inputText", text: "Invalid ask_user_question payload" }],
|
|
1756
|
+
success: false,
|
|
1757
|
+
},
|
|
1758
|
+
})
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
test("routes session orchestration dynamic tools through the shared orchestrator", async () => {
|
|
1762
|
+
const spawnCalls: unknown[] = []
|
|
1763
|
+
const sendCalls: unknown[] = []
|
|
1764
|
+
const waitCalls: unknown[] = []
|
|
1765
|
+
const closeCalls: unknown[] = []
|
|
1766
|
+
const listCalls: string[] = []
|
|
1767
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1768
|
+
if (message.method === "initialize") {
|
|
1769
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1770
|
+
return
|
|
1771
|
+
}
|
|
1772
|
+
if (message.method === "thread/start") {
|
|
1773
|
+
child.writeServerMessage({
|
|
1774
|
+
id: message.id,
|
|
1775
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1776
|
+
})
|
|
1777
|
+
return
|
|
1778
|
+
}
|
|
1779
|
+
if (message.method === "turn/start") {
|
|
1780
|
+
child.writeServerMessage({
|
|
1781
|
+
id: message.id,
|
|
1782
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1783
|
+
})
|
|
1784
|
+
child.writeServerMessage({
|
|
1785
|
+
id: "dyn-spawn",
|
|
1786
|
+
method: "item/tool/call",
|
|
1787
|
+
params: {
|
|
1788
|
+
threadId: "thread-1",
|
|
1789
|
+
turnId: "turn-1",
|
|
1790
|
+
callId: "call-spawn",
|
|
1791
|
+
tool: "spawn_agent",
|
|
1792
|
+
arguments: { instruction: "say hello", provider: "claude", fork_context: true },
|
|
1793
|
+
},
|
|
1794
|
+
})
|
|
1795
|
+
child.writeServerMessage({
|
|
1796
|
+
id: "dyn-list",
|
|
1797
|
+
method: "item/tool/call",
|
|
1798
|
+
params: {
|
|
1799
|
+
threadId: "thread-1",
|
|
1800
|
+
turnId: "turn-1",
|
|
1801
|
+
callId: "call-list",
|
|
1802
|
+
tool: "list_agents",
|
|
1803
|
+
arguments: {},
|
|
1804
|
+
},
|
|
1805
|
+
})
|
|
1806
|
+
child.writeServerMessage({
|
|
1807
|
+
id: "dyn-send",
|
|
1808
|
+
method: "item/tool/call",
|
|
1809
|
+
params: {
|
|
1810
|
+
threadId: "thread-1",
|
|
1811
|
+
turnId: "turn-1",
|
|
1812
|
+
callId: "call-send",
|
|
1813
|
+
tool: "send_input",
|
|
1814
|
+
arguments: { targetChatId: "child-1", content: "continue" },
|
|
1815
|
+
},
|
|
1816
|
+
})
|
|
1817
|
+
child.writeServerMessage({
|
|
1818
|
+
id: "dyn-wait",
|
|
1819
|
+
method: "item/tool/call",
|
|
1820
|
+
params: {
|
|
1821
|
+
threadId: "thread-1",
|
|
1822
|
+
turnId: "turn-1",
|
|
1823
|
+
callId: "call-wait",
|
|
1824
|
+
tool: "wait_agent",
|
|
1825
|
+
arguments: { targetChatId: "child-1", timeoutMs: 25 },
|
|
1826
|
+
},
|
|
1827
|
+
})
|
|
1828
|
+
child.writeServerMessage({
|
|
1829
|
+
id: "dyn-close",
|
|
1830
|
+
method: "item/tool/call",
|
|
1831
|
+
params: {
|
|
1832
|
+
threadId: "thread-1",
|
|
1833
|
+
turnId: "turn-1",
|
|
1834
|
+
callId: "call-close",
|
|
1835
|
+
tool: "close_agent",
|
|
1836
|
+
arguments: { targetChatId: "child-1" },
|
|
1837
|
+
},
|
|
1838
|
+
})
|
|
1839
|
+
child.writeServerMessage({
|
|
1840
|
+
method: "turn/completed",
|
|
1841
|
+
params: {
|
|
1842
|
+
threadId: "thread-1",
|
|
1843
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1844
|
+
},
|
|
1845
|
+
})
|
|
1846
|
+
}
|
|
1847
|
+
})
|
|
1848
|
+
|
|
1849
|
+
const manager = new CodexAppServerManager({
|
|
1850
|
+
spawnProcess: () => process as never,
|
|
1851
|
+
})
|
|
1852
|
+
|
|
1853
|
+
await manager.startSession({
|
|
1854
|
+
chatId: "chat-1",
|
|
1855
|
+
cwd: "/tmp/project",
|
|
1856
|
+
model: "gpt-5.4",
|
|
1857
|
+
sessionToken: null,
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
const turn = await manager.startTurn({
|
|
1861
|
+
chatId: "chat-1",
|
|
1862
|
+
model: "gpt-5.4",
|
|
1863
|
+
content: "delegate work",
|
|
1864
|
+
planMode: false,
|
|
1865
|
+
orchestrator: {
|
|
1866
|
+
async spawnAgent(callerChatId, args) {
|
|
1867
|
+
spawnCalls.push({ callerChatId, args })
|
|
1868
|
+
return { chatId: "child-1" }
|
|
1869
|
+
},
|
|
1870
|
+
listAgents(chatId) {
|
|
1871
|
+
listCalls.push(chatId)
|
|
1872
|
+
return { children: [{ chatId: "child-1", status: "running" }] }
|
|
1873
|
+
},
|
|
1874
|
+
async sendInput(callerChatId, args) {
|
|
1875
|
+
sendCalls.push({ callerChatId, args })
|
|
1876
|
+
},
|
|
1877
|
+
async waitForResult(callerChatId, args) {
|
|
1878
|
+
waitCalls.push({ callerChatId, args })
|
|
1879
|
+
return { result: "child done", isError: false }
|
|
1880
|
+
},
|
|
1881
|
+
async closeAgent(callerChatId, args) {
|
|
1882
|
+
closeCalls.push({ callerChatId, args })
|
|
1883
|
+
},
|
|
1884
|
+
},
|
|
1885
|
+
orchestrationChatId: "chat-1",
|
|
1886
|
+
onToolRequest: async () => ({}),
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
const events = await collectStream(turn.stream)
|
|
1890
|
+
const toolCalls = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1891
|
+
const toolResults = events.filter((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1892
|
+
|
|
1893
|
+
expect(toolCalls.map((event) => event.entry.tool.toolKind)).toEqual([
|
|
1894
|
+
"mcp_generic",
|
|
1895
|
+
"mcp_generic",
|
|
1896
|
+
"mcp_generic",
|
|
1897
|
+
"mcp_generic",
|
|
1898
|
+
"mcp_generic",
|
|
1899
|
+
])
|
|
1900
|
+
expect(toolCalls.map((event) => event.entry.tool.toolName)).toEqual([
|
|
1901
|
+
"spawn_agent",
|
|
1902
|
+
"list_agents",
|
|
1903
|
+
"send_input",
|
|
1904
|
+
"wait_agent",
|
|
1905
|
+
"close_agent",
|
|
1906
|
+
])
|
|
1907
|
+
expect(spawnCalls).toEqual([{ callerChatId: "chat-1", args: { instruction: "say hello", provider: "claude", forkContext: true, model: undefined } }])
|
|
1908
|
+
expect(listCalls).toEqual(["chat-1"])
|
|
1909
|
+
expect(sendCalls).toEqual([{ callerChatId: "chat-1", args: { targetChatId: "child-1", content: "continue", model: undefined } }])
|
|
1910
|
+
expect(waitCalls).toEqual([{ callerChatId: "chat-1", args: { targetChatId: "child-1", timeoutMs: 25 } }])
|
|
1911
|
+
expect(closeCalls).toEqual([{ callerChatId: "chat-1", args: { targetChatId: "child-1" } }])
|
|
1912
|
+
expect(toolResults.map((event) => event.entry.content)).toEqual([
|
|
1913
|
+
{ chatId: "child-1" },
|
|
1914
|
+
{ children: [{ chatId: "child-1", status: "running" }] },
|
|
1915
|
+
"Input sent",
|
|
1916
|
+
{ result: "child done", isError: false },
|
|
1917
|
+
"Agent closed",
|
|
1918
|
+
])
|
|
1919
|
+
expect(process.messages.filter((message: any) => typeof message.id === "string" && String(message.id).startsWith("dyn-"))).toEqual([
|
|
1920
|
+
{ id: "dyn-spawn", result: { contentItems: [{ type: "inputText", text: "{\"chatId\":\"child-1\"}" }], success: true } },
|
|
1921
|
+
{ id: "dyn-list", result: { contentItems: [{ type: "inputText", text: "{\"children\":[{\"chatId\":\"child-1\",\"status\":\"running\"}]}" }], success: true } },
|
|
1922
|
+
{ id: "dyn-send", result: { contentItems: [{ type: "inputText", text: "Input sent" }], success: true } },
|
|
1923
|
+
{ id: "dyn-wait", result: { contentItems: [{ type: "inputText", text: "{\"result\":\"child done\",\"isError\":false}" }], success: true } },
|
|
1924
|
+
{ id: "dyn-close", result: { contentItems: [{ type: "inputText", text: "Agent closed" }], success: true } },
|
|
1925
|
+
])
|
|
1926
|
+
})
|
|
1927
|
+
|
|
1928
|
+
test("marks failed MCP tool calls as transcript errors", async () => {
|
|
1929
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
1930
|
+
if (message.method === "initialize") {
|
|
1931
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1932
|
+
return
|
|
1933
|
+
}
|
|
1934
|
+
if (message.method === "thread/start") {
|
|
1935
|
+
child.writeServerMessage({
|
|
1936
|
+
id: message.id,
|
|
1937
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1938
|
+
})
|
|
1939
|
+
return
|
|
1940
|
+
}
|
|
1941
|
+
if (message.method === "turn/start") {
|
|
1942
|
+
child.writeServerMessage({
|
|
1943
|
+
id: message.id,
|
|
1944
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
1945
|
+
})
|
|
1946
|
+
child.writeServerMessage({
|
|
1947
|
+
method: "item/completed",
|
|
1948
|
+
params: {
|
|
1949
|
+
threadId: "thread-1",
|
|
1950
|
+
turnId: "turn-1",
|
|
1951
|
+
item: {
|
|
1952
|
+
type: "mcpToolCall",
|
|
1953
|
+
id: "mcp-1",
|
|
1954
|
+
server: "sentry",
|
|
1955
|
+
tool: "search_issues",
|
|
1956
|
+
arguments: { query: "regression" },
|
|
1957
|
+
status: "failed",
|
|
1958
|
+
content: [{ type: "input_text", text: "MCP server unavailable" }],
|
|
1959
|
+
},
|
|
1960
|
+
},
|
|
1961
|
+
})
|
|
1962
|
+
child.writeServerMessage({
|
|
1963
|
+
method: "turn/completed",
|
|
1964
|
+
params: {
|
|
1965
|
+
threadId: "thread-1",
|
|
1966
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
1967
|
+
},
|
|
1968
|
+
})
|
|
1969
|
+
}
|
|
1970
|
+
})
|
|
1971
|
+
|
|
1972
|
+
const manager = new CodexAppServerManager({
|
|
1973
|
+
spawnProcess: () => process as never,
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
await manager.startSession({
|
|
1977
|
+
chatId: "chat-1",
|
|
1978
|
+
cwd: "/tmp/project",
|
|
1979
|
+
model: "gpt-5.4",
|
|
1980
|
+
sessionToken: null,
|
|
1981
|
+
})
|
|
1982
|
+
|
|
1983
|
+
const turn = await manager.startTurn({
|
|
1984
|
+
chatId: "chat-1",
|
|
1985
|
+
model: "gpt-5.4",
|
|
1986
|
+
content: "call mcp",
|
|
1987
|
+
planMode: false,
|
|
1988
|
+
onToolRequest: async () => ({}),
|
|
1989
|
+
})
|
|
1990
|
+
|
|
1991
|
+
const events = await collectStream(turn.stream)
|
|
1992
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
1993
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
1994
|
+
|
|
1995
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
1996
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
1997
|
+
expect(toolCall.entry.tool.toolKind).toBe("mcp_generic")
|
|
1998
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
1999
|
+
if (!toolResult || toolResult.entry.kind !== "tool_result") throw new Error("missing tool result")
|
|
2000
|
+
expect(toolResult.entry.isError).toBe(true)
|
|
2001
|
+
})
|
|
2002
|
+
|
|
2003
|
+
test("rejects present_content payloads with wrong optional types without crashing the turn", async () => {
|
|
2004
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2005
|
+
if (message.method === "initialize") {
|
|
2006
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2007
|
+
return
|
|
2008
|
+
}
|
|
2009
|
+
if (message.method === "thread/start") {
|
|
2010
|
+
child.writeServerMessage({
|
|
2011
|
+
id: message.id,
|
|
2012
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2013
|
+
})
|
|
2014
|
+
return
|
|
2015
|
+
}
|
|
2016
|
+
if (message.method === "turn/start") {
|
|
2017
|
+
child.writeServerMessage({
|
|
2018
|
+
id: message.id,
|
|
2019
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2020
|
+
})
|
|
2021
|
+
child.writeServerMessage({
|
|
2022
|
+
id: "dyn-3",
|
|
2023
|
+
method: "item/tool/call",
|
|
2024
|
+
params: {
|
|
2025
|
+
threadId: "thread-1",
|
|
2026
|
+
turnId: "turn-1",
|
|
2027
|
+
callId: "call-present-invalid-1",
|
|
2028
|
+
tool: "present_content",
|
|
2029
|
+
arguments: {
|
|
2030
|
+
title: "System Design",
|
|
2031
|
+
kind: "diagram",
|
|
2032
|
+
format: "mermaid",
|
|
2033
|
+
source: "graph TD\\nA-->B",
|
|
2034
|
+
summary: 42,
|
|
2035
|
+
},
|
|
2036
|
+
},
|
|
2037
|
+
})
|
|
2038
|
+
child.writeServerMessage({
|
|
2039
|
+
method: "turn/completed",
|
|
2040
|
+
params: {
|
|
2041
|
+
threadId: "thread-1",
|
|
2042
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2043
|
+
},
|
|
2044
|
+
})
|
|
2045
|
+
}
|
|
2046
|
+
})
|
|
2047
|
+
|
|
2048
|
+
const manager = new CodexAppServerManager({
|
|
2049
|
+
spawnProcess: () => process as never,
|
|
2050
|
+
})
|
|
2051
|
+
|
|
2052
|
+
await manager.startSession({
|
|
2053
|
+
chatId: "chat-1",
|
|
2054
|
+
cwd: "/tmp/project",
|
|
2055
|
+
model: "gpt-5.4",
|
|
2056
|
+
sessionToken: null,
|
|
2057
|
+
})
|
|
2058
|
+
|
|
2059
|
+
const turn = await manager.startTurn({
|
|
2060
|
+
chatId: "chat-1",
|
|
2061
|
+
model: "gpt-5.4",
|
|
2062
|
+
content: "show invalid card",
|
|
2063
|
+
planMode: false,
|
|
2064
|
+
onToolRequest: async () => ({}),
|
|
2065
|
+
})
|
|
2066
|
+
|
|
2067
|
+
const events = await collectStream(turn.stream)
|
|
2068
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
2069
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
2070
|
+
const response = process.messages.find((message: any) => message.id === "dyn-3")
|
|
2071
|
+
|
|
2072
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
2073
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
2074
|
+
expect(toolCall.entry.tool.toolKind).toBe("present_content")
|
|
2075
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
2076
|
+
if (!toolResult || toolResult.entry.kind !== "tool_result") throw new Error("missing tool result")
|
|
2077
|
+
expect(toolResult.entry.isError).toBe(true)
|
|
2078
|
+
expectPresentContentSchemaValidationError(toolResult.entry.content)
|
|
2079
|
+
expect(response).toEqual({
|
|
2080
|
+
id: "dyn-3",
|
|
2081
|
+
result: {
|
|
2082
|
+
contentItems: [{ type: "inputText", text: "Invalid present_content payload" }],
|
|
2083
|
+
success: false,
|
|
2084
|
+
},
|
|
2085
|
+
})
|
|
2086
|
+
})
|
|
2087
|
+
|
|
2088
|
+
test("rejects present_content payloads with extra keys under strict parsing", async () => {
|
|
2089
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2090
|
+
if (message.method === "initialize") {
|
|
2091
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2092
|
+
return
|
|
2093
|
+
}
|
|
2094
|
+
if (message.method === "thread/start") {
|
|
2095
|
+
child.writeServerMessage({
|
|
2096
|
+
id: message.id,
|
|
2097
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2098
|
+
})
|
|
2099
|
+
return
|
|
2100
|
+
}
|
|
2101
|
+
if (message.method === "turn/start") {
|
|
2102
|
+
child.writeServerMessage({
|
|
2103
|
+
id: message.id,
|
|
2104
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2105
|
+
})
|
|
2106
|
+
child.writeServerMessage({
|
|
2107
|
+
id: "dyn-4",
|
|
2108
|
+
method: "item/tool/call",
|
|
2109
|
+
params: {
|
|
2110
|
+
threadId: "thread-1",
|
|
2111
|
+
turnId: "turn-1",
|
|
2112
|
+
callId: "call-present-invalid-2",
|
|
2113
|
+
tool: "present_content",
|
|
2114
|
+
arguments: {
|
|
2115
|
+
title: "System Design",
|
|
2116
|
+
kind: "diagram",
|
|
2117
|
+
format: "mermaid",
|
|
2118
|
+
source: "graph TD\\nA-->B",
|
|
2119
|
+
extra: true,
|
|
2120
|
+
},
|
|
2121
|
+
},
|
|
2122
|
+
})
|
|
2123
|
+
child.writeServerMessage({
|
|
2124
|
+
method: "turn/completed",
|
|
2125
|
+
params: {
|
|
2126
|
+
threadId: "thread-1",
|
|
2127
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2128
|
+
},
|
|
2129
|
+
})
|
|
2130
|
+
}
|
|
2131
|
+
})
|
|
2132
|
+
|
|
2133
|
+
const manager = new CodexAppServerManager({
|
|
2134
|
+
spawnProcess: () => process as never,
|
|
2135
|
+
})
|
|
2136
|
+
|
|
2137
|
+
await manager.startSession({
|
|
2138
|
+
chatId: "chat-1",
|
|
2139
|
+
cwd: "/tmp/project",
|
|
2140
|
+
model: "gpt-5.4",
|
|
2141
|
+
sessionToken: null,
|
|
2142
|
+
})
|
|
2143
|
+
|
|
2144
|
+
const turn = await manager.startTurn({
|
|
2145
|
+
chatId: "chat-1",
|
|
2146
|
+
model: "gpt-5.4",
|
|
2147
|
+
content: "show invalid card",
|
|
2148
|
+
planMode: false,
|
|
2149
|
+
onToolRequest: async () => ({}),
|
|
2150
|
+
})
|
|
2151
|
+
|
|
2152
|
+
const events = await collectStream(turn.stream)
|
|
2153
|
+
const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
2154
|
+
const toolResult = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_result")
|
|
2155
|
+
const response = process.messages.find((message: any) => message.id === "dyn-4")
|
|
2156
|
+
|
|
2157
|
+
expect(toolCall?.entry.kind).toBe("tool_call")
|
|
2158
|
+
if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
|
|
2159
|
+
expect(toolCall.entry.tool.toolKind).toBe("present_content")
|
|
2160
|
+
expect(toolResult?.entry.kind).toBe("tool_result")
|
|
2161
|
+
if (!toolResult || toolResult.entry.kind !== "tool_result") throw new Error("missing tool result")
|
|
2162
|
+
expect(toolResult.entry.isError).toBe(true)
|
|
2163
|
+
expectPresentContentSchemaValidationError(toolResult.entry.content)
|
|
2164
|
+
expect(response).toEqual({
|
|
2165
|
+
id: "dyn-4",
|
|
2166
|
+
result: {
|
|
2167
|
+
contentItems: [{ type: "inputText", text: "Invalid present_content payload" }],
|
|
2168
|
+
success: false,
|
|
2169
|
+
},
|
|
2170
|
+
})
|
|
2171
|
+
})
|
|
2172
|
+
|
|
2173
|
+
test("answers requestUserInput requests with the official JSON-RPC result payload", async () => {
|
|
2174
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2175
|
+
if (message.method === "initialize") {
|
|
2176
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2177
|
+
} else if (message.method === "thread/start") {
|
|
2178
|
+
child.writeServerMessage({
|
|
2179
|
+
id: message.id,
|
|
2180
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2181
|
+
})
|
|
2182
|
+
} else if (message.method === "turn/start") {
|
|
2183
|
+
child.writeServerMessage({
|
|
2184
|
+
id: message.id,
|
|
2185
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2186
|
+
})
|
|
2187
|
+
child.writeServerMessage({
|
|
2188
|
+
id: "req-1",
|
|
2189
|
+
method: "item/tool/requestUserInput",
|
|
2190
|
+
params: {
|
|
2191
|
+
threadId: "thread-1",
|
|
2192
|
+
turnId: "turn-1",
|
|
2193
|
+
itemId: "ask-1",
|
|
2194
|
+
questions: [
|
|
2195
|
+
{
|
|
2196
|
+
id: "runtime",
|
|
2197
|
+
header: "Runtime",
|
|
2198
|
+
question: "Which runtime?",
|
|
2199
|
+
isOther: false,
|
|
2200
|
+
isSecret: false,
|
|
2201
|
+
options: null,
|
|
2202
|
+
},
|
|
2203
|
+
],
|
|
2204
|
+
},
|
|
2205
|
+
})
|
|
2206
|
+
child.writeServerMessage({
|
|
2207
|
+
method: "turn/completed",
|
|
2208
|
+
params: {
|
|
2209
|
+
threadId: "thread-1",
|
|
2210
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2211
|
+
},
|
|
2212
|
+
})
|
|
2213
|
+
}
|
|
2214
|
+
})
|
|
2215
|
+
|
|
2216
|
+
const manager = new CodexAppServerManager({
|
|
2217
|
+
spawnProcess: () => process as never,
|
|
2218
|
+
})
|
|
2219
|
+
|
|
2220
|
+
await manager.startSession({
|
|
2221
|
+
chatId: "chat-1",
|
|
2222
|
+
cwd: "/tmp/project",
|
|
2223
|
+
model: "gpt-5.4",
|
|
2224
|
+
sessionToken: null,
|
|
2225
|
+
})
|
|
2226
|
+
|
|
2227
|
+
const turn = await manager.startTurn({
|
|
2228
|
+
chatId: "chat-1",
|
|
2229
|
+
model: "gpt-5.4",
|
|
2230
|
+
content: "ask me",
|
|
2231
|
+
planMode: false,
|
|
2232
|
+
onToolRequest: async () => ({
|
|
2233
|
+
questions: [{
|
|
2234
|
+
id: "runtime",
|
|
2235
|
+
question: "Which runtime?",
|
|
2236
|
+
}],
|
|
2237
|
+
answers: {
|
|
2238
|
+
runtime: "bun",
|
|
2239
|
+
},
|
|
2240
|
+
}),
|
|
2241
|
+
})
|
|
2242
|
+
|
|
2243
|
+
const events = await collectStream(turn.stream)
|
|
2244
|
+
const askEntry = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
|
|
2245
|
+
expect(askEntry?.entry.tool.toolKind).toBe("ask_user_question")
|
|
2246
|
+
|
|
2247
|
+
const response = process.messages.find((message: any) => message.id === "req-1")
|
|
2248
|
+
expect(response).toEqual({
|
|
2249
|
+
id: "req-1",
|
|
2250
|
+
result: {
|
|
2251
|
+
answers: {
|
|
2252
|
+
runtime: {
|
|
2253
|
+
answers: ["bun"],
|
|
2254
|
+
},
|
|
2255
|
+
},
|
|
2256
|
+
},
|
|
2257
|
+
})
|
|
2258
|
+
})
|
|
2259
|
+
|
|
2260
|
+
test("falls back to question text when requestUserInput answers are keyed by prompt text", async () => {
|
|
2261
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2262
|
+
if (message.method === "initialize") {
|
|
2263
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2264
|
+
} else if (message.method === "thread/start") {
|
|
2265
|
+
child.writeServerMessage({
|
|
2266
|
+
id: message.id,
|
|
2267
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2268
|
+
})
|
|
2269
|
+
} else if (message.method === "turn/start") {
|
|
2270
|
+
child.writeServerMessage({
|
|
2271
|
+
id: message.id,
|
|
2272
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2273
|
+
})
|
|
2274
|
+
child.writeServerMessage({
|
|
2275
|
+
id: "req-1",
|
|
2276
|
+
method: "item/tool/requestUserInput",
|
|
2277
|
+
params: {
|
|
2278
|
+
threadId: "thread-1",
|
|
2279
|
+
turnId: "turn-1",
|
|
2280
|
+
itemId: "ask-1",
|
|
2281
|
+
questions: [
|
|
2282
|
+
{
|
|
2283
|
+
id: "favorite_color",
|
|
2284
|
+
header: "Color",
|
|
2285
|
+
question: "What is your favorite color right now?",
|
|
2286
|
+
isOther: true,
|
|
2287
|
+
isSecret: false,
|
|
2288
|
+
options: [
|
|
2289
|
+
{ label: "Red", description: null },
|
|
2290
|
+
{ label: "Blue", description: null },
|
|
2291
|
+
],
|
|
2292
|
+
},
|
|
2293
|
+
],
|
|
2294
|
+
},
|
|
2295
|
+
})
|
|
2296
|
+
child.writeServerMessage({
|
|
2297
|
+
method: "turn/completed",
|
|
2298
|
+
params: {
|
|
2299
|
+
threadId: "thread-1",
|
|
2300
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2301
|
+
},
|
|
2302
|
+
})
|
|
2303
|
+
}
|
|
2304
|
+
})
|
|
2305
|
+
|
|
2306
|
+
const manager = new CodexAppServerManager({
|
|
2307
|
+
spawnProcess: () => process as never,
|
|
2308
|
+
})
|
|
2309
|
+
|
|
2310
|
+
await manager.startSession({
|
|
2311
|
+
chatId: "chat-1",
|
|
2312
|
+
cwd: "/tmp/project",
|
|
2313
|
+
model: "gpt-5.4",
|
|
2314
|
+
sessionToken: null,
|
|
2315
|
+
})
|
|
2316
|
+
|
|
2317
|
+
const turn = await manager.startTurn({
|
|
2318
|
+
chatId: "chat-1",
|
|
2319
|
+
model: "gpt-5.4",
|
|
2320
|
+
content: "ask me",
|
|
2321
|
+
planMode: false,
|
|
2322
|
+
onToolRequest: async () => ({
|
|
2323
|
+
questions: [{
|
|
2324
|
+
id: "favorite_color",
|
|
2325
|
+
question: "What is your favorite color right now?",
|
|
2326
|
+
}],
|
|
2327
|
+
answers: {
|
|
2328
|
+
"What is your favorite color right now?": "Red",
|
|
2329
|
+
},
|
|
2330
|
+
}),
|
|
2331
|
+
})
|
|
2332
|
+
|
|
2333
|
+
await collectStream(turn.stream)
|
|
2334
|
+
|
|
2335
|
+
const response = process.messages.find((message: any) => message.id === "req-1")
|
|
2336
|
+
expect(response).toEqual({
|
|
2337
|
+
id: "req-1",
|
|
2338
|
+
result: {
|
|
2339
|
+
answers: {
|
|
2340
|
+
favorite_color: {
|
|
2341
|
+
answers: ["Red"],
|
|
2342
|
+
},
|
|
2343
|
+
},
|
|
2344
|
+
},
|
|
2345
|
+
})
|
|
2346
|
+
})
|
|
2347
|
+
|
|
2348
|
+
test("infers multi-select Codex questions from prompt text and returns multiple answers", async () => {
|
|
2349
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2350
|
+
if (message.method === "initialize") {
|
|
2351
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2352
|
+
} else if (message.method === "thread/start") {
|
|
2353
|
+
child.writeServerMessage({
|
|
2354
|
+
id: message.id,
|
|
2355
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2356
|
+
})
|
|
2357
|
+
} else if (message.method === "turn/start") {
|
|
2358
|
+
child.writeServerMessage({
|
|
2359
|
+
id: message.id,
|
|
2360
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2361
|
+
})
|
|
2362
|
+
child.writeServerMessage({
|
|
2363
|
+
id: "req-1",
|
|
2364
|
+
method: "item/tool/requestUserInput",
|
|
2365
|
+
params: {
|
|
2366
|
+
threadId: "thread-1",
|
|
2367
|
+
turnId: "turn-1",
|
|
2368
|
+
itemId: "ask-1",
|
|
2369
|
+
questions: [
|
|
2370
|
+
{
|
|
2371
|
+
id: "runtimes",
|
|
2372
|
+
header: "Runtime",
|
|
2373
|
+
question: "Select all runtimes that apply",
|
|
2374
|
+
isOther: true,
|
|
2375
|
+
isSecret: false,
|
|
2376
|
+
options: [
|
|
2377
|
+
{ label: "bun", description: null },
|
|
2378
|
+
{ label: "node", description: null },
|
|
2379
|
+
],
|
|
2380
|
+
},
|
|
2381
|
+
],
|
|
2382
|
+
},
|
|
2383
|
+
})
|
|
2384
|
+
child.writeServerMessage({
|
|
2385
|
+
method: "turn/completed",
|
|
2386
|
+
params: {
|
|
2387
|
+
threadId: "thread-1",
|
|
2388
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2389
|
+
},
|
|
2390
|
+
})
|
|
2391
|
+
}
|
|
2392
|
+
})
|
|
2393
|
+
|
|
2394
|
+
const manager = new CodexAppServerManager({
|
|
2395
|
+
spawnProcess: () => process as never,
|
|
2396
|
+
})
|
|
2397
|
+
|
|
2398
|
+
await manager.startSession({
|
|
2399
|
+
chatId: "chat-1",
|
|
2400
|
+
cwd: "/tmp/project",
|
|
2401
|
+
model: "gpt-5.4",
|
|
2402
|
+
sessionToken: null,
|
|
2403
|
+
})
|
|
2404
|
+
|
|
2405
|
+
const turn = await manager.startTurn({
|
|
2406
|
+
chatId: "chat-1",
|
|
2407
|
+
model: "gpt-5.4",
|
|
2408
|
+
content: "ask me",
|
|
2409
|
+
planMode: false,
|
|
2410
|
+
onToolRequest: async ({ tool }) => {
|
|
2411
|
+
expect(tool.toolKind).toBe("ask_user_question")
|
|
2412
|
+
if (tool.toolKind !== "ask_user_question") {
|
|
2413
|
+
return {}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
expect(tool.input.questions[0]?.multiSelect).toBe(true)
|
|
2417
|
+
|
|
2418
|
+
return {
|
|
2419
|
+
questions: [{
|
|
2420
|
+
id: "runtimes",
|
|
2421
|
+
question: "Select all runtimes that apply",
|
|
2422
|
+
multiSelect: true,
|
|
2423
|
+
}],
|
|
2424
|
+
answers: {
|
|
2425
|
+
runtimes: ["bun", "node"],
|
|
2426
|
+
},
|
|
2427
|
+
}
|
|
2428
|
+
},
|
|
2429
|
+
})
|
|
2430
|
+
|
|
2431
|
+
await collectStream(turn.stream)
|
|
2432
|
+
|
|
2433
|
+
const response = process.messages.find((message: any) => message.id === "req-1")
|
|
2434
|
+
expect(response).toEqual({
|
|
2435
|
+
id: "req-1",
|
|
2436
|
+
result: {
|
|
2437
|
+
answers: {
|
|
2438
|
+
runtimes: {
|
|
2439
|
+
answers: ["bun", "node"],
|
|
2440
|
+
},
|
|
2441
|
+
},
|
|
2442
|
+
},
|
|
2443
|
+
})
|
|
2444
|
+
})
|
|
2445
|
+
|
|
2446
|
+
test("sends approval decisions back to the app-server", async () => {
|
|
2447
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2448
|
+
if (message.method === "initialize") {
|
|
2449
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2450
|
+
} else if (message.method === "thread/start") {
|
|
2451
|
+
child.writeServerMessage({
|
|
2452
|
+
id: message.id,
|
|
2453
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2454
|
+
})
|
|
2455
|
+
} else if (message.method === "turn/start") {
|
|
2456
|
+
child.writeServerMessage({
|
|
2457
|
+
id: message.id,
|
|
2458
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2459
|
+
})
|
|
2460
|
+
child.writeServerMessage({
|
|
2461
|
+
id: "approval-1",
|
|
2462
|
+
method: "item/commandExecution/requestApproval",
|
|
2463
|
+
params: {
|
|
2464
|
+
threadId: "thread-1",
|
|
2465
|
+
turnId: "turn-1",
|
|
2466
|
+
itemId: "call-1",
|
|
2467
|
+
command: "rm -rf .",
|
|
2468
|
+
cwd: "/tmp/project",
|
|
2469
|
+
},
|
|
2470
|
+
})
|
|
2471
|
+
child.writeServerMessage({
|
|
2472
|
+
method: "turn/completed",
|
|
2473
|
+
params: {
|
|
2474
|
+
threadId: "thread-1",
|
|
2475
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2476
|
+
},
|
|
2477
|
+
})
|
|
2478
|
+
}
|
|
2479
|
+
})
|
|
2480
|
+
|
|
2481
|
+
const manager = new CodexAppServerManager({
|
|
2482
|
+
spawnProcess: () => process as never,
|
|
2483
|
+
})
|
|
2484
|
+
|
|
2485
|
+
await manager.startSession({
|
|
2486
|
+
chatId: "chat-1",
|
|
2487
|
+
cwd: "/tmp/project",
|
|
2488
|
+
model: "gpt-5.4",
|
|
2489
|
+
sessionToken: null,
|
|
2490
|
+
})
|
|
2491
|
+
|
|
2492
|
+
const turn = await manager.startTurn({
|
|
2493
|
+
chatId: "chat-1",
|
|
2494
|
+
model: "gpt-5.4",
|
|
2495
|
+
content: "approve something",
|
|
2496
|
+
planMode: false,
|
|
2497
|
+
onToolRequest: async () => ({}),
|
|
2498
|
+
onApprovalRequest: async () => "accept",
|
|
2499
|
+
})
|
|
2500
|
+
|
|
2501
|
+
await collectStream(turn.stream)
|
|
2502
|
+
|
|
2503
|
+
const response = process.messages.find((message: any) => message.id === "approval-1")
|
|
2504
|
+
expect(response).toEqual({
|
|
2505
|
+
id: "approval-1",
|
|
2506
|
+
result: {
|
|
2507
|
+
decision: "accept",
|
|
2508
|
+
},
|
|
2509
|
+
})
|
|
2510
|
+
})
|
|
2511
|
+
|
|
2512
|
+
test("interrupt sends turn/interrupt for the active turn", async () => {
|
|
2513
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2514
|
+
if (message.method === "initialize") {
|
|
2515
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2516
|
+
} else if (message.method === "thread/start") {
|
|
2517
|
+
child.writeServerMessage({
|
|
2518
|
+
id: message.id,
|
|
2519
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2520
|
+
})
|
|
2521
|
+
} else if (message.method === "turn/start") {
|
|
2522
|
+
child.writeServerMessage({
|
|
2523
|
+
id: message.id,
|
|
2524
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2525
|
+
})
|
|
2526
|
+
} else if (message.method === "turn/interrupt") {
|
|
2527
|
+
child.writeServerMessage({ id: message.id, result: {} })
|
|
2528
|
+
}
|
|
2529
|
+
})
|
|
2530
|
+
|
|
2531
|
+
const manager = new CodexAppServerManager({
|
|
2532
|
+
spawnProcess: () => process as never,
|
|
2533
|
+
})
|
|
2534
|
+
|
|
2535
|
+
await manager.startSession({
|
|
2536
|
+
chatId: "chat-1",
|
|
2537
|
+
cwd: "/tmp/project",
|
|
2538
|
+
model: "gpt-5.4",
|
|
2539
|
+
sessionToken: null,
|
|
2540
|
+
})
|
|
2541
|
+
|
|
2542
|
+
const turn = await manager.startTurn({
|
|
2543
|
+
chatId: "chat-1",
|
|
2544
|
+
model: "gpt-5.4",
|
|
2545
|
+
content: "wait",
|
|
2546
|
+
planMode: false,
|
|
2547
|
+
onToolRequest: async () => ({}),
|
|
2548
|
+
})
|
|
2549
|
+
|
|
2550
|
+
await turn.interrupt()
|
|
2551
|
+
|
|
2552
|
+
const interruptRequest = process.messages.find((message: any) => message.method === "turn/interrupt") as
|
|
2553
|
+
| { id: string; method: "turn/interrupt"; params: { threadId: string; turnId: string } }
|
|
2554
|
+
| undefined
|
|
2555
|
+
expect(interruptRequest).toBeDefined()
|
|
2556
|
+
if (!interruptRequest) throw new Error("missing interrupt request")
|
|
2557
|
+
expect(interruptRequest).toEqual({
|
|
2558
|
+
id: interruptRequest.id,
|
|
2559
|
+
method: "turn/interrupt",
|
|
2560
|
+
params: {
|
|
2561
|
+
threadId: "thread-1",
|
|
2562
|
+
turnId: "turn-1",
|
|
2563
|
+
},
|
|
2564
|
+
})
|
|
2565
|
+
})
|
|
2566
|
+
|
|
2567
|
+
test("interrupt clears a pending exit-plan wait so a new turn can start immediately", async () => {
|
|
2568
|
+
let resolveToolRequest!: (value: unknown) => void
|
|
2569
|
+
|
|
2570
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2571
|
+
if (message.method === "initialize") {
|
|
2572
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2573
|
+
} else if (message.method === "thread/start") {
|
|
2574
|
+
child.writeServerMessage({
|
|
2575
|
+
id: message.id,
|
|
2576
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2577
|
+
})
|
|
2578
|
+
} else if (message.method === "turn/start") {
|
|
2579
|
+
if (message.params.input[0]?.text === "make a plan") {
|
|
2580
|
+
child.writeServerMessage({
|
|
2581
|
+
id: message.id,
|
|
2582
|
+
result: { turn: { id: "turn-plan", status: "completed", error: null } },
|
|
2583
|
+
})
|
|
2584
|
+
child.writeServerMessage({
|
|
2585
|
+
method: "turn/plan/updated",
|
|
2586
|
+
params: {
|
|
2587
|
+
threadId: "thread-1",
|
|
2588
|
+
turnId: "turn-plan",
|
|
2589
|
+
explanation: "Plan the work",
|
|
2590
|
+
plan: [{ step: "Inspect repo", status: "completed" }],
|
|
2591
|
+
},
|
|
2592
|
+
})
|
|
2593
|
+
child.writeServerMessage({
|
|
2594
|
+
method: "turn/completed",
|
|
2595
|
+
params: {
|
|
2596
|
+
threadId: "thread-1",
|
|
2597
|
+
turn: { id: "turn-plan", status: "completed", error: null },
|
|
2598
|
+
},
|
|
2599
|
+
})
|
|
2600
|
+
} else {
|
|
2601
|
+
child.writeServerMessage({
|
|
2602
|
+
id: message.id,
|
|
2603
|
+
result: { turn: { id: "turn-next", status: "completed", error: null } },
|
|
2604
|
+
})
|
|
2605
|
+
child.writeServerMessage({
|
|
2606
|
+
method: "turn/completed",
|
|
2607
|
+
params: {
|
|
2608
|
+
threadId: "thread-1",
|
|
2609
|
+
turn: { id: "turn-next", status: "completed", error: null },
|
|
2610
|
+
},
|
|
2611
|
+
})
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
})
|
|
2615
|
+
|
|
2616
|
+
const manager = new CodexAppServerManager({
|
|
2617
|
+
spawnProcess: () => process as never,
|
|
2618
|
+
})
|
|
2619
|
+
|
|
2620
|
+
await manager.startSession({
|
|
2621
|
+
chatId: "chat-1",
|
|
2622
|
+
cwd: "/tmp/project",
|
|
2623
|
+
model: "gpt-5.4",
|
|
2624
|
+
sessionToken: null,
|
|
2625
|
+
})
|
|
2626
|
+
|
|
2627
|
+
const turn = await manager.startTurn({
|
|
2628
|
+
chatId: "chat-1",
|
|
2629
|
+
model: "gpt-5.4",
|
|
2630
|
+
content: "make a plan",
|
|
2631
|
+
planMode: true,
|
|
2632
|
+
onToolRequest: async () => await new Promise((resolve) => {
|
|
2633
|
+
resolveToolRequest = resolve
|
|
2634
|
+
}),
|
|
2635
|
+
})
|
|
2636
|
+
|
|
2637
|
+
const iterator = turn.stream[Symbol.asyncIterator]()
|
|
2638
|
+
await iterator.next()
|
|
2639
|
+
await iterator.next()
|
|
2640
|
+
await iterator.next()
|
|
2641
|
+
await turn.interrupt()
|
|
2642
|
+
|
|
2643
|
+
const nextTurn = await manager.startTurn({
|
|
2644
|
+
chatId: "chat-1",
|
|
2645
|
+
model: "gpt-5.4",
|
|
2646
|
+
content: "continue",
|
|
2647
|
+
planMode: false,
|
|
2648
|
+
onToolRequest: async () => ({}),
|
|
2649
|
+
})
|
|
2650
|
+
|
|
2651
|
+
await collectStream(nextTurn.stream)
|
|
2652
|
+
resolveToolRequest({})
|
|
2653
|
+
})
|
|
2654
|
+
|
|
2655
|
+
test("emits an error result when the app-server exits mid-turn", async () => {
|
|
2656
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2657
|
+
if (message.method === "initialize") {
|
|
2658
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2659
|
+
} else if (message.method === "thread/start") {
|
|
2660
|
+
child.writeServerMessage({
|
|
2661
|
+
id: message.id,
|
|
2662
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2663
|
+
})
|
|
2664
|
+
} else if (message.method === "turn/start") {
|
|
2665
|
+
child.writeServerMessage({
|
|
2666
|
+
id: message.id,
|
|
2667
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2668
|
+
})
|
|
2669
|
+
child.writeStderr("fatal: app-server crashed")
|
|
2670
|
+
child.closeWithCode(1)
|
|
2671
|
+
}
|
|
2672
|
+
})
|
|
2673
|
+
|
|
2674
|
+
const manager = new CodexAppServerManager({
|
|
2675
|
+
spawnProcess: () => process as never,
|
|
2676
|
+
})
|
|
2677
|
+
|
|
2678
|
+
await manager.startSession({
|
|
2679
|
+
chatId: "chat-1",
|
|
2680
|
+
cwd: "/tmp/project",
|
|
2681
|
+
model: "gpt-5.4",
|
|
2682
|
+
sessionToken: null,
|
|
2683
|
+
})
|
|
2684
|
+
|
|
2685
|
+
const turn = await manager.startTurn({
|
|
2686
|
+
chatId: "chat-1",
|
|
2687
|
+
model: "gpt-5.4",
|
|
2688
|
+
content: "crash",
|
|
2689
|
+
planMode: false,
|
|
2690
|
+
onToolRequest: async () => ({}),
|
|
2691
|
+
})
|
|
2692
|
+
|
|
2693
|
+
const events = await collectStream(turn.stream)
|
|
2694
|
+
const resultEvent = events.find((event) => event.type === "transcript" && event.entry.kind === "result")
|
|
2695
|
+
expect(resultEvent?.entry.subtype).toBe("error")
|
|
2696
|
+
expect(resultEvent?.entry.result).toContain("fatal: app-server crashed")
|
|
2697
|
+
})
|
|
2698
|
+
|
|
2699
|
+
test("stopSession marks context closed, silencing subsequent writes", async () => {
|
|
2700
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2701
|
+
if (message.method === "initialize") {
|
|
2702
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2703
|
+
} else if (message.method === "thread/start") {
|
|
2704
|
+
child.writeServerMessage({
|
|
2705
|
+
id: message.id,
|
|
2706
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2707
|
+
})
|
|
2708
|
+
}
|
|
2709
|
+
})
|
|
2710
|
+
|
|
2711
|
+
const manager = new CodexAppServerManager({
|
|
2712
|
+
spawnProcess: () => process as never,
|
|
2713
|
+
})
|
|
2714
|
+
|
|
2715
|
+
await manager.startSession({
|
|
2716
|
+
chatId: "chat-1",
|
|
2717
|
+
cwd: "/tmp/project",
|
|
2718
|
+
model: "gpt-5.4",
|
|
2719
|
+
sessionToken: null,
|
|
2720
|
+
})
|
|
2721
|
+
|
|
2722
|
+
// Stop session — marks context as closed
|
|
2723
|
+
manager.stopSession("chat-1")
|
|
2724
|
+
|
|
2725
|
+
// Starting a new turn on a stopped session should throw cleanly, not EPIPE
|
|
2726
|
+
expect(() => manager.stopSession("chat-1")).not.toThrow()
|
|
2727
|
+
})
|
|
2728
|
+
|
|
2729
|
+
test("child crash during handleServerRequest does not produce unhandled rejection", async () => {
|
|
2730
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2731
|
+
if (message.method === "initialize") {
|
|
2732
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2733
|
+
} else if (message.method === "thread/start") {
|
|
2734
|
+
child.writeServerMessage({
|
|
2735
|
+
id: message.id,
|
|
2736
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2737
|
+
})
|
|
2738
|
+
} else if (message.method === "turn/start") {
|
|
2739
|
+
child.writeServerMessage({
|
|
2740
|
+
id: message.id,
|
|
2741
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2742
|
+
})
|
|
2743
|
+
// Send a server request, then immediately crash the child
|
|
2744
|
+
child.writeServerMessage({
|
|
2745
|
+
method: "item/tool/requestUserInput",
|
|
2746
|
+
id: "req-1",
|
|
2747
|
+
params: {
|
|
2748
|
+
itemId: "tool-crash",
|
|
2749
|
+
questions: [{ type: "text", text: "Pick:", options: ["x"] }],
|
|
2750
|
+
},
|
|
2751
|
+
})
|
|
2752
|
+
// Simulate child crash while request is in flight
|
|
2753
|
+
queueMicrotask(() => {
|
|
2754
|
+
child.closeWithCode(1)
|
|
2755
|
+
})
|
|
2756
|
+
}
|
|
2757
|
+
})
|
|
2758
|
+
|
|
2759
|
+
const manager = new CodexAppServerManager({
|
|
2760
|
+
spawnProcess: () => process as never,
|
|
2761
|
+
})
|
|
2762
|
+
|
|
2763
|
+
await manager.startSession({
|
|
2764
|
+
chatId: "chat-1",
|
|
2765
|
+
cwd: "/tmp/project",
|
|
2766
|
+
model: "gpt-5.4",
|
|
2767
|
+
sessionToken: null,
|
|
2768
|
+
})
|
|
2769
|
+
|
|
2770
|
+
const turn = await manager.startTurn({
|
|
2771
|
+
chatId: "chat-1",
|
|
2772
|
+
model: "gpt-5.4",
|
|
2773
|
+
content: "crash during request",
|
|
2774
|
+
planMode: false,
|
|
2775
|
+
onToolRequest: async () => {
|
|
2776
|
+
// Simulate slow tool response — child will crash before this resolves
|
|
2777
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
2778
|
+
return { answer: "x" }
|
|
2779
|
+
},
|
|
2780
|
+
})
|
|
2781
|
+
|
|
2782
|
+
// Should produce an error event, not an unhandled rejection
|
|
2783
|
+
const events = await collectStream(turn.stream)
|
|
2784
|
+
const resultEvent = events.find((event) => event.type === "transcript" && event.entry.kind === "result")
|
|
2785
|
+
expect(resultEvent?.entry.isError).toBe(true)
|
|
2786
|
+
})
|
|
2787
|
+
|
|
2788
|
+
test("close() kills the codex session subprocess", async () => {
|
|
2789
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2790
|
+
if (message.method === "initialize") {
|
|
2791
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2792
|
+
} else if (message.method === "thread/start") {
|
|
2793
|
+
child.writeServerMessage({
|
|
2794
|
+
id: message.id,
|
|
2795
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2796
|
+
})
|
|
2797
|
+
} else if (message.method === "turn/start") {
|
|
2798
|
+
child.writeServerMessage({
|
|
2799
|
+
id: message.id,
|
|
2800
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2801
|
+
})
|
|
2802
|
+
child.writeServerMessage({
|
|
2803
|
+
method: "turn/completed",
|
|
2804
|
+
params: {
|
|
2805
|
+
threadId: "thread-1",
|
|
2806
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2807
|
+
},
|
|
2808
|
+
})
|
|
2809
|
+
}
|
|
2810
|
+
})
|
|
2811
|
+
|
|
2812
|
+
const manager = new CodexAppServerManager({
|
|
2813
|
+
spawnProcess: () => process as never,
|
|
2814
|
+
})
|
|
2815
|
+
|
|
2816
|
+
await manager.startSession({
|
|
2817
|
+
chatId: "chat-1",
|
|
2818
|
+
cwd: "/tmp/project",
|
|
2819
|
+
model: "gpt-5.4",
|
|
2820
|
+
sessionToken: null,
|
|
2821
|
+
})
|
|
2822
|
+
|
|
2823
|
+
const turn = await manager.startTurn({
|
|
2824
|
+
chatId: "chat-1",
|
|
2825
|
+
model: "gpt-5.4",
|
|
2826
|
+
content: "do something",
|
|
2827
|
+
planMode: false,
|
|
2828
|
+
onToolRequest: async () => ({}),
|
|
2829
|
+
})
|
|
2830
|
+
|
|
2831
|
+
await collectStream(turn.stream)
|
|
2832
|
+
turn.close()
|
|
2833
|
+
|
|
2834
|
+
expect(process.killed).toBe(true)
|
|
2835
|
+
})
|
|
2836
|
+
|
|
2837
|
+
test("close() is idempotent — calling twice does not throw", async () => {
|
|
2838
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2839
|
+
if (message.method === "initialize") {
|
|
2840
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2841
|
+
} else if (message.method === "thread/start") {
|
|
2842
|
+
child.writeServerMessage({
|
|
2843
|
+
id: message.id,
|
|
2844
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2845
|
+
})
|
|
2846
|
+
} else if (message.method === "turn/start") {
|
|
2847
|
+
child.writeServerMessage({
|
|
2848
|
+
id: message.id,
|
|
2849
|
+
result: { turn: { id: "turn-1", status: "inProgress", error: null } },
|
|
2850
|
+
})
|
|
2851
|
+
child.writeServerMessage({
|
|
2852
|
+
method: "turn/completed",
|
|
2853
|
+
params: {
|
|
2854
|
+
threadId: "thread-1",
|
|
2855
|
+
turn: { id: "turn-1", status: "completed", error: null },
|
|
2856
|
+
},
|
|
2857
|
+
})
|
|
2858
|
+
}
|
|
2859
|
+
})
|
|
2860
|
+
|
|
2861
|
+
const manager = new CodexAppServerManager({
|
|
2862
|
+
spawnProcess: () => process as never,
|
|
2863
|
+
})
|
|
2864
|
+
|
|
2865
|
+
await manager.startSession({
|
|
2866
|
+
chatId: "chat-1",
|
|
2867
|
+
cwd: "/tmp/project",
|
|
2868
|
+
model: "gpt-5.4",
|
|
2869
|
+
sessionToken: null,
|
|
2870
|
+
})
|
|
2871
|
+
|
|
2872
|
+
const turn = await manager.startTurn({
|
|
2873
|
+
chatId: "chat-1",
|
|
2874
|
+
model: "gpt-5.4",
|
|
2875
|
+
content: "do something",
|
|
2876
|
+
planMode: false,
|
|
2877
|
+
onToolRequest: async () => ({}),
|
|
2878
|
+
})
|
|
2879
|
+
|
|
2880
|
+
await collectStream(turn.stream)
|
|
2881
|
+
expect(() => {
|
|
2882
|
+
turn.close()
|
|
2883
|
+
turn.close()
|
|
2884
|
+
}).not.toThrow()
|
|
2885
|
+
})
|
|
2886
|
+
|
|
2887
|
+
test("stopSession rejects pending RPC promises", async () => {
|
|
2888
|
+
const process = new FakeCodexProcess((message, child) => {
|
|
2889
|
+
if (message.method === "initialize") {
|
|
2890
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2891
|
+
} else if (message.method === "thread/start") {
|
|
2892
|
+
child.writeServerMessage({
|
|
2893
|
+
id: message.id,
|
|
2894
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2895
|
+
})
|
|
2896
|
+
} else if (message.method === "turn/start") {
|
|
2897
|
+
// Don't respond — leave the request pending, then kill the session
|
|
2898
|
+
queueMicrotask(() => {
|
|
2899
|
+
manager.stopSession("chat-1")
|
|
2900
|
+
})
|
|
2901
|
+
}
|
|
2902
|
+
})
|
|
2903
|
+
|
|
2904
|
+
const manager = new CodexAppServerManager({
|
|
2905
|
+
spawnProcess: () => process as never,
|
|
2906
|
+
})
|
|
2907
|
+
|
|
2908
|
+
await manager.startSession({
|
|
2909
|
+
chatId: "chat-1",
|
|
2910
|
+
cwd: "/tmp/project",
|
|
2911
|
+
model: "gpt-5.4",
|
|
2912
|
+
sessionToken: null,
|
|
2913
|
+
})
|
|
2914
|
+
|
|
2915
|
+
// startTurn sends turn/start which will never get a response — stopSession should reject it
|
|
2916
|
+
await expect(
|
|
2917
|
+
manager.startTurn({
|
|
2918
|
+
chatId: "chat-1",
|
|
2919
|
+
model: "gpt-5.4",
|
|
2920
|
+
content: "pending request",
|
|
2921
|
+
planMode: false,
|
|
2922
|
+
onToolRequest: async () => ({}),
|
|
2923
|
+
})
|
|
2924
|
+
).rejects.toThrow("Session stopped")
|
|
2925
|
+
})
|
|
2926
|
+
|
|
2927
|
+
test("new session re-spawns a fresh process after close()", async () => {
|
|
2928
|
+
let spawnCount = 0
|
|
2929
|
+
|
|
2930
|
+
function createProcess() {
|
|
2931
|
+
spawnCount++
|
|
2932
|
+
return new FakeCodexProcess((message, child) => {
|
|
2933
|
+
if (message.method === "initialize") {
|
|
2934
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
2935
|
+
} else if (message.method === "thread/start" || message.method === "thread/resume") {
|
|
2936
|
+
child.writeServerMessage({
|
|
2937
|
+
id: message.id,
|
|
2938
|
+
result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
2939
|
+
})
|
|
2940
|
+
} else if (message.method === "turn/start") {
|
|
2941
|
+
child.writeServerMessage({
|
|
2942
|
+
id: message.id,
|
|
2943
|
+
result: { turn: { id: `turn-${spawnCount}`, status: "inProgress", error: null } },
|
|
2944
|
+
})
|
|
2945
|
+
child.writeServerMessage({
|
|
2946
|
+
method: "turn/completed",
|
|
2947
|
+
params: {
|
|
2948
|
+
threadId: "thread-1",
|
|
2949
|
+
turn: { id: `turn-${spawnCount}`, status: "completed", error: null },
|
|
2950
|
+
},
|
|
2951
|
+
})
|
|
2952
|
+
}
|
|
2953
|
+
})
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
const manager = new CodexAppServerManager({
|
|
2957
|
+
spawnProcess: () => createProcess() as never,
|
|
2958
|
+
})
|
|
2959
|
+
|
|
2960
|
+
// First session + turn
|
|
2961
|
+
await manager.startSession({
|
|
2962
|
+
chatId: "chat-1",
|
|
2963
|
+
cwd: "/tmp/project",
|
|
2964
|
+
model: "gpt-5.4",
|
|
2965
|
+
sessionToken: null,
|
|
2966
|
+
})
|
|
2967
|
+
|
|
2968
|
+
const turn1 = await manager.startTurn({
|
|
2969
|
+
chatId: "chat-1",
|
|
2970
|
+
model: "gpt-5.4",
|
|
2971
|
+
content: "first",
|
|
2972
|
+
planMode: false,
|
|
2973
|
+
onToolRequest: async () => ({}),
|
|
2974
|
+
})
|
|
2975
|
+
await collectStream(turn1.stream)
|
|
2976
|
+
turn1.close()
|
|
2977
|
+
|
|
2978
|
+
expect(spawnCount).toBe(1)
|
|
2979
|
+
|
|
2980
|
+
// Second session should spawn a new process since close() killed the first
|
|
2981
|
+
await manager.startSession({
|
|
2982
|
+
chatId: "chat-1",
|
|
2983
|
+
cwd: "/tmp/project",
|
|
2984
|
+
model: "gpt-5.4",
|
|
2985
|
+
sessionToken: "thread-1",
|
|
2986
|
+
})
|
|
2987
|
+
|
|
2988
|
+
expect(spawnCount).toBe(2)
|
|
2989
|
+
})
|
|
2990
|
+
})
|