diff --git a/src/backends/shared/NativeToolEngine.ts b/src/backends/shared/NativeToolEngine.ts index 58b1ca9b..47f7c0a3 100644 --- a/src/backends/shared/NativeToolEngine.ts +++ b/src/backends/shared/NativeToolEngine.ts @@ -17,6 +17,8 @@ * the subprocess pattern captured here. */ +import type { z } from 'zod'; +import { getEngineSettings } from '../../config/engineSettings.js'; import type { AgentEngine, AgentEngineDefinition, @@ -102,6 +104,28 @@ export abstract class NativeToolEngine implements AgentEngine { await cleanupContextFiles(plan.repoDir); } + /** + * Resolve engine-specific settings from an execution plan. + * + * Reads from `input.engineSettings ?? input.project.engineSettings` and + * validates the result against `schema`. Returns `{}` (empty object typed + * as `z.infer`) when no settings are configured for this engine. + * + * Subclasses should call this inside `execute()` with their own schema, + * passing the engine id via `this.definition.id`: + * + * ```ts + * const raw = this.resolveSettings(input, MyEngineSettingsSchema); + * ``` + */ + protected resolveSettings>>( + input: AgentExecutionPlan, + schema: S, + ): z.infer { + const effectiveSettings = input.engineSettings ?? input.project.engineSettings; + return getEngineSettings(effectiveSettings, this.definition.id, schema) ?? ({} as z.infer); + } + /** * Subclasses must provide the actual subprocess execution logic. */ diff --git a/src/config/engineSettings.ts b/src/config/engineSettings.ts index 142bd218..33ddffeb 100644 --- a/src/config/engineSettings.ts +++ b/src/config/engineSettings.ts @@ -1,30 +1,16 @@ import { z } from 'zod'; -// Import and re-export schemas from engine directories. -import { ClaudeCodeSettingsSchema } from '../backends/claude-code/settings.js'; -export { ClaudeCodeSettingsSchema }; -export type { ClaudeCodeSettings } from '../backends/claude-code/settings.js'; -import { CodexSettingsSchema } from '../backends/codex/settings.js'; -export { CodexSettingsSchema }; -export type { CodexSettings } from '../backends/codex/settings.js'; -import { OpenCodeSettingsSchema } from '../backends/opencode/settings.js'; -export { OpenCodeSettingsSchema }; -export type { OpenCodeSettings } from '../backends/opencode/settings.js'; - /** * Dynamic registry of engine settings schemas. * Engines register their schema during bootstrap via registerEngineSettingsSchema(). + * + * Schemas are registered exclusively through registerBuiltInEngines() (called from + * bootstrap.ts) — there are no static pre-registration imports here. All entry + * points (router, worker, dashboard) must call registerBuiltInEngines() before any + * config parsing that uses EngineSettingsSchema. */ const ENGINE_SETTINGS_SCHEMAS: Map>> = new Map(); -// Pre-register built-in engine settings schemas at module initialization. -// Any process that imports EngineSettingsSchema (via config/schema.ts) will -// have these registered automatically — no explicit registerBuiltInEngines() -// call required for schema validation. -ENGINE_SETTINGS_SCHEMAS.set('claude-code', ClaudeCodeSettingsSchema); -ENGINE_SETTINGS_SCHEMAS.set('codex', CodexSettingsSchema); -ENGINE_SETTINGS_SCHEMAS.set('opencode', OpenCodeSettingsSchema); - /** * Register a settings schema for an engine. Called during bootstrap when an engine * implementing getSettingsSchema() is registered. diff --git a/tests/unit/backends/NativeToolEngine.test.ts b/tests/unit/backends/NativeToolEngine.test.ts index 522ce35f..56037cf5 100644 --- a/tests/unit/backends/NativeToolEngine.test.ts +++ b/tests/unit/backends/NativeToolEngine.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { NativeToolEngine } from '../../../src/backends/shared/NativeToolEngine.js'; import type { AgentEngineDefinition, @@ -61,6 +62,14 @@ class StubEngine extends NativeToolEngine { throw new Error(`Incompatible model: ${cascadeModel}`); } + // Expose protected resolveSettings for testing + resolveSettingsPublic>>( + input: AgentExecutionPlan, + schema: S, + ): z.infer { + return this.resolveSettings(input, schema); + } + // Simple stub: always returns a successful result async execute(_input: AgentExecutionPlan): Promise { return { success: true, output: 'stub output' }; @@ -245,6 +254,73 @@ describe('NativeToolEngine (via StubEngine)', () => { }); }); + // ------------------------------------------------------------------------- + // resolveSettings — reads engine settings from the execution plan + // ------------------------------------------------------------------------- + describe('resolveSettings', () => { + const StubSchema = z.object({ + timeout: z.number().optional(), + mode: z.string().optional(), + }); + + it('returns {} when no engineSettings or project.engineSettings are set', () => { + const plan = makeMinimalPlan(); + const result = engine.resolveSettingsPublic(plan, StubSchema); + expect(result).toEqual({}); + }); + + it('returns settings from project.engineSettings when input.engineSettings is absent', () => { + const plan = makeMinimalPlan({ + project: { + id: 'test-project', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + engineSettings: { stub: { timeout: 5000, mode: 'fast' } }, + } as AgentExecutionPlan['project'], + }); + const result = engine.resolveSettingsPublic(plan, StubSchema); + expect(result).toEqual({ timeout: 5000, mode: 'fast' }); + }); + + it('prefers input.engineSettings over project.engineSettings', () => { + const plan = makeMinimalPlan({ + project: { + id: 'test-project', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + engineSettings: { stub: { timeout: 1000 } }, + } as AgentExecutionPlan['project'], + engineSettings: { stub: { timeout: 9999 } }, + }); + const result = engine.resolveSettingsPublic(plan, StubSchema); + expect(result).toEqual({ timeout: 9999 }); + }); + + it('returns {} when engineSettings exists but has no entry for this engine id', () => { + const plan = makeMinimalPlan({ + engineSettings: { 'other-engine': { timeout: 5000 } }, + }); + const result = engine.resolveSettingsPublic(plan, StubSchema); + expect(result).toEqual({}); + }); + + it('uses definition.id ("stub") as the engine settings key', () => { + const plan = makeMinimalPlan({ + engineSettings: { stub: { timeout: 42 } }, + }); + const result = engine.resolveSettingsPublic(plan, StubSchema); + expect(result.timeout).toBe(42); + }); + }); + // ------------------------------------------------------------------------- // implements AgentEngine interface // -------------------------------------------------------------------------