add codex goals settings toggle

This commit is contained in:
ejclaw
2026-05-02 00:06:26 +09:00
parent 5b544b69c6
commit e6f417c343
7 changed files with 265 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ describe('SettingsPanel', () => {
expect(html).toContain('모델');
expect(html).toContain('MoA 참조 모델');
expect(html).toContain('패스트 모드');
expect(html).toContain('Codex 실험 기능');
expect(html).toContain('불러오는 중');
expect(html).toContain('Claude');
expect(html).toContain('계정');

View File

@@ -3,17 +3,20 @@ import { useEffect, useState } from 'react';
import {
type ClaudeAccountSummary,
type CodexAccountSummary,
type CodexFeatureSnapshot,
type FastModeSnapshot,
type ModelConfigSnapshot,
type ModelRoleConfig,
addClaudeAccount,
deleteAccount,
fetchAccounts,
fetchCodexFeatures,
fetchFastMode,
fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateCodexFeatures,
updateFastMode,
updateModels,
} from './api';
@@ -82,6 +85,8 @@ export function SettingsPanel({
<FastModeSettings />
<CodexFeatureSettings onRestartStack={onRestartStack} />
<AccountSettings onRestartStack={onRestartStack} />
</div>
);
@@ -302,6 +307,104 @@ function FastModeSettings() {
);
}
function CodexFeatureSettings({
onRestartStack,
}: {
onRestartStack: () => void;
}) {
const [state, setState] = useState<CodexFeatureSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
fetchCodexFeatures()
.then((snapshot) => {
if (cancelled) return;
setState(snapshot);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggleGoals() {
if (!state) return;
const previous = state;
const optimistic = { ...state, goals: !state.goals };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateCodexFeatures({ goals: optimistic.goals });
setState(fresh);
setSavedAt(Date.now());
} catch (err) {
setState(previous);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h3>Codex </h3>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint"> </p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">/goal</span>
<small className="settings-hint">
CODEX_GOALS=true Codex 0.128 under-development goals
. OFF이며, .
</small>
</span>
<input
checked={state.goals}
disabled={busy}
onChange={() => void toggleGoals()}
type="checkbox"
/>
</label>
<div className="settings-actions">
{savedAt ? (
<small className="settings-hint">
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
</>
)}
</section>
);
}
function formatExpiry(
iso: string | null,
): { label: string; cls: string } | null {

View File

@@ -388,6 +388,10 @@ export interface FastModeSnapshot {
claude: boolean;
}
export interface CodexFeatureSnapshot {
goals: boolean;
}
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
@@ -538,6 +542,34 @@ export async function updateFastMode(
return (await response.json()) as FastModeSnapshot;
}
export async function fetchCodexFeatures(): Promise<CodexFeatureSnapshot> {
return fetchJson('/api/settings/codex-features');
}
export async function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>,
): Promise<CodexFeatureSnapshot> {
const response = await fetch('/api/settings/codex-features', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update codex features failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as CodexFeatureSnapshot;
}
export async function fetchMoaSettings(): Promise<MoaSettingsSnapshot> {
return fetchJson('/api/settings/moa');
}

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

View File

@@ -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,

View File

@@ -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}]}',
]);
});

View File

@@ -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,