This commit is contained in:
477
packages/server/tests/local/external-tool-runs.test.ts
Normal file
477
packages/server/tests/local/external-tool-runs.test.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ExternalRunEvent, ExternalToolRunResult } from '@waggle/agent';
|
||||
import { AgentRunRegistry } from '../../src/local/agent-run-registry.js';
|
||||
import { SignalBus } from '../../src/local/signal-bus.js';
|
||||
import { externalToolRunRoutes } from '../../src/local/routes/external-tool-runs.js';
|
||||
import { resolveWorkspaceExecutionRoot } from '../../src/local/workspace-execution-root.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const collaborationRuntime = {
|
||||
nodePath: 'C:\\Waggle Runtime\\node.exe',
|
||||
cliEntry: 'C:\\Waggle Runtime\\node_modules\\@waggle\\hive-mind-cli\\dist\\index.js',
|
||||
};
|
||||
|
||||
function tempDir(name = 'waggle-external-runs-'): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), name));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
describe('resolveWorkspaceExecutionRoot', () => {
|
||||
it('uses a configured trusted directory and creates the managed fallback', () => {
|
||||
const dataDir = tempDir();
|
||||
const linked = path.join(dataDir, 'linked');
|
||||
fs.mkdirSync(linked);
|
||||
expect(resolveWorkspaceExecutionRoot(dataDir, {
|
||||
id: 'linked-workspace', name: 'Linked', group: 'test', created: new Date().toISOString(), directory: linked,
|
||||
})).toBe(fs.realpathSync(linked));
|
||||
|
||||
const managed = resolveWorkspaceExecutionRoot(dataDir, {
|
||||
id: 'managed-workspace', name: 'Managed', group: 'test', created: new Date().toISOString(),
|
||||
});
|
||||
expect(managed).toBe(fs.realpathSync(path.join(dataDir, 'workspaces', 'managed-workspace', 'files')));
|
||||
expect(fs.statSync(managed).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when a configured workspace root is missing', () => {
|
||||
const dataDir = tempDir();
|
||||
expect(() => resolveWorkspaceExecutionRoot(dataDir, {
|
||||
id: 'broken', name: 'Broken', group: 'test', created: new Date().toISOString(),
|
||||
directory: path.join(dataDir, 'does-not-exist'),
|
||||
})).toThrow(/does not exist/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external tool run routes', () => {
|
||||
it('fans out into isolated workspace runs and delivers results to Room, Dance, and memory', async () => {
|
||||
const dataDir = tempDir();
|
||||
const alphaDir = path.join(dataDir, 'alpha-files');
|
||||
const betaDir = path.join(dataDir, 'beta-files');
|
||||
fs.mkdirSync(alphaDir);
|
||||
fs.mkdirSync(betaDir);
|
||||
const registryPath = path.join(dataDir, 'agent-runs.json');
|
||||
const registry = new AgentRunRegistry(registryPath);
|
||||
const bus = new SignalBus();
|
||||
const attribution = {
|
||||
routeDecisionId: '6b7df0df-e082-4c99-bd11-55d5ac4ba403',
|
||||
briefHash: 'a'.repeat(64),
|
||||
};
|
||||
const calls: Array<{
|
||||
workspaceId: string;
|
||||
workspacePath: string;
|
||||
binary: string;
|
||||
prompt: string;
|
||||
danceUrl?: string;
|
||||
runToken?: string;
|
||||
nodePath?: string;
|
||||
cliEntry?: string;
|
||||
dataDir?: string;
|
||||
credentialWasActive: boolean;
|
||||
}> = [];
|
||||
const memoryRuns: Array<{ id: string; attribution?: typeof attribution }> = [];
|
||||
const healthyExecutorIds: string[] = [];
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' });
|
||||
server.decorate('workspaceManager', {
|
||||
get: (id: string) => ({
|
||||
alpha: { id: 'alpha', name: 'Alpha', group: 'test', created: new Date().toISOString(), directory: alphaDir },
|
||||
beta: { id: 'beta', name: 'Beta', group: 'test', created: new Date().toISOString(), directory: betaDir },
|
||||
} as Record<string, unknown>)[id],
|
||||
} as never);
|
||||
server.decorate('agentRunRegistry', registry);
|
||||
server.decorate('signalBus', bus);
|
||||
server.decorate('externalCollaborationRuntime', collaborationRuntime);
|
||||
server.decorate('executorRegistry', {
|
||||
snapshot: async () => [],
|
||||
noteHealthy: (executorId: string) => healthyExecutorIds.push(executorId),
|
||||
noteRateLimit: () => undefined,
|
||||
} as never);
|
||||
server.decorate('externalToolDetector', async () => ({
|
||||
platform: 'win32', detectedAt: new Date().toISOString(),
|
||||
tools: [{ id: 'codex', displayName: 'Codex CLI', installed: true, installedPath: 'C:\\trusted\\codex.cmd', version: 'test', hooksInstalled: true, hookPointerPath: null }],
|
||||
}));
|
||||
server.decorate('externalToolRunner', async (request) => {
|
||||
calls.push({
|
||||
workspaceId: request.workspaceId,
|
||||
workspacePath: request.workspacePath,
|
||||
binary: request.binary,
|
||||
prompt: request.prompt,
|
||||
danceUrl: request.dance?.url,
|
||||
runToken: request.dance?.token,
|
||||
nodePath: request.dance?.nodePath,
|
||||
cliEntry: request.dance?.cliEntry,
|
||||
dataDir: request.dataDir,
|
||||
credentialWasActive: Boolean(
|
||||
request.dance?.token && registry.authenticateCredential(request.dance.token)?.id === request.runId,
|
||||
),
|
||||
});
|
||||
request.onEvent?.({
|
||||
runId: request.runId, roomId: request.roomId, workspaceId: request.workspaceId,
|
||||
toolId: request.manifest.id, seq: 1, type: 'started', timestamp: new Date().toISOString(), pid: calls.length + 100,
|
||||
});
|
||||
request.onEvent?.({
|
||||
runId: request.runId, roomId: request.roomId, workspaceId: request.workspaceId,
|
||||
toolId: request.manifest.id, seq: 2, type: 'progress', timestamp: new Date().toISOString(), text: `Working in ${request.workspaceId}`,
|
||||
});
|
||||
return {
|
||||
status: 'completed', exitCode: 0, summary: `Result for ${request.workspaceId}`,
|
||||
sessionId: `session-${request.workspaceId}`, stdoutTail: '', stderrTail: '', durationMs: 10,
|
||||
};
|
||||
});
|
||||
server.decorate('externalResultRecorder', async ({ run }) => {
|
||||
memoryRuns.push({ id: run.id, attribution: run.attribution });
|
||||
return { status: 'complete', personalFrameIds: [1], workspaceFrameIds: { [run.workspaceId]: [2] } };
|
||||
});
|
||||
await server.register(externalToolRunRoutes);
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: {
|
||||
toolId: 'codex', workspaceIds: ['alpha', 'beta'], prompt: 'Inspect both workspaces',
|
||||
access: 'read-only', installedPath: 'C:\\attacker\\fake.exe', cwd: 'C:\\attacker',
|
||||
attribution,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json() as { roomId: string; runs: Array<{ runId: string; workspaceId: string }> };
|
||||
await waitFor(
|
||||
() => body.runs.every(({ runId }) => registry.get(runId)?.memoryRefs.status === 'complete'),
|
||||
'external runs did not finish',
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(new Set(calls.map((call) => call.workspacePath))).toEqual(new Set([fs.realpathSync(alphaDir), fs.realpathSync(betaDir)]));
|
||||
expect(calls.every((call) => call.binary === 'C:\\trusted\\codex.cmd')).toBe(true);
|
||||
expect(calls.every((call) => call.credentialWasActive)).toBe(true);
|
||||
expect(calls.every((call) => call.danceUrl?.startsWith('http://127.0.0.1:'))).toBe(true);
|
||||
expect(calls.every((call) => call.nodePath === collaborationRuntime.nodePath)).toBe(true);
|
||||
expect(calls.every((call) => call.cliEntry === collaborationRuntime.cliEntry)).toBe(true);
|
||||
expect(calls.every((call) => call.dataDir === dataDir)).toBe(true);
|
||||
expect(calls.every((call) => call.prompt.includes('Inspect both workspaces'))).toBe(true);
|
||||
expect(calls.every((call) => call.prompt.includes(JSON.stringify(call.workspacePath)))).toBe(true);
|
||||
expect(calls.every((call) => call.prompt.includes('Resolve every relative task path from that root'))).toBe(true);
|
||||
expect(calls.every((call) => call.prompt.includes('host-managed relays'))).toBe(true);
|
||||
expect(calls.every((call) => call.prompt.includes('Do not inspect WAGGLE_* variables'))).toBe(true);
|
||||
expect(calls.every((call) => !call.prompt.includes("'dance' 'receive' '--json'"))).toBe(true);
|
||||
expect(calls.every((call) => !registry.authenticateCredential(call.runToken ?? ''))).toBe(true);
|
||||
expect(memoryRuns).toHaveLength(2);
|
||||
expect(memoryRuns).toEqual(expect.arrayContaining(
|
||||
body.runs.map(({ runId }) => ({ id: runId, attribution })),
|
||||
));
|
||||
expect(healthyExecutorIds).toEqual(['codex', 'codex']);
|
||||
const durableRegistry = new AgentRunRegistry(registryPath);
|
||||
expect(durableRegistry.get(body.roomId)).toMatchObject({ status: 'completed', attribution });
|
||||
for (const { runId, workspaceId } of body.runs) {
|
||||
expect(durableRegistry.get(runId)).toMatchObject({
|
||||
kind: 'worker', workspaceId, status: 'completed',
|
||||
attribution,
|
||||
result: { summary: `Result for ${workspaceId}`, sessionId: `session-${workspaceId}` },
|
||||
memoryRefs: { status: 'complete' },
|
||||
});
|
||||
}
|
||||
const subtypes = bus.query().map((message) => message.subtype);
|
||||
expect(subtypes).toEqual(expect.arrayContaining(['task_delegation', 'task_claim', 'discovery', 'routed_share']));
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('launches different external tools as peers in one canonical Room', async () => {
|
||||
const dataDir = tempDir();
|
||||
const workspaceDir = path.join(dataDir, 'shared-files');
|
||||
fs.mkdirSync(workspaceDir);
|
||||
const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json'));
|
||||
const calls: Array<{ toolId: string; runId: string; roomId: string; token?: string; prompt: string }> = [];
|
||||
const bus = new SignalBus();
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' });
|
||||
server.decorate('workspaceManager', {
|
||||
get: (id: string) => id === 'shared'
|
||||
? { id, name: 'Shared', group: 'test', created: new Date().toISOString(), directory: workspaceDir }
|
||||
: undefined,
|
||||
} as never);
|
||||
server.decorate('agentRunRegistry', registry);
|
||||
server.decorate('signalBus', bus);
|
||||
server.decorate('externalCollaborationRuntime', collaborationRuntime);
|
||||
server.decorate('externalToolDetector', async () => ({
|
||||
platform: 'win32', detectedAt: new Date().toISOString(),
|
||||
tools: [
|
||||
{ id: 'claude-code', displayName: 'Claude Code', installed: true, installedPath: 'claude.exe', version: 'test', hooksInstalled: true, hookPointerPath: null },
|
||||
{ id: 'codex', displayName: 'Codex CLI', installed: true, installedPath: 'codex.exe', version: 'test', hooksInstalled: true, hookPointerPath: null },
|
||||
],
|
||||
}));
|
||||
server.decorate('externalToolRunner', async (request) => {
|
||||
calls.push({
|
||||
toolId: request.manifest.id,
|
||||
runId: request.runId,
|
||||
roomId: request.roomId,
|
||||
token: request.dance?.token,
|
||||
prompt: request.prompt,
|
||||
});
|
||||
request.onEvent?.({
|
||||
runId: request.runId, roomId: request.roomId, workspaceId: request.workspaceId,
|
||||
toolId: request.manifest.id, seq: 1, type: 'started', timestamp: new Date().toISOString(),
|
||||
});
|
||||
if (request.manifest.id === 'codex') {
|
||||
return {
|
||||
status: 'failed', exitCode: 1, summary: 'Provider quota exhausted',
|
||||
stdoutTail: '', stderrTail: '', durationMs: 5,
|
||||
};
|
||||
}
|
||||
if (request.prompt.includes('Peer findings delivered through WaggleDance')) {
|
||||
return {
|
||||
status: 'completed', exitCode: 0, summary: 'Synthesis used HONEY-17',
|
||||
stdoutTail: '', stderrTail: '', durationMs: 5,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'completed', exitCode: 0, summary: `${request.manifest.displayName} found HONEY-17`,
|
||||
stdoutTail: '', stderrTail: '', durationMs: 5,
|
||||
};
|
||||
});
|
||||
server.decorate('externalResultRecorder', async ({ run }) => ({
|
||||
status: 'complete', personalFrameIds: [1], workspaceFrameIds: { [run.workspaceId]: [2] },
|
||||
}));
|
||||
await server.register(externalToolRunRoutes);
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: {
|
||||
workspaceIds: ['shared'],
|
||||
prompt: 'Review together and exchange findings',
|
||||
participants: [
|
||||
{ toolId: 'claude-code', access: 'read-only' },
|
||||
{ toolId: 'codex', access: 'workspace-write' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json() as {
|
||||
roomId: string;
|
||||
runs: Array<{ runId: string; toolId: string; workspaceId: string }>;
|
||||
};
|
||||
await waitFor(
|
||||
() => body.runs.every(({ runId }) => registry.get(runId)?.memoryRefs.status === 'complete'),
|
||||
'multi-tool Room did not finish',
|
||||
);
|
||||
|
||||
expect(body.runs).toHaveLength(3);
|
||||
expect(new Set(body.runs.map((run) => run.toolId))).toEqual(new Set(['claude-code', 'codex']));
|
||||
expect(new Set(calls.map((call) => call.roomId))).toEqual(new Set([body.roomId]));
|
||||
expect(registry.get(body.roomId)).toMatchObject({
|
||||
kind: 'room', status: 'completed', workspaceIds: ['shared'],
|
||||
result: { summary: 'Synthesis used HONEY-17' },
|
||||
memoryRefs: { status: 'complete' },
|
||||
});
|
||||
const codexRun = body.runs.find((run) => run.toolId === 'codex');
|
||||
expect(registry.get(codexRun!.runId)).toMatchObject({
|
||||
status: 'failed',
|
||||
result: { summary: 'Provider quota exhausted', error: 'Provider quota exhausted' },
|
||||
});
|
||||
const synthesisCall = calls.find((call) => call.prompt.includes('Peer findings delivered through WaggleDance'));
|
||||
expect(synthesisCall?.prompt).toContain('Claude Code found HONEY-17');
|
||||
const subtypes = bus.query({ teamId: `room::${body.roomId}` }).map((message) => message.subtype);
|
||||
expect(subtypes).toEqual(expect.arrayContaining(['routed_share', 'knowledge_match']));
|
||||
expect(calls.every((call) => !registry.authenticateCredential(call.token ?? ''))).toBe(true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('feeds parsed external rate limits into the executor registry', async () => {
|
||||
const nowMs = Date.UTC(2026, 6, 15, 12, 0, 0);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(nowMs);
|
||||
const dataDir = tempDir();
|
||||
const workspaceDir = path.join(dataDir, 'alpha-files');
|
||||
fs.mkdirSync(workspaceDir);
|
||||
const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json'));
|
||||
const rateLimitCalls: Array<{ executorId: string; resumeAtMs: number | null }> = [];
|
||||
const healthyExecutorIds: string[] = [];
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' });
|
||||
server.decorate('workspaceManager', {
|
||||
get: (id: string) => id === 'alpha'
|
||||
? { id, name: 'Alpha', group: 'test', created: new Date().toISOString(), directory: workspaceDir }
|
||||
: undefined,
|
||||
} as never);
|
||||
server.decorate('agentRunRegistry', registry);
|
||||
server.decorate('externalCollaborationRuntime', collaborationRuntime);
|
||||
server.decorate('executorRegistry', {
|
||||
snapshot: async () => [],
|
||||
noteHealthy: (executorId: string) => healthyExecutorIds.push(executorId),
|
||||
noteRateLimit: (executorId: string, resumeAtMs: number | null) => {
|
||||
rateLimitCalls.push({ executorId, resumeAtMs });
|
||||
},
|
||||
} as never);
|
||||
server.decorate('externalToolDetector', async () => ({
|
||||
platform: 'win32', detectedAt: new Date().toISOString(),
|
||||
tools: [{ id: 'codex', displayName: 'Codex CLI', installed: true, installedPath: 'codex.cmd', version: 'test', hooksInstalled: false, hookPointerPath: null }],
|
||||
}));
|
||||
server.decorate('externalToolRunner', async () => ({
|
||||
status: 'failed', exitCode: 1, summary: 'Request failed', stdoutTail: '',
|
||||
stderrTail: '429 rate limit exceeded\nRetry-After: 120', durationMs: 5,
|
||||
}));
|
||||
server.decorate('externalResultRecorder', async ({ run }) => ({
|
||||
status: 'complete', personalFrameIds: [], workspaceFrameIds: { [run.workspaceId]: [] },
|
||||
}));
|
||||
await server.register(externalToolRunRoutes);
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: { toolId: 'codex', workspaceIds: ['alpha'], prompt: 'Try the provider' },
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json() as { runs: Array<{ runId: string }> };
|
||||
await waitFor(
|
||||
() => registry.get(body.runs[0].runId)?.memoryRefs.status === 'complete',
|
||||
'failed external run did not finish',
|
||||
);
|
||||
|
||||
expect(registry.get(body.runs[0].runId)?.status).toBe('failed');
|
||||
expect(rateLimitCalls).toEqual([{ executorId: 'codex', resumeAtMs: nowMs + 120_000 }]);
|
||||
expect(healthyExecutorIds).toEqual([]);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('tracks stall recovery while cancelling one live run without aborting its sibling', async () => {
|
||||
const dataDir = tempDir();
|
||||
for (const id of ['alpha', 'beta']) fs.mkdirSync(path.join(dataDir, id));
|
||||
const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json'));
|
||||
const pending = new Map<string, (result: ExternalToolRunResult) => void>();
|
||||
const emitEvents = new Map<string, (event: ExternalRunEvent) => void>();
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' });
|
||||
server.decorate('workspaceManager', {
|
||||
get: (id: string) => ['alpha', 'beta'].includes(id)
|
||||
? { id, name: id, group: 'test', created: new Date().toISOString(), directory: path.join(dataDir, id) }
|
||||
: undefined,
|
||||
} as never);
|
||||
server.decorate('agentRunRegistry', registry);
|
||||
server.decorate('externalCollaborationRuntime', collaborationRuntime);
|
||||
server.decorate('externalToolDetector', async () => ({
|
||||
platform: 'win32', detectedAt: new Date().toISOString(),
|
||||
tools: [{ id: 'codex', displayName: 'Codex CLI', installed: true, installedPath: 'codex.cmd', version: 'test', hooksInstalled: false, hookPointerPath: null }],
|
||||
}));
|
||||
server.decorate('externalToolRunner', (request) => new Promise<ExternalToolRunResult>((resolve) => {
|
||||
request.onEvent?.({
|
||||
runId: request.runId, roomId: request.roomId, workspaceId: request.workspaceId,
|
||||
toolId: 'codex', seq: 1, type: 'started', timestamp: new Date().toISOString(), pid: 200,
|
||||
});
|
||||
if (request.onEvent) emitEvents.set(request.workspaceId, request.onEvent);
|
||||
pending.set(request.workspaceId, resolve);
|
||||
request.signal?.addEventListener('abort', () => resolve({
|
||||
status: 'cancelled', exitCode: null, summary: 'Cancelled', stdoutTail: '', stderrTail: '', durationMs: 1,
|
||||
}), { once: true });
|
||||
}));
|
||||
server.decorate('externalResultRecorder', async ({ run }) => ({
|
||||
status: 'complete', personalFrameIds: [], workspaceFrameIds: { [run.workspaceId]: [] },
|
||||
}));
|
||||
await server.register(externalToolRunRoutes);
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: { toolId: 'codex', workspaceIds: ['alpha', 'beta'], prompt: 'Wait', access: 'read-only' },
|
||||
});
|
||||
const body = response.json() as { roomId: string; runs: Array<{ runId: string; workspaceId: string }> };
|
||||
await waitFor(() => pending.size === 2, 'workers did not start concurrently');
|
||||
const alpha = body.runs.find((run) => run.workspaceId === 'alpha')!;
|
||||
const beta = body.runs.find((run) => run.workspaceId === 'beta')!;
|
||||
|
||||
const event = {
|
||||
runId: beta.runId, roomId: body.roomId, workspaceId: 'beta', toolId: 'codex',
|
||||
type: 'progress' as const, timestamp: new Date().toISOString(),
|
||||
};
|
||||
emitEvents.get('beta')!({ ...event, seq: 2, text: '[stalled] no output for 120s', stalled: true });
|
||||
expect(registry.get(beta.runId)).toMatchObject({
|
||||
status: 'running', progress: { message: '[stalled] no output for 120s', phase: 'stalled' },
|
||||
});
|
||||
emitEvents.get('beta')!({ ...event, seq: 3, text: '[recovered] output resumed', stalled: false });
|
||||
expect(registry.get(beta.runId)).toMatchObject({
|
||||
status: 'running', progress: { message: '[recovered] output resumed', phase: 'running' },
|
||||
});
|
||||
|
||||
await registry.control(alpha.runId, 'cancel');
|
||||
expect(registry.get(alpha.runId)?.status).toBe('cancelled');
|
||||
expect(registry.get(beta.runId)?.status).toBe('running');
|
||||
pending.get('beta')!({
|
||||
status: 'completed', exitCode: 0, summary: 'Beta done', stdoutTail: '', stderrTail: '', durationMs: 2,
|
||||
});
|
||||
await waitFor(() => registry.get(beta.runId)?.status === 'completed', 'beta did not complete');
|
||||
expect(registry.get(beta.runId)?.result?.summary).toBe('Beta done');
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('returns explicit errors for unknown workspaces and broken configured roots', async () => {
|
||||
const dataDir = tempDir();
|
||||
const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json'));
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('localConfig', { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' });
|
||||
server.decorate('agentRunRegistry', registry);
|
||||
server.decorate('workspaceManager', {
|
||||
get: (id: string) => id === 'broken'
|
||||
? {
|
||||
id, name: 'Broken', group: 'test', created: new Date().toISOString(),
|
||||
directory: path.join(dataDir, 'missing-root'),
|
||||
}
|
||||
: undefined,
|
||||
} as never);
|
||||
server.decorate('externalToolDetector', async () => ({
|
||||
platform: 'win32', detectedAt: new Date().toISOString(),
|
||||
tools: [{
|
||||
id: 'codex', displayName: 'Codex CLI', installed: true, installedPath: 'codex.cmd',
|
||||
version: 'test', hooksInstalled: false, hookPointerPath: null,
|
||||
}],
|
||||
}));
|
||||
server.decorate('externalToolRunner', async () => {
|
||||
throw new Error('runner must not start for an invalid workspace');
|
||||
});
|
||||
await server.register(externalToolRunRoutes);
|
||||
|
||||
const missing = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: { toolId: 'codex', workspaceIds: ['unknown'], prompt: 'Do work' },
|
||||
});
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(missing.json()).toMatchObject({ error: 'workspace_not_found' });
|
||||
|
||||
const broken = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: { toolId: 'codex', workspaceIds: ['broken'], prompt: 'Do work' },
|
||||
});
|
||||
expect(broken.statusCode).toBe(409);
|
||||
expect(broken.json()).toMatchObject({ error: 'workspace_root_invalid', workspaceId: 'broken' });
|
||||
expect(registry.snapshot().runs).toHaveLength(0);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('rejects GUI-only tools with an explicit task-capability error', async () => {
|
||||
const dataDir = tempDir();
|
||||
const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json'));
|
||||
const server = Fastify({ logger: false });
|
||||
server.decorate('agentRunRegistry', registry);
|
||||
await server.register(externalToolRunRoutes);
|
||||
const response = await server.inject({
|
||||
method: 'POST', url: '/api/tools/run',
|
||||
payload: { toolId: 'cursor', workspaceIds: ['alpha'], prompt: 'Do work' },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error).toBe('TOOL_NOT_HEADLESS');
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user