Skip to content

Commit 4c32182

Browse files
willwashburnclaude
andcommitted
test: repair core suite for @agent-relay 8.x migration
Reconcile the @relayflows/core test suite to the migrated runner and the bumped @agent-relay 8.x dependencies. Treats the runner/SDK as source of truth and updates test expectations to match. Test infrastructure: - Cap the vitest fork pool (maxForks: 4) so workflow tests that spawn child processes can't overwhelm the host. - Add a Vite plugin to externalize the `node:sqlite` builtin, which Vite's resolver does not yet recognize (collectors load it at import time). Eliminate real broker/PTY spawning in tests: - start-from / resume-fallback mocked stale module paths (`@relaycast/sdk`, `../relay.js`) that the migrated runner no longer imports, so execute() hit the real HarnessDriverClient and spawned live runtimes. Switch both to the runner's `executor` injection seam — no broker init, no spawn. - error-scenarios: drop a dead `vi.mock('@agent-relay/sdk/relay')` whose subpath was removed in sdk v8 (it failed the whole suite at load); the covered tests only exercise validation/early-reject paths. Reconcile to current runtime behavior: - Handle the SpawnedAgentHandle `{ reason }` contract in inline custom mock handles (raw reason strings destructured to undefined → undetected timeouts / hangs). - Opt failure-path supervised/owner-timeout tests into errorHandling.strategy:'fail-fast' so they exercise first-pass failures instead of the runner's auto-enabled repair/retry loop (was hanging to the 30s timeout). - agent-trajectories v0.6 moved the default data dir to `.agentworkforce/trajectories` (per-id subdirs); update trajectory readers. - @agent-relay/cloud v8 moved the permission-audit path and now grants write on all non-denied files under readwrite access; update expectations and convert provisioner-audit from node:test to vitest. - @relayfile/sdk v0.8 only removes mount points it created itself; let the SDK own the mount so stop() exercises cleanup. run-script.ts: validate file extension before existence so an unsupported file type is reported as such regardless of whether the path exists. Full core suite: 769 passing. Typecheck and build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dda4aaf commit 4c32182

22 files changed

Lines changed: 705 additions & 469 deletions

packages/core/src/__tests__/budget-enforcement.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,22 +99,26 @@ vi.mock('node:child_process', async () => {
9999

100100
const mockRelayInstance = {
101101
spawnPty: vi.fn(),
102-
human: vi.fn().mockReturnValue({ sendMessage: vi.fn().mockResolvedValue(undefined) }),
103-
shutdown: vi.fn().mockResolvedValue(undefined),
104-
onBrokerStderr: vi.fn().mockReturnValue(() => {}),
105-
listAgentsRaw: vi.fn().mockResolvedValue([]),
102+
onEvent: vi.fn(() => () => {}),
103+
connectEvents: vi.fn(),
106104
listAgents: vi.fn().mockResolvedValue([]),
107-
addListener: vi.fn(() => () => {}),
105+
release: vi.fn().mockResolvedValue({ name: '' }),
106+
sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }),
107+
shutdown: vi.fn().mockResolvedValue(undefined),
108108
};
109109

110110
vi.mock('@relaycast/sdk', () => ({
111111
RelayCast: vi.fn(),
112112
RelayError: class RelayError extends Error {},
113113
}));
114114

115-
vi.mock('../../relay.js', () => ({
116-
AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance),
117-
}));
115+
vi.mock('@agent-relay/harness-driver', async (importOriginal) => {
116+
const actual = await importOriginal<typeof import('@agent-relay/harness-driver')>();
117+
return {
118+
...actual,
119+
HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) },
120+
};
121+
});
118122

119123
const { WorkflowRunner } = await import('../runner.js');
120124

@@ -274,7 +278,7 @@ beforeEach(() => {
274278
collectorResultsByCwd = new Map();
275279
activeRunner = undefined;
276280
mockRelayInstance.shutdown.mockResolvedValue(undefined);
277-
mockRelayInstance.onBrokerStderr.mockReturnValue(() => {});
281+
mockRelayInstance.onEvent.mockReturnValue(() => {});
278282
mockRelayInstance.listAgents.mockResolvedValue([]);
279283
});
280284

packages/core/src/__tests__/completion-pipeline.test.ts

