Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/backends/shared/NativeToolEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
* the subprocess pattern captured here.
*/

import type { z } from 'zod';
import { getEngineSettings } from '../../config/engineSettings.js';
import type {
AgentEngine,
AgentEngineDefinition,
Expand Down Expand Up @@ -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<S>`) 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<S extends z.ZodType<Record<string, unknown>>>(
input: AgentExecutionPlan,
schema: S,
): z.infer<S> {
const effectiveSettings = input.engineSettings ?? input.project.engineSettings;
return getEngineSettings(effectiveSettings, this.definition.id, schema) ?? ({} as z.infer<S>);
}

/**
* Subclasses must provide the actual subprocess execution logic.
*/
Expand Down
24 changes: 5 additions & 19 deletions src/config/engineSettings.ts
Original file line number Diff line number Diff line change
@@ -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<string, z.ZodType<Record<string, unknown>>> = 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.
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/backends/NativeToolEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,6 +62,14 @@ class StubEngine extends NativeToolEngine {
throw new Error(`Incompatible model: ${cascadeModel}`);
}

// Expose protected resolveSettings for testing
resolveSettingsPublic<S extends z.ZodType<Record<string, unknown>>>(
input: AgentExecutionPlan,
schema: S,
): z.infer<S> {
return this.resolveSettings(input, schema);
}

// Simple stub: always returns a successful result
async execute(_input: AgentExecutionPlan): Promise<AgentEngineResult> {
return { success: true, output: 'stub output' };
Expand Down Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
Loading