Update agent SDKs and add gated Codex goals support (#114)
* add gated codex goals support * sync README SDK versions * bump claude agent sdk * add codex goals settings toggle
This commit is contained in:
@@ -370,6 +370,79 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareGroupEnvironment Codex goals handling', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
let previousCodexGoals: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-goals-'));
|
||||
previousCwd = process.cwd();
|
||||
previousCodexGoals = process.env.CODEX_GOALS;
|
||||
process.chdir(tempRoot);
|
||||
process.env.EJ_TEST_ROOT = tempRoot;
|
||||
process.env.EJ_TEST_HOME = path.join(tempRoot, 'home');
|
||||
delete process.env.CODEX_GOALS;
|
||||
|
||||
fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
mockReadEnvFile.mockReset();
|
||||
mockGetActiveCodexAuthPath.mockReset();
|
||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false);
|
||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||
chat_jid: 'dc:test',
|
||||
owner_service_id: 'claude',
|
||||
reviewer_service_id: 'codex-main',
|
||||
arbiter_service_id: null,
|
||||
owner_failover_active: false,
|
||||
activated_at: null,
|
||||
reason: null,
|
||||
explicit: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
delete process.env.EJ_TEST_ROOT;
|
||||
delete process.env.EJ_TEST_HOME;
|
||||
if (previousCodexGoals) process.env.CODEX_GOALS = previousCodexGoals;
|
||||
else delete process.env.CODEX_GOALS;
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps Codex goals disabled by default and enables them only via opt-in config', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
|
||||
const defaultPrepared = prepareGroupEnvironment(group, false, 'dc:test');
|
||||
expect(defaultPrepared.env.CODEX_GOALS).toBeUndefined();
|
||||
|
||||
const enabledPrepared = prepareGroupEnvironment(
|
||||
{
|
||||
...group,
|
||||
agentConfig: {
|
||||
codexGoals: true,
|
||||
},
|
||||
},
|
||||
false,
|
||||
'dc:test',
|
||||
);
|
||||
expect(enabledPrepared.env.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
|
||||
it('allows CODEX_GOALS env opt-in for Codex runner sessions', () => {
|
||||
mockReadEnvFile.mockReturnValue({
|
||||
CODEX_GOALS: 'true',
|
||||
});
|
||||
|
||||
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
|
||||
|
||||
expect(prepared.env.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareReadonlySessionEnvironment codex compatibility', () => {
|
||||
let tempRoot: string;
|
||||
let previousCwd: string;
|
||||
|
||||
@@ -319,6 +319,15 @@ function prepareCodexSessionEnvironment(args: {
|
||||
process.env.CODEX_EFFORT;
|
||||
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
|
||||
|
||||
const codexGoals =
|
||||
args.group.agentConfig?.codexGoals ??
|
||||
(args.envVars.CODEX_GOALS === 'true' || process.env.CODEX_GOALS === 'true');
|
||||
if (codexGoals) {
|
||||
args.env.CODEX_GOALS = 'true';
|
||||
} else {
|
||||
delete args.env.CODEX_GOALS;
|
||||
}
|
||||
|
||||
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
|
||||
syncHostCodexSessionFiles(sessionCodexDir);
|
||||
|
||||
@@ -527,6 +536,7 @@ export function prepareGroupEnvironment(
|
||||
'CLAUDE_EFFORT',
|
||||
'CODEX_MODEL',
|
||||
'CODEX_EFFORT',
|
||||
'CODEX_GOALS',
|
||||
]);
|
||||
|
||||
const env = buildBaseRunnerEnv({
|
||||
|
||||
@@ -680,3 +680,59 @@ OUROBOROS_LLM_BACKEND = "codex"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-runner Codex goals', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
fakeProc = createFakeProcess();
|
||||
vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => {
|
||||
const str = String(p);
|
||||
return (
|
||||
str.includes('dist/index.js') ||
|
||||
str.includes('dist/ipc-mcp-stdio.js') ||
|
||||
str.endsWith('/.codex/config.toml')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes Codex goal opt-in config through env and runner stdin payload', async () => {
|
||||
let stdinPayload = '';
|
||||
fakeProc.stdin.on('data', (chunk) => {
|
||||
stdinPayload += chunk.toString();
|
||||
});
|
||||
|
||||
const codexGroup: RegisteredGroup = {
|
||||
...testGroup,
|
||||
agentType: 'codex',
|
||||
agentConfig: {
|
||||
codexGoals: true,
|
||||
},
|
||||
};
|
||||
|
||||
const resultPromise = runAgentProcess(
|
||||
codexGroup,
|
||||
testInput,
|
||||
() => {},
|
||||
async () => {},
|
||||
);
|
||||
|
||||
fakeProc.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
expect(JSON.parse(stdinPayload)).toMatchObject({
|
||||
prompt: 'Hello',
|
||||
codexGoals: true,
|
||||
});
|
||||
|
||||
const spawnEnv = vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env as
|
||||
| Record<string, string>
|
||||
| undefined;
|
||||
expect(spawnEnv?.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface AgentInput {
|
||||
useTaskScopedSession?: boolean;
|
||||
assistantName?: string;
|
||||
agentType?: 'claude-code' | 'codex';
|
||||
codexGoals?: boolean;
|
||||
roomRoleContext?: RoomRoleContext;
|
||||
}
|
||||
|
||||
@@ -150,13 +151,17 @@ export async function runAgentProcess(
|
||||
|
||||
onProcess(proc, processName, env.EJCLAW_IPC_DIR);
|
||||
|
||||
proc.stdin.write(JSON.stringify(input));
|
||||
const runnerInput: AgentInput = {
|
||||
...input,
|
||||
...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}),
|
||||
};
|
||||
proc.stdin.write(JSON.stringify(runnerInput));
|
||||
proc.stdin.end();
|
||||
|
||||
runSpawnedAgentProcess({
|
||||
proc,
|
||||
group,
|
||||
input,
|
||||
input: runnerInput,
|
||||
processName,
|
||||
logsDir,
|
||||
startTime,
|
||||
|
||||
45
src/settings-store.test.ts
Normal file
45
src/settings-store.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getCodexFeatures, updateCodexFeatures } from './settings-store.js';
|
||||
|
||||
describe('settings-store Codex features', () => {
|
||||
let tempDir: string;
|
||||
let previousCwd: string;
|
||||
let previousCodexGoals: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-'));
|
||||
previousCwd = process.cwd();
|
||||
previousCodexGoals = process.env.CODEX_GOALS;
|
||||
delete process.env.CODEX_GOALS;
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
if (previousCodexGoals === undefined) {
|
||||
delete process.env.CODEX_GOALS;
|
||||
} else {
|
||||
process.env.CODEX_GOALS = previousCodexGoals;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
|
||||
expect(getCodexFeatures()).toEqual({ goals: false });
|
||||
|
||||
expect(updateCodexFeatures({ goals: true })).toEqual({ goals: true });
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
|
||||
'CODEX_GOALS=true',
|
||||
);
|
||||
|
||||
expect(updateCodexFeatures({ goals: false })).toEqual({ goals: false });
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
|
||||
'CODEX_GOALS=false',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,10 @@ export interface FastModeSnapshot {
|
||||
claude: boolean;
|
||||
}
|
||||
|
||||
export interface CodexFeatureSnapshot {
|
||||
goals: boolean;
|
||||
}
|
||||
|
||||
const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
|
||||
|
||||
function envFilePath(): string {
|
||||
@@ -634,6 +638,36 @@ export function updateFastMode(
|
||||
return getFastMode();
|
||||
}
|
||||
|
||||
function readCodexGoals(): boolean {
|
||||
return readEnvOrProcess('CODEX_GOALS') === 'true';
|
||||
}
|
||||
|
||||
function writeCodexGoals(value: boolean): void {
|
||||
const file = envFilePath();
|
||||
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const updated = setOrInsertEnvLine(
|
||||
content,
|
||||
'CODEX_GOALS',
|
||||
value ? 'true' : 'false',
|
||||
);
|
||||
const tempPath = `${file}.tmp`;
|
||||
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, file);
|
||||
}
|
||||
|
||||
export function getCodexFeatures(): CodexFeatureSnapshot {
|
||||
return {
|
||||
goals: readCodexGoals(),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCodexFeatures(
|
||||
input: Partial<CodexFeatureSnapshot>,
|
||||
): CodexFeatureSnapshot {
|
||||
if (typeof input.goals === 'boolean') writeCodexGoals(input.goals);
|
||||
return getCodexFeatures();
|
||||
}
|
||||
|
||||
export function removeAccountDirectory(
|
||||
provider: 'claude' | 'codex',
|
||||
index: number,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AgentConfig {
|
||||
// Per-group model/effort overrides (take precedence over global env vars)
|
||||
codexModel?: string;
|
||||
codexEffort?: string;
|
||||
codexGoals?: boolean;
|
||||
claudeModel?: string;
|
||||
claudeEffort?: string;
|
||||
claudeThinking?: 'adaptive' | 'enabled' | 'disabled';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type {
|
||||
ClaudeAccountSummary,
|
||||
CodexAccountSummary,
|
||||
CodexFeatureSnapshot,
|
||||
FastModeSnapshot,
|
||||
ModelConfigSnapshot,
|
||||
} from './settings-store.js';
|
||||
@@ -42,6 +43,7 @@ const modelConfig: ModelConfigSnapshot = {
|
||||
};
|
||||
|
||||
const fastMode: FastModeSnapshot = { codex: true, claude: false };
|
||||
const codexFeatures: CodexFeatureSnapshot = { goals: false };
|
||||
|
||||
const moaSettings: MoaSettingsSnapshot = {
|
||||
enabled: true,
|
||||
@@ -107,6 +109,7 @@ function makeDeps(
|
||||
responseLength: 2,
|
||||
}),
|
||||
getActiveCodexSettingsIndex: () => 1,
|
||||
getCodexFeatures: () => codexFeatures,
|
||||
getFastMode: () => fastMode,
|
||||
getModelConfig: () => modelConfig,
|
||||
getMoaSettings: () => moaSettings,
|
||||
@@ -116,6 +119,7 @@ function makeDeps(
|
||||
refreshCodexAccount: async () => makeCodexAccount(),
|
||||
removeAccountDirectory: () => undefined,
|
||||
setActiveCodexSettingsIndex: () => undefined,
|
||||
updateCodexFeatures: () => codexFeatures,
|
||||
updateFastMode: () => fastMode,
|
||||
updateModelConfig: () => modelConfig,
|
||||
updateMoaSettings: () => moaSettings,
|
||||
@@ -155,6 +159,10 @@ describe('web dashboard settings routes', () => {
|
||||
expect(mode?.status).toBe(200);
|
||||
await expect(mode?.json()).resolves.toEqual(fastMode);
|
||||
|
||||
const features = await route('/api/settings/codex-features');
|
||||
expect(features?.status).toBe(200);
|
||||
await expect(features?.json()).resolves.toEqual(codexFeatures);
|
||||
|
||||
const moa = await route('/api/settings/moa');
|
||||
expect(moa?.status).toBe(200);
|
||||
const moaJson = (await moa?.json()) as MoaSettingsSnapshot;
|
||||
@@ -187,6 +195,10 @@ describe('web dashboard settings routes', () => {
|
||||
calls.push(`models:${JSON.stringify(input)}`);
|
||||
return modelConfig;
|
||||
},
|
||||
updateCodexFeatures: (input) => {
|
||||
calls.push(`codex-features:${JSON.stringify(input)}`);
|
||||
return { goals: input.goals === true };
|
||||
},
|
||||
updateMoaSettings: (input) => {
|
||||
calls.push(`moa:${JSON.stringify(input)}`);
|
||||
return moaSettings;
|
||||
@@ -230,6 +242,15 @@ describe('web dashboard settings routes', () => {
|
||||
codexCurrentIndex: 1,
|
||||
});
|
||||
|
||||
const goals = await route(
|
||||
'/api/settings/codex-features',
|
||||
'PATCH',
|
||||
{ goals: true },
|
||||
deps,
|
||||
);
|
||||
expect(goals?.status).toBe(200);
|
||||
await expect(goals?.json()).resolves.toEqual({ goals: true });
|
||||
|
||||
const moa = await route(
|
||||
'/api/settings/moa',
|
||||
'PATCH',
|
||||
@@ -255,6 +276,7 @@ describe('web dashboard settings routes', () => {
|
||||
'add:claude-token',
|
||||
'delete:codex:4',
|
||||
'current:4',
|
||||
'codex-features:{"goals":true}',
|
||||
'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
addClaudeAccountFromToken,
|
||||
getActiveCodexSettingsIndex,
|
||||
getCodexFeatures,
|
||||
getFastMode,
|
||||
getModelConfig,
|
||||
listClaudeAccounts,
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
refreshCodexAccount,
|
||||
removeAccountDirectory,
|
||||
setActiveCodexSettingsIndex,
|
||||
updateCodexFeatures,
|
||||
updateFastMode,
|
||||
updateModelConfig,
|
||||
type ClaudeAccountSummary,
|
||||
type CodexFeatureSnapshot,
|
||||
type CodexAccountSummary,
|
||||
type FastModeSnapshot,
|
||||
type ModelConfigSnapshot,
|
||||
@@ -33,6 +36,7 @@ export interface SettingsRouteDependencies {
|
||||
addClaudeAccountFromToken: typeof addClaudeAccountFromToken;
|
||||
checkMoaModel: typeof checkMoaModel;
|
||||
getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex;
|
||||
getCodexFeatures: typeof getCodexFeatures;
|
||||
getFastMode: typeof getFastMode;
|
||||
getModelConfig: typeof getModelConfig;
|
||||
getMoaSettings: typeof getMoaSettings;
|
||||
@@ -42,6 +46,7 @@ export interface SettingsRouteDependencies {
|
||||
refreshCodexAccount: typeof refreshCodexAccount;
|
||||
removeAccountDirectory: typeof removeAccountDirectory;
|
||||
setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex;
|
||||
updateCodexFeatures: typeof updateCodexFeatures;
|
||||
updateFastMode: typeof updateFastMode;
|
||||
updateModelConfig: typeof updateModelConfig;
|
||||
updateMoaSettings: typeof updateMoaSettings;
|
||||
@@ -58,6 +63,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
|
||||
addClaudeAccountFromToken,
|
||||
checkMoaModel,
|
||||
getActiveCodexSettingsIndex,
|
||||
getCodexFeatures,
|
||||
getFastMode,
|
||||
getModelConfig,
|
||||
getMoaSettings,
|
||||
@@ -67,6 +73,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
|
||||
refreshCodexAccount,
|
||||
removeAccountDirectory,
|
||||
setActiveCodexSettingsIndex,
|
||||
updateCodexFeatures,
|
||||
updateFastMode,
|
||||
updateModelConfig,
|
||||
updateMoaSettings,
|
||||
@@ -133,6 +140,23 @@ async function handleFastModeRoute(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodexFeaturesRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
deps: SettingsRouteDependencies,
|
||||
): Promise<Response | null> {
|
||||
if (readMethod(request.method)) return jsonResponse(deps.getCodexFeatures());
|
||||
if (request.method !== 'PUT' && request.method !== 'PATCH') return null;
|
||||
|
||||
const body = await readJsonObject(request, jsonResponse);
|
||||
if (body instanceof Response) return body;
|
||||
try {
|
||||
return jsonResponse(deps.updateCodexFeatures(body));
|
||||
} catch (err) {
|
||||
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoaSettingsRoute(
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
@@ -299,6 +323,9 @@ export async function handleSettingsRoute({
|
||||
if (url.pathname === '/api/settings/fast-mode') {
|
||||
return handleFastModeRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/codex-features') {
|
||||
return handleCodexFeaturesRoute(request, jsonResponse, deps);
|
||||
}
|
||||
if (url.pathname === '/api/settings/moa') {
|
||||
return handleMoaSettingsRoute(request, jsonResponse, deps);
|
||||
}
|
||||
@@ -335,6 +362,7 @@ export async function handleSettingsRoute({
|
||||
export type {
|
||||
ClaudeAccountSummary,
|
||||
CodexAccountSummary,
|
||||
CodexFeatureSnapshot,
|
||||
FastModeSnapshot,
|
||||
ModelConfigSnapshot,
|
||||
MoaSettingsSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user