feat(subagent): add Codex product provider
This commit is contained in:
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
Reference in New Issue
Block a user