feat: feature-flag Codex SDK runner (#218)
Add optional @openai/codex-sdk exec-backed runner mode behind CODEX_RUNTIME=sdk, with CODEX_RUNTIME_SDK_ROLES canary limiting and app-server fallback for unsupported flows.
This commit is contained in:
@@ -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": {
|
||||
|
||||
301
runners/codex-runner/src/codex-sdk-client.ts
Normal file
301
runners/codex-runner/src/codex-sdk-client.ts
Normal file
@@ -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<ThreadEvent | unknown> }>;
|
||||
}
|
||||
|
||||
export interface CodexSdkPort {
|
||||
startThread(options?: ThreadOptions): CodexSdkThreadPort;
|
||||
resumeThread(id: string, options?: ThreadOptions): CodexSdkThreadPort;
|
||||
}
|
||||
|
||||
export interface CodexSdkClientOptions {
|
||||
codex?: CodexSdkPort;
|
||||
env?: Record<string, string>;
|
||||
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<string, CodexSdkThreadPort>();
|
||||
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<string> {
|
||||
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<void>;
|
||||
interrupt: () => Promise<void>;
|
||||
wait: () => Promise<CodexSdkTurnResult>;
|
||||
}> {
|
||||
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<CodexSdkTurnResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -455,8 +638,20 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
85
runners/codex-runner/src/runtime-mode.ts
Normal file
85
runners/codex-runner/src/runtime-mode.ts
Normal file
@@ -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' };
|
||||
}
|
||||
64
runners/codex-runner/test/runtime-mode.test.ts
Normal file
64
runners/codex-runner/test/runtime-mode.test.ts
Normal file
@@ -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' });
|
||||
});
|
||||
});
|
||||
194
runners/codex-runner/test/sdk-client.test.ts
Normal file
194
runners/codex-runner/test/sdk-client.test.ts
Normal file
@@ -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<unknown> {
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user