diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index bde3f3cf11..9331bac1a3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 3b9fd51632439da5b3c3fd9187de552d6c9ca5e2 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 36be903640ad1c839d45ed1bf5e605f4e4d6e000 +2026-08-04-claude-code-and-codex-subagent-backends.md: 1908eb3466fd6ae6cd14f74e70366d2c7b4c977f +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 6d173133a308be2613cc71b99c5dfd695d18f337 diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 3b9fd51632..1908eb3466 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -8,7 +8,7 @@ English | [中文](2026-08-04-claude-code-and-codex-subagent-backends.zh.md) The named [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) registry lets a parent agent delegate work without knowing how the child runs, but the harness needs first-party routes to the real Codex and Claude Code products. A useful first version must hand either product one self-contained task, let it work in the parent Session's workspace, return a final answer or an explicit failure or cancellation, and leave no managed product process behind. -The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required keyless evidence therefore separates two facts: a real-product test proves the official protocol, native authentication shape, final answer, and teardown, while a Loader composition test proves that the public package and documented tool configuration load without starting the product. Direct model HTTP or a product double cannot replace the former; a hand-mounted plugin cannot replace the latter. +The product integrations must not become second owners for task text, cwd, cancellation, result settlement, or process trees. Required evidence therefore separates three facts: a keyless real-product test proves the official protocol, native authentication shape, deterministic answer, and teardown; a Loader composition test proves that the public package and documented tool configuration load without starting the product; and a credentialed e2e proves that the production provider and real product can obtain a unique answer from the real DeepSeek service. Direct model HTTP or a product double cannot replace either product-running tier, and a hand-mounted plugin cannot replace the Loader tier. ## Proposal @@ -45,16 +45,20 @@ For command and file approvals, the unattended wire selects a non-approval decis An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. +Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. + ## Claude Code provider The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented. ## Evidence contract -Each product owns branch-complete package tests, a required real-product spec, and a Loader composition e2e. The real-product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. +Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The separate Loader tier boots the README-shaped user configuration, verifies the fixed provider and foreground-only common tool, and must not start a product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader e2e resolves `@deepseek-ai/dsh-subagent-codex` by package name, verifies the `codex` registration and `subagent_codex` schema with background omitted, accepts `maxDepth: 'provider-managed'`, and records zero child starts while no `codex` command is available. The npm package is a development dependency for reproducible real-product evidence; production still supplies `codex` on `PATH`. +The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. + The combined contract is complete only when the Claude sibling has equivalent real-product evidence and both public Loader configurations prove the fixed tools use the unchanged common subagent contract. ## Alternatives considered @@ -73,7 +77,7 @@ The combined contract is complete only when the Claude sibling has equivalent re ## Acceptance criteria -Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required real-product specs return exact final answers or explicit failure or cancellation and prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. +Both public provider packages load from user-owned Cordis configurations and form their fixed foreground tools without appearing in the shipped CLI defaults. Separate required keyless real-product specs return exact final answers or explicit failure or cancellation, and separate credentialed e2e tests traverse each production provider and real product to a unique DeepSeek answer; both tiers prove managed process-tree quiescence. Both packages document their configuration, lifecycle, failure behavior, model experience, and limitations; generated package, configuration, capability, dependency, and third-party records agree with the shipped manifests. The implemented Codex half satisfies this contract for its fixed tool and 0.146.0 baseline. The proposal becomes implemented only after the Claude Code sibling and the combined two-product evidence satisfy the same ownership and lifecycle boundaries. @@ -81,6 +85,7 @@ The implemented Codex half satisfies this contract for its fixed tool and 0.146. - The product protocols are versioned and may change. Production performs no runtime version probe, so every supported baseline change requires refreshed compatibility evidence. - Product-native configuration makes behavior depend on the deployment's installed product and account state. Required tests isolate those inputs, while production deliberately leaves them under the product's authority. +- Credentialed e2e runs spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. - Every delegation pays for a fresh process and independent model context, and only final text reaches the parent. - Product tool or file side effects are not rolled back when a run fails or is cancelled. - Unattended interaction denial prevents hidden approval hangs but cannot satisfy tasks that require new permission or human input. diff --git a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 36be903640..6d173133a3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -8,7 +8,7 @@ Status: proposed 命名的 [`ctx.subagents`](../../implemented/feature/2026-06-21-subagent-capability-seam.md) 注册表让父 agent(智能体)无需了解子级的运行方式即可委派工作,但 harness 需要通往真实 Codex 与 Claude Code 产品的第一方路径。可用的首版必须能向任一产品交付一项自包含任务,让它在父会话的工作区中执行,返回最终回答或明确的失败或取消结果,并且不留下任何受管的产品进程。 -产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性的无密钥证据会分别证明两个事实:真实产品测试证明官方协议、原生身份验证形态、最终回答和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品。直接发起模型 HTTP 请求或使用产品替身无法取代前者,手工挂载插件则无法取代后者。 +产品集成不得成为任务文本、cwd、取消、结果结算或进程树的第二责任方。因此,强制性证据会分别证明三个事实:无密钥真实产品测试证明官方协议、原生身份验证形态、确定性答案和资源清理;Loader 装配测试证明公开包与文档中的工具配置可以加载,且不会启动产品;带密钥 e2e 证明生产提供方与真实产品能够从真实 DeepSeek 服务获得唯一答案。直接发起模型 HTTP 请求或使用产品替身无法取代任一产品运行层级,手工挂载插件则无法取代 Loader 层级。 ## 提案 @@ -45,16 +45,20 @@ fixed tool → shared subagent service → product provider → official product 若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 +Codex 0.146.0 使用 Responses 协议,而 DeepSeek 公开的 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会使用一个仅限回环、仅供测试内部使用的桥接层来完成一次不使用工具的随机数请求:真实 Codex 向该桥接层发送 Responses 请求,桥接层将收到的 Bearer 凭证与提取出的任务转发到固定的 DeepSeek 官方端点,并将真实文本封装进最小的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能证明 Codex 原生连接 DeepSeek Chat Completions。 + ## Claude Code 提供方 Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界,本 Agent Note 才能进入 implemented 状态。 ## 证据契约 -每个产品都负责覆盖所有分支的包(package)测试、一项必跑的真实产品测试和一项 Loader 装配 e2e。真实产品测试层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。 +每个产品都负责覆盖所有分支的包(package)测试、一项必跑的无密钥真实产品测试、一项 Loader 装配 e2e 和一项带密钥的 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。独立的 Loader 层级会启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的通用工具,并且不得启动产品进程。带密钥层级使用仅在运行时提供的密钥启动相同的生产提供方与真实产品,要求从固定的 DeepSeek 官方服务获得一个唯一的随机数,并再次证明完全停稳;只有本地操作方未提供密钥时才会自行跳过,受信 CI 则会对该 secret 执行 preflight 检查。 Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader e2e 会按包名解析 `@deepseek-ai/dsh-subagent-codex`,验证 `codex` 注册与省略后台参数的 `subagent_codex` schema,接受 `maxDepth: 'provider-managed'`,并在环境中没有可用 `codex` 命令时记录零次子级启动。该 NPM 包是用于复现真实产品证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 带密钥 e2e 会注册生产提供方,启动同一个真实 app-server,并通过上文所述、仅供测试内部使用的桥接层请求一个随机数。它会固定外部端点和模型,不存储任何凭证或请求 payload,要求恰好有一个上游响应完成,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待每个受管句柄退出。 + 只有在 Claude 兄弟提供方具备同等的真实产品证据,并且两个公开 Loader 配置都证明固定工具使用未变的通用 subagent 契约时,组合契约才算完整。 ## 曾考虑的替代方案 @@ -73,7 +77,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 ## 验收标准 -两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,并证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 +两个公开提供方包都能从用户自有的 Cordis 配置加载并组成固定的前台工具,而且不会出现在正式 CLI 默认配置中。独立的强制无密钥真实产品测试会返回完全一致的最终回答或明确的失败或取消结果,独立的带密钥 e2e 测试则会贯穿每个生产提供方与真实产品,取得唯一的 DeepSeek 答案;两个层级都会证明受管进程树完全停稳。两个包都会记录其配置、生命周期、失败行为、模型体验和限制;生成的包、配置、功能、依赖与第三方记录均与已交付的 manifest(元数据清单)一致。 已经实现的 Codex 部分为其固定工具和 0.146.0 基线满足了本契约。只有在 Claude Code 兄弟提供方及两种产品的组合证据满足相同的归属与生命周期边界后,本提案才会进入 implemented 状态。 @@ -81,6 +85,7 @@ Codex 证据锁定 `@openai/codex@0.146.0` 与 `codex-cli 0.146.0`。其真实 - 产品协议受版本约束,且可能发生变化。生产环境不会执行运行时版本探测,因此每次更改受支持的基线都必须刷新兼容性证据。 - 产品原生配置使行为取决于部署环境中安装的产品与账户状态。强制测试会隔离这些输入,而生产环境会有意让产品继续负责它们。 +- 带密钥 e2e 运行会消耗外部 API 配额,并依赖 DeepSeek 官方端点;协议、失败、取消与审批的确定性覆盖仍由无密钥层级负责。 - 每次委派都要承担新建进程和独立模型上下文的开销,且只有最终文本会到达父级。 - 运行失败或被取消时,产品工具或文件产生的副作用不会回滚。 - 拒绝无人值守交互可以防止审批流程暗中挂起,但无法完成需要新权限或人工输入的任务。 diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index c3d4da77bf..a40167b79a 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-codex/README.md -README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8 -README.zh.md: 110953312162e146f01ef037a40d2f70b136850c +README.md: 1dde57e10e27786ae06d395c7088976bf6f60ece +README.zh.md: cb56281d9018e7a400ceb770e31da8a60d10d54f diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index d7293a0ef3..1dde57e10e 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -47,7 +47,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.146.0` / `codex-cli 0.146.0`: the keyless real-product spec drives the official binary against a loopback Responses service with a non-empty fake key and proves the task, authentication, exact answer, cancellation, approvals, and process-tree exit. A separate Loader composition e2e boots the README-shaped user configuration with no `codex` command available, verifies the fixed provider and foreground-only tool schema, and records zero child starts. A credentialed e2e starts the production provider and real Codex, then obtains a unique answer from the fixed official DeepSeek service through a loopback-only test bridge from Responses to Chat Completions; that bridge is not production functionality or native Codex support for DeepSeek's Chat Completions API. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`. ## Model Experience @@ -83,7 +83,7 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. -- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, and real-product tests. +- **Compatibility is pinned by development evidence** — upgrading from the verified 0.146.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. - **Final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1109533121..cb56281d90 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -47,7 +47,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:无密钥真实产品测试使用非空的伪密钥,驱动官方二进制程序连接回环 Responses 服务,并证明任务、身份验证、精确回答、取消、审批与进程树退出。独立的 Loader 装配 e2e 会在没有可用 `codex` 命令时启动与 README 同形的用户配置,验证固定提供方与只支持前台执行的工具 schema,并记录零次子级启动。带密钥 e2e 会启动生产提供方和真实 Codex,再通过一个仅限回环、将 Responses 转为 Chat Completions 的测试桥接层,从固定的 DeepSeek 官方服务获得唯一答案;该桥接层既不属于生产功能,也不代表 Codex 原生支持 DeepSeek 的 Chat Completions API。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。 ## 模型体验 @@ -83,7 +83,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 -- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。 +- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 - **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts new file mode 100644 index 0000000000..b59738031e --- /dev/null +++ b/packages/subagent/subagent-codex/tests/deepseek-responses-bridge.ts @@ -0,0 +1,190 @@ +import { createServer } from 'node:http' +import type { + IncomingMessage, + Server, + ServerResponse, +} from 'node:http' +import { completeResponsesEvents } from './responses-fixture.ts' + +const OFFICIAL_DEEPSEEK_BASE_URL = 'https://api.deepseek.com' +const MAX_REQUEST_BYTES = 1_048_576 + +/** One running test-only Responses-to-DeepSeek bridge. */ +export interface DeepSeekResponsesBridge { + readonly baseUrl: string + readonly completedRequests: number + close(): Promise +} + +function readRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { + body += chunk + if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) { + request.destroy(new Error('DeepSeek bridge request exceeded its byte limit')) + } + }) + request.on('end', () => { resolve(body) }) + request.on('error', reject) + }) +} + +function responseInputTexts(body: Record): string[] { + if (!Array.isArray(body.input)) return [] + return body.input.flatMap((item): string[] => { + if (item === null || typeof item !== 'object') return [] + const content = (item as Record).content + if (!Array.isArray(content)) return [] + return content.flatMap((part): string[] => ( + part !== null + && typeof part === 'object' + && typeof (part as Record).text === 'string' + ? [(part as Record).text as string] + : [] + )) + }) +} + +function taskText(body: Record): string { + const input = responseInputTexts(body).join('\n') + if (input.trim().length > 0) return input + return typeof body.instructions === 'string' ? body.instructions : '' +} + +function deepSeekBaseUrl(): string { + const configured = (process.env.DEEPSEEK_BASE_URL ?? OFFICIAL_DEEPSEEK_BASE_URL) + .replace(/\/+$/, '') + if (configured !== OFFICIAL_DEEPSEEK_BASE_URL) { + throw new Error('Codex DeepSeek e2e requires the official DeepSeek base URL') + } + return configured +} + +async function completeWithDeepSeek( + authorization: string, + task: string, +): Promise { + const response = await fetch(`${deepSeekBaseUrl()}/chat/completions`, { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-v4-flash', + messages: [ + { + role: 'system', + content: 'Follow the user instruction and return only the requested nonce.', + }, + { role: 'user', content: task }, + ], + temperature: 0, + max_tokens: 64, + stream: false, + }), + }) + if (!response.ok) { + void response.body?.cancel() + throw new Error(`DeepSeek bridge upstream returned HTTP ${response.status}`) + } + const payload = await response.json() as { + choices?: Array<{ message?: { content?: unknown } }> + } + const content = payload.choices?.[0]?.message?.content + if (typeof content !== 'string' || content.trim().length === 0) { + throw new Error('DeepSeek bridge upstream returned no text') + } + return content +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + server.closeAllConnections() + }) +} + +/** + * Start the single-purpose loopback bridge used by the Codex credentialed e2e. + * @param nonce - unique answer the incoming Responses task must request. + * @returns loopback endpoint, completion count, and close operation. + */ +export async function startDeepSeekResponsesBridge( + nonce: string, +): Promise { + let seenRequests = 0 + let completedRequests = 0 + const openResponses = new Set() + const server = createServer((request, response) => { + openResponses.add(response) + response.on('close', () => { openResponses.delete(response) }) + void (async () => { + if (request.method !== 'POST' || request.url !== '/v1/responses') { + response.writeHead(404) + response.end() + return + } + if (seenRequests !== 0) { + response.writeHead(409) + response.end() + return + } + seenRequests += 1 + const authorization = request.headers.authorization + if ( + typeof authorization !== 'string' + || !authorization.startsWith('Bearer ') + || authorization.length === 'Bearer '.length + ) { + throw new Error('Codex DeepSeek bridge received no bearer credential') + } + const body = JSON.parse(await readRequest(request)) as Record + const task = taskText(body) + if (!task.includes(nonce)) { + throw new Error('Codex DeepSeek bridge request omitted the expected nonce') + } + const text = await completeWithDeepSeek(authorization, task) + completedRequests += 1 + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + 'x-request-id': 'req_deepseek_e2e', + }) + for (const event of completeResponsesEvents(text)) { + response.write(`data: ${JSON.stringify(event)}\n\n`) + } + response.end('data: [DONE]\n\n') + })().catch(() => { + if (!response.headersSent) { + response.writeHead(502, { 'content-type': 'application/json' }) + } + response.end(JSON.stringify({ error: { message: 'DeepSeek bridge request failed' } })) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('DeepSeek bridge did not acquire a TCP port') + } + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + get completedRequests(): number { return completedRequests }, + async close(): Promise { + for (const response of openResponses) response.destroy() + await closeServer(server) + }, + } +} diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts new file mode 100644 index 0000000000..29c5536bc0 --- /dev/null +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -0,0 +1,141 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import * as codex from '../src/index.ts' +import { + startDeepSeekResponsesBridge, + type DeepSeekResponsesBridge, +} from './deepseek-responses-bridge.ts' + +const execFileAsync = promisify(execFile) +const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url))) +const codexBinDir = join(packageRoot, 'node_modules', '.bin') +const codexPackage = JSON.parse(readFileSync( + join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'), + 'utf8', +)) as { version: string } + +const roots: string[] = [] +const contexts: Context[] = [] +const bridges: DeepSeekResponsesBridge[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(bridges.splice(0).map(bridge => bridge.close())) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise { + expect(handles.length).toBeGreaterThan(0) + for (const handle of handles) { + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.done).resolves.toHaveProperty('exitCode') + } +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)( + 'Codex provider with real DeepSeek API', + () => { + it('returns one unique nonce through the production provider and real Codex', async () => { + const apiKey = process.env.DEEPSEEK_API_KEY + if (apiKey === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const root = mkdtempSync(join(tmpdir(), 'dsh-codex-deepseek-e2e-')) + roots.push(root) + const workspace = join(root, 'workspace') + const codexHome = join(root, 'codex-home') + mkdirSync(workspace) + mkdirSync(codexHome) + const nonce = `DSH_CODEX_DEEPSEEK_${randomUUID()}` + const bridge = await startDeepSeekResponsesBridge(nonce) + bridges.push(bridge) + writeFileSync(join(codexHome, 'config.toml'), [ + 'model = "deepseek-v4-flash"', + 'model_provider = "deepseek-e2e"', + 'approval_policy = "never"', + 'sandbox_mode = "read-only"', + 'disable_response_storage = true', + 'check_for_update_on_startup = false', + '', + '[model_providers.deepseek-e2e]', + 'name = "DeepSeek E2E bridge"', + `base_url = "${bridge.baseUrl}"`, + 'env_key = "DEEPSEEK_API_KEY"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + '[analytics]', + 'enabled = false', + '', + ].join('\n')) + const env = { + DEEPSEEK_API_KEY: apiKey, + CODEX_HOME: codexHome, + HOME: root, + XDG_CONFIG_HOME: join(root, 'xdg-config'), + PATH: `${codexBinDir}${delimiter}${process.env.PATH ?? ''}`, + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + NO_PROXY: '127.0.0.1,localhost', + } + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) + const handles: SubprocessHandle[] = [] + const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) + vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + const handle = spawn(spec) + handles.push(handle) + return handle + }) + await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], { + env: { ...process.env, ...env }, + }) + expect(codexPackage.version).toBe('0.146.0') + expect(version.stdout.trim()).toBe('codex-cli 0.146.0') + + const parent = { + id: 'deepseek-e2e-parent', + session: { header: { cwd: workspace } }, + } as unknown as Agent + const run = await ctx.subagents.start('codex', { + prompt: [{ + type: 'text', + text: `Reply with exactly ${nonce} and nothing else. Do not use tools.`, + }], + parent, + signal: new AbortController().signal, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + const text = result.output + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .trim() + expect(text).toBe(nonce) + expect(bridge.completedRequests).toBe(1) + await expectQuiescent(handles) + }, 180_000) + }, +) diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 940b0d52a0..cac49b9158 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -85,7 +85,12 @@ function responseObject(text: string): Record { } } -function completeEvents(text: string): Record[] { +/** + * Build the minimal Responses SSE event sequence consumed by Codex 0.146.0. + * @param text - exact assistant answer. + * @returns ordered response lifecycle events. + */ +export function completeResponsesEvents(text: string): Record[] { const completed = responseObject(text) const message = (completed.output as Record[])[0]! const part = (message.content as Record[])[0]! @@ -250,7 +255,7 @@ export async function startResponsesFixture( }) if (behavior.kind === 'hold') return const events = behavior.kind === 'complete' - ? completeEvents(behavior.text) + ? completeResponsesEvents(behavior.text) : functionCallEvents(behavior.name, behavior.arguments) for (const event of events) { response.write(`data: ${JSON.stringify(event)}\n\n`)