import { EventEmitter } from 'node:events'; import { describe, expect, mock, test } from 'bun:test'; import type { RunTurnCommand, SidecarCapabilities, WorkflowCheckpointSavedEvent, WorkflowDiagnosticEvent, } from '@shared/contracts/sidecar'; class FakeReadableStream extends EventEmitter { setEncoding(_encoding: BufferEncoding): void {} } class FakeWritableStream { readonly writes: string[] = []; write(chunk: string): boolean { this.writes.push(chunk); return true; } } class FakeChildProcess extends EventEmitter { readonly stdout = new FakeReadableStream(); readonly stderr = new FakeReadableStream(); readonly stdin = new FakeWritableStream(); exitCode: number | null = null; killed = false; kill(): boolean { this.killed = true; return true; } emitStdout(line: string): void { this.stdout.emit('data', line); } completeExit(code = 0): void { this.exitCode = code; this.emit('exit', code, null); this.emit('close', code, null); } } const spawnedProcesses: FakeChildProcess[] = []; mock.module('electron', () => { const electronMock = { app: { isPackaged: false, getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', }, }; return { ...electronMock, default: electronMock, }; }); mock.module('node:child_process', () => ({ spawn: () => { const child = new FakeChildProcess(); spawnedProcesses.push(child); return child; }, })); const { SidecarClient } = await import('@main/sidecar/sidecarProcess'); const CAPABILITIES_FIXTURE: SidecarCapabilities = { runtime: 'dotnet-maf', modes: { single: { available: true }, sequential: { available: true }, concurrent: { available: true }, handoff: { available: true }, magentic: { available: true }, 'group-chat': { available: true }, }, models: [], runtimeTools: [], connection: { status: 'ready', summary: 'Connected', checkedAt: '2026-03-25T00:00:00.000Z', }, }; function getRequestId(process: FakeChildProcess): string { const rawRequest = process.stdin.writes.at(-1); if (!rawRequest) { throw new Error('Expected the fake sidecar to receive a request.'); } return (JSON.parse(rawRequest.trim()) as { requestId: string }).requestId; } describe('SidecarClient', () => { test('waits for the disposed sidecar to fully close before spawning a replacement process', async () => { spawnedProcesses.length = 0; const client = new SidecarClient(); const firstCapabilities = client.describeCapabilities(); await Promise.resolve(); expect(spawnedProcesses).toHaveLength(1); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'capabilities', requestId: getRequestId(spawnedProcesses[0]!), capabilities: CAPABILITIES_FIXTURE, })}\n`, ); await expect(firstCapabilities).resolves.toEqual(CAPABILITIES_FIXTURE); let disposeCompleted = false; const disposePromise = client.dispose().then(() => { disposeCompleted = true; }); const replacementCapabilities = client.describeCapabilities(); await Promise.resolve(); expect(disposeCompleted).toBe(false); expect(spawnedProcesses).toHaveLength(1); spawnedProcesses[0]!.completeExit(); await disposePromise; expect(disposeCompleted).toBe(true); expect(spawnedProcesses).toHaveLength(2); spawnedProcesses[1]!.emitStdout( `${JSON.stringify({ type: 'capabilities', requestId: getRequestId(spawnedProcesses[1]!), capabilities: CAPABILITIES_FIXTURE, })}\n`, ); await expect(replacementCapabilities).resolves.toEqual(CAPABILITIES_FIXTURE); const finalDispose = client.dispose(); spawnedProcesses[1]!.completeExit(); await finalDispose; }); test('routes workflow diagnostic events through the turn-scoped callback', async () => { spawnedProcesses.length = 0; const client = new SidecarClient(); const diagnostics: WorkflowDiagnosticEvent[] = []; const command: RunTurnCommand = { type: 'run-turn', requestId: 'turn-1', sessionId: 'session-1', projectPath: 'C:\\workspace\\project', pattern: { id: 'pattern-1', name: 'Single Agent', description: '', mode: 'single', availability: 'available', maxIterations: 1, agents: [ { id: 'agent-1', name: 'Primary', description: '', instructions: 'Help with the request.', model: 'gpt-5.4', }, ], createdAt: '2026-04-01T00:00:00.000Z', updatedAt: '2026-04-01T00:00:00.000Z', }, messages: [], }; const turn = client.runTurn( command, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async (event) => { if (event.type === 'workflow-diagnostic') { diagnostics.push(event); } }, ); await Promise.resolve(); expect(spawnedProcesses).toHaveLength(1); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'workflow-diagnostic', requestId: command.requestId, sessionId: command.sessionId, severity: 'error', diagnosticKind: 'executor-failed', message: 'Tool crashed.', agentId: 'agent-1', agentName: 'Primary', executorId: 'agent-1', exceptionType: 'InvalidOperationException', } satisfies WorkflowDiagnosticEvent)}\n`, ); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'turn-complete', requestId: command.requestId, sessionId: command.sessionId, messages: [], cancelled: false, })}\n`, ); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'command-complete', requestId: command.requestId, })}\n`, ); await expect(turn).resolves.toEqual([]); expect(diagnostics).toEqual([ expect.objectContaining({ type: 'workflow-diagnostic', severity: 'error', diagnosticKind: 'executor-failed', message: 'Tool crashed.', executorId: 'agent-1', }), ]); const dispose = client.dispose(); spawnedProcesses[0]!.completeExit(); await dispose; }); test('routes workflow checkpoint events through the turn-scoped callback', async () => { spawnedProcesses.length = 0; const client = new SidecarClient(); const checkpoints: WorkflowCheckpointSavedEvent[] = []; const command: RunTurnCommand = { type: 'run-turn', requestId: 'turn-1', sessionId: 'session-1', projectPath: 'C:\\workspace\\project', pattern: { id: 'pattern-1', name: 'Handoff', description: '', mode: 'handoff', availability: 'available', maxIterations: 1, agents: [ { id: 'agent-1', name: 'Primary', description: '', instructions: 'Help with the request.', model: 'gpt-5.4', }, ], createdAt: '2026-04-01T00:00:00.000Z', updatedAt: '2026-04-01T00:00:00.000Z', }, messages: [], }; const turn = client.runTurn( command, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async (event) => { if (event.type === 'workflow-checkpoint-saved') { checkpoints.push(event); } }, ); await Promise.resolve(); expect(spawnedProcesses).toHaveLength(1); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'workflow-checkpoint-saved', requestId: command.requestId, sessionId: command.sessionId, workflowSessionId: 'turn-1', checkpointId: 'checkpoint-1', storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1', stepNumber: 2, } satisfies WorkflowCheckpointSavedEvent)}\n`, ); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'turn-complete', requestId: command.requestId, sessionId: command.sessionId, messages: [], cancelled: false, })}\n`, ); spawnedProcesses[0]!.emitStdout( `${JSON.stringify({ type: 'command-complete', requestId: command.requestId, })}\n`, ); await expect(turn).resolves.toEqual([]); expect(checkpoints).toEqual([ expect.objectContaining({ type: 'workflow-checkpoint-saved', workflowSessionId: 'turn-1', checkpointId: 'checkpoint-1', stepNumber: 2, }), ]); const dispose = client.dispose(); spawnedProcesses[0]!.completeExit(); await dispose; }); });