feat(subagent): add Codex product provider
This commit is contained in:
@@ -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/README.md
|
||||
README.md: f9b04b4aa80b6feacf5d0d1fa4cf6b3b2aebc211
|
||||
README.zh.md: 0afc01a00ae9089f603531345c8a3ac4dd760326
|
||||
README.md: abe1432d3c4ea0f67ed3cdf1bb4aec5f817d17b5
|
||||
README.zh.md: 3df2b6c62dd355db2991468ad19883cd27c280cd
|
||||
|
||||
@@ -11,11 +11,12 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](..
|
||||
| `subagent-spawn/` | In-process backend: a fresh child agent, with cold resume | (registers on `ctx.subagents`) |
|
||||
| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix, with cold resume | (registers on `ctx.subagents`) |
|
||||
| `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP (one-shot) | (registers on `ctx.subagents`) |
|
||||
| `subagent-codex/` | Out-of-process backend: a real Codex app-server process with one ephemeral thread and turn | (registers on `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | Out-of-process backend: a child harness runtime in a spawned subprocess, driven over stdio JSON-RPC through the TypeScript SDK client | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-control/` | The optional, globally named `send_message` and `list_agents` tools over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-subagent-report/` | Child-scoped `report` return channel for continuable in-process children | (registers in each child scope) |
|
||||
|
||||
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures.
|
||||
The interface and continuation orchestration live at `subagent/subagent/`. One-shot provider `start` dispatch stays independent of persistence; an internal continuation manager owns each durable continuable child as one Session plus at most one process-local Activation, binding no Task, and exists only while the Agent service is present, resolving persistence per continuation operation. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` backends spawn their children through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only external or nondeterministic product boundaries with package-local fixtures.
|
||||
|
||||
The design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), and [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
|
||||
|
||||
@@ -11,11 +11,12 @@ subagent(子 agent)seam 允许 agent(智能体)把工作委派给子 age
|
||||
| `subagent-spawn/` | 进程内后端:支持冷恢复的全新子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容、支持冷恢复的子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-acp/` | 进程外后端:在 spawn 的子进程中运行并通过 ACP(Agent Client Protocol)驱动的一次性子 agent | (注册到 `ctx.subagents`) |
|
||||
| `subagent-codex/` | 进程外后端:一个真实的 Codex app-server 进程,包含一个临时 thread 和一个轮次 | (注册到 `ctx.subagents`) |
|
||||
| `subagent-dsh-sdk/` | 进程外后端:在 spawn 的子进程中运行的子 harness 运行时,经 TypeScript SDK 客户端走 stdio JSON-RPC 驱动 | (注册到 `ctx.subagents`) |
|
||||
| `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-control/` | 基于 `ctx.subagents`、可选且全局名称唯一的 `send_message` 与 `list_agents` 工具 | (注册到 `ctx.tools`) |
|
||||
| `tool-subagent-report/` | 子级作用域的 `report` 返回通道,用于可继续的进程内子级 | (注册到每个子级作用域) |
|
||||
|
||||
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。
|
||||
接口和继续执行编排位于 `subagent/subagent/`。一次性提供方 `start` 分发不依赖持久化;内部继续执行管理器把每个持久化可继续子 agent 作为一个 Session 加至多一个进程内 Activation 来拥有,不绑定任何 Task,且只在 Agent 服务存在时存在,并按每项继续执行操作解析持久化。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不含提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` / `subagent-codex` / `subagent-dsh-sdk` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换外部或非确定性的产品边界。
|
||||
|
||||
设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md](../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md) 和 [.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
|
||||
|
||||
6
packages/subagent/subagent-codex/README.i18n.yaml
Normal file
6
packages/subagent/subagent-codex/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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: ca92f935539812351dd578dca700c9a0113dcd46
|
||||
README.zh.md: 6f6690ea51970dd39c738ad0ec4f55c2a5ab2467
|
||||
88
packages/subagent/subagent-codex/README.md
Normal file
88
packages/subagent/subagent-codex/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-subagent-codex
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
|
||||
|
||||
## Start and ownership
|
||||
|
||||
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`.
|
||||
|
||||
The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error.
|
||||
|
||||
The unattended provider answers command and file approvals with `decline`, answers permission requests with an empty turn-scoped permission set, and declines MCP elicitation. Any other server request fails the run instead of waiting for interaction that this provider cannot supply.
|
||||
|
||||
Local cancellation wins the result race and maps to `aborted`; a remote interrupted or failed turn maps to `error`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` when the current ids are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
|
||||
|
||||
## Capabilities and context
|
||||
|
||||
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Codex receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The ephemeral Codex thread id and turn id stay private to this run and are never persisted in the parent Session.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
|
||||
| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. |
|
||||
|
||||
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
|
||||
|
||||
```yaml
|
||||
- id: subagent-codex
|
||||
name: '@deepseek-ai/dsh-subagent-codex'
|
||||
config:
|
||||
env:
|
||||
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
|
||||
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
## 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`: package tests drive the real binary against a loopback Responses service with a non-empty fake key, and the Loader snapshot fixes the model-visible tool schema, exact tool result, persisted parent Session, original child task, authentication header, and pre-teardown process-tree quiescence. The npm package is a test-only dependency; deployments still supply `codex` on `PATH`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Child request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The child pays for an independent Codex context and turn. Child tokens do not enter the parent's context.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of the parent request cache. Reuse depends only on Codex's own provider, model, instructions, tools, and ephemeral-thread request.
|
||||
|
||||
### Parent tool result, indirectly
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-subagent`, the parent sees only the selected final Codex answer or the consumer's exact error for a non-completed result. Codex commentary, reasoning, tool activity, stderr, workspace diffs, and product ids are not copied into the parent Session.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Parent input grows only by the final answer or error retained in the tool result. This provider adds no parent tool schema by itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only: the new tool result follows the reusable parent request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.
|
||||
88
packages/subagent/subagent-codex/README.zh.md
Normal file
88
packages/subagent/subagent-codex/README.zh.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-subagent-codex
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本包(package)注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果契约仅返回最终答案。
|
||||
|
||||
## 启动与所有权
|
||||
|
||||
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
|
||||
|
||||
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
|
||||
|
||||
无人值守的提供方对命令与文件审批答复 `decline`,对权限请求返回作用域限于当前轮次的空权限集,并拒绝 MCP elicitation。其他任何服务器请求都会导致此次运行失败,而不会等待本提供方无法提供的交互。
|
||||
|
||||
本地取消会在结果竞态中胜出并映射为 `aborted`;远端轮次若中断或失败,则映射为 `error`。`dispose()` 具有幂等性:如果当前标识符已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
|
||||
|
||||
## 能力与上下文
|
||||
|
||||
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Codex 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出契约。临时 Codex 线程 ID 与轮次 ID 仅在此次运行内部可见,绝不会持久化到父会话。
|
||||
|
||||
## 配置
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
|
||||
| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 |
|
||||
|
||||
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
|
||||
|
||||
```yaml
|
||||
- id: subagent-codex
|
||||
name: '@deepseek-ai/dsh-subagent-codex'
|
||||
config:
|
||||
env:
|
||||
OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY
|
||||
|
||||
- id: tool-subagent-codex
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: codex
|
||||
toolName: subagent_codex
|
||||
enableRunInBackground: false
|
||||
maxDepth: provider-managed
|
||||
```
|
||||
|
||||
## 产品兼容性与证据
|
||||
|
||||
生产环境的协议层有意只实现这一单次执行契约所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`:包测试使用非空的伪密钥,驱动真实二进制程序连接回环 Responses 服务;Loader 快照则锁定模型可见的工具 schema、确切的工具结果、已持久化的父会话、原始子任务、身份验证请求头,以及清理前进程树的完全停稳状态。该 NPM 包仅作为测试依赖;部署环境仍需通过 `PATH` 提供 `codex`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 子任务请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
Codex 子任务会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。
|
||||
|
||||
#### 对 token 的影响
|
||||
|
||||
子任务需为独立的 Codex 上下文和轮次承担 token 开销。子任务 token 不会进入父级上下文。
|
||||
|
||||
#### 对 KV Cache 的影响
|
||||
|
||||
这与父请求缓存相互独立。能否复用只取决于 Codex 自身的提供方、模型、指令、工具和临时线程请求。
|
||||
|
||||
### 父级工具结果(间接)
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
通过 `dsh-tool-subagent`,父级模型只会看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异和产品标识符均不会复制到父会话。
|
||||
|
||||
#### 对 token 的影响
|
||||
|
||||
父级输入只会增加工具结果中保留的最终答案或错误内容。本提供方自身不添加父级工具 schema。
|
||||
|
||||
#### 对 KV Cache 的影响
|
||||
|
||||
仅追加:新的工具结果接在可复用的父请求前缀之后。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
|
||||
- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
|
||||
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消和真实产品测试。
|
||||
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
|
||||
- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
|
||||
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
|
||||
- **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。
|
||||
53
packages/subagent/subagent-codex/package.json
Normal file
53
packages/subagent/subagent-codex/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-subagent-codex",
|
||||
"description": "One-shot Codex subagent provider over the official app-server protocol",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@openai/codex": "0.146.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
89
packages/subagent/subagent-codex/src/index.ts
Normal file
89
packages/subagent/subagent-codex/src/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Fixed Codex one-shot subagent provider. Every accepted run starts a fresh
|
||||
* official `codex app-server --stdio` process in the delegating Session's
|
||||
* workspace and publishes only after an ephemeral thread exists.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-codex
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
assertPositiveFinite,
|
||||
NO_START_CAPABILITIES,
|
||||
resolveChildCwd,
|
||||
type ResolvedSubagentStartRequest,
|
||||
type SubagentCapabilities,
|
||||
type SubagentProvider,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
DEFAULT_DISPOSE_GRACE_MS,
|
||||
startCodexRun,
|
||||
type CodexRunSpec,
|
||||
} from './run.ts'
|
||||
|
||||
export const name = 'subagent-codex'
|
||||
export const inject = ['subagents', 'subprocess']
|
||||
|
||||
/** Deployment-owned environment and process-release bound. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Explicit environment entries layered over the subprocess seam's
|
||||
* credential-scrubbed parent environment.
|
||||
*/
|
||||
env?: Record<string, string>
|
||||
/** Grace in milliseconds for app-server process-tree termination. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
env: z.dict(z.string()).default({}),
|
||||
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
|
||||
})
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
class CodexProvider implements SubagentProvider {
|
||||
readonly name = 'codex'
|
||||
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
|
||||
readonly inheritsParentContext = false
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {}
|
||||
|
||||
start(request: ResolvedSubagentStartRequest) {
|
||||
const spec: CodexRunSpec = {
|
||||
cwd: resolveChildCwd(
|
||||
'subagent-codex',
|
||||
undefined,
|
||||
request.parent.session.header.cwd,
|
||||
),
|
||||
env: this.config.env,
|
||||
disposeGraceMs: this.config.disposeGraceMs,
|
||||
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
|
||||
onError: (error, stopReason) => {
|
||||
this.ctx.logger.warn(
|
||||
`subagent-codex: child run failed (${stopReason}): ${error.message}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
return startCodexRun(request, spec)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the fixed `codex` provider.
|
||||
* @param ctx - context carrying shared subagent and subprocess services.
|
||||
* @param config - explicit child environment and disposal grace.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveFinite(
|
||||
'subagent-codex',
|
||||
'disposeGraceMs',
|
||||
resolved.disposeGraceMs,
|
||||
)
|
||||
ctx.subagents.registerProvider(new CodexProvider(ctx, resolved))
|
||||
}
|
||||
30
packages/subagent/subagent-codex/src/invariant.ts
Normal file
30
packages/subagent/subagent-codex/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-codex`.
|
||||
* @module @deepseek-ai/dsh-subagent-codex/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'subagent-codex-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: lifecycle pairing belongs to the shared subagent
|
||||
* service and process-tree ownership belongs to the subprocess service.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - plugin context carrying the invariant registry.
|
||||
* @returns the installed registration's disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
209
packages/subagent/subagent-codex/src/run.ts
Normal file
209
packages/subagent/subagent-codex/src/run.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* One-shot Codex child lifecycle: spawn the real app-server through the
|
||||
* subprocess seam, publish only after initialization and ephemeral thread
|
||||
* creation, flatten post-publication failures, and dispose to whole-tree
|
||||
* quiescence.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-codex/run
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
settleRunResult,
|
||||
subprocessRunHandle,
|
||||
type SubagentResult,
|
||||
type SubagentRun,
|
||||
type SubagentStartRequest,
|
||||
type SubagentStopReason,
|
||||
} from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { CodexAppServerWire } from './wire.ts'
|
||||
|
||||
/** Default POSIX grace between subprocess termination tiers. */
|
||||
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
|
||||
|
||||
/** Fully resolved inputs for one Codex app-server run. */
|
||||
export interface CodexRunSpec {
|
||||
/** Parent Session workspace, also supplied to `thread/start`. */
|
||||
readonly cwd: string
|
||||
/** Explicit deployment/test environment layered after the shared scrub. */
|
||||
readonly env: Record<string, string>
|
||||
/** Subprocess termination grace and final tree-exit bound. */
|
||||
readonly disposeGraceMs: number
|
||||
/** Shared subprocess service spawn operation. */
|
||||
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
|
||||
/** Diagnostic sink for a post-publication error flattened into a result. */
|
||||
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
|
||||
}
|
||||
|
||||
function thrown(value: unknown): Error {
|
||||
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and preserve the one-shot task before crossing the process seam.
|
||||
* @param prompt - task content accepted from the shared subagent service.
|
||||
* @returns the exact non-empty text block sequence.
|
||||
*/
|
||||
export function textTask(prompt: readonly ContentBlock[]): string[] {
|
||||
if (prompt.length === 0) {
|
||||
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
|
||||
}
|
||||
const texts: string[] = []
|
||||
for (const block of prompt) {
|
||||
if (block.type !== 'text') {
|
||||
throw new Error('subagent-codex: the one-shot task must contain only text blocks')
|
||||
}
|
||||
texts.push(block.text)
|
||||
}
|
||||
if (texts.every(text => text.trim().length === 0)) {
|
||||
throw new Error('subagent-codex: the one-shot task must not be empty')
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise<boolean> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => { controller.abort() }, ms)
|
||||
try {
|
||||
return await child.waitForExit(controller.signal)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the private wire, terminate the managed process tree, and wait for the
|
||||
* subprocess owner to prove it is gone.
|
||||
* @param wire - private app-server protocol connection.
|
||||
* @param child - shared-service handle that owns the process tree.
|
||||
* @param graceMs - termination grace used to bound final exit observation.
|
||||
*/
|
||||
export async function disposeCodexChild(
|
||||
wire: CodexAppServerWire,
|
||||
child: SubprocessHandle,
|
||||
graceMs: number,
|
||||
): Promise<void> {
|
||||
wire.close()
|
||||
if (child.pid <= 0) {
|
||||
await child.done.catch(() => {})
|
||||
return
|
||||
}
|
||||
try {
|
||||
child.stdin?.end()
|
||||
} catch {
|
||||
// A concurrently closed stdin does not change tree ownership below.
|
||||
}
|
||||
child.terminate()
|
||||
if (!(await treeExitsWithin(child, graceMs * 2))) {
|
||||
throw new Error('subagent-codex: app-server process tree did not exit within its dispose window')
|
||||
}
|
||||
await child.done
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the real `codex app-server --stdio` child and publish its one-shot run.
|
||||
* @param request - resolved shared subagent request.
|
||||
* @param spec - workspace, environment, process seam, and diagnostic policy.
|
||||
* @returns the published run after initialization and ephemeral thread creation.
|
||||
*/
|
||||
export async function startCodexRun(
|
||||
request: SubagentStartRequest,
|
||||
spec: CodexRunSpec,
|
||||
): Promise<SubagentRun> {
|
||||
const texts = textTask(request.prompt)
|
||||
if (request.signal.aborted) {
|
||||
throw new Error('subagent-codex: request was aborted before app-server startup')
|
||||
}
|
||||
|
||||
const child = spec.spawn({
|
||||
argv: ['codex', 'app-server', '--stdio'],
|
||||
cwd: spec.cwd,
|
||||
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
||||
graceMs: spec.disposeGraceMs,
|
||||
env: spec.env,
|
||||
})
|
||||
if (child.stdin === undefined || child.stdout === undefined) {
|
||||
child.terminate()
|
||||
await child.waitForExit()
|
||||
throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream')
|
||||
}
|
||||
|
||||
const wire = new CodexAppServerWire(child.stdout, child.stdin)
|
||||
const disposeProcess = (): Promise<void> =>
|
||||
disposeCodexChild(wire, child, spec.disposeGraceMs)
|
||||
|
||||
const processFailure: Promise<never> = child.done.then(
|
||||
outcome => Promise.reject(new Error(
|
||||
'subagent-codex: app-server exited before the run settled '
|
||||
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
|
||||
)),
|
||||
(error: unknown) => Promise.reject(thrown(error)),
|
||||
)
|
||||
// A normal post-result dispose also closes the process. Keep that expected
|
||||
// late rejection observed after the result race has already settled.
|
||||
processFailure.catch(() => {})
|
||||
|
||||
const flags = { cancelled: false }
|
||||
const runAbort = new AbortController()
|
||||
let settleCancellation!: () => void
|
||||
const cancellation = new Promise<void>((resolve) => { settleCancellation = resolve })
|
||||
const requestCancel = (): void => {
|
||||
if (flags.cancelled) return
|
||||
flags.cancelled = true
|
||||
runAbort.abort(new Error('subagent-codex: run cancelled locally'))
|
||||
settleCancellation()
|
||||
wire.interrupt()
|
||||
}
|
||||
const onAbort = (): void => { requestCancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
wire.start()
|
||||
await Promise.race([wire.initialize(request.signal), processFailure])
|
||||
await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure])
|
||||
} catch (error: unknown) {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
try {
|
||||
await disposeProcess()
|
||||
} catch (disposeError: unknown) {
|
||||
throw new AggregateError(
|
||||
[thrown(error), thrown(disposeError)],
|
||||
'subagent-codex: startup failed and app-server cleanup also failed',
|
||||
)
|
||||
}
|
||||
if (flags.cancelled) {
|
||||
throw new Error('subagent-codex: request was aborted before app-server startup')
|
||||
}
|
||||
throw thrown(error)
|
||||
}
|
||||
|
||||
const collectOutput = (): ContentBlock[] => wire.collectOutput()
|
||||
const result: Promise<SubagentResult> = settleRunResult({
|
||||
attempt: () => Promise.race([
|
||||
wire.runTurn(texts, runAbort.signal, () => flags.cancelled),
|
||||
processFailure,
|
||||
cancellation.then((): SubagentResult => ({
|
||||
output: collectOutput(),
|
||||
stopReason: 'aborted',
|
||||
})),
|
||||
]),
|
||||
collectOutput,
|
||||
cancelled: () => flags.cancelled,
|
||||
onError: spec.onError,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
})
|
||||
|
||||
return subprocessRunHandle({
|
||||
id: SessionId(randomUUID()),
|
||||
result,
|
||||
signal: request.signal,
|
||||
onAbort,
|
||||
requestCancel,
|
||||
teardown: disposeProcess,
|
||||
})
|
||||
}
|
||||
366
packages/subagent/subagent-codex/src/wire.ts
Normal file
366
packages/subagent/subagent-codex/src/wire.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC
|
||||
* transport owns framing and request correlation; this module owns only the
|
||||
* product methods, current thread/turn association, unattended approval
|
||||
* responses, and terminal-answer selection.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-codex/wire
|
||||
*/
|
||||
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
|
||||
import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>
|
||||
readonly resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((settle) => { resolve = settle })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function object(value: unknown, label: string): JsonObject {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
|
||||
}
|
||||
return value as JsonObject
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`subagent-codex: app-server returned invalid ${label}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function thrown(value: unknown): Error {
|
||||
/* v8 ignore next -- typed protocol and stream failures reject with Error. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`)
|
||||
}
|
||||
|
||||
async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
void pending.catch(() => {})
|
||||
throw abortError(signal)
|
||||
}
|
||||
let rejectAbort!: (error: Error) => void
|
||||
const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
|
||||
const onAbort = (): void => { rejectAbort(abortError(signal)) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
return await Promise.race([pending, aborted])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One app-server connection and its single ephemeral thread/turn.
|
||||
*
|
||||
* The class deliberately exposes no generic request surface. Supporting
|
||||
* another product method must first become part of the provider contract.
|
||||
*/
|
||||
export class CodexAppServerWire {
|
||||
private readonly transport: JsonRpcLineTransport
|
||||
private readonly fatal = deferred<Error>()
|
||||
private threadId: string | undefined
|
||||
private turnId: string | undefined
|
||||
private pendingTurnId: string | undefined
|
||||
private turnCompleted: Deferred<JsonObject> | undefined
|
||||
private readonly earlyTurnNotifications: Array<{
|
||||
readonly method: string
|
||||
readonly params: JsonObject
|
||||
}> = []
|
||||
private readonly finalAnswers: string[] = []
|
||||
private readonly unphasedAnswers: string[] = []
|
||||
private started = false
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly input: Readable,
|
||||
output: Writable,
|
||||
) {
|
||||
this.transport = new JsonRpcLineTransport(input, output)
|
||||
this.transport.onRequest((method, params) => this.handleServerRequest(method, params))
|
||||
this.transport.onNotification((method, params) => {
|
||||
try {
|
||||
this.handleNotification(method, params)
|
||||
} catch (error: unknown) {
|
||||
this.fail(thrown(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Start reading app-server frames. */
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.input.on('error', this.onInputError)
|
||||
this.input.on('end', this.onInputEnd)
|
||||
this.transport.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the required app-server initialize/initialized handshake.
|
||||
* @param signal - unpublished-start cancellation.
|
||||
*/
|
||||
async initialize(signal: AbortSignal): Promise<void> {
|
||||
const response = object(await this.guarded(this.transport.request('initialize', {
|
||||
clientInfo: {
|
||||
name: 'deepseek-harness',
|
||||
title: 'DeepSeek Harness',
|
||||
version: '0.0.1',
|
||||
},
|
||||
capabilities: {
|
||||
experimentalApi: false,
|
||||
requestAttestation: false,
|
||||
},
|
||||
}, signal), signal), 'initialize response')
|
||||
string(response.userAgent, 'initialize userAgent')
|
||||
this.transport.notify('initialized')
|
||||
await this.guarded(this.transport.flush(), signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the run's private ephemeral thread and retain its identity.
|
||||
* @param cwd - parent Session workspace.
|
||||
* @param signal - unpublished-start cancellation.
|
||||
* @returns the app-server thread id.
|
||||
*/
|
||||
async startThread(cwd: string, signal: AbortSignal): Promise<string> {
|
||||
const response = object(await this.guarded(this.transport.request('thread/start', {
|
||||
cwd,
|
||||
ephemeral: true,
|
||||
}, signal), signal), 'thread/start response')
|
||||
const thread = object(response.thread, 'thread/start thread')
|
||||
const id = string(thread.id, 'thread/start thread id')
|
||||
if (thread.ephemeral !== true) {
|
||||
throw new Error('subagent-codex: app-server did not create an ephemeral thread')
|
||||
}
|
||||
this.threadId = id
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the one text-only task and wait for this thread/turn's authoritative
|
||||
* terminal notification.
|
||||
* @param texts - already validated task text blocks.
|
||||
* @param signal - local cancellation for the published run.
|
||||
* @param cancelled - whether local cancellation has already won.
|
||||
* @returns the shared three-state subagent result.
|
||||
*/
|
||||
async runTurn(
|
||||
texts: readonly string[],
|
||||
signal: AbortSignal,
|
||||
cancelled: () => boolean,
|
||||
): Promise<SubagentResult> {
|
||||
if (this.threadId === undefined) {
|
||||
throw new Error('subagent-codex: cannot start a turn before thread/start')
|
||||
}
|
||||
if (this.turnCompleted !== undefined) {
|
||||
throw new Error('subagent-codex: this one-shot wire already started its turn')
|
||||
}
|
||||
const completion = deferred<JsonObject>()
|
||||
this.turnCompleted = completion
|
||||
const response = object(await this.guarded(this.transport.request('turn/start', {
|
||||
threadId: this.threadId,
|
||||
input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
|
||||
}, signal), signal), 'turn/start response')
|
||||
const turn = object(response.turn, 'turn/start turn')
|
||||
this.commitTurnId(string(turn.id, 'turn/start turn id'))
|
||||
|
||||
const completed = await this.guarded(completion.promise, signal)
|
||||
if (cancelled()) return { output: this.collectOutput(), stopReason: 'aborted' }
|
||||
|
||||
const terminal = object(completed.turn, 'turn/completed turn')
|
||||
const status = terminal.status
|
||||
if (status !== 'completed') {
|
||||
const detail = status === 'failed'
|
||||
? `: ${JSON.stringify(terminal.error)}`
|
||||
: ''
|
||||
throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
|
||||
}
|
||||
const output = this.collectOutput()
|
||||
if (output.length === 0) {
|
||||
throw new Error('subagent-codex: Codex completed without a final answer')
|
||||
}
|
||||
return { output, stopReason: 'completed' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort remote cancellation. Local settlement and process teardown
|
||||
* remain authoritative when the child no longer accepts protocol requests.
|
||||
*/
|
||||
interrupt(): void {
|
||||
if (this.threadId === undefined || this.turnId === undefined || this.closed) return
|
||||
void this.transport.request('turn/interrupt', {
|
||||
threadId: this.threadId,
|
||||
turnId: this.turnId,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* The best non-commentary answer observed so far, preserving exact bytes.
|
||||
* @returns the selected final or nullable-phase text block, if any.
|
||||
*/
|
||||
collectOutput(): ContentBlock[] {
|
||||
const selected = this.finalAnswers.length > 0
|
||||
? this.finalAnswers.at(-1)
|
||||
: this.unphasedAnswers.at(-1)
|
||||
return selected !== undefined && selected.trim().length > 0
|
||||
? [{ type: 'text', text: selected }]
|
||||
: []
|
||||
}
|
||||
|
||||
/** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.input.off('error', this.onInputError)
|
||||
this.input.off('end', this.onInputEnd)
|
||||
this.transport.close()
|
||||
}
|
||||
|
||||
private async guarded<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
const withFatal = Promise.race([
|
||||
pending,
|
||||
this.fatal.promise.then((error): Promise<never> => Promise.reject(error)),
|
||||
])
|
||||
return raceAbort(withFatal, signal)
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
this.fatal.resolve(error)
|
||||
}
|
||||
|
||||
private readonly onInputError = (error: Error): void => {
|
||||
this.fail(error)
|
||||
}
|
||||
|
||||
private readonly onInputEnd = (): void => {
|
||||
this.fail(new Error('subagent-codex: app-server protocol stream closed'))
|
||||
}
|
||||
|
||||
private observePendingTurnId(id: string): void {
|
||||
if (this.turnCompleted === undefined) {
|
||||
throw new Error('subagent-codex: app-server referenced a turn before turn/start')
|
||||
}
|
||||
if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
|
||||
throw new Error('subagent-codex: app-server referenced conflicting turns')
|
||||
}
|
||||
this.pendingTurnId = id
|
||||
}
|
||||
|
||||
private commitTurnId(id: string): void {
|
||||
if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
|
||||
throw new Error('subagent-codex: turn/start response did not match the active turn')
|
||||
}
|
||||
this.turnId = id
|
||||
const notifications = this.earlyTurnNotifications.splice(0)
|
||||
for (const notification of notifications) {
|
||||
this.handleNotification(notification.method, notification.params)
|
||||
}
|
||||
}
|
||||
|
||||
private validateRunIds(params: JsonObject, nullableTurn = false): void {
|
||||
if (params.threadId !== this.threadId) {
|
||||
throw new Error('subagent-codex: app-server request referenced another thread')
|
||||
}
|
||||
if (nullableTurn && params.turnId === null) return
|
||||
const id = string(params.turnId, 'server request turn id')
|
||||
if (this.turnId === undefined) {
|
||||
this.observePendingTurnId(id)
|
||||
return
|
||||
}
|
||||
if (id !== this.turnId) {
|
||||
throw new Error('subagent-codex: app-server request referenced another turn')
|
||||
}
|
||||
}
|
||||
|
||||
private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
|
||||
try {
|
||||
switch (method) {
|
||||
case 'item/commandExecution/requestApproval':
|
||||
case 'item/fileChange/requestApproval':
|
||||
this.validateRunIds(params)
|
||||
return Promise.resolve({ decision: 'decline' })
|
||||
case 'item/permissions/requestApproval':
|
||||
this.validateRunIds(params)
|
||||
return Promise.resolve({ permissions: {}, scope: 'turn' })
|
||||
case 'mcpServer/elicitation/request':
|
||||
this.validateRunIds(params, true)
|
||||
return Promise.resolve({ action: 'decline', content: null, _meta: null })
|
||||
default:
|
||||
throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalized = thrown(error)
|
||||
this.fail(normalized)
|
||||
return Promise.reject(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: JsonObject): void {
|
||||
if (method === 'turn/started') {
|
||||
if (params.threadId !== this.threadId) return
|
||||
const turn = object(params.turn, 'turn/started turn')
|
||||
if (this.turnCompleted !== undefined && this.turnId === undefined) {
|
||||
this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (method === 'item/completed') {
|
||||
if (params.threadId !== this.threadId) return
|
||||
const id = string(params.turnId, 'item/completed turn id')
|
||||
if (this.turnId === undefined) {
|
||||
if (this.turnCompleted !== undefined) {
|
||||
this.observePendingTurnId(id)
|
||||
this.earlyTurnNotifications.push({ method, params })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (id !== this.turnId) return
|
||||
const item = object(params.item, 'item/completed item')
|
||||
if (item.type !== 'agentMessage') return
|
||||
const text = typeof item.text === 'string'
|
||||
? item.text
|
||||
: (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })()
|
||||
if (item.phase === 'final_answer') {
|
||||
this.finalAnswers.push(text)
|
||||
} else if (item.phase === null) {
|
||||
this.unphasedAnswers.push(text)
|
||||
} else if (item.phase !== 'commentary') {
|
||||
throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (method !== 'turn/completed') return
|
||||
if (params.threadId !== this.threadId) return
|
||||
const turn = object(params.turn, 'turn/completed turn')
|
||||
const id = string(turn.id, 'turn/completed turn id')
|
||||
const turnCompleted = this.turnCompleted
|
||||
if (turnCompleted === undefined) return
|
||||
if (this.turnId === undefined) {
|
||||
this.observePendingTurnId(id)
|
||||
this.earlyTurnNotifications.push({ method, params })
|
||||
return
|
||||
}
|
||||
if (id !== this.turnId) return
|
||||
if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
|
||||
throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
|
||||
}
|
||||
turnCompleted.resolve(params)
|
||||
}
|
||||
}
|
||||
230
packages/subagent/subagent-codex/tests/real-product.spec.ts
Normal file
230
packages/subagent/subagent-codex/tests/real-product.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
existsSync,
|
||||
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 {
|
||||
startResponsesFixture,
|
||||
type ResponsesBehavior,
|
||||
type ResponsesFixture,
|
||||
} from './responses-fixture.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 fixtures: ResponsesFixture[] = []
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
interface RealHarness {
|
||||
readonly ctx: Context
|
||||
readonly handles: SubprocessHandle[]
|
||||
readonly parent: Agent
|
||||
readonly env: Record<string, string>
|
||||
readonly workspace: string
|
||||
}
|
||||
|
||||
async function realHarness(script: readonly ResponsesBehavior[]): Promise<{
|
||||
readonly harness: RealHarness
|
||||
readonly fixture: ResponsesFixture
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-codex-real-'))
|
||||
roots.push(root)
|
||||
const workspace = join(root, 'workspace')
|
||||
const codexHome = join(root, 'codex-home')
|
||||
const fixture = await startResponsesFixture(script)
|
||||
fixtures.push(fixture)
|
||||
mkdirSync(workspace)
|
||||
mkdirSync(codexHome)
|
||||
writeFileSync(join(codexHome, 'config.toml'), [
|
||||
'model = "fixture-model"',
|
||||
'model_provider = "fixture"',
|
||||
'approval_policy = "on-request"',
|
||||
'sandbox_mode = "read-only"',
|
||||
'disable_response_storage = true',
|
||||
'check_for_update_on_startup = false',
|
||||
'',
|
||||
'[model_providers.fixture]',
|
||||
'name = "Fixture Responses"',
|
||||
`base_url = "${fixture.baseUrl}"`,
|
||||
'env_key = "OPENAI_API_KEY"',
|
||||
'wire_api = "responses"',
|
||||
'requires_openai_auth = false',
|
||||
'',
|
||||
'[analytics]',
|
||||
'enabled = false',
|
||||
'',
|
||||
].join('\n'))
|
||||
const env = {
|
||||
OPENAI_API_KEY: 'dsh-fake-openai-key',
|
||||
CODEX_HOME: codexHome,
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: join(root, 'xdg'),
|
||||
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 parent = {
|
||||
id: 'real-parent',
|
||||
session: { header: { cwd: workspace } },
|
||||
} as unknown as Agent
|
||||
return { harness: { ctx, handles, parent, env, workspace }, fixture }
|
||||
}
|
||||
|
||||
async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise<void> {
|
||||
expect(handles.length).toBeGreaterThan(0)
|
||||
for (const handle of handles) {
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
const outcome = await handle.done
|
||||
expect(outcome).toHaveProperty('exitCode')
|
||||
expect(outcome).toHaveProperty('signal')
|
||||
}
|
||||
}
|
||||
|
||||
function responseInputTexts(body: Record<string, unknown>): 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<string, unknown>).content
|
||||
if (!Array.isArray(content)) return []
|
||||
return content.flatMap((part): string[] => (
|
||||
part !== null
|
||||
&& typeof part === 'object'
|
||||
&& typeof (part as Record<string, unknown>).text === 'string'
|
||||
? [(part as Record<string, unknown>).text as string]
|
||||
: []
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
describe('real @openai/codex 0.146.0 product', () => {
|
||||
it('passes the exact task and fake authentication to local Responses and returns exact text', async () => {
|
||||
const sentinel = 'REAL_CODEX_SENTINEL_0_146_0'
|
||||
const task = 'Return the fixture sentinel exactly.'
|
||||
const { harness, fixture } = await realHarness([
|
||||
{ kind: 'complete', text: sentinel },
|
||||
])
|
||||
expect(codexPackage.version).toBe('0.146.0')
|
||||
const version = await execFileAsync(join(codexBinDir, 'codex'), ['--version'], {
|
||||
env: { ...process.env, ...harness.env },
|
||||
})
|
||||
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
|
||||
|
||||
const run = await harness.ctx.subagents.start('codex', {
|
||||
prompt: [{ type: 'text', text: task }],
|
||||
parent: harness.parent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: sentinel }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
await run.dispose()
|
||||
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
const recorded = fixture.requests[0]!
|
||||
expect(recorded.method).toBe('POST')
|
||||
expect(recorded.path).toBe('/v1/responses')
|
||||
expect(recorded.headers.authorization).toBe('Bearer dsh-fake-openai-key')
|
||||
expect(responseInputTexts(recorded.body)).toContain(task)
|
||||
await expectQuiescent(harness.handles)
|
||||
}, 20_000)
|
||||
|
||||
it('declines a real app-server command approval without executing the command', async () => {
|
||||
const sentinel = 'REAL_CODEX_APPROVAL_DECLINED'
|
||||
const { harness, fixture } = await realHarness([
|
||||
{
|
||||
kind: 'functionCall',
|
||||
name: 'exec_command',
|
||||
arguments: {
|
||||
cmd: 'touch approval-side-effect',
|
||||
sandbox_permissions: 'require_escalated',
|
||||
justification: 'exercise the unattended approval boundary',
|
||||
},
|
||||
},
|
||||
{ kind: 'complete', text: sentinel },
|
||||
])
|
||||
const sideEffect = join(harness.workspace, 'approval-side-effect')
|
||||
const run = await harness.ctx.subagents.start('codex', {
|
||||
prompt: [{ type: 'text', text: 'Attempt the fixture command.' }],
|
||||
parent: harness.parent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await expect(run.result).resolves.toEqual({
|
||||
output: [{ type: 'text', text: sentinel }],
|
||||
stopReason: 'completed',
|
||||
})
|
||||
await run.dispose()
|
||||
|
||||
expect(existsSync(sideEffect)).toBe(false)
|
||||
expect(fixture.requests).toHaveLength(2)
|
||||
const tools = fixture.requests[0]!.body.tools as Array<Record<string, unknown>>
|
||||
expect(tools).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'function', name: 'exec_command' }),
|
||||
]))
|
||||
const followup = JSON.stringify(fixture.requests[1]!.body)
|
||||
expect(followup).toContain('call_fixture')
|
||||
expect(followup).toContain('rejected by user')
|
||||
expect(fixture.requests.every(requestEntry =>
|
||||
requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key',
|
||||
)).toBe(true)
|
||||
await expectQuiescent(harness.handles)
|
||||
}, 20_000)
|
||||
|
||||
it('settles cancellation locally and leaves the real app-server tree quiescent', async () => {
|
||||
const { harness, fixture } = await realHarness([{ kind: 'hold' }])
|
||||
const controller = new AbortController()
|
||||
const run = await harness.ctx.subagents.start('codex', {
|
||||
prompt: [{ type: 'text', text: 'Wait for cancellation.' }],
|
||||
parent: harness.parent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await fixture.requestStarted
|
||||
controller.abort(new Error('real product cancellation'))
|
||||
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
|
||||
await run.dispose()
|
||||
await expectQuiescent(harness.handles)
|
||||
}, 20_000)
|
||||
})
|
||||
283
packages/subagent/subagent-codex/tests/responses-fixture.ts
Normal file
283
packages/subagent/subagent-codex/tests/responses-fixture.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type {
|
||||
IncomingHttpHeaders,
|
||||
IncomingMessage,
|
||||
Server,
|
||||
ServerResponse,
|
||||
} from 'node:http'
|
||||
|
||||
/** One request observed by the package-private Responses fixture. */
|
||||
interface RecordedResponsesRequest {
|
||||
readonly method: string | undefined
|
||||
readonly path: string | undefined
|
||||
readonly headers: IncomingHttpHeaders
|
||||
readonly body: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Behavior consumed by one Responses request. */
|
||||
export type ResponsesBehavior =
|
||||
| { readonly kind: 'complete'; readonly text: string }
|
||||
| {
|
||||
readonly kind: 'functionCall'
|
||||
readonly name: string
|
||||
readonly arguments: Record<string, unknown>
|
||||
}
|
||||
| { readonly kind: 'hold' }
|
||||
|
||||
/** Running package-private Responses fixture. */
|
||||
export interface ResponsesFixture {
|
||||
readonly baseUrl: string
|
||||
readonly requests: RecordedResponsesRequest[]
|
||||
readonly requestStarted: Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
function responseObject(text: string): Record<string, unknown> {
|
||||
const message = {
|
||||
id: 'msg_fixture',
|
||||
type: 'message',
|
||||
status: 'completed',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'output_text',
|
||||
annotations: [],
|
||||
logprobs: [],
|
||||
text,
|
||||
}],
|
||||
}
|
||||
return {
|
||||
id: 'resp_fixture',
|
||||
object: 'response',
|
||||
created_at: 1,
|
||||
status: 'completed',
|
||||
background: false,
|
||||
error: null,
|
||||
incomplete_details: null,
|
||||
instructions: null,
|
||||
max_output_tokens: null,
|
||||
max_tool_calls: null,
|
||||
model: 'fixture-model',
|
||||
output: [message],
|
||||
parallel_tool_calls: true,
|
||||
previous_response_id: null,
|
||||
prompt_cache_key: null,
|
||||
prompt_cache_retention: null,
|
||||
reasoning: { effort: null, summary: null },
|
||||
safety_identifier: null,
|
||||
service_tier: 'default',
|
||||
store: false,
|
||||
temperature: null,
|
||||
text: { format: { type: 'text' }, verbosity: 'medium' },
|
||||
tool_choice: 'auto',
|
||||
tools: [],
|
||||
top_logprobs: 0,
|
||||
top_p: null,
|
||||
truncation: 'disabled',
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
output_tokens: 1,
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
total_tokens: 11,
|
||||
},
|
||||
user: null,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
function completeEvents(text: string): Record<string, unknown>[] {
|
||||
const completed = responseObject(text)
|
||||
const message = (completed.output as Record<string, unknown>[])[0]!
|
||||
const part = (message.content as Record<string, unknown>[])[0]!
|
||||
return [
|
||||
{
|
||||
type: 'response.created',
|
||||
response: { ...completed, status: 'in_progress', output: [] },
|
||||
},
|
||||
{
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: { ...message, status: 'in_progress', content: [] },
|
||||
},
|
||||
{
|
||||
type: 'response.content_part.added',
|
||||
item_id: message.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
part: { ...part, text: '' },
|
||||
},
|
||||
{
|
||||
type: 'response.output_text.delta',
|
||||
item_id: message.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: text,
|
||||
logprobs: [],
|
||||
},
|
||||
{
|
||||
type: 'response.output_text.done',
|
||||
item_id: message.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
text,
|
||||
logprobs: [],
|
||||
},
|
||||
{
|
||||
type: 'response.content_part.done',
|
||||
item_id: message.id,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
part,
|
||||
},
|
||||
{
|
||||
type: 'response.output_item.done',
|
||||
output_index: 0,
|
||||
item: message,
|
||||
},
|
||||
{ type: 'response.completed', response: completed },
|
||||
]
|
||||
}
|
||||
|
||||
function functionCallEvents(
|
||||
name: string,
|
||||
argumentsValue: Record<string, unknown>,
|
||||
): Record<string, unknown>[] {
|
||||
const argumentsText = JSON.stringify(argumentsValue)
|
||||
const item = {
|
||||
id: 'fc_fixture',
|
||||
type: 'function_call',
|
||||
status: 'completed',
|
||||
name,
|
||||
arguments: argumentsText,
|
||||
call_id: 'call_fixture',
|
||||
}
|
||||
const completed = {
|
||||
...responseObject(''),
|
||||
output: [item],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
output_tokens: 5,
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'response.created',
|
||||
response: { ...completed, status: 'in_progress', output: [] },
|
||||
},
|
||||
{
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: { ...item, status: 'in_progress', arguments: '' },
|
||||
},
|
||||
{
|
||||
type: 'response.function_call_arguments.delta',
|
||||
item_id: item.id,
|
||||
output_index: 0,
|
||||
delta: argumentsText,
|
||||
},
|
||||
{
|
||||
type: 'response.function_call_arguments.done',
|
||||
item_id: item.id,
|
||||
output_index: 0,
|
||||
arguments: argumentsText,
|
||||
},
|
||||
{
|
||||
type: 'response.output_item.done',
|
||||
output_index: 0,
|
||||
item,
|
||||
},
|
||||
{ type: 'response.completed', response: completed },
|
||||
]
|
||||
}
|
||||
|
||||
function readRequest(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => { resolve(body) })
|
||||
request.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error !== undefined) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
server.closeAllConnections()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback-only Responses SSE fixture.
|
||||
* @param script - one behavior per expected Responses request.
|
||||
* @returns the running fixture and its observed requests.
|
||||
*/
|
||||
export async function startResponsesFixture(
|
||||
script: readonly ResponsesBehavior[],
|
||||
): Promise<ResponsesFixture> {
|
||||
const behaviors = [...script]
|
||||
const requests: RecordedResponsesRequest[] = []
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const openResponses = new Set<ServerResponse>()
|
||||
const server = createServer((request, response) => {
|
||||
openResponses.add(response)
|
||||
response.on('close', () => { openResponses.delete(response) })
|
||||
void readRequest(request).then((body) => {
|
||||
requests.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(body) as Record<string, unknown>,
|
||||
})
|
||||
started.resolve(undefined)
|
||||
const behavior = behaviors.shift()
|
||||
if (behavior === undefined) {
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } }))
|
||||
return
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive',
|
||||
'x-request-id': 'req_fixture',
|
||||
})
|
||||
if (behavior.kind === 'hold') return
|
||||
const events = behavior.kind === 'complete'
|
||||
? completeEvents(behavior.text)
|
||||
: functionCallEvents(behavior.name, behavior.arguments)
|
||||
for (const event of events) {
|
||||
response.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
}).catch((error: unknown) => {
|
||||
response.destroy(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((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('responses fixture did not acquire a TCP port')
|
||||
}
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
requests,
|
||||
requestStarted: started.promise,
|
||||
async close(): Promise<void> {
|
||||
for (const response of openResponses) response.destroy()
|
||||
await closeServer(server)
|
||||
},
|
||||
}
|
||||
}
|
||||
1053
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
Normal file
1053
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
42
packages/subagent/subagent-codex/tsconfig.json
Normal file
42
packages/subagent/subagent-codex/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../sdk/sdk-protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/README.md
|
||||
README.md: e54f0b98ec3649cec428a47026e6657a9749608b
|
||||
README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4
|
||||
README.md: 4040f9a48bd61cc230adec1bd9725cf30bdfd8f7
|
||||
README.zh.md: 5f6a041887e3227d92a88eac344524e55a598413
|
||||
|
||||
@@ -14,6 +14,7 @@ The family separates the stable interface from implementations and model-facing
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | Fresh in-process child; supports continuable children. |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | In-process child seeded with completed parent turns; supports continuable children. |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | Fresh out-of-process ACP child (one-shot). |
|
||||
| `@deepseek-ai/dsh-subagent-codex` | Fresh real Codex app-server child with one ephemeral thread and turn (one-shot). |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | Model-facing delegation tool over one configured provider. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | The globally named `send_message` follow-up tool. |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | Child-scoped return channel to the direct parent. |
|
||||
|
||||
@@ -14,6 +14,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `@deepseek-ai/dsh-subagent-spawn` | 全新的进程内子 agent;支持可继续子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-fork` | 以父 agent 已完成轮次作为初始内容的进程内子 agent;支持可继续子 agent。 |
|
||||
| `@deepseek-ai/dsh-subagent-acp` | 全新的进程外 ACP(Agent Client Protocol)子 agent(一次性)。 |
|
||||
| `@deepseek-ai/dsh-subagent-codex` | 全新的真实 Codex app-server 子 agent,包含一个临时 thread 和一个轮次(一次性)。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | 基于一个已配置提供方、面向模型的委派工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-control` | 全局具名 `send_message` 后续操作工具。 |
|
||||
| `@deepseek-ai/dsh-tool-subagent-report` | 子级作用域的返回通道,指向直接父级。 |
|
||||
|
||||
Reference in New Issue
Block a user