Lines changed: 91 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ vi.mock('@relaycast/sdk', () => ({
6565
RelayError: MockRelayError,
6666
}));
6767

68-
// ── Mock AgentRelay ──────────────────────────────────────────────────────────
68+
// ── Mock HarnessDriverClient ─────────────────────────────────────────────────
6969

7070
let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>;
7171
let waitForIdleFn: (ms?: number) => Promise<'idle' | 'timeout' | 'exited'>;
@@ -95,26 +95,25 @@ vi.mock('node:child_process', async () => {
9595
};
9696
});
9797

98-
const mockAgent = {
99-
name: 'test-agent-abc',
100-
get waitForExit() {
101-
return waitForExitFn;
102-
},
103-
get waitForIdle() {
104-
return waitForIdleFn;
105-
},
106-
release: vi.fn().mockResolvedValue(undefined),
107-
};
108-
109-
const mockHuman = {
110-
name: 'WorkflowRunner',
111-
sendMessage: vi.fn().mockResolvedValue(undefined),
112-
};
113-
11498
function never<T>(): Promise<T> {
11599
return new Promise(() => {});
116100
}
117101

102+
// Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, but
103+
// driven by the test's waitForExitFn/waitForIdleFn. waitForExit/waitForIdle
104+
// return `{ reason }` objects (the runner reads `.reason`), not raw strings.
105+
function makeMockHandle(name: string) {
106+
return {
107+
name,
108+
runtime: 'pty' as const,
109+
exitCode: undefined as number | undefined,
110+
exitSignal: undefined as string | undefined,
111+
waitForExit: (ms?: number) => waitForExitFn(ms).then((reason) => ({ reason })),
112+
waitForIdle: (ms?: number) => waitForIdleFn(ms).then((reason) => ({ reason })),
113+
release: vi.fn().mockResolvedValue({ name }),
114+
};
115+
}
116+
118117
const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; task?: string }) => {
119118
const queued = mockSpawnOutputs.shift();
120119
const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim();
@@ -128,45 +127,71 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas
128127
: 'STEP_COMPLETE:unknown\n');
129128

130129
queueMicrotask(() => {
131-
emitRelayEvent('workerOutput', { name, chunk: output });
130+
emitMockEvent('workerOutput', { name, chunk: output });
132131
});
133132

134-
return { ...mockAgent, name };
133+
return makeMockHandle(name);
135134
};
136135

137-
const relayListeners = new Map<string, Set<(...args: any[]) => void>>();
138-
139-
function emitRelayEvent(event: string, payload: any) {
140-
const set = relayListeners.get(event);
141-
if (!set) return;
142-
for (const fn of set) fn(payload);
136+
// The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests
137+
// still call `emitMockEvent('workerOutput'|'messageReceived'|...)`; translate
138+
// those legacy named events into the BrokerEvent shapes the runner switches on.
139+
const eventListeners = new Set<(event: any) => void>();
140+
function emitMockEvent(event: string, payload: any = {}): void {
141+
let broker: any;
142+
switch (event) {
143+
case 'workerOutput':
144+
broker = { kind: 'worker_stream', name: payload.name, stream: 'stdout', chunk: payload.chunk };
145+
break;
146+
case 'messageReceived':
147+
broker = {
148+
kind: 'relay_inbound',
149+
event_id: payload.eventId,
150+
from: payload.from,
151+
target: payload.to,
152+
body: payload.text,
153+
thread_id: payload.threadId,
154+
};
155+
break;
156+
case 'agentSpawned':
157+
broker = { kind: 'agent_spawned', name: payload.name, runtime: payload.runtime ?? 'pty' };
158+
break;
159+
case 'agentReleased':
160+
broker = { kind: 'agent_released', name: payload.name };
161+
break;
162+
case 'agentExited':
163+
broker = { kind: 'agent_exited', name: payload.name, code: payload.exitCode, signal: payload.exitSignal };
164+
break;
165+
case 'agentIdle':
166+
broker = { kind: 'agent_idle', name: payload.name, idle_secs: payload.idleSecs };
167+
break;
168+
default:
169+
broker = { kind: event, ...payload };
170+
}
171+
for (const cb of [...eventListeners]) cb(broker);
143172
}
144173

174+
// Back-compat alias for tests that still call emitRelayEvent.
175+
const emitRelayEvent = emitMockEvent;
176+
145177
const mockRelayInstance = {
146178
spawnPty: vi.fn().mockImplementation(defaultSpawnPtyImplementation),
147-
human: vi.fn().mockReturnValue(mockHuman),
148-
shutdown: vi.fn().mockResolvedValue(undefined),
149-
onBrokerStderr: vi.fn().mockReturnValue(() => {}),
150-
addListener: vi.fn((event: string, fn: (...args: any[]) => void) => {
151-
let set = relayListeners.get(event);
152-
if (!set) {
153-
set = new Set();
154-
relayListeners.set(event, set);
155-
}
156-
set.add(fn);
157-
return () => {
158-
set!.delete(fn);
159-
};
179+
onEvent: vi.fn((cb: (event: any) => void) => {
180+
eventListeners.add(cb);
181+
return () => eventListeners.delete(cb);
160182
}),
183+
connectEvents: vi.fn(),
161184
listAgents: vi.fn().mockResolvedValue([]),
162-
listAgentsRaw: vi.fn().mockResolvedValue([]),
185+
release: vi.fn().mockResolvedValue({ name: '' }),
186+
sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }),
187+
shutdown: vi.fn().mockResolvedValue(undefined),
163188
};
164189

165190
let relayEventCounter = 0;
166191

167192
function emitRelayChannelMessage(message: { from: string; to: string; text: string }) {
168193
setTimeout(() => {
169-
emitRelayEvent('messageReceived', {
194+
emitMockEvent('messageReceived', {
170195
eventId: `evt-${++relayEventCounter}`,
171196
from: message.from,
172197
to: message.to,
@@ -176,9 +201,13 @@ function emitRelayChannelMessage(message: { from: string; to: string; text: stri
176201
}, 0);
177202
}
178203

179-
vi.mock('../relay.js', () => ({
180-
AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance),
181-
}));
204+
vi.mock('@agent-relay/harness-driver', async (importOriginal) => {
205+
const actual = await importOriginal<typeof import('@agent-relay/harness-driver')>();
206+
return {
207+
...actual,
208+
HarnessDriverClient: { spawn: vi.fn(async () => mockRelayInstance) },
209+
};
210+
});
182211

183212
// Import after mocking
184213
const { WorkflowRunner } = await import('../runner.js');
@@ -241,6 +270,11 @@ type WorkflowStepOverride = Partial<NonNullable<RelayYamlConfig['workflows']>[nu
241270

242271
function makeSupervisedConfig(stepOverrides: WorkflowStepOverride = {}): RelayYamlConfig {
243272
return makeConfig({
273+
// The runner now defaults to strategy:'retry' with repairRetries; these
274+
// supervised tests assert first-pass review/owner outcomes, so opt into
275+
// fail-fast to exercise the failure path deterministically (no retry loop).
276+
// Step-level `retries` overrides still drive the explicit retry tests.
277+
errorHandling: { strategy: 'fail-fast' },
244278
swarm: { pattern: 'hub-spoke' },
245279
agents: [
246280
{ name: 'specialist', cli: 'claude', role: 'engineer' },
@@ -314,9 +348,8 @@ describe('Completion Pipeline', () => {
314348
waitForExitFn = vi.fn().mockResolvedValue('exited');
315349
waitForIdleFn = vi.fn().mockImplementation(() => never());
316350
mockSpawnOutputs = [];
317-
mockAgent.release.mockResolvedValue(undefined);
318351
mockRelayInstance.spawnPty.mockImplementation(defaultSpawnPtyImplementation);
319-
relayListeners.clear();
352+
eventListeners.clear();
320353
db = makeDb();
321354
runner = new WorkflowRunner({ db, workspaceId: 'ws-test' });
322355
});
@@ -478,6 +511,7 @@ describe('Completion Pipeline', () => {
478511

479512
const run = await runner.execute(
480513
makeConfig({
514+
errorHandling: { strategy: 'fail-fast' },
481515
agents: [{ name: 'reviewer', cli: 'claude', preset: 'reviewer' }],
482516
workflows: [
483517
{
@@ -754,7 +788,9 @@ describe('Completion Pipeline', () => {
754788
// These tests validate the tolerant parser once it's implemented.
755789
// The tolerant parser should accept semantic equivalents.
756790

757-
it('should still fail on review output with no usable approval or rejection signal', async () => {
791+
it('should fail closed (reject) when the reviewer hedges instead of deciding', async () => {
792+
// Declared indecision ("I need more context before deciding") is treated as
793+
// a fail-closed REJECT so the step retries instead of crashing as malformed.
758794
mockSpawnOutputs = [
759795
'worker finished\n',
760796
'STEP_COMPLETE:step-1\n',
@@ -763,7 +799,7 @@ describe('Completion Pipeline', () => {
763799

764800
const run = await runner.execute(makeSupervisedConfig(), 'default');
765801
expect(run.status).toBe('failed');
766-
expect(run.error).toContain('review response malformed');
802+
expect(run.error).toContain('review rejected');
767803
}, 15000);
768804
});
769805

@@ -1014,7 +1050,7 @@ describe('Completion Pipeline', () => {
10141050
emitRelayEvent('workerOutput', { name, chunk: output });
10151051
});
10161052

1017-
const agent = { ...mockAgent, name };
1053+
const agent = makeMockHandle(name);
10181054
if (name.includes('map-1-worker')) {
10191055
emitRelayChannelMessage({
10201056
from: agent.name,
@@ -1259,7 +1295,10 @@ describe('Completion Pipeline', () => {
12591295

12601296
it('should still fail when marker, owner decision, and evidence are all missing', async () => {
12611297
mockSpawnOutputs = ['Did the work but no marker\n'];
1262-
const run = await runner.execute(makeConfig(), 'default');
1298+
const run = await runner.execute(
1299+
makeConfig({ errorHandling: { strategy: 'fail-fast' } }),
1300+
'default'
1301+
);
12631302
expect(run.status).toBe('failed');
12641303
expect(run.error).toContain('owner completion decision missing');
12651304
}, 15000);
@@ -1706,6 +1745,7 @@ describe('Completion Pipeline', () => {
17061745
it('should fail when process exits with non-zero code and no signal', async () => {
17071746
// Agent exits with non-zero and no coordination signal — should fail
17081747
const config = makeConfig({
1748+
errorHandling: { strategy: 'fail-fast' },
17091749
swarm: { pattern: 'dag', completionGracePeriodMs: 5000 },
17101750
agents: [{ name: 'agent-a', cli: 'claude' }],
17111751
workflows: [
@@ -1735,6 +1775,7 @@ describe('Completion Pipeline', () => {
17351775
it('should respect completionGracePeriodMs: 0 to disable fallback', async () => {
17361776
// With grace period disabled, missing signals should always fail
17371777
const config = makeConfig({
1778+
errorHandling: { strategy: 'fail-fast' },
17381779
swarm: { pattern: 'dag', completionGracePeriodMs: 0 },
17391780
agents: [{ name: 'agent-a', cli: 'claude' }],
17401781
workflows: [
@@ -1862,6 +1903,7 @@ describe('Completion Pipeline', () => {
18621903

18631904
it('should not complete via process-exit fallback when output contains INCOMPLETE_RETRY', async () => {
18641905
const config = makeConfig({
1906+
errorHandling: { strategy: 'fail-fast' },
18651907
swarm: { pattern: 'dag', completionGracePeriodMs: 5000 },
18661908
agents: [{ name: 'agent-a', cli: 'claude' }],
18671909
workflows: [

packages/core/src/__tests__/e2big-and-verify.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,23 @@ vi.mock('@relaycast/sdk', () => ({
55
RelayError: class RelayError extends Error {},
66
}));
77

8-
vi.mock('../../relay.js', () => ({
9-
AgentRelay: vi.fn(),
10-
}));
8+
vi.mock('@agent-relay/harness-driver', async (importOriginal) => {
9+
const actual = await importOriginal<typeof import('@agent-relay/harness-driver')>();
10+
return {
11+
...actual,
12+
HarnessDriverClient: {
13+
spawn: vi.fn(async () => ({
14+
spawnPty: vi.fn(),
15+
onEvent: vi.fn(() => () => {}),
16+
connectEvents: vi.fn(),
17+
listAgents: vi.fn().mockResolvedValue([]),
18+
release: vi.fn().mockResolvedValue({ name: '' }),
19+
sendMessage: vi.fn().mockResolvedValue({ event_id: 'evt', targets: [] }),
20+
shutdown: vi.fn().mockResolvedValue(undefined),
21+
})),
22+
},
23+
};
24+
});
1125

1226
const { WorkflowRunner } = await import('../runner.js');
1327

packages/core/src/__tests__/e2e-owner-review.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ type WorkflowStepOverride = Partial<NonNullable<RelayYamlConfig['workflows']>[nu
207207

208208
function makeSupervisedConfig(stepOverrides: WorkflowStepOverride = {}): RelayYamlConfig {
209209
return makeConfig({
210+
// The runner auto-enables strategy:'retry' with repairRetries when agents are
211+
// present (applyReliabilityDefaults). These supervised scenarios assert
212+
// first-pass owner/review outcomes, so opt into fail-fast to exercise the
213+
// failure path deterministically instead of entering the repair/retry loop.
214+
errorHandling: { strategy: 'fail-fast' },
210215
swarm: { pattern: 'hub-spoke' },
211216
agents: [
212217
{ name: 'specialist', cli: 'claude', role: 'engineer' },
@@ -562,7 +567,13 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => {
562567

563568
return {
564569
name,
565-
waitForExit: vi.fn().mockResolvedValue(isReview ? 'timeout' : 'exited'),
570+
runtime: 'pty' as const,
571+
exitCode: undefined,
572+
exitSignal: undefined,
573+
// SpawnedAgentHandle resolves to `{ reason }`; the runner's
574+
// WorkflowAgentHandle destructures it, so raw strings would map to
575+
// undefined and the timeout would never be detected.
576+
waitForExit: vi.fn().mockResolvedValue({ reason: isReview ? 'timeout' : 'exited' }),
566577
waitForIdle: vi.fn().mockImplementation(() => never()),
567578
release: vi.fn().mockResolvedValue(undefined),
568579
};
@@ -599,7 +610,13 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => {
599610

600611
return {
601612
name,
602-
waitForExit: vi.fn().mockResolvedValue(isReview ? 'timeout' : 'exited'),
613+
runtime: 'pty' as const,
614+
exitCode: undefined,
615+
exitSignal: undefined,
616+
// SpawnedAgentHandle resolves to `{ reason }`; the runner's
617+
// WorkflowAgentHandle destructures it, so raw strings would map to
618+
// undefined and the timeout would never be detected.
619+
waitForExit: vi.fn().mockResolvedValue({ reason: isReview ? 'timeout' : 'exited' }),
603620
waitForIdle: vi.fn().mockImplementation(() => never()),
604621
release: vi.fn().mockResolvedValue(undefined),
605622
};
@@ -630,7 +647,9 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => {
630647
waitForExitFn = vi.fn().mockResolvedValue('timeout');
631648
waitForIdleFn = vi.fn().mockResolvedValue('timeout');
632649

633-
const run = await runner.execute(makeConfig(), 'default');
650+
// fail-fast so the single owner timeout surfaces immediately rather than
651+
// re-entering the auto-enabled repair/retry loop with timing-out mocks.
652+
const run = await runner.execute(makeConfig({ errorHandling: { strategy: 'fail-fast' } }), 'default');
634653
expect(run.status).toBe('failed');
635654
expect(run.error).toContain('timed out');
636655
expect(events.length).toBeGreaterThanOrEqual(1);
@@ -758,7 +777,9 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => {
758777
it('should fail when owner does not provide a marker, decision, or evidence', async () => {
759778
mockSpawnOutputs = ['The work is done but I forgot the sentinel.\n'];
760779

761-
const run = await runner.execute(makeConfig(), 'default');
780+
// fail-fast so the missing-decision failure surfaces on the first pass
781+
// instead of entering the auto-enabled repair/retry loop.
782+
const run = await runner.execute(makeConfig({ errorHandling: { strategy: 'fail-fast' } }), 'default');
762783
expect(run.status).toBe('failed');
763784
expect(run.error).toContain('owner completion decision missing');
764785
}, 15000);

0 commit comments

Comments
 (0)