diff --git a/src/core/config.ts b/src/core/config.ts index e5287a17..fc06234e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -209,6 +209,20 @@ function buildEnvBase(cwd: string, env: ResolvedEnv): ProjectRuntimeConfig { "CODEX_REASONING_EFFORT_REVIEW_TEST", ), }, + fastModes: { + plan: normalizeBooleanEnvValue( + env.CODEX_FAST_MODE_PLAN, + "CODEX_FAST_MODE_PLAN", + ), + implement: normalizeBooleanEnvValue( + env.CODEX_FAST_MODE_IMPLEMENT, + "CODEX_FAST_MODE_IMPLEMENT", + ), + reviewTest: normalizeBooleanEnvValue( + env.CODEX_FAST_MODE_REVIEW_TEST, + "CODEX_FAST_MODE_REVIEW_TEST", + ), + }, sandbox, codexHome, }, @@ -788,6 +802,11 @@ function mergeRuntime( ...(rootDefaults.codex?.reasoningEfforts ?? {}), ...(project.codex?.reasoningEfforts ?? {}), }, + fastModes: { + ...(base.codex.fastModes ?? {}), + ...(rootDefaults.codex?.fastModes ?? {}), + ...(project.codex?.fastModes ?? {}), + }, }, skills: { root: skillRoot, @@ -983,6 +1002,30 @@ function normalizeReasoningEffortValue( ); } +function normalizeBooleanEnvValue( + input: string | undefined, + envName: string, +): boolean | undefined { + if (!input) { + return undefined; + } + + const value = input.trim().toLowerCase(); + if (!value || value === "off" || value === "none") { + return undefined; + } + if (value === "1" || value === "true" || value === "yes" || value === "on") { + return true; + } + if (value === "0" || value === "false" || value === "no") { + return false; + } + + throw new Error( + `Invalid ${envName} value '${input}'. Use true/false, 1/0, yes/no, or leave empty.`, + ); +} + function validateProject(project: ResolvedProjectConfig): void { if (!project.linear.apiKey) { throw new Error(`LINEAR_API_KEY is required for project '${project.id}'`); diff --git a/src/core/types.ts b/src/core/types.ts index 77bd553d..379135dd 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -81,6 +81,11 @@ export interface ProjectRuntimeConfig { implement?: CodexReasoningEffort; reviewTest?: CodexReasoningEffort; }; + fastModes?: { + plan?: boolean; + implement?: boolean; + reviewTest?: boolean; + }; plugins?: string[]; skillsets?: string[]; /** diff --git a/src/services/codex-adapter.ts b/src/services/codex-adapter.ts index 490945ce..3e87eb2a 100644 --- a/src/services/codex-adapter.ts +++ b/src/services/codex-adapter.ts @@ -15,12 +15,14 @@ export class CodexAdapter implements AgentAdapter { const reasoningEffort = this.config.codex.reasoningEfforts?.plan ?? this.config.codex.reasoningEffort; + const fastModeEnabled = this.config.codex.fastModes?.plan; return this.runCodex( this.buildExecArgs( prompt, await this.nextOutputFile(), model, reasoningEffort, + fastModeEnabled, ), ); } @@ -31,6 +33,7 @@ export class CodexAdapter implements AgentAdapter { const reasoningEffort = this.config.codex.reasoningEfforts?.implement ?? this.config.codex.reasoningEffort; + const fastModeEnabled = this.config.codex.fastModes?.implement; return this.runCodex( this.buildResumeArgs( sessionId, @@ -38,6 +41,7 @@ export class CodexAdapter implements AgentAdapter { await this.nextOutputFile(), model, reasoningEffort, + fastModeEnabled, ), ); } @@ -51,12 +55,16 @@ export class CodexAdapter implements AgentAdapter { this.config.codex.reasoningEfforts?.reviewTest ?? this.config.codex.reasoningEfforts?.implement ?? this.config.codex.reasoningEffort; + const fastModeEnabled = + this.config.codex.fastModes?.reviewTest ?? + this.config.codex.fastModes?.implement; return this.runCodex( this.buildExecArgs( prompt, await this.nextOutputFile(), model, reasoningEffort, + fastModeEnabled, ), ); } @@ -66,6 +74,7 @@ export class CodexAdapter implements AgentAdapter { outputFile: string, modelOverride?: string, reasoningEffortOverride?: CodexReasoningEffort, + fastModeEnabled?: boolean, ): string[] { const args = [ "exec", @@ -83,7 +92,7 @@ export class CodexAdapter implements AgentAdapter { if (this.config.codex.sandbox) { args.push("--sandbox", this.config.codex.sandbox); } - this.appendConfigArgs(args, reasoningEffortOverride); + this.appendConfigArgs(args, reasoningEffortOverride, fastModeEnabled); args.push(prompt); return args; } @@ -94,6 +103,7 @@ export class CodexAdapter implements AgentAdapter { outputFile: string, modelOverride?: string, reasoningEffortOverride?: CodexReasoningEffort, + fastModeEnabled?: boolean, ): string[] { const args = [ "exec", @@ -107,7 +117,7 @@ export class CodexAdapter implements AgentAdapter { if (model) { args.push("--model", model); } - this.appendConfigArgs(args, reasoningEffortOverride); + this.appendConfigArgs(args, reasoningEffortOverride, fastModeEnabled); args.push(sessionId, prompt); return args; } @@ -149,14 +159,19 @@ export class CodexAdapter implements AgentAdapter { private appendConfigArgs( args: string[], reasoningEffortOverride?: CodexReasoningEffort, + fastModeEnabled?: boolean, ): void { - for (const override of this.buildConfigOverrides(reasoningEffortOverride)) { + for (const override of this.buildConfigOverrides( + reasoningEffortOverride, + fastModeEnabled, + )) { args.push("--config", override); } } private buildConfigOverrides( reasoningEffortOverride?: CodexReasoningEffort, + fastModeEnabled?: boolean, ): string[] { const overrides: string[] = []; const plugins = normalizeList(this.config.codex.plugins); @@ -174,6 +189,10 @@ export class CodexAdapter implements AgentAdapter { `model_reasoning_effort=${JSON.stringify(reasoningEffortOverride)}`, ); } + if (fastModeEnabled) { + overrides.push('service_tier="fast"'); + overrides.push("features.fast_mode=true"); + } for (const [rawKey, rawValue] of Object.entries( this.config.codex.configOverrides ?? {}, )) { diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 07b476ae..1a8a618f 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -48,6 +48,11 @@ const config: ResolvedProjectConfig = { plan: "high", implement: "low", }, + fastModes: { + plan: true, + implement: false, + reviewTest: true, + }, plugins: ["github@openai-curated", "linear@openai-curated"], skillsets: ["adhd-ai", "repo-defaults"], configOverrides: { @@ -128,6 +133,32 @@ describe("codex adapter", () => { expect(calls[2]).toContain('model_reasoning_effort="low"'); }); + it("uses stage-specific fast mode overrides", async () => { + const adapter = new CodexAdapter(config); + const calls: string[][] = []; + ( + adapter as unknown as { runCodex: (args: string[]) => Promise } + ).runCodex = async (args: string[]) => { + calls.push(args); + return { finalMessage: "", stdout: "" }; + }; + ( + adapter as unknown as { nextOutputFile: () => Promise } + ).nextOutputFile = async () => "/tmp/out.txt"; + + await adapter.runPlan("plan prompt"); + await adapter.resume("session-1", "implement prompt"); + await adapter.runReview("review prompt"); + + expect(calls).toHaveLength(3); + expect(calls[0]).toContain('service_tier="fast"'); + expect(calls[0]).toContain("features.fast_mode=true"); + expect(calls[1]).not.toContain('service_tier="fast"'); + expect(calls[1]).not.toContain("features.fast_mode=true"); + expect(calls[2]).toContain('service_tier="fast"'); + expect(calls[2]).toContain("features.fast_mode=true"); + }); + it("keeps raw config override as final reasoning-effort escape hatch", () => { const adapter = new CodexAdapter({ ...config, @@ -140,7 +171,7 @@ describe("codex adapter", () => { }); const overrides = ( adapter as unknown as { - buildConfigOverrides: (effort?: string) => string[]; + buildConfigOverrides: (effort?: string, fastMode?: boolean) => string[]; } ).buildConfigOverrides("low"); const first = overrides.indexOf('model_reasoning_effort="low"'); @@ -148,4 +179,30 @@ describe("codex adapter", () => { expect(first).toBeGreaterThanOrEqual(0); expect(second).toBeGreaterThan(first); }); + + it("keeps raw config override as final fast-mode escape hatch", () => { + const adapter = new CodexAdapter({ + ...config, + codex: { + ...config.codex, + configOverrides: { + service_tier: '"default"', + "features.fast_mode": "false", + }, + }, + }); + const overrides = ( + adapter as unknown as { + buildConfigOverrides: (effort?: string, fastMode?: boolean) => string[]; + } + ).buildConfigOverrides(undefined, true); + const fastTier = overrides.indexOf('service_tier="fast"'); + const defaultTier = overrides.indexOf('service_tier="default"'); + const fastModeTrue = overrides.indexOf("features.fast_mode=true"); + const fastModeFalse = overrides.indexOf("features.fast_mode=false"); + expect(fastTier).toBeGreaterThanOrEqual(0); + expect(defaultTier).toBeGreaterThan(fastTier); + expect(fastModeTrue).toBeGreaterThanOrEqual(0); + expect(fastModeFalse).toBeGreaterThan(fastModeTrue); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 0abd9c35..78c8da63 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -24,6 +24,9 @@ const envKeys = [ "CODEX_REASONING_EFFORT_PLAN", "CODEX_REASONING_EFFORT_IMPLEMENT", "CODEX_REASONING_EFFORT_REVIEW_TEST", + "CODEX_FAST_MODE_PLAN", + "CODEX_FAST_MODE_IMPLEMENT", + "CODEX_FAST_MODE_REVIEW_TEST", "CODEX_MODEL_PLAN", "CODEX_MODEL_IMPLEMENT", "CODEX_MODEL_REVIEW_TEST", @@ -58,6 +61,9 @@ describe("loadConfig", () => { key === "CODEX_REASONING_EFFORT_PLAN" || key === "CODEX_REASONING_EFFORT_IMPLEMENT" || key === "CODEX_REASONING_EFFORT_REVIEW_TEST" || + key === "CODEX_FAST_MODE_PLAN" || + key === "CODEX_FAST_MODE_IMPLEMENT" || + key === "CODEX_FAST_MODE_REVIEW_TEST" || key === "CLAUDE_CODE_MODEL" || key === "CLAUDE_CODE_ALLOWED_TOOLS" ? "" @@ -591,6 +597,82 @@ describe("loadConfig", () => { } }); + it("loads stage-specific codex fast mode from env", async () => { + process.env.CODEX_FAST_MODE_PLAN = "true"; + process.env.CODEX_FAST_MODE_IMPLEMENT = "1"; + process.env.CODEX_FAST_MODE_REVIEW_TEST = "no"; + const tempDir = await mkdtemp( + path.join(process.cwd(), ".tmp-config-test-"), + ); + await writeFile( + path.join(tempDir, "adhd-ai.config.ts"), + ["export default { projects: [{ id: 'default' }] };", ""].join("\n"), + ); + + try { + const config = await loadConfig(tempDir); + expect(config.projects[0]?.codex.fastModes).toEqual({ + plan: true, + implement: true, + reviewTest: false, + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("preserves env fast mode values when config overrides only plan", async () => { + process.env.CODEX_FAST_MODE_IMPLEMENT = "true"; + process.env.CODEX_FAST_MODE_REVIEW_TEST = "false"; + const tempDir = await mkdtemp( + path.join(process.cwd(), ".tmp-config-test-"), + ); + await writeFile( + path.join(tempDir, "adhd-ai.config.ts"), + [ + "export default {", + " codex: {", + " fastModes: {", + " plan: true", + " }", + " },", + " projects: [{ id: 'default' }]", + "};", + "", + ].join("\n"), + ); + + try { + const config = await loadConfig(tempDir); + expect(config.projects[0]?.codex.fastModes).toEqual({ + plan: true, + implement: true, + reviewTest: false, + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("throws on invalid codex fast mode env value", async () => { + process.env.CODEX_FAST_MODE_PLAN = "maybe"; + const tempDir = await mkdtemp( + path.join(process.cwd(), ".tmp-config-test-"), + ); + await writeFile( + path.join(tempDir, "adhd-ai.config.ts"), + ["export default { projects: [{ id: 'default' }] };", ""].join("\n"), + ); + + try { + await expect(loadConfig(tempDir)).rejects.toThrow( + "Invalid CODEX_FAST_MODE_PLAN value", + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("does not stream codex logs by default", async () => { process.env.PIV_DEV_MODE = "0"; process.env.PIV_PRINT_CODEX_LOGS = "0";