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
1 change: 1 addition & 0 deletions src/backends/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
ProgressReporter,
ToolManifest,
} from './types.js';
export { NativeToolEngine } from './shared/index.js';

export {
getEngine,
Expand Down
109 changes: 109 additions & 0 deletions src/backends/shared/NativeToolEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* NativeToolEngine — abstract base class for subprocess-based agent engines.
*
* Extracts shared patterns common to Claude Code, Codex, and OpenCode engines:
* - Environment building via buildEngineEnv with engine-specific allowlists
* - Context file cleanup in afterExecute
* - supportsAgentType returning true (all native-tool engines support every agent type)
*
* Each concrete engine subclass must implement:
* - definition — the engine's AgentEngineDefinition
* - getAllowedEnvExact() — engine-specific env var allowlist
* - getExtraEnvVars() — unconditionally injected env vars (e.g. CI=true)
* - resolveEngineModel() — cascade model string → engine-specific model identifier
* - execute() — the actual subprocess execution logic
*
* LLMist stays separate — it is an in-process SDK, fundamentally different from
* the subprocess pattern captured here.
*/

import type {
AgentEngine,
AgentEngineDefinition,
AgentEngineResult,
AgentExecutionPlan,
} from '../types.js';
import { cleanupContextFiles } from './contextFiles.js';
import { buildEngineEnv } from './envBuilder.js';

export abstract class NativeToolEngine implements AgentEngine {
// -------------------------------------------------------------------------
// Abstract members — subclasses must implement these
// -------------------------------------------------------------------------

abstract readonly definition: AgentEngineDefinition;

/**
* Engine-specific exact-match env var allowlist.
* Merged on top of the shared set by buildEnv().
*/
abstract getAllowedEnvExact(): Set<string>;

/**
* Extra env vars injected unconditionally into every subprocess
* (e.g. { CI: 'true', CODEX_DISABLE_UPDATE_NOTIFIER: '1' }).
*/
abstract getExtraEnvVars(): Record<string, string>;

/**
* Resolve a CASCADE model string to the engine-specific model identifier.
* Throw an Error if the model is incompatible with this engine.
*/
abstract resolveEngineModel(cascadeModel: string): string;

// -------------------------------------------------------------------------
// Shared / template methods
// -------------------------------------------------------------------------

/**
* Delegates to resolveEngineModel so the AgentEngine.resolveModel() contract is
* satisfied without requiring subclasses to remember to call super.
*/
resolveModel(cascadeModel: string): string {
return this.resolveEngineModel(cascadeModel);
}

/**
* All native-tool engines support every agent type.
* Override in a subclass only if you need to restrict this.
*/
supportsAgentType(_agentType: string): boolean {
return true;
}

/**
* Build a sanitised environment record for the subprocess.
*
* Calls buildEngineEnv with:
* - allowedEnvExact from getAllowedEnvExact()
* - extraVars from getExtraEnvVars()
* - projectSecrets and path dirs from the execution plan
*/
buildEnv(
projectSecrets?: Record<string, string>,
cliToolsDir?: string,
nativeToolShimDir?: string,
): Record<string, string | undefined> {
return buildEngineEnv({
allowedEnvExact: this.getAllowedEnvExact(),
extraVars: this.getExtraEnvVars(),
projectSecrets,
cliToolsDir,
nativeToolShimDir,
});
}

/**
* Clean up offloaded context files after execution.
* Engines that need additional cleanup should override this method and
* call super.afterExecute() to ensure context files are removed.
*/
async afterExecute(plan: AgentExecutionPlan, _result: AgentEngineResult): Promise<void> {
await cleanupContextFiles(plan.repoDir);
}

/**
* Subclasses must provide the actual subprocess execution logic.
*/
abstract execute(input: AgentExecutionPlan): Promise<AgentEngineResult>;
}
1 change: 1 addition & 0 deletions src/backends/shared/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NativeToolEngine } from './NativeToolEngine.js';
272 changes: 272 additions & 0 deletions tests/unit/backends/NativeToolEngine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
/**
* Tests for the NativeToolEngine abstract base class.
*
* We exercise the base class through a StubEngine that implements only the
* required abstract members without any real subprocess logic.
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NativeToolEngine } from '../../../src/backends/shared/NativeToolEngine.js';
import type {
AgentEngineDefinition,
AgentEngineResult,
AgentExecutionPlan,
} from '../../../src/backends/types.js';

// ---------------------------------------------------------------------------
// Mock cleanupContextFiles to avoid filesystem I/O in tests
// ---------------------------------------------------------------------------
vi.mock('../../../src/backends/shared/contextFiles.js', () => ({
cleanupContextFiles: vi.fn().mockResolvedValue(undefined),
}));

import { cleanupContextFiles } from '../../../src/backends/shared/contextFiles.js';

// ---------------------------------------------------------------------------
// Mock buildEngineEnv so we can verify it is called correctly
// ---------------------------------------------------------------------------
vi.mock('../../../src/backends/shared/envBuilder.js', () => ({
buildEngineEnv: vi.fn().mockReturnValue({ HOME: '/test', PATH: '/usr/bin' }),
}));

import { buildEngineEnv } from '../../../src/backends/shared/envBuilder.js';

// ---------------------------------------------------------------------------
// StubEngine — minimal concrete subclass for testing the base class
// ---------------------------------------------------------------------------

const STUB_ENGINE_DEFINITION: AgentEngineDefinition = {
id: 'stub',
label: 'Stub Engine',
description: 'Test stub — not a real engine.',
archetype: 'native-tool',
capabilities: ['native_file_edit_tools'],
modelSelection: { type: 'free-text' },
logLabel: 'Stub Log',
};

class StubEngine extends NativeToolEngine {
readonly definition = STUB_ENGINE_DEFINITION;

getAllowedEnvExact(): Set<string> {
return new Set(['STUB_API_KEY']);
}

getExtraEnvVars(): Record<string, string> {
return { CI: 'true', STUB_FLAG: '1' };
}

resolveEngineModel(cascadeModel: string): string {
if (cascadeModel === 'stub:v1') return 'stub-v1';
throw new Error(`Incompatible model: ${cascadeModel}`);
}

// Simple stub: always returns a successful result
async execute(_input: AgentExecutionPlan): Promise<AgentEngineResult> {
return { success: true, output: 'stub output' };
}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeMinimalPlan(overrides?: Partial<AgentExecutionPlan>): AgentExecutionPlan {
return {
agentType: 'implementation',
project: {
id: 'test-project',
orgId: 'org-1',
name: 'Test',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
pm: { type: 'trello' },
} as AgentExecutionPlan['project'],
config: {} as AgentExecutionPlan['config'],
repoDir: '/tmp/test-repo',
agentInput: {} as AgentExecutionPlan['agentInput'],
progressReporter: {
onIteration: vi.fn().mockResolvedValue(undefined),
onText: vi.fn(),
onToolCall: vi.fn(),
},
logWriter: vi.fn(),
systemPrompt: 'You are a helpful assistant.',
taskPrompt: 'Do the task.',
availableTools: [],
contextInjections: [],
maxIterations: 10,
model: 'stub:v1',
cliToolsDir: '/usr/local/cascade-tools',
...overrides,
} as AgentExecutionPlan;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('NativeToolEngine (via StubEngine)', () => {
let engine: StubEngine;

beforeEach(() => {
engine = new StubEngine();
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

// -------------------------------------------------------------------------
// definition
// -------------------------------------------------------------------------
describe('definition', () => {
it('exposes the definition from the subclass', () => {
expect(engine.definition).toBe(STUB_ENGINE_DEFINITION);
});

it('has archetype = native-tool', () => {
expect(engine.definition.archetype).toBe('native-tool');
});
});

// -------------------------------------------------------------------------
// supportsAgentType
// -------------------------------------------------------------------------
describe('supportsAgentType', () => {
it('returns true for any agent type', () => {
expect(engine.supportsAgentType('implementation')).toBe(true);
expect(engine.supportsAgentType('review')).toBe(true);
expect(engine.supportsAgentType('splitting')).toBe(true);
expect(engine.supportsAgentType('some-unknown-agent')).toBe(true);
});
});

// -------------------------------------------------------------------------
// resolveModel — delegates to resolveEngineModel
// -------------------------------------------------------------------------
describe('resolveModel', () => {
it('returns the engine-specific model string for a compatible model', () => {
expect(engine.resolveModel('stub:v1')).toBe('stub-v1');
});

it('throws for an incompatible model', () => {
expect(() => engine.resolveModel('claude-3-5-sonnet')).toThrow('Incompatible model');
});
});

// -------------------------------------------------------------------------
// buildEnv — calls buildEngineEnv with the right arguments
// -------------------------------------------------------------------------
describe('buildEnv', () => {
it('calls buildEngineEnv with allowedEnvExact from getAllowedEnvExact()', () => {
engine.buildEnv();

expect(buildEngineEnv).toHaveBeenCalledWith(
expect.objectContaining({
allowedEnvExact: new Set(['STUB_API_KEY']),
}),
);
});

it('calls buildEngineEnv with extraVars from getExtraEnvVars()', () => {
engine.buildEnv();

expect(buildEngineEnv).toHaveBeenCalledWith(
expect.objectContaining({
extraVars: { CI: 'true', STUB_FLAG: '1' },
}),
);
});

it('passes projectSecrets through to buildEngineEnv', () => {
const secrets = { STUB_API_KEY: 'key-value' };
engine.buildEnv(secrets);

expect(buildEngineEnv).toHaveBeenCalledWith(
expect.objectContaining({ projectSecrets: secrets }),
);
});

it('passes cliToolsDir through to buildEngineEnv', () => {
engine.buildEnv(undefined, '/usr/local/cascade-tools');

expect(buildEngineEnv).toHaveBeenCalledWith(
expect.objectContaining({ cliToolsDir: '/usr/local/cascade-tools' }),
);
});

it('passes nativeToolShimDir through to buildEngineEnv', () => {
engine.buildEnv(undefined, undefined, '/tmp/shims');

expect(buildEngineEnv).toHaveBeenCalledWith(
expect.objectContaining({ nativeToolShimDir: '/tmp/shims' }),
);
});

it('returns the env record produced by buildEngineEnv', () => {
vi.mocked(buildEngineEnv).mockReturnValueOnce({ HOME: '/test', PATH: '/usr/bin' });
const env = engine.buildEnv();
expect(env).toEqual({ HOME: '/test', PATH: '/usr/bin' });
});
});

// -------------------------------------------------------------------------
// afterExecute — calls cleanupContextFiles
// -------------------------------------------------------------------------
describe('afterExecute', () => {
it('calls cleanupContextFiles with the plan repoDir', async () => {
const plan = makeMinimalPlan({ repoDir: '/tmp/test-repo' });
const result: AgentEngineResult = { success: true, output: '' };

await engine.afterExecute(plan, result);

expect(cleanupContextFiles).toHaveBeenCalledWith('/tmp/test-repo');
});

it('resolves without throwing when cleanupContextFiles succeeds', async () => {
const plan = makeMinimalPlan();
const result: AgentEngineResult = { success: true, output: '' };

await expect(engine.afterExecute(plan, result)).resolves.toBeUndefined();
});
});

// -------------------------------------------------------------------------
// execute — delegated to subclass
// -------------------------------------------------------------------------
describe('execute', () => {
it('returns the result from the subclass execute implementation', async () => {
const plan = makeMinimalPlan();
const result = await engine.execute(plan);
expect(result).toEqual({ success: true, output: 'stub output' });
});
});

// -------------------------------------------------------------------------
// implements AgentEngine interface
// -------------------------------------------------------------------------
describe('AgentEngine interface conformance', () => {
it('has a definition property', () => {
expect(engine.definition).toBeDefined();
});

it('has an execute function', () => {
expect(typeof engine.execute).toBe('function');
});

it('has a supportsAgentType function', () => {
expect(typeof engine.supportsAgentType).toBe('function');
});

it('has a resolveModel function', () => {
expect(typeof engine.resolveModel).toBe('function');
});

it('has an afterExecute function', () => {
expect(typeof engine.afterExecute).toBe('function');
});
});
});
Loading