feat(subagent): add Codex product provider

This commit is contained in:
pku-xht
2026-08-04 16:02:17 +08:00
parent 4af4c10075
commit 1daa35b6e3
45 changed files with 3170 additions and 126 deletions

View 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)
})

View 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)
},
}
}

File diff suppressed because it is too large Load Diff