Skip to content

Commit f89c1fb

Browse files
feat: Runner abstraction layer β€” pluggable agent backend interface (#3263)
* feat: Runner abstraction layer β€” pluggable agent backend interface (#3263) Define AgentRunner interface that abstracts agent lifecycle operations (start, sendInput, readOutput, kill, isAlive). This decouples session management from the Claude Code-specific AcpBackend, enabling future support for Codex, Gemini CLI, and other agent backends. New modules: - src/runners/types.ts β€” AgentRunner interface, ProcessHandle, config/result types - src/runners/registry.ts β€” InMemoryRunnerRegistry for runner lookup - src/runners/stubs/codex-runner.ts β€” Codex CLI stub (interface-only) - src/runners/stubs/gemini-runner.ts β€” Gemini CLI stub (interface-only) - src/__tests__/fix-3263-runner-abstraction.test.ts β€” 14 test cases No existing code modified β€” pure additive change. Zero test regressions. Unblocks: #3180 (multi-agent), #3049 (ACP agent mode) Refs: #3003, #3004 * fix: extract shared NotImplementedError, add async generator semantics comment (#3268 review) Argus review feedback: - Extract duplicated NotImplementedError to runners/stubs/shared.ts (DRY) - Add comment explaining async generator throw semantics on stub readOutput - Rebase on develop
1 parent e246308 commit f89c1fb

7 files changed

Lines changed: 547 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* fix-3263-runner-abstraction.test.ts β€” Tests for the Runner abstraction layer.
3+
*
4+
* Issue #3263: AgentRunner interface, RunnerRegistry, and stub runners.
5+
*/
6+
7+
import { describe, expect, it } from 'vitest';
8+
9+
import type {
10+
AgentRunner,
11+
ProcessHandle,
12+
RunnerStartConfig,
13+
RunnerStartResult,
14+
RunnerSendResult,
15+
OutputChunk,
16+
RunnerKillResult,
17+
} from '../runners/types.js';
18+
import { InMemoryRunnerRegistry } from '../runners/registry.js';
19+
import { CodexRunner } from '../runners/stubs/codex-runner.js';
20+
import { GeminiCliRunner } from '../runners/stubs/gemini-runner.js';
21+
22+
// ── Test helpers ─────────────────────────────────────────────────
23+
24+
/** A minimal mock runner for testing the registry. */
25+
class MockRunner implements AgentRunner {
26+
readonly name: string;
27+
private readonly handles = new Map<string, ProcessHandle>();
28+
private alive = new Set<ProcessHandle>();
29+
30+
constructor(name: string) {
31+
this.name = name;
32+
}
33+
34+
async start(sessionId: string, _config: RunnerStartConfig): Promise<RunnerStartResult> {
35+
const handle = `${this.name}-${sessionId}`;
36+
this.handles.set(sessionId, handle);
37+
this.alive.add(handle);
38+
return { handle, capabilities: { mock: true }, agentInfo: { name: this.name } };
39+
}
40+
41+
async sendInput(handle: ProcessHandle, _input: string): Promise<RunnerSendResult> {
42+
if (!this.alive.has(handle)) return { delivered: false, attempts: 1, error: 'not alive' };
43+
return { delivered: true, attempts: 1 };
44+
}
45+
46+
async *readOutput(_handle: ProcessHandle): AsyncIterable<OutputChunk> {
47+
yield { text: 'mock output', source: 'stdout', timestamp: new Date().toISOString() };
48+
}
49+
50+
async kill(handle: ProcessHandle): Promise<RunnerKillResult> {
51+
this.alive.delete(handle);
52+
return { exitCode: 0, clean: true };
53+
}
54+
55+
isAlive(handle: ProcessHandle): boolean {
56+
return this.alive.has(handle);
57+
}
58+
59+
getHandle(sessionId: string): ProcessHandle | undefined {
60+
return this.handles.get(sessionId);
61+
}
62+
}
63+
64+
// ── Tests ────────────────────────────────────────────────────────
65+
66+
describe('Issue #3263 β€” Runner abstraction layer', () => {
67+
describe('AgentRunner interface', () => {
68+
it('MockRunner satisfies the interface contract', async () => {
69+
const runner = new MockRunner('test-runner');
70+
expect(runner.name).toBe('test-runner');
71+
72+
const result = await runner.start('session-1', { cwd: '/tmp' });
73+
expect(result.handle).toBe('test-runner-session-1');
74+
expect(result.capabilities).toEqual({ mock: true });
75+
76+
expect(runner.isAlive(result.handle)).toBe(true);
77+
expect(runner.getHandle('session-1')).toBe(result.handle);
78+
79+
const sendResult = await runner.sendInput(result.handle, 'hello');
80+
expect(sendResult.delivered).toBe(true);
81+
82+
const killResult = await runner.kill(result.handle);
83+
expect(killResult.clean).toBe(true);
84+
expect(runner.isAlive(result.handle)).toBe(false);
85+
});
86+
87+
it('runner swap does not break session lifecycle', async () => {
88+
const runnerA = new MockRunner('runner-a');
89+
const runnerB = new MockRunner('runner-b');
90+
91+
const resultA = await runnerA.start('swap-session', { cwd: '/tmp' });
92+
expect(runnerA.isAlive(resultA.handle)).toBe(true);
93+
94+
await runnerA.kill(resultA.handle);
95+
expect(runnerA.isAlive(resultA.handle)).toBe(false);
96+
97+
const resultB = await runnerB.start('swap-session', { cwd: '/tmp' });
98+
expect(runnerB.isAlive(resultB.handle)).toBe(true);
99+
expect(resultB.handle).not.toBe(resultA.handle);
100+
});
101+
});
102+
103+
describe('RunnerRegistry', () => {
104+
it('registers and retrieves runners', () => {
105+
const registry = new InMemoryRunnerRegistry();
106+
const runner = new MockRunner('claude-code');
107+
registry.register(runner);
108+
109+
expect(registry.get('claude-code')).toBe(runner);
110+
expect(registry.listNames()).toEqual(['claude-code']);
111+
});
112+
113+
it('first registered runner becomes default', () => {
114+
const registry = new InMemoryRunnerRegistry();
115+
const first = new MockRunner('first');
116+
const second = new MockRunner('second');
117+
118+
registry.register(first);
119+
registry.register(second);
120+
121+
expect(registry.getDefault()).toBe(first);
122+
expect(registry.listNames()).toEqual(['first', 'second']);
123+
});
124+
125+
it('throws when no runners registered and getDefault is called', () => {
126+
const registry = new InMemoryRunnerRegistry();
127+
expect(() => registry.getDefault()).toThrow('No runners registered');
128+
});
129+
130+
it('returns undefined for unknown runner name', () => {
131+
const registry = new InMemoryRunnerRegistry();
132+
expect(registry.get('nonexistent')).toBeUndefined();
133+
});
134+
135+
it('supports multiple runners', () => {
136+
const registry = new InMemoryRunnerRegistry();
137+
registry.register(new MockRunner('claude-code'));
138+
registry.register(new MockRunner('codex'));
139+
registry.register(new MockRunner('gemini-cli'));
140+
141+
expect(registry.listNames()).toHaveLength(3);
142+
expect(registry.listNames()).toContain('claude-code');
143+
expect(registry.listNames()).toContain('codex');
144+
expect(registry.listNames()).toContain('gemini-cli');
145+
});
146+
});
147+
148+
describe('CodexRunner stub', () => {
149+
it('implements AgentRunner interface', () => {
150+
const runner = new CodexRunner();
151+
expect(runner.name).toBe('codex');
152+
expect(runner.isAlive('any')).toBe(false);
153+
expect(runner.getHandle('any')).toBeUndefined();
154+
});
155+
156+
it('throws on start', async () => {
157+
const runner = new CodexRunner();
158+
await expect(runner.start('s1', { cwd: '/tmp' })).rejects.toThrow('not implemented');
159+
});
160+
161+
it('throws on sendInput', async () => {
162+
const runner = new CodexRunner();
163+
await expect(runner.sendInput('h1', 'test')).rejects.toThrow('not implemented');
164+
});
165+
166+
it('throws on kill', async () => {
167+
const runner = new CodexRunner();
168+
await expect(runner.kill('h1')).rejects.toThrow('not implemented');
169+
});
170+
});
171+
172+
describe('GeminiCliRunner stub', () => {
173+
it('implements AgentRunner interface', () => {
174+
const runner = new GeminiCliRunner();
175+
expect(runner.name).toBe('gemini-cli');
176+
expect(runner.isAlive('any')).toBe(false);
177+
expect(runner.getHandle('any')).toBeUndefined();
178+
});
179+
180+
it('throws on start', async () => {
181+
const runner = new GeminiCliRunner();
182+
await expect(runner.start('s1', { cwd: '/tmp' })).rejects.toThrow('not implemented');
183+
});
184+
});
185+
186+
describe('Integration: registry with stubs', () => {
187+
it('all stub runners compile and register', () => {
188+
const registry = new InMemoryRunnerRegistry();
189+
registry.register(new CodexRunner());
190+
registry.register(new GeminiCliRunner());
191+
192+
expect(registry.listNames()).toEqual(['codex', 'gemini-cli']);
193+
expect(registry.get('codex')).toBeInstanceOf(CodexRunner);
194+
expect(registry.get('gemini-cli')).toBeInstanceOf(GeminiCliRunner);
195+
});
196+
});
197+
});

β€Žsrc/runners/index.tsβ€Ž

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* runners/index.ts β€” Barrel export for the runner abstraction.
3+
*
4+
* Issue #3263: Pluggable agent backend interface.
5+
*/
6+
7+
export type {
8+
AgentRunner,
9+
RunnerRegistry,
10+
ProcessHandle,
11+
OutputChunk,
12+
RunnerStartConfig,
13+
RunnerStartResult,
14+
RunnerSendResult,
15+
RunnerKillOptions,
16+
RunnerKillResult,
17+
} from './types.js';
18+
19+
export { InMemoryRunnerRegistry } from './registry.js';

β€Žsrc/runners/registry.tsβ€Ž

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* runners/registry.ts β€” In-memory RunnerRegistry implementation.
3+
*
4+
* Issue #3263: Manages registered agent runners and resolves them by name.
5+
*/
6+
7+
import type { AgentRunner, RunnerRegistry } from './types.js';
8+
9+
export class InMemoryRunnerRegistry implements RunnerRegistry {
10+
private readonly runners = new Map<string, AgentRunner>();
11+
private defaultRunner: AgentRunner | undefined;
12+
13+
register(runner: AgentRunner): void {
14+
this.runners.set(runner.name, runner);
15+
// First registered runner becomes the default
16+
if (!this.defaultRunner) {
17+
this.defaultRunner = runner;
18+
}
19+
}
20+
21+
get(name: string): AgentRunner | undefined {
22+
return this.runners.get(name);
23+
}
24+
25+
listNames(): string[] {
26+
return [...this.runners.keys()];
27+
}
28+
29+
getDefault(): AgentRunner {
30+
if (!this.defaultRunner) {
31+
throw new Error('No runners registered. Register at least one AgentRunner.');
32+
}
33+
return this.defaultRunner;
34+
}
35+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* runners/stubs/codex-runner.ts β€” Stub Codex runner (interface only).
3+
*
4+
* Issue #3263: Placeholder for OpenAI Codex CLI integration.
5+
* Will be implemented when Codex CLI gains ACP/stdio support.
6+
*/
7+
8+
import type {
9+
AgentRunner,
10+
ProcessHandle,
11+
RunnerStartConfig,
12+
RunnerStartResult,
13+
RunnerSendResult,
14+
OutputChunk,
15+
RunnerKillOptions,
16+
RunnerKillResult,
17+
} from '../types.js';
18+
import { NotImplementedError } from './shared.js';
19+
20+
/**
21+
* CodexRunner β€” stub implementation for OpenAI Codex CLI.
22+
*
23+
* All methods throw to indicate this runner is not yet implemented.
24+
* Registered in the runner registry to prove interface compatibility
25+
* and allow configuration references.
26+
*/
27+
export class CodexRunner implements AgentRunner {
28+
readonly name = 'codex';
29+
30+
async start(_sessionId: string, _config: RunnerStartConfig): Promise<RunnerStartResult> {
31+
throw new NotImplementedError('CodexRunner.start');
32+
}
33+
34+
async sendInput(_handle: ProcessHandle, _input: string): Promise<RunnerSendResult> {
35+
throw new NotImplementedError('CodexRunner.sendInput');
36+
}
37+
38+
// Note: the throw occurs before any yield, so the caller gets a rejected
39+
// promise rather than an error during iteration.
40+
async *readOutput(_handle: ProcessHandle): AsyncIterable<OutputChunk> {
41+
throw new NotImplementedError('CodexRunner.readOutput');
42+
}
43+
44+
async kill(_handle: ProcessHandle, _options?: RunnerKillOptions): Promise<RunnerKillResult> {
45+
throw new NotImplementedError('CodexRunner.kill');
46+
}
47+
48+
isAlive(_handle: ProcessHandle): boolean {
49+
return false;
50+
}
51+
52+
getHandle(_sessionId: string): ProcessHandle | undefined {
53+
return undefined;
54+
}
55+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* runners/stubs/gemini-runner.ts β€” Stub Gemini CLI runner (interface only).
3+
*
4+
* Issue #3263: Placeholder for Google Gemini CLI integration.
5+
* Will be implemented when Gemini CLI gains ACP/stdio support.
6+
*/
7+
8+
import type {
9+
AgentRunner,
10+
ProcessHandle,
11+
RunnerStartConfig,
12+
RunnerStartResult,
13+
RunnerSendResult,
14+
OutputChunk,
15+
RunnerKillOptions,
16+
RunnerKillResult,
17+
} from '../types.js';
18+
import { NotImplementedError } from './shared.js';
19+
20+
/**
21+
* GeminiCliRunner β€” stub implementation for Google Gemini CLI.
22+
*
23+
* All methods throw to indicate this runner is not yet implemented.
24+
* Registered in the runner registry to prove interface compatibility
25+
* and allow configuration references.
26+
*/
27+
export class GeminiCliRunner implements AgentRunner {
28+
readonly name = 'gemini-cli';
29+
30+
async start(_sessionId: string, _config: RunnerStartConfig): Promise<RunnerStartResult> {
31+
throw new NotImplementedError('GeminiCliRunner.start');
32+
}
33+
34+
async sendInput(_handle: ProcessHandle, _input: string): Promise<RunnerSendResult> {
35+
throw new NotImplementedError('GeminiCliRunner.sendInput');
36+
}
37+
38+
// Note: the throw occurs before any yield, so the caller gets a rejected
39+
// promise rather than an error during iteration.
40+
async *readOutput(_handle: ProcessHandle): AsyncIterable<OutputChunk> {
41+
throw new NotImplementedError('GeminiCliRunner.readOutput');
42+
}
43+
44+
async kill(_handle: ProcessHandle, _options?: RunnerKillOptions): Promise<RunnerKillResult> {
45+
throw new NotImplementedError('GeminiCliRunner.kill');
46+
}
47+
48+
isAlive(_handle: ProcessHandle): boolean {
49+
return false;
50+
}
51+
52+
getHandle(_sessionId: string): ProcessHandle | undefined {
53+
return undefined;
54+
}
55+
}

β€Žsrc/runners/stubs/shared.tsβ€Ž

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* runners/stubs/shared.ts β€” Shared utilities for stub runners.
3+
*
4+
* Issue #3263: Common error types for placeholder runner implementations.
5+
*/
6+
7+
/**
8+
* Error thrown by stub runner methods that are not yet implemented.
9+
*
10+
* Stubs compile and register successfully but throw on any lifecycle operation,
11+
* allowing the interface contract to be validated without a real agent binary.
12+
*/
13+
export class NotImplementedError extends Error {
14+
constructor(method: string) {
15+
super(`${method} is not implemented. This runner is a stub for future agent integration.`);
16+
this.name = 'NotImplementedError';
17+
}
18+
}

0 commit comments

Comments
Β (0)