mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
Remove the entire patterns domain model, IPC channels, sidecar services, renderer components, and tests. Sessions now bind exclusively to workflows via workflowId. Builtin workflows replace builtin patterns. Backend: - Make AgentNodeConfig standalone (no longer extends PatternAgentDefinition) - Add WorkflowOrchestrationMode and WorkflowExecutionDefinition - Create builtin workflows (single-agent, sequential, concurrent, handoff, group-chat) - Rewrite session model config helpers for workflow-only - Remove pattern IPC channels, handlers, and preload bindings - Merge createSession/createWorkflowSession into single method - Remove sidecar PatternGraphResolver, PatternValidator, pattern DTOs - Add workspace migration for legacy sessions (patternId -> workflowId) Frontend: - Delete PatternEditor, pattern-graph components, patternGraph lib - Delete NewSessionModal (session creation uses workflows directly) - Remove PatternsSection from SettingsPanel - Update App.tsx, ChatPane, ActivityPanel, Sidebar, RunTimeline, AgentConfigFields, InlinePills, sessionActivity to use workflow types - Delete pattern.ts domain module 78 files changed across backend and frontend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
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',
|
|
workflow: {
|
|
id: 'workflow-1',
|
|
name: 'Single Agent',
|
|
description: '',
|
|
graph: {
|
|
nodes: [
|
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
|
{
|
|
id: 'agent-1',
|
|
kind: 'agent',
|
|
label: 'Primary',
|
|
position: { x: 200, y: 0 },
|
|
order: 0,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-1',
|
|
name: 'Primary',
|
|
description: '',
|
|
instructions: 'Help with the request.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
},
|
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
|
],
|
|
edges: [
|
|
{ id: 'edge-start-agent', source: 'start', target: 'agent-1', kind: 'direct' },
|
|
{ id: 'edge-agent-end', source: 'agent-1', target: 'end', kind: 'direct' },
|
|
],
|
|
},
|
|
settings: {
|
|
checkpointing: { enabled: false },
|
|
executionMode: 'off-thread',
|
|
orchestrationMode: 'single',
|
|
maxIterations: 1,
|
|
},
|
|
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',
|
|
workflow: {
|
|
id: 'workflow-1',
|
|
name: 'Handoff',
|
|
description: '',
|
|
graph: {
|
|
nodes: [
|
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
|
{
|
|
id: 'agent-1',
|
|
kind: 'agent',
|
|
label: 'Primary',
|
|
position: { x: 200, y: 0 },
|
|
order: 0,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-1',
|
|
name: 'Primary',
|
|
description: '',
|
|
instructions: 'Help with the request.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
},
|
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
|
],
|
|
edges: [
|
|
{ id: 'edge-start-agent', source: 'start', target: 'agent-1', kind: 'direct' },
|
|
{ id: 'edge-agent-end', source: 'agent-1', target: 'end', kind: 'direct' },
|
|
],
|
|
},
|
|
settings: {
|
|
checkpointing: { enabled: true },
|
|
executionMode: 'off-thread',
|
|
orchestrationMode: 'handoff',
|
|
maxIterations: 1,
|
|
},
|
|
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;
|
|
});
|
|
});
|