diff --git a/bun.lock b/bun.lock index 7ba4144..f99aa81 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,8 @@ "name": "ejclaw-codex-runner", "version": "1.0.0", "dependencies": { - "@openai/codex": "0.133.0", + "@openai/codex": "0.136.0", + "@openai/codex-sdk": "0.136.0", "ejclaw-runners-shared": "workspace:*", }, "devDependencies": { @@ -221,19 +222,21 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@openai/codex": ["@openai/codex@0.133.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.133.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.133.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.133.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.133.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.133.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.133.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ=="], + "@openai/codex": ["@openai/codex@0.136.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.136.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.136.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.136.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.136.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.136.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.136.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-yu+diXznSOt8h296/CDOf2CNi55z1raA7aZ6sejjGy1QWPqn72YkBc2ByTzZG6G+1yiUqlm2G5Hid54hm3/kaA=="], - "@openai/codex-darwin-arm64": ["@openai/codex@0.133.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg=="], + "@openai/codex-darwin-arm64": ["@openai/codex@0.136.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9xnEohnUO9l6qtPSDbG0Y2UXX5mBZ9Lsn0VMgjtkREGfWL9TawNC1d7D1ERMSJtHTlVvieoaFyK9LHPLQCO9Vw=="], - "@openai/codex-darwin-x64": ["@openai/codex@0.133.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg=="], + "@openai/codex-darwin-x64": ["@openai/codex@0.136.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-HD9YLalPg0cv+CiZSmRWOl/8sefuJlxJcAmxyzb8mSaAMchD3V+2yS6M6E1Z2I6NX1irp4Polt1fPsjGdlHYhQ=="], - "@openai/codex-linux-arm64": ["@openai/codex@0.133.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ=="], + "@openai/codex-linux-arm64": ["@openai/codex@0.136.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-xDxTOk5ClEX/nzlQseqEoiubjIzVZfcWKn9nmKkHOLKknz+c7n6tbmw7eY4mwt29zTAAoFoecdRnbbsPZUHJ1g=="], - "@openai/codex-linux-x64": ["@openai/codex@0.133.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA=="], + "@openai/codex-linux-x64": ["@openai/codex@0.136.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-Q6yZHRtUWD+27B5yAiOYyPAtM0BzW0Mm23YGaXDmfNEO5BYgHuUT80VDofUpjSyo2+ShYoZQUFVQAsNPKqd+Sw=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.133.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g=="], + "@openai/codex-sdk": ["@openai/codex-sdk@0.136.0", "", { "dependencies": { "@openai/codex": "0.136.0" } }, "sha512-qrSirrVxrpVHR1YIQQ4WSouneaQrQrRAAlQWJYRflvMCcruFXorsmIPeIbDqFXOa8A9H9FHHyc+2U4tdbEGGiQ=="], - "@openai/codex-win32-x64": ["@openai/codex@0.133.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ=="], + "@openai/codex-win32-arm64": ["@openai/codex@0.136.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-mAHZXfygQxBg1JjZ9+bAZfBXe6fg8MabJhuDYhj5z0VF++orKSFsC1lMiv6PksQN36ikQefgVjc9EOlaE4lt7g=="], + + "@openai/codex-win32-x64": ["@openai/codex@0.136.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-zS6DAmvjdWeAB1CL9KTUMkwzTwfXtxHy8GAtePw2a93jIqawoG07fBxAXuyoHZ3QXQkwEgqBx1zEEh33gdIKAw=="], "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], diff --git a/runners/codex-runner/package.json b/runners/codex-runner/package.json index ea04ccc..4387092 100644 --- a/runners/codex-runner/package.json +++ b/runners/codex-runner/package.json @@ -10,7 +10,8 @@ "start": "bun dist/index.js" }, "dependencies": { - "@openai/codex": "0.133.0", + "@openai/codex": "0.136.0", + "@openai/codex-sdk": "0.136.0", "ejclaw-runners-shared": "workspace:*" }, "devDependencies": { diff --git a/runners/codex-runner/src/codex-sdk-client.ts b/runners/codex-runner/src/codex-sdk-client.ts new file mode 100644 index 0000000..2ea1fb6 --- /dev/null +++ b/runners/codex-runner/src/codex-sdk-client.ts @@ -0,0 +1,301 @@ +import { + Codex, + type CodexOptions, + type Input, + type ModelReasoningEffort, + type ThreadEvent, + type ThreadOptions, + type Usage, + type UserInput, +} from '@openai/codex-sdk'; + +import type { AppServerInputItem } from './app-server-client.js'; + +export interface CodexSdkThreadPort { + id: string | null; + runStreamed( + input: Input | unknown, + options?: { signal?: AbortSignal }, + ): Promise<{ events: AsyncIterable }>; +} + +export interface CodexSdkPort { + startThread(options?: ThreadOptions): CodexSdkThreadPort; + resumeThread(id: string, options?: ThreadOptions): CodexSdkThreadPort; +} + +export interface CodexSdkClientOptions { + codex?: CodexSdkPort; + env?: Record; + log: (message: string) => void; + codexPathOverride?: string; + config?: CodexOptions['config']; +} + +export interface CodexSdkThreadStartOptions { + cwd: string; + model?: string; + effort?: string; +} + +export interface CodexSdkTurnOptions extends CodexSdkThreadStartOptions { + onProgress?: (message: string) => void; +} + +export interface CodexSdkTurnState { + status: 'pending' | 'inProgress' | 'completed' | 'failed' | 'interrupted'; + threadId: string | null; + finalAnswer: string | null; + latestAgentMessage: string | null; + errorMessage: string | null; + usage: Usage | null; +} + +export interface CodexSdkTurnResult { + state: CodexSdkTurnState; + result: string | null; + threadId: string | null; +} + +export function createInitialCodexSdkTurnState(): CodexSdkTurnState { + return { + status: 'pending', + threadId: null, + finalAnswer: null, + latestAgentMessage: null, + errorMessage: null, + usage: null, + }; +} + +function isKnownReasoningEffort( + effort: string | undefined, +): effort is ModelReasoningEffort { + return ( + effort === 'minimal' || + effort === 'low' || + effort === 'medium' || + effort === 'high' || + effort === 'xhigh' + ); +} + +function normalizeReasoningEffort( + effort: string | undefined, +): ModelReasoningEffort | undefined { + if (effort === 'minimal') { + return 'low'; + } + return isKnownReasoningEffort(effort) ? effort : undefined; +} + +function buildThreadOptions( + options: CodexSdkThreadStartOptions, +): ThreadOptions { + return { + approvalPolicy: 'never', + model: options.model, + modelReasoningEffort: normalizeReasoningEffort(options.effort), + networkAccessEnabled: true, + sandboxMode: 'danger-full-access', + workingDirectory: options.cwd, + }; +} + +export function toCodexSdkInput(input: AppServerInputItem[]): UserInput[] { + return input.map((item) => { + if (item.type === 'text') { + return { type: 'text', text: item.text }; + } + return { type: 'local_image', path: item.path }; + }); +} + +export function reduceCodexSdkTurnState( + state: CodexSdkTurnState, + event: ThreadEvent | unknown, +): CodexSdkTurnState { + if (!event || typeof event !== 'object' || !('type' in event)) { + return state; + } + + const typed = event as ThreadEvent; + if (typed.type === 'thread.started') { + return { ...state, threadId: typed.thread_id }; + } + + if (typed.type === 'turn.started') { + return { ...state, status: 'inProgress' }; + } + + if (typed.type === 'item.completed') { + if (typed.item.type === 'agent_message') { + const text = typed.item.text.trim(); + if (!text) return state; + return { + ...state, + finalAnswer: text, + latestAgentMessage: text, + }; + } + if (typed.item.type === 'error') { + return { + ...state, + errorMessage: typed.item.message, + }; + } + return state; + } + + if (typed.type === 'turn.completed') { + return { + ...state, + status: state.status === 'failed' ? 'failed' : 'completed', + usage: typed.usage, + }; + } + + if (typed.type === 'turn.failed') { + return { + ...state, + status: 'failed', + errorMessage: typed.error.message, + }; + } + + if (typed.type === 'error') { + return { + ...state, + status: 'failed', + errorMessage: typed.message, + }; + } + + return state; +} + +function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'AbortError' || + error.message.toLowerCase().includes('abort')) + ); +} + +export class CodexSdkClient { + private readonly codex: CodexSdkPort; + private readonly log: (message: string) => void; + private readonly threads = new Map(); + private nextPendingThreadId = 1; + + constructor(options: CodexSdkClientOptions) { + this.log = options.log; + this.codex = + options.codex ?? + new Codex({ + codexPathOverride: options.codexPathOverride, + config: options.config, + env: options.env, + }); + } + + async startOrResumeThread( + sessionId: string | undefined, + options: CodexSdkThreadStartOptions, + ): Promise { + const threadOptions = buildThreadOptions(options); + const thread = sessionId + ? this.codex.resumeThread(sessionId, threadOptions) + : this.codex.startThread(threadOptions); + const handle = + sessionId ?? `pending-sdk-thread-${this.nextPendingThreadId++}`; + this.threads.set(handle, thread); + return handle; + } + + async startTurn( + threadHandle: string, + input: AppServerInputItem[], + options: CodexSdkTurnOptions, + ): Promise<{ + turnId: string; + steer: (nextInput: AppServerInputItem[]) => Promise; + interrupt: () => Promise; + wait: () => Promise; + }> { + const thread = this.threads.get(threadHandle); + if (!thread) { + throw new Error(`Unknown Codex SDK thread handle: ${threadHandle}`); + } + + const abortController = new AbortController(); + const waitPromise = this.consumeTurn( + threadHandle, + thread, + input, + options, + abortController.signal, + ); + + return { + turnId: threadHandle, + steer: async () => { + throw new Error( + 'Codex SDK runner does not support mid-turn steering; queue the message for the next turn.', + ); + }, + interrupt: async () => { + abortController.abort(); + }, + wait: async () => waitPromise, + }; + } + + private async consumeTurn( + threadHandle: string, + thread: CodexSdkThreadPort, + input: AppServerInputItem[], + options: CodexSdkTurnOptions, + signal: AbortSignal, + ): Promise { + let state = createInitialCodexSdkTurnState(); + try { + const { events } = await thread.runStreamed(toCodexSdkInput(input), { + signal, + }); + for await (const event of events) { + state = reduceCodexSdkTurnState(state, event); + if (state.threadId && state.threadId !== threadHandle) { + this.threads.set(state.threadId, thread); + this.threads.delete(threadHandle); + } + if ( + state.latestAgentMessage && + state.latestAgentMessage !== state.finalAnswer + ) { + options.onProgress?.(state.latestAgentMessage); + } + } + } catch (error) { + if (isAbortError(error)) { + state = { ...state, status: 'interrupted' }; + } else { + const message = error instanceof Error ? error.message : String(error); + this.log(`Codex SDK turn failed: ${message}`); + state = { ...state, status: 'failed', errorMessage: message }; + } + } + + const threadId = state.threadId ?? thread.id; + if (threadId && threadId !== threadHandle) { + this.threads.set(threadId, thread); + this.threads.delete(threadHandle); + } + + return { + state: { ...state, threadId }, + result: state.finalAnswer, + threadId, + }; + } +} diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index 0db0b5e..14b5680 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -1,7 +1,10 @@ /** * EJClaw Codex Runner * - * App-server only runtime. + * App-server by default, with optional CODEX_RUNTIME=sdk lane. + * SDK mode uses @openai/codex-sdk / codex exec JSON events and leaves + * mid-turn IPC follow-ups queued for the next run because SDK steering is + * not currently available. * * Input protocol: * Stdin: Full RunnerInput JSON (read until EOF) @@ -27,6 +30,8 @@ import { import { CodexAppServerClient } from './app-server-client.js'; import { parseAppServerInput } from './app-server-input.js'; +import { CodexSdkClient } from './codex-sdk-client.js'; +import { resolveCodexRuntimeMode } from './runtime-mode.js'; import { prependRoomRoleHeader, type RoomRoleContext, @@ -104,6 +109,15 @@ function log(message: string): void { console.error(`[codex-runner] ${message}`); } +function toStringEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => { + const [, value] = entry; + return typeof value === 'string'; + }), + ); +} + function isCodexGoalsEnabled(runnerInput: RunnerInput): boolean { return runnerInput.codexGoals === true || process.env.CODEX_GOALS === 'true'; } @@ -283,6 +297,117 @@ async function executeAppServerTurn( } } +function countQueuedIpcInputFiles(): number { + try { + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + return fs + .readdirSync(IPC_INPUT_DIR) + .filter((file) => file.endsWith('.json')).length; + } catch (err) { + log( + `IPC queued input count error: ${err instanceof Error ? err.message : String(err)}`, + ); + return 0; + } +} + +async function executeSdkTurn( + client: CodexSdkClient, + threadHandle: string, + prompt: string, + retryCount = 0, +): Promise<{ + result: string | null; + error?: string; + threadId: string; +}> { + let lastProgressMessage: string | null = null; + const activeTurn = await client.startTurn( + threadHandle, + parseAppServerInput(prompt, log), + { + cwd: EFFECTIVE_CWD, + model: CODEX_MODEL || undefined, + effort: CODEX_EFFORT || undefined, + onProgress: (message) => { + const trimmed = message.trim(); + if (!trimmed || trimmed === lastProgressMessage) { + return; + } + lastProgressMessage = trimmed; + writeOutput({ + status: 'success', + phase: 'progress', + ...normalizeStructuredOutput(trimmed), + newSessionId: threadHandle, + }); + }, + }, + ); + + let elapsedMs = 0; + let polling = true; + let lastQueuedInputCount = 0; + const pollDuringTurn = async () => { + if (!polling) return; + + if (consumeCloseSentinel()) { + log('Close sentinel detected during SDK turn, interrupting'); + polling = false; + try { + await activeTurn.interrupt(); + } catch (err) { + log( + `Failed to interrupt active SDK turn: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + return; + } + + const queuedInputCount = countQueuedIpcInputFiles(); + if (queuedInputCount > 0 && queuedInputCount !== lastQueuedInputCount) { + lastQueuedInputCount = queuedInputCount; + log( + `SDK runtime cannot steer active turns; leaving ${queuedInputCount} queued input file(s) for the next run`, + ); + } + + elapsedMs += IPC_POLL_MS; + if (elapsedMs > 0 && elapsedMs % 60000 === 0) { + log(`SDK turn in progress... (${formatProgressElapsed(elapsedMs)})`); + } + setTimeout(() => void pollDuringTurn(), IPC_POLL_MS); + }; + + setTimeout(() => void pollDuringTurn(), IPC_POLL_MS); + + try { + const { state, result, threadId } = await activeTurn.wait(); + const finalThreadId = threadId ?? threadHandle; + if (state.status === 'completed') { + return { result, threadId: finalThreadId }; + } + if (state.status === 'interrupted' && consumeCloseSentinel()) { + return { result, threadId: finalThreadId }; + } + if (state.status === 'interrupted' && retryCount < 1) { + log('Codex SDK turn interrupted, retrying once...'); + return executeSdkTurn(client, finalThreadId, prompt, retryCount + 1); + } + return { + result, + error: + state.errorMessage || + `Codex SDK turn finished with status ${state.status}`, + threadId: finalThreadId, + }; + } finally { + polling = false; + } +} + async function runAppServerCompact( client: CodexAppServerClient, threadId: string | undefined, @@ -405,6 +530,64 @@ async function runAppServerSession( } } +async function runSdkSession( + runnerInput: RunnerInput, + prompt: string, +): Promise { + const reviewerRuntime = + isReviewerRuntimeEnvEnabled(process.env) || + isReviewerRuntime(runnerInput.roomRoleContext); + const readonlyRuntime = + reviewerRuntime || isArbiterRuntimeEnvEnabled(process.env); + const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime); + assertReadonlyWorkspaceRepoConnectivity(clientEnv, readonlyRuntime); + const client = new CodexSdkClient({ + env: toStringEnv(clientEnv), + log, + }); + + const threadHandle = await client.startOrResumeThread(runnerInput.sessionId, { + cwd: EFFECTIVE_CWD, + model: CODEX_MODEL || undefined, + effort: CODEX_EFFORT || undefined, + }); + log( + runnerInput.sessionId + ? `SDK thread resumed (${threadHandle})` + : `SDK thread started (${threadHandle})`, + ); + + log('Starting SDK turn...'); + const { result, error, threadId } = await executeSdkTurn( + client, + threadHandle, + prompt, + ); + + if (error) { + const normalized = normalizeStructuredOutput(result || null); + log(`SDK turn error: ${error}`); + writeOutput({ + status: 'error', + ...normalized, + newSessionId: threadId, + error, + }); + } else { + const normalized = normalizeStructuredOutput(result || null); + writeOutput({ + status: 'success', + ...normalized, + ...(result ? { phase: 'final' as const } : {}), + newSessionId: threadId, + }); + } + + if (consumeCloseSentinel()) { + log('Close sentinel detected, exiting SDK runtime'); + } +} + // ── Main ────────────────────────────────────────────────────────── async function main(): Promise { @@ -455,8 +638,20 @@ async function main(): Promise { } try { - log('Runtime selected: app-server'); - await runAppServerSession(runnerInput, prompt); + const runtimeMode = resolveCodexRuntimeMode( + process.env, + { + codexGoals: isCodexGoalsEnabled(runnerInput), + roomRole: runnerInput.roomRoleContext?.role, + }, + rawPrompt, + ); + log(`Runtime selected: ${runtimeMode.mode} (${runtimeMode.reason})`); + if (runtimeMode.mode === 'sdk') { + await runSdkSession(runnerInput, prompt); + } else { + await runAppServerSession(runnerInput, prompt); + } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); log(`Runner error: ${errorMessage}`); diff --git a/runners/codex-runner/src/runtime-mode.ts b/runners/codex-runner/src/runtime-mode.ts new file mode 100644 index 0000000..f675ed8 --- /dev/null +++ b/runners/codex-runner/src/runtime-mode.ts @@ -0,0 +1,85 @@ +export type CodexRuntimeMode = 'app-server' | 'sdk'; + +export interface CodexRuntimeModeInput { + codexGoals?: boolean; + roomRole?: string; +} + +export interface CodexRuntimeModeResolution { + mode: CodexRuntimeMode; + reason: + | 'default' + | 'CODEX_RUNTIME=app-server' + | 'CODEX_RUNTIME=sdk' + | 'sdk-unsupported-goals' + | 'sdk-unsupported-session-command' + | 'sdk-role-not-enabled' + | 'invalid-CODEX_RUNTIME'; +} + +function normalizeRuntimeMode( + raw: string | undefined, +): CodexRuntimeMode | null { + if (!raw) return null; + const normalized = raw.trim().toLowerCase(); + if (normalized === 'app-server' || normalized === 'appserver') { + return 'app-server'; + } + if (normalized === 'sdk') { + return 'sdk'; + } + return null; +} + +export interface CodexRuntimeEnv { + [key: string]: string | undefined; + CODEX_RUNTIME?: string; + CODEX_RUNTIME_SDK_ROLES?: string; +} + +function sdkRoleEnabled( + env: CodexRuntimeEnv, + roomRole: string | undefined, +): boolean { + const raw = env.CODEX_RUNTIME_SDK_ROLES?.trim(); + if (!raw) return true; + if (!roomRole) return false; + const allowed = raw + .split(',') + .map((role) => role.trim().toLowerCase()) + .filter(Boolean); + return allowed.includes(roomRole.trim().toLowerCase()); +} + +export function resolveCodexRuntimeMode( + env: CodexRuntimeEnv, + input: CodexRuntimeModeInput, + rawPrompt: string, +): CodexRuntimeModeResolution { + const requested = normalizeRuntimeMode(env.CODEX_RUNTIME); + if (requested === 'sdk') { + if (rawPrompt.trim() === '/compact') { + return { + mode: 'app-server', + reason: 'sdk-unsupported-session-command', + }; + } + if (input.codexGoals === true) { + return { mode: 'app-server', reason: 'sdk-unsupported-goals' }; + } + if (!sdkRoleEnabled(env, input.roomRole)) { + return { mode: 'app-server', reason: 'sdk-role-not-enabled' }; + } + return { mode: 'sdk', reason: 'CODEX_RUNTIME=sdk' }; + } + + if (requested === 'app-server') { + return { mode: 'app-server', reason: 'CODEX_RUNTIME=app-server' }; + } + + if (env.CODEX_RUNTIME && env.CODEX_RUNTIME.trim()) { + return { mode: 'app-server', reason: 'invalid-CODEX_RUNTIME' }; + } + + return { mode: 'app-server', reason: 'default' }; +} diff --git a/runners/codex-runner/test/runtime-mode.test.ts b/runners/codex-runner/test/runtime-mode.test.ts new file mode 100644 index 0000000..40e4245 --- /dev/null +++ b/runners/codex-runner/test/runtime-mode.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveCodexRuntimeMode } from '../src/runtime-mode.js'; + +describe('codex runner runtime mode selection', () => { + it('keeps app-server as the default runtime', () => { + expect( + resolveCodexRuntimeMode({}, { codexGoals: false }, 'do work'), + ).toEqual({ + mode: 'app-server', + reason: 'default', + }); + }); + + it('selects SDK mode when CODEX_RUNTIME=sdk', () => { + expect( + resolveCodexRuntimeMode( + { CODEX_RUNTIME: 'sdk' }, + { codexGoals: false }, + 'do work', + ), + ).toEqual({ mode: 'sdk', reason: 'CODEX_RUNTIME=sdk' }); + }); + + it('canary-limits SDK mode to configured paired roles', () => { + expect( + resolveCodexRuntimeMode( + { CODEX_RUNTIME: 'sdk', CODEX_RUNTIME_SDK_ROLES: 'owner,arbiter' }, + { codexGoals: false, roomRole: 'owner' }, + 'do work', + ), + ).toEqual({ mode: 'sdk', reason: 'CODEX_RUNTIME=sdk' }); + expect( + resolveCodexRuntimeMode( + { CODEX_RUNTIME: 'sdk', CODEX_RUNTIME_SDK_ROLES: 'owner,arbiter' }, + { codexGoals: false, roomRole: 'reviewer' }, + 'do work', + ), + ).toEqual({ mode: 'app-server', reason: 'sdk-role-not-enabled' }); + }); + + it('falls back to app-server for /compact because SDK lacks compaction', () => { + expect( + resolveCodexRuntimeMode( + { CODEX_RUNTIME: 'sdk' }, + { codexGoals: false }, + '/compact', + ), + ).toEqual({ + mode: 'app-server', + reason: 'sdk-unsupported-session-command', + }); + }); + + it('falls back to app-server when Codex goals are enabled', () => { + expect( + resolveCodexRuntimeMode( + { CODEX_RUNTIME: 'sdk' }, + { codexGoals: true }, + 'do work', + ), + ).toEqual({ mode: 'app-server', reason: 'sdk-unsupported-goals' }); + }); +}); diff --git a/runners/codex-runner/test/sdk-client.test.ts b/runners/codex-runner/test/sdk-client.test.ts new file mode 100644 index 0000000..243a509 --- /dev/null +++ b/runners/codex-runner/test/sdk-client.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; + +import { + CodexSdkClient, + createInitialCodexSdkTurnState, + reduceCodexSdkTurnState, + toCodexSdkInput, + type CodexSdkThreadPort, +} from '../src/codex-sdk-client.js'; + +const usage = { + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 2, + reasoning_output_tokens: 0, +}; + +async function* events(items: unknown[]): AsyncGenerator { + for (const item of items) { + yield item; + } +} + +class FakeThread implements CodexSdkThreadPort { + id: string | null = null; + lastInput: unknown = null; + lastSignal: AbortSignal | undefined; + + constructor(private readonly scriptedEvents: unknown[]) {} + + async runStreamed(input: unknown, options?: { signal?: AbortSignal }) { + this.lastInput = input; + this.lastSignal = options?.signal; + return { events: events(this.scriptedEvents) }; + } +} + +class FakeCodex { + readonly started: unknown[] = []; + readonly resumed: unknown[] = []; + readonly threads: FakeThread[] = []; + + constructor(private readonly scriptedEvents: unknown[]) {} + + startThread(options?: unknown): FakeThread { + this.started.push(options); + const thread = new FakeThread(this.scriptedEvents); + this.threads.push(thread); + return thread; + } + + resumeThread(id: string, options?: unknown): FakeThread { + this.resumed.push({ id, options }); + const thread = new FakeThread(this.scriptedEvents); + thread.id = id; + this.threads.push(thread); + return thread; + } +} + +describe('Codex SDK runner spike', () => { + it('maps existing app-server text/image input into Codex SDK input shape', () => { + expect( + toCodexSdkInput([ + { type: 'text', text: 'inspect this' }, + { type: 'localImage', path: '/tmp/screenshot.png' }, + ]), + ).toEqual([ + { type: 'text', text: 'inspect this' }, + { type: 'local_image', path: '/tmp/screenshot.png' }, + ]); + }); + + it('reduces SDK thread events into the runner final result and thread id', () => { + let state = createInitialCodexSdkTurnState(); + state = reduceCodexSdkTurnState(state, { + type: 'thread.started', + thread_id: 'thread-1', + }); + state = reduceCodexSdkTurnState(state, { type: 'turn.started' }); + state = reduceCodexSdkTurnState(state, { + type: 'item.completed', + item: { + id: 'msg-1', + type: 'agent_message', + text: 'STEP_DONE\n검증 완료', + }, + }); + state = reduceCodexSdkTurnState(state, { + type: 'turn.completed', + usage, + }); + + expect(state).toMatchObject({ + status: 'completed', + threadId: 'thread-1', + finalAnswer: 'STEP_DONE\n검증 완료', + usage, + }); + }); + + it('runs a streamed SDK turn and returns the first real thread id', async () => { + const codex = new FakeCodex([ + { type: 'thread.started', thread_id: 'thread-42' }, + { type: 'turn.started' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'TASK_DONE\nSDK_OK' }, + }, + { type: 'turn.completed', usage }, + ]); + const client = new CodexSdkClient({ + codex, + env: { PATH: '/usr/bin' }, + log: () => undefined, + }); + + const handle = await client.startOrResumeThread(undefined, { + cwd: '/repo', + model: 'gpt-5.5', + effort: 'high', + }); + const turn = await client.startTurn( + handle, + [{ type: 'text', text: 'respond' }], + { cwd: '/repo', model: 'gpt-5.5', effort: 'high' }, + ); + + await expect(turn.wait()).resolves.toMatchObject({ + threadId: 'thread-42', + result: 'TASK_DONE\nSDK_OK', + state: { status: 'completed' }, + }); + expect(codex.started).toEqual([ + { + approvalPolicy: 'never', + model: 'gpt-5.5', + modelReasoningEffort: 'high', + networkAccessEnabled: true, + sandboxMode: 'danger-full-access', + workingDirectory: '/repo', + }, + ]); + expect(codex.threads[0].lastInput).toEqual([ + { type: 'text', text: 'respond' }, + ]); + }); + + it('coerces minimal effort to low because Codex SDK exec keeps tools enabled', async () => { + const codex = new FakeCodex([ + { type: 'thread.started', thread_id: 'thread-minimal' }, + { type: 'turn.started' }, + { + type: 'item.completed', + item: { id: 'msg-1', type: 'agent_message', text: 'SDK_OK' }, + }, + { type: 'turn.completed', usage }, + ]); + const client = new CodexSdkClient({ codex, log: () => undefined }); + + await client.startOrResumeThread(undefined, { + cwd: '/repo', + effort: 'minimal', + }); + + expect(codex.started[0]).toMatchObject({ modelReasoningEffort: 'low' }); + }); + + it('documents the SDK gap: mid-turn steering is not available', async () => { + const codex = new FakeCodex([ + { type: 'thread.started', thread_id: 'thread-1' }, + { type: 'turn.started' }, + { type: 'turn.completed', usage }, + ]); + const client = new CodexSdkClient({ codex, log: () => undefined }); + const handle = await client.startOrResumeThread('thread-1', { + cwd: '/repo', + }); + const turn = await client.startTurn( + handle, + [{ type: 'text', text: 'start' }], + { + cwd: '/repo', + }, + ); + + await expect( + turn.steer([{ type: 'text', text: 'follow-up' }]), + ).rejects.toThrow('Codex SDK runner does not support mid-turn steering'); + await expect(turn.wait()).resolves.toMatchObject({ + state: { status: 'completed' }, + }); + }); +}); diff --git a/spikes/001-codex-sdk-runner/README.md b/spikes/001-codex-sdk-runner/README.md new file mode 100644 index 0000000..ec5767c --- /dev/null +++ b/spikes/001-codex-sdk-runner/README.md @@ -0,0 +1,81 @@ +# 001: codex-sdk-runner + +## Question + +Given EJClaw currently wraps `codex app-server` JSON-RPC directly, when we switch the runner core to the official `@openai/codex-sdk`, can we preserve the key runner guarantees: thread resume, final result extraction, cancellation, and structured event visibility? + +## Approach + +- Added `@openai/codex-sdk@0.136.0` beside `@openai/codex@0.136.0` in the codex runner workspace. +- Built a `CodexSdkClient` adapter that maps EJClaw's existing app-server input shape into SDK input. +- Added `CODEX_RUNTIME=sdk` runtime selection while keeping app-server as the default. +- Added `CODEX_RUNTIME_SDK_ROLES=owner,arbiter` canary limiting so production can try SDK only on selected paired roles. +- Fallbacks: `/compact` and `codexGoals` stay on app-server because SDK lacks those APIs. +- Wrote unit tests around SDK event reduction, thread-id propagation, runtime selection, and unsupported steering. +- Ran real smoke turns through both the SDK adapter and the integrated codex-runner entrypoint against temporary git repos. + +## Evidence + +Commands run: + +```bash +bunx vitest run runners/codex-runner/test/sdk-client.test.ts +bunx vitest run runners/codex-runner/test/runtime-mode.test.ts +bunx tsc --noEmit -p runners/codex-runner/tsconfig.json +bun run --cwd runners/codex-runner build +bunx vitest run \ + runners/codex-runner/test/app-server-client.test.ts \ + runners/codex-runner/test/app-server-state.test.ts \ + runners/codex-runner/test/runtime-mode.test.ts \ + runners/codex-runner/test/sdk-client.test.ts +bun run check +CODEX_RUNTIME=sdk CODEX_EFFORT=minimal node runners/codex-runner/dist/index.js < /tmp/input.json +``` + +Observed adapter smoke result: + +```json +{ + "status": "completed", + "result": "SDK_OK", + "error": null +} +``` + +Observed integrated runner smoke result: + +```json +{ + "status": "success", + "result": "SDK_RUNNER_OK", + "phase": "final", + "newSessionId": "019e..." +} +``` + +## What worked + +- SDK import and runner workspace build work on Node/Bun in this repo. +- SDK streaming events can be reduced into EJClaw-style `status`, `threadId`, `result`, `error`, and `usage`. +- `thread.started` gives the real thread id during the first streamed turn, so a pending local handle can be replaced with the real session id. +- `AbortController` gives a clean replacement for close-sentinel cancellation. +- Real SDK smoke and integrated runner smoke completed successfully. +- Runtime flag selection works: default app-server; `CODEX_RUNTIME=sdk` selects SDK; `CODEX_RUNTIME_SDK_ROLES` canaries SDK to chosen paired roles; `/compact` and goals fall back to app-server. + +## What did not work / gaps + +- SDK does **not** expose app-server `turn/steer`; mid-turn Discord follow-up steering would need to be queued for the next turn or stay on the app-server path. +- SDK TypeScript wrapper uses `codex exec --experimental-json`, not persistent `codex app-server`, so process-per-turn overhead and session behavior must be measured under real long tasks. +- Passing `modelReasoningEffort: "minimal"` failed in live smoke because Codex exec still enabled tools that are incompatible with minimal reasoning. The spike adapter coerces `minimal` to `low`. +- Existing Codex app-server-only features (`thread/goal/*`, `thread/compact/start`) are not available through the current SDK API. + +## Verdict: FEATURE-FLAGGED + +SDK is now viable as an optional **exec-backed Codex runner lane** behind `CODEX_RUNTIME=sdk`. The default remains app-server, and `CODEX_RUNTIME_SDK_ROLES=owner,arbiter` can limit SDK to safer paired roles while reviewer/non-paired turns keep the old path. + +## Recommended rollout + +1. Merge as a draft/feature-flagged implementation without changing production env. +2. Enable `CODEX_RUNTIME=sdk` for one low-risk owner/arbiter lane first. +3. Keep app-server mode for rooms/tasks that require live steering, goals, or compaction. +4. Watch `turn.failed`, `error`, `item.completed:error`, empty final output, and process-per-turn latency before promoting SDK broadly.