agent-orchestrator/tests/e2e/daemon/agent-bridge.ts

146 lines
4.3 KiB
TypeScript

// Agent bridge — NDJSON stdio interface for Claude Agent SDK integration
// Accepts queries on stdin, responds on stdout. Allows an agent to control
// and query the test daemon programmatically.
import { createInterface } from 'node:readline';
import type { Dashboard } from './dashboard.ts';
import { clearCache, type RunOptions } from './runner.ts';
// ── Query/Response types ──
type Query =
| { type: 'status' }
| { type: 'rerun'; pattern?: string }
| { type: 'failures' }
| { type: 'reset-cache' };
type Response =
| { type: 'status'; running: boolean; passed: number; failed: number; skipped: number; pending: number; total: number; failures: Array<{ name: string; error?: string }> }
| { type: 'rerun'; specsQueued: number }
| { type: 'failures'; failures: Array<{ name: string; specFile: string; error?: string }> }
| { type: 'reset-cache'; ok: true }
| { type: 'error'; message: string };
// ── Bridge ──
export class AgentBridge {
private dashboard: Dashboard;
private running = false;
private triggerRerun: ((opts: RunOptions) => void) | null = null;
private rl: ReturnType<typeof createInterface> | null = null;
constructor(dashboard: Dashboard) {
this.dashboard = dashboard;
}
/** Register callback that triggers a new test run from the main loop */
onRerunRequest(cb: (opts: RunOptions) => void): void {
this.triggerRerun = cb;
}
setRunning(running: boolean): void {
this.running = running;
}
start(): void {
this.rl = createInterface({
input: process.stdin,
terminal: false,
});
this.rl.on('line', (line) => {
const trimmed = line.trim();
if (!trimmed) return;
try {
const query = JSON.parse(trimmed) as Query;
const response = this.handleQuery(query);
this.send(response);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.send({ type: 'error', message: `Invalid query: ${msg}` });
}
});
this.rl.on('close', () => {
this.stop();
});
}
stop(): void {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
private send(response: Response): void {
// Write to stdout as NDJSON — use fd 3 or stderr if stdout is used by dashboard
// Since dashboard writes to stdout, we use stderr for NDJSON responses
// when the dashboard is active. The agent reads from our stderr.
process.stderr.write(JSON.stringify(response) + '\n');
}
private handleQuery(query: Query): Response {
switch (query.type) {
case 'status':
return this.handleStatus();
case 'rerun':
return this.handleRerun(query);
case 'failures':
return this.handleFailures();
case 'reset-cache':
return this.handleResetCache();
default:
return { type: 'error', message: `Unknown query type: ${(query as { type: string }).type}` };
}
}
private handleStatus(): Response {
const tests = this.dashboard.getTests();
const passed = tests.filter((t) => t.status === 'passed').length;
const failed = tests.filter((t) => t.status === 'failed').length;
const skipped = tests.filter((t) => t.status === 'skipped').length;
const pending = tests.filter((t) => t.status === 'pending' || t.status === 'running').length;
const failures = tests
.filter((t) => t.status === 'failed')
.map((t) => ({ name: t.name, error: t.error }));
return {
type: 'status',
running: this.running,
passed,
failed,
skipped,
pending,
total: tests.length,
failures,
};
}
private handleRerun(query: Extract<Query, { type: 'rerun' }>): Response {
if (this.running) return { type: 'rerun', specsQueued: 0 };
const opts: RunOptions = { full: true };
if (query.pattern) opts.pattern = query.pattern;
this.triggerRerun?.(opts);
return { type: 'rerun', specsQueued: 1 };
}
private handleFailures(): Response {
const tests = this.dashboard.getTests();
const failures = tests
.filter((t) => t.status === 'failed')
.map((t) => ({
name: t.name,
specFile: t.specFile,
error: t.error,
}));
return { type: 'failures', failures };
}
private handleResetCache(): Response {
clearCache();
return { type: 'reset-cache', ok: true };
}
}