Merge commit '404501a41ccf2a3b638b1b737087948fe08d5c4c' into codex/product-subagent-presets
# Conflicts: # packages/subagent/subagent-claude-code/tests/real-product.spec.ts
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/subagent-codex/README.md
|
||||
README.md: f10ebe0448b2942e2cad8efecb6be4681cf601a6
|
||||
README.zh.md: ef107577afcdc81a64ea46b2e996d46a562d1505
|
||||
README.md: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4
|
||||
README.zh.md: b60cb1bba9b2d7b3f61c544c1600862a0ad6ce5b
|
||||
|
||||
@@ -10,7 +10,7 @@ This package registers the fixed `codex` subagent provider. Each accepted run st
|
||||
|
||||
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.
|
||||
|
||||
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
|
||||
For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run.
|
||||
|
||||
Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they 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.
|
||||
|
||||
@@ -48,7 +48,7 @@ Shipped profiles load this provider once on the host and start no Codex process
|
||||
|
||||
## 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 npm package is a test-only dependency, and 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.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -84,7 +84,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, keyless real-product, and credentialed DeepSeek nonce tests.
|
||||
- **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.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.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。
|
||||
|
||||
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。
|
||||
对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。
|
||||
|
||||
本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。`dispose()` 具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
## 产品兼容性与证据
|
||||
|
||||
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.146.0` / `codex-cli 0.146.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。
|
||||
生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -84,7 +84,7 @@ Codex 子任务会在一个全新的临时线程中,以单个轮次接收这
|
||||
|
||||
- **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。
|
||||
- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。
|
||||
- **兼容性由开发证据锁定**:若要从已验证的 0.146.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
|
||||
- **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。
|
||||
- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。
|
||||
- **仅返回最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。
|
||||
- **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@openai/codex": "0.146.0",
|
||||
"@openai/codex": "0.147.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Minimal Codex app-server 0.146.0 protocol adapter. The shared JSON-RPC
|
||||
* Minimal Codex app-server 0.147.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.
|
||||
|
||||
@@ -109,8 +109,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
|
||||
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')
|
||||
expect(codexPackage.version).toBe('0.147.0')
|
||||
expect(version.stdout.trim()).toBe('codex-cli 0.147.0')
|
||||
|
||||
const parent = {
|
||||
id: 'deepseek-e2e-parent',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
const execFileAsync = promisify(execFile)
|
||||
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
const codexBinDir = join(packageRoot, 'node_modules', '.bin')
|
||||
const codexEntry = join(packageRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js')
|
||||
const codexPackage = JSON.parse(readFileSync(
|
||||
join(packageRoot, 'node_modules', '@openai', 'codex', 'package.json'),
|
||||
'utf8',
|
||||
@@ -40,7 +41,7 @@ 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 })
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -139,18 +140,18 @@ function responseInputTexts(body: Record<string, unknown>): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
describe('real @openai/codex 0.146.0 product', () => {
|
||||
describe('real @openai/codex 0.147.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 sentinel = 'REAL_CODEX_SENTINEL_0_147_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'], {
|
||||
expect(codexPackage.version).toBe('0.147.0')
|
||||
const version = await execFileAsync(process.execPath, [codexEntry, '--version'], {
|
||||
env: { ...process.env, ...harness.env },
|
||||
})
|
||||
expect(version.stdout.trim()).toBe('codex-cli 0.146.0')
|
||||
expect(version.stdout.trim()).toBe('codex-cli 0.147.0')
|
||||
|
||||
const run = await harness.ctx.subagents.start('codex', {
|
||||
prompt: [{ type: 'text', text: task }],
|
||||
@@ -173,16 +174,32 @@ describe('real @openai/codex 0.146.0 product', () => {
|
||||
}, 60_000)
|
||||
|
||||
it('cancels a real app-server command approval without executing the command', async () => {
|
||||
const { harness, fixture } = await realHarness([
|
||||
const command = process.platform === 'win32'
|
||||
? 'cmd /c type nul > approval-side-effect'
|
||||
: 'touch approval-side-effect'
|
||||
const commandCalls = [
|
||||
{
|
||||
kind: 'functionCall',
|
||||
name: 'exec_command',
|
||||
arguments: {
|
||||
cmd: 'touch approval-side-effect',
|
||||
cmd: command,
|
||||
sandbox_permissions: 'require_escalated',
|
||||
justification: 'exercise the unattended approval boundary',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'shell_command',
|
||||
arguments: {
|
||||
command,
|
||||
sandbox_permissions: 'require_escalated',
|
||||
justification: 'exercise the unattended approval boundary',
|
||||
},
|
||||
},
|
||||
] as const
|
||||
const { harness, fixture } = await realHarness([
|
||||
{
|
||||
kind: 'advertisedFunctionCall',
|
||||
choices: commandCalls,
|
||||
},
|
||||
])
|
||||
const sideEffect = join(harness.workspace, 'approval-side-effect')
|
||||
const run = await harness.ctx.subagents.start('codex', {
|
||||
@@ -199,9 +216,9 @@ describe('real @openai/codex 0.146.0 product', () => {
|
||||
expect(existsSync(sideEffect)).toBe(false)
|
||||
expect(fixture.requests).toHaveLength(1)
|
||||
const tools = fixture.requests[0]!.body.tools as Array<Record<string, unknown>>
|
||||
expect(tools).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'function', name: 'exec_command' }),
|
||||
]))
|
||||
expect(commandCalls.some(call => tools.some(tool => (
|
||||
tool.type === 'function' && tool.name === call.name
|
||||
)))).toBe(true)
|
||||
expect(fixture.requests.every(requestEntry =>
|
||||
requestEntry.headers.authorization === 'Bearer dsh-fake-openai-key',
|
||||
)).toBe(true)
|
||||
|
||||
@@ -22,6 +22,13 @@ export type ResponsesBehavior =
|
||||
readonly name: string
|
||||
readonly arguments: Record<string, unknown>
|
||||
}
|
||||
| {
|
||||
readonly kind: 'advertisedFunctionCall'
|
||||
readonly choices: readonly {
|
||||
readonly name: string
|
||||
readonly arguments: Record<string, unknown>
|
||||
}[]
|
||||
}
|
||||
| { readonly kind: 'hold' }
|
||||
|
||||
/** Running package-private Responses fixture. */
|
||||
@@ -86,7 +93,7 @@ function responseObject(text: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the minimal Responses SSE event sequence consumed by Codex 0.146.0.
|
||||
* Build the minimal Responses SSE event sequence consumed by Codex 0.147.0.
|
||||
* @param text - exact assistant answer.
|
||||
* @returns ordered response lifecycle events.
|
||||
*/
|
||||
@@ -218,6 +225,18 @@ function closeServer(server: Server): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function advertisedFunctionNames(body: Record<string, unknown>): Set<string> {
|
||||
if (!Array.isArray(body.tools)) return new Set()
|
||||
return new Set(body.tools.flatMap((tool): string[] => (
|
||||
tool !== null
|
||||
&& typeof tool === 'object'
|
||||
&& (tool as Record<string, unknown>).type === 'function'
|
||||
&& typeof (tool as Record<string, unknown>).name === 'string'
|
||||
? [(tool as Record<string, unknown>).name as string]
|
||||
: []
|
||||
)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback-only Responses SSE fixture.
|
||||
* @param script - one behavior per expected Responses request.
|
||||
@@ -234,11 +253,12 @@ export async function startResponsesFixture(
|
||||
openResponses.add(response)
|
||||
response.on('close', () => { openResponses.delete(response) })
|
||||
void readRequest(request).then((body) => {
|
||||
const parsedBody = JSON.parse(body) as Record<string, unknown>
|
||||
requests.push({
|
||||
method: request.method,
|
||||
path: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(body) as Record<string, unknown>,
|
||||
body: parsedBody,
|
||||
})
|
||||
started.resolve(undefined)
|
||||
const behavior = behaviors.shift()
|
||||
@@ -247,6 +267,14 @@ export async function startResponsesFixture(
|
||||
response.end(JSON.stringify({ error: { message: 'fixture script exhausted' } }))
|
||||
return
|
||||
}
|
||||
const advertisedCall = behavior.kind === 'advertisedFunctionCall'
|
||||
? behavior.choices.find(choice => advertisedFunctionNames(parsedBody).has(choice.name))
|
||||
: undefined
|
||||
if (behavior.kind === 'advertisedFunctionCall' && advertisedCall === undefined) {
|
||||
response.writeHead(500, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } }))
|
||||
return
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
@@ -254,9 +282,15 @@ export async function startResponsesFixture(
|
||||
'x-request-id': 'req_fixture',
|
||||
})
|
||||
if (behavior.kind === 'hold') return
|
||||
const events = behavior.kind === 'complete'
|
||||
? completeResponsesEvents(behavior.text)
|
||||
: functionCallEvents(behavior.name, behavior.arguments)
|
||||
let events: Record<string, unknown>[]
|
||||
if (behavior.kind === 'complete') {
|
||||
events = completeResponsesEvents(behavior.text)
|
||||
} else {
|
||||
const call = behavior.kind === 'functionCall'
|
||||
? behavior
|
||||
: advertisedCall!
|
||||
events = functionCallEvents(call.name, call.arguments)
|
||||
}
|
||||
for (const event of events) {
|
||||
response.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ async function initializeWire(): Promise<{
|
||||
wire.start()
|
||||
const initializing = wire.initialize(new AbortController().signal)
|
||||
const initialize = await child.peer.nextMethod('initialize')
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await initializing
|
||||
expect(await child.peer.nextMethod('initialized')).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
@@ -219,7 +219,7 @@ async function publishRun(
|
||||
) {
|
||||
const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
|
||||
const initialize = await child.peer.nextMethod('initialize')
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await child.peer.nextMethod('initialized')
|
||||
const threadStart = await child.peer.nextMethod('thread/start')
|
||||
child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
|
||||
@@ -380,7 +380,7 @@ describe('CodexAppServerWire', () => {
|
||||
requestAttestation: false,
|
||||
},
|
||||
})
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await initializing
|
||||
await child.peer.nextMethod('initialized')
|
||||
|
||||
@@ -855,7 +855,7 @@ describe('run lifecycle and quiescence', () => {
|
||||
void starting.then(() => { published = true })
|
||||
const initialize = await child.peer.nextMethod('initialize')
|
||||
expect(published).toBe(false)
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await child.peer.nextMethod('initialized')
|
||||
const threadStart = await child.peer.nextMethod('thread/start')
|
||||
expect(published).toBe(false)
|
||||
@@ -962,7 +962,7 @@ describe('run lifecycle and quiescence', () => {
|
||||
runSpec(child),
|
||||
)
|
||||
const initialize = await child.peer.nextMethod('initialize')
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await child.peer.nextMethod('initialized')
|
||||
const threadStart = await child.peer.nextMethod('thread/start')
|
||||
child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
|
||||
@@ -1032,7 +1032,7 @@ describe('run lifecycle and quiescence', () => {
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
const initialize = await child.peer.nextMethod('initialize')
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
|
||||
child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
|
||||
await child.peer.nextMethod('initialized')
|
||||
const threadStart = await child.peer.nextMethod('thread/start')
|
||||
child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
|
||||
|
||||
Reference in New Issue
Block a user