refactor: remove legacy patterns system, unify on workflows

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>
This commit is contained in:
David Kaya
2026-04-06 22:37:00 +02:00
co-authored by Copilot
parent 69d0804161
commit 805e369b67
78 changed files with 2303 additions and 4441 deletions
@@ -1,7 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
@@ -49,11 +49,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
};
}
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-alpha',
projectId,
patternId,
workflowId,
title: 'Alpha session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -66,7 +66,7 @@ function createSession(projectId: string, patternId: string, overrides?: Partial
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
pattern: WorkflowDefinition,
options?: {
captureRunTurn?: (command: RunTurnCommand) => void;
},
@@ -107,7 +107,7 @@ function createService(
describe('AryxAppService discovered tooling', () => {
test('allows project-discovered MCP servers to be accepted and used in project sessions', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
}
@@ -139,7 +139,7 @@ describe('AryxAppService discovered tooling', () => {
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
@@ -168,7 +168,7 @@ describe('AryxAppService discovered tooling', () => {
test('allows accepted user-discovered MCP servers to be used across projects', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
}
@@ -199,7 +199,7 @@ describe('AryxAppService discovered tooling', () => {
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
@@ -234,7 +234,7 @@ describe('AryxAppService discovered tooling', () => {
test('selecting a session also selects that session project', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
}
+7 -7
View File
@@ -1,7 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type {
ProjectGitRunChangeSummary,
ProjectGitWorkingTreeSnapshot,
@@ -68,11 +68,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
};
}
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-alpha',
projectId,
patternId,
workflowId,
title: 'Alpha session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -88,12 +88,12 @@ function createFixture(overrides?: {
session?: Partial<SessionRecord>;
}): {
workspace: WorkspaceState;
pattern: PatternDefinition;
pattern: WorkflowDefinition;
project: ProjectRecord;
session: SessionRecord;
} {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
}
@@ -104,7 +104,7 @@ function createFixture(overrides?: {
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = session.id;
return { workspace, pattern, project, session };
@@ -173,7 +173,7 @@ function createRunSummary(): ProjectGitRunChangeSummary {
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
pattern: WorkflowDefinition,
options?: {
snapshot?: ProjectGitWorkingTreeSnapshot;
runSummary?: ProjectGitRunChangeSummary;
@@ -2,12 +2,12 @@ import { describe, expect, mock, test } from 'bun:test';
import type { PendingApprovalRecord } from '@shared/domain/approval';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { SessionRecord } from '@shared/domain/session';
import type { PendingUserInputRecord } from '@shared/domain/userInput';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import type { WorkflowDefinition } from '@shared/domain/workflow';
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
const INTERRUPTED_RUN_ERROR =
@@ -44,20 +44,20 @@ mock.module('keytar', () => ({
const { AryxAppService } = await import('@main/AryxAppService');
function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
function requireSingleWorkflow(workspace: WorkspaceState): WorkflowDefinition {
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!workflow) {
throw new Error('Expected the workspace seed to include a single-agent workflow.');
}
return pattern;
return workflow;
}
function createSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createSession(workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-1',
projectId: 'project-1',
patternId,
workflowId,
title: 'Session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -69,7 +69,7 @@ function createSession(patternId: string, overrides?: Partial<SessionRecord>): S
}
function createRun(
pattern: PatternDefinition,
workflow: WorkflowDefinition,
overrides?: Partial<SessionRunRecord>,
): SessionRunRecord {
return {
@@ -78,9 +78,9 @@ function createRun(
projectId: 'project-1',
projectPath: 'C:\\workspace\\personal\\repositories\\aryx',
workspaceKind: 'project',
patternId: pattern.id,
patternName: pattern.name,
patternMode: pattern.mode,
workflowId: workflow.id,
workflowName: workflow.name,
workflowMode: workflow.settings.orchestrationMode ?? 'single',
triggerMessageId: 'message-1',
startedAt: TIMESTAMP,
status: 'running',
@@ -171,9 +171,9 @@ function createService(
describe('AryxAppService interrupted session cleanup', () => {
test('clears stale approvals and user input, then fails the interrupted run on load', async () => {
const workspace = createWorkspaceSeed();
const pattern = requireSinglePattern(workspace);
const runningRun = createRun(pattern);
const session = createSession(pattern.id, {
const workflow = requireSingleWorkflow(workspace);
const runningRun = createRun(workflow);
const session = createSession(workflow.id, {
status: 'running',
pendingApproval: createPendingApproval('approval-1', 'Approve reading the repo'),
pendingApprovalQueue: [createPendingApproval('approval-2', 'Approve writing the repo')],
@@ -212,10 +212,10 @@ describe('AryxAppService interrupted session cleanup', () => {
test('fails sessions that were left in running state even without pending interaction records', async () => {
const workspace = createWorkspaceSeed();
const pattern = requireSinglePattern(workspace);
const session = createSession(pattern.id, {
const workflow = requireSingleWorkflow(workspace);
const session = createSession(workflow.id, {
status: 'running',
runs: [createRun(pattern)],
runs: [createRun(workflow)],
});
workspace.sessions = [session];
@@ -235,10 +235,10 @@ describe('AryxAppService interrupted session cleanup', () => {
test('preserves restart-safe plan review and MCP auth prompts', async () => {
const workspace = createWorkspaceSeed();
const pattern = requireSinglePattern(workspace);
const workflow = requireSingleWorkflow(workspace);
const planReview = createPendingPlanReview();
const mcpAuth = createPendingMcpAuth();
const session = createSession(pattern.id, {
const session = createSession(workflow.id, {
pendingPlanReview: planReview,
pendingMcpAuth: mcpAuth,
});
+7 -7
View File
@@ -1,7 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
@@ -49,11 +49,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
};
}
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-alpha',
projectId,
patternId,
workflowId,
title: 'Alpha session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -102,12 +102,12 @@ function createSession(projectId: string, patternId: string, overrides?: Partial
function createFixture(): {
workspace: WorkspaceState;
pattern: PatternDefinition;
pattern: WorkflowDefinition;
project: ProjectRecord;
session: SessionRecord;
} {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
}
@@ -118,7 +118,7 @@ function createFixture(): {
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = session.id;
return { workspace, pattern, project, session };
@@ -126,7 +126,7 @@ function createFixture(): {
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
pattern: WorkflowDefinition,
options?: {
captureRunTurn?: (command: RunTurnCommand) => void;
},
-144
View File
@@ -1,144 +0,0 @@
import { describe, expect, mock, test } from 'bun:test';
import type { PatternDefinition } from '@shared/domain/pattern';
import { resolvePatternGraph } from '@shared/domain/pattern';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
const TIMESTAMP = '2026-03-27T00:00:00.000Z';
mock.module('electron', () => {
const electronMock = {
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
},
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
};
return {
...electronMock,
default: electronMock,
};
});
mock.module('keytar', () => ({
default: {
getPassword: async () => null,
setPassword: async () => undefined,
deletePassword: async () => false,
},
}));
const { AryxAppService } = await import('@main/AryxAppService');
function createService(
workspace: WorkspaceState,
options?: { knownApprovalToolNames?: string[] },
): InstanceType<typeof AryxAppService> {
const service = new AryxAppService();
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.listKnownApprovalToolNames = async () => options?.knownApprovalToolNames ?? ['read', 'write', 'shell'];
return service;
}
function requirePattern(workspace: WorkspaceState, mode: PatternDefinition['mode']): PatternDefinition {
const pattern = workspace.patterns.find((candidate) => candidate.mode === mode);
if (!pattern) {
throw new Error(`Expected workspace seed to include a ${mode} pattern.`);
}
return pattern;
}
describe('AryxAppService deletePattern', () => {
test('deletes a built-in pattern and tracks its ID', async () => {
const workspace = createWorkspaceSeed();
const builtinPattern = requirePattern(workspace, 'sequential');
const service = createService(workspace);
const result = await service.deletePattern(builtinPattern.id);
expect(result.patterns.find((p) => p.id === builtinPattern.id)).toBeUndefined();
expect(result.deletedBuiltinPatternIds).toContain(builtinPattern.id);
});
test('deletes a custom pattern without tracking its ID', async () => {
const workspace = createWorkspaceSeed();
const customPattern: PatternDefinition = {
...requirePattern(workspace, 'single'),
id: 'custom-pattern',
name: 'My Custom Pattern',
};
workspace.patterns.push(customPattern);
const service = createService(workspace);
const result = await service.deletePattern('custom-pattern');
expect(result.patterns.find((p) => p.id === 'custom-pattern')).toBeUndefined();
expect(result.deletedBuiltinPatternIds ?? []).not.toContain('custom-pattern');
});
test('selects first remaining pattern when the active pattern is deleted', async () => {
const workspace = createWorkspaceSeed();
const targetPattern = requirePattern(workspace, 'single');
workspace.selectedPatternId = targetPattern.id;
const service = createService(workspace);
const result = await service.deletePattern(targetPattern.id);
expect(result.selectedPatternId).toBe(result.patterns[0]?.id);
expect(result.selectedPatternId).not.toBe(targetPattern.id);
});
});
describe('AryxAppService savePattern', () => {
test('preserves a provided custom graph instead of re-syncing it', async () => {
const workspace = createWorkspaceSeed();
const pattern = requirePattern(workspace, 'sequential');
const baseGraph = resolvePatternGraph(pattern);
const customGraph = {
...baseGraph,
nodes: baseGraph.nodes.map((node, index) => ({
...node,
position: {
x: node.position.x + 37 + index,
y: node.position.y + 19 + index * 7,
},
})),
};
const service = createService(workspace);
const result = await service.savePattern({
...pattern,
graph: customGraph,
updatedAt: TIMESTAMP,
});
const savedPattern = requirePattern(result, 'sequential');
expect(savedPattern.graph).toEqual(customGraph);
});
test('still seeds a default graph when the pattern graph is missing', async () => {
const workspace = createWorkspaceSeed();
const pattern = requirePattern(workspace, 'group-chat');
const service = createService(workspace);
const result = await service.savePattern({
...pattern,
graph: undefined,
updatedAt: TIMESTAMP,
});
const savedPattern = requirePattern(result, 'group-chat');
expect(savedPattern.graph).toEqual(resolvePatternGraph(pattern));
});
});
@@ -2,10 +2,10 @@ import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import { buildAvailableModelCatalog } from '@shared/domain/models';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { resolveWorkflowAgentNodes, type WorkflowDefinition } from '@shared/domain/workflow';
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
@@ -50,11 +50,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
};
}
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-alpha',
projectId,
patternId,
workflowId,
title: 'Alpha session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -67,7 +67,7 @@ function createSession(projectId: string, patternId: string, overrides?: Partial
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
workflow: WorkflowDefinition,
options?: {
captureRunTurn?: (command: RunTurnCommand) => void;
},
@@ -76,7 +76,7 @@ function createService(
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
internals.buildEffectivePattern = async () => pattern;
internals.buildEffectiveWorkflow = async () => workflow;
internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog();
internals.awaitFinalResponseApproval = async () => undefined;
internals.finalizeTurn = () => undefined;
@@ -101,19 +101,45 @@ function createService(
return service;
}
function updatePrimaryAgent(
workflow: WorkflowDefinition,
update: (agent: NonNullable<ReturnType<typeof resolveWorkflowAgentNodes>[number]>['config']) => NonNullable<ReturnType<typeof resolveWorkflowAgentNodes>[number]>['config'],
): WorkflowDefinition {
let updated = false;
return {
...workflow,
graph: {
...workflow.graph,
nodes: workflow.graph.nodes.map((node) => {
if (updated || node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
updated = true;
return {
...node,
config: update(node.config),
};
}),
},
};
}
function getPrimaryAgentConfig(workflow: WorkflowDefinition | undefined) {
const node = workflow ? resolveWorkflowAgentNodes(workflow)[0] : undefined;
return node?.config.kind === 'agent' ? node.config : undefined;
}
describe('AryxAppService project customization', () => {
test('sendSessionMessage injects project instructions and enabled project agent profiles', async () => {
const workspace = createWorkspaceSeed();
const basePattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!basePattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
const baseWorkflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!baseWorkflow) {
throw new Error('Expected a single-agent workflow in the workspace seed.');
}
const pattern: PatternDefinition = {
...basePattern,
agents: [
{
...basePattern.agents[0]!,
const workflow = updatePrimaryAgent(baseWorkflow, (agent) => ({
...agent,
copilot: {
customAgents: [
{
@@ -122,9 +148,7 @@ describe('AryxAppService project customization', () => {
},
],
},
},
],
};
}));
const project = createProject({
customization: {
@@ -171,16 +195,16 @@ describe('AryxAppService project customization', () => {
lastScannedAt: TIMESTAMP,
},
});
const session = createSession(project.id, pattern.id);
const session = createSession(project.id, workflow.id);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = workflow.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
const service = createService(workspace, pattern, {
const service = createService(workspace, workflow, {
captureRunTurn: (capturedCommand) => {
command = capturedCommand;
},
@@ -189,7 +213,7 @@ describe('AryxAppService project customization', () => {
await service.sendSessionMessage(session.id, 'Use the repository guidance.');
expect(command?.projectInstructions).toBe('Use TypeScript.\n\nPrefer focused tests.');
expect(command?.pattern.agents[0]?.copilot?.customAgents).toEqual([
expect(getPrimaryAgentConfig(command?.workflow)?.copilot?.customAgents).toEqual([
{
name: 'reviewer',
prompt: 'Built-in reviewer prompt.',
@@ -206,9 +230,9 @@ describe('AryxAppService project customization', () => {
test('sendSessionMessage formats file-scoped and task-scoped project instructions for the sidecar', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!workflow) {
throw new Error('Expected a single-agent workflow in the workspace seed.');
}
const project = createProject({
@@ -248,16 +272,16 @@ describe('AryxAppService project customization', () => {
lastScannedAt: TIMESTAMP,
},
});
const session = createSession(project.id, pattern.id);
const session = createSession(project.id, workflow.id);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = workflow.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
const service = createService(workspace, pattern, {
const service = createService(workspace, workflow, {
captureRunTurn: (capturedCommand) => {
command = capturedCommand;
},
@@ -286,22 +310,22 @@ describe('AryxAppService project customization', () => {
test('sendSessionMessage carries structured prompt invocations and uses prompt agent plan mode', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!workflow) {
throw new Error('Expected a single-agent workflow in the workspace seed.');
}
const project = createProject();
const session = createSession(project.id, pattern.id);
const session = createSession(project.id, workflow.id);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = workflow.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
const service = createService(workspace, pattern, {
const service = createService(workspace, workflow, {
captureRunTurn: (capturedCommand) => {
command = capturedCommand;
},
@@ -348,21 +372,16 @@ describe('AryxAppService project customization', () => {
test('sendSessionMessage hydrates prompt model metadata and overrides the turn pattern model', async () => {
const workspace = createWorkspaceSeed();
const basePattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!basePattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
const baseWorkflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!baseWorkflow) {
throw new Error('Expected a single-agent workflow in the workspace seed.');
}
const pattern: PatternDefinition = {
...basePattern,
agents: [
{
...basePattern.agents[0]!,
const workflow = updatePrimaryAgent(baseWorkflow, (agent) => ({
...agent,
model: 'gpt-5.4',
reasoningEffort: 'high',
},
],
};
}));
const project = createProject({
customization: {
@@ -382,16 +401,16 @@ describe('AryxAppService project customization', () => {
lastScannedAt: TIMESTAMP,
},
});
const session = createSession(project.id, pattern.id);
const session = createSession(project.id, workflow.id);
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = workflow.id;
workspace.selectedSessionId = session.id;
let command: RunTurnCommand | undefined;
const service = createService(workspace, pattern, {
const service = createService(workspace, workflow, {
captureRunTurn: (capturedCommand) => {
command = capturedCommand;
},
@@ -412,8 +431,8 @@ describe('AryxAppService project customization', () => {
model: 'Claude Sonnet 4.5',
resolvedPrompt: 'Review the REST API for security gaps.',
});
expect(command?.pattern.agents[0]?.model).toBe('claude-sonnet-4.5');
expect(command?.pattern.agents[0]?.reasoningEffort).toBeUndefined();
expect(getPrimaryAgentConfig(command?.workflow)?.model).toBe('claude-sonnet-4.5');
expect(getPrimaryAgentConfig(command?.workflow)?.reasoningEffort).toBeUndefined();
expect(command?.promptInvocation).toEqual({
id: 'project_customization_prompt_rest_review',
name: 'rest-review',
@@ -426,9 +445,9 @@ describe('AryxAppService project customization', () => {
test('setProjectAgentProfileEnabled persists the updated enabled state', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
if (!pattern) {
throw new Error('Expected a single-agent pattern in the workspace seed.');
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!workflow) {
throw new Error('Expected a single-agent workflow in the workspace seed.');
}
const project = createProject({
@@ -447,12 +466,12 @@ describe('AryxAppService project customization', () => {
lastScannedAt: TIMESTAMP,
},
});
const session = createSession(project.id, pattern.id);
const session = createSession(project.id, workflow.id);
workspace.projects = [project];
workspace.sessions = [session];
const service = createService(workspace, pattern);
const service = createService(workspace, workflow);
const updated = await service.setProjectAgentProfileEnabled(project.id, 'agent-readme', false);
expect(updated.projects[0]?.customization?.agentProfiles).toEqual([
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { access, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createScratchpadProject } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
@@ -50,8 +50,8 @@ async function pathExists(path: string): Promise<boolean> {
}
}
function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
function requireSinglePattern(workspace: WorkspaceState): WorkflowDefinition {
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
}
@@ -59,11 +59,11 @@ function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
return pattern;
}
function createScratchpadSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createScratchpadSession(workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-scratchpad',
projectId: 'project-scratchpad',
patternId,
workflowId,
title: 'Scratchpad',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -116,7 +116,7 @@ describe('AryxAppService scratchpad directories', () => {
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
workspace.projects = [scratchpadProject];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
const service = createService(workspace);
@@ -152,7 +152,7 @@ describe('AryxAppService scratchpad directories', () => {
workspace.projects = [scratchpadProject];
workspace.sessions = [originalSession];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = originalSession.id;
const service = createService(workspace);
@@ -212,7 +212,7 @@ describe('AryxAppService scratchpad directories', () => {
workspace.projects = [scratchpadProject];
workspace.sessions = [originalSession];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = originalSession.id;
const service = createService(workspace);
@@ -248,7 +248,7 @@ describe('AryxAppService scratchpad directories', () => {
workspace.projects = [scratchpadProject];
workspace.sessions = [session];
workspace.selectedProjectId = scratchpadProject.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = session.id;
const service = createService(workspace);
+1 -1
View File
@@ -201,7 +201,7 @@ describe('AryxAppService sub-workflow operations', () => {
expect(session?.workflowId).toBe('parent');
expect(session?.sessionModelConfig).toEqual({
model: 'gpt-5.4',
reasoningEffort: 'medium',
reasoningEffort: undefined,
});
});
});
+3 -3
View File
@@ -48,11 +48,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
};
}
function createSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
function createSession(workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-1',
projectId: 'project-1',
patternId,
workflowId,
title: 'Terminal Session',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -133,7 +133,7 @@ function createService(workspace: WorkspaceState, terminalSnapshot = createTermi
describe('AryxAppService terminal integration', () => {
test('uses the selected session cwd when creating and restarting the terminal', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns[0];
const pattern = workspace.workflows[0];
if (!pattern) {
throw new Error('Expected a seeded pattern.');
}
+6 -6
View File
@@ -1,7 +1,7 @@
import { describe, expect, mock, test } from 'bun:test';
import type { RunTurnCommand } from '@shared/contracts/sidecar';
import type { PatternDefinition } from '@shared/domain/pattern';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createScratchpadProject } from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
@@ -43,11 +43,11 @@ const { AryxAppService } = await import('@main/AryxAppService');
function createWorkspaceFixture(): {
workspace: WorkspaceState;
pattern: PatternDefinition;
pattern: WorkflowDefinition;
session: SessionRecord;
} {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
if (!pattern) {
throw new Error('Expected the workspace seed to include a single-agent pattern.');
}
@@ -56,7 +56,7 @@ function createWorkspaceFixture(): {
const session: SessionRecord = {
id: 'session-scratchpad',
projectId: project.id,
patternId: pattern.id,
workflowId: pattern.id,
title: 'Scratchpad',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -69,7 +69,7 @@ function createWorkspaceFixture(): {
workspace.projects = [project];
workspace.sessions = [session];
workspace.selectedProjectId = project.id;
workspace.selectedPatternId = pattern.id;
workspace.selectedWorkflowId = pattern.id;
workspace.selectedSessionId = session.id;
workspace.settings.tooling = {
mcpServers: [
@@ -105,7 +105,7 @@ function createWorkspaceFixture(): {
function createService(
workspace: WorkspaceState,
pattern: PatternDefinition,
pattern: WorkflowDefinition,
options?: {
captureRunTurn?: (command: RunTurnCommand) => void;
knownApprovalToolNames?: string[];
+10 -20
View File
@@ -87,11 +87,13 @@ function createWorkflow(): WorkflowDefinition {
describe('AryxAppService workflow operations', () => {
test('saves workflows into workspace state', async () => {
const workspace = createWorkspaceSeed();
const initialWorkflowCount = workspace.workflows.length;
const service = createService(workspace);
const result = await service.saveWorkflow(createWorkflow());
expect(result.workflows).toHaveLength(1);
expect(result.workflows).toHaveLength(initialWorkflowCount + 1);
expect(result.workflows.some((workflow) => workflow.id === 'workflow-test')).toBe(true);
expect(result.selectedWorkflowId).toBe('workflow-test');
});
@@ -115,40 +117,28 @@ describe('AryxAppService workflow operations', () => {
test('creates workflows from templates and selects the new workflow', async () => {
const workspace = createWorkspaceSeed();
const initialWorkflowCount = workspace.workflows.length;
const service = createService(workspace);
const result = await service.createWorkflowFromTemplate('workflow-template-code-review', {
name: 'Template Copy',
});
expect(result.workflows).toHaveLength(1);
expect(result.workflows[0]?.name).toBe('Template Copy');
expect(result.selectedWorkflowId).toBe(result.workflows[0]?.id);
});
test('upgrades a pattern to a saved workflow without removing the pattern', async () => {
const workspace = createWorkspaceSeed();
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'handoff');
if (!pattern) {
throw new Error('Expected a handoff pattern.');
}
const service = createService(workspace);
const result = await service.upgradePatternToWorkflow(pattern.id, { save: true });
expect(result.workspace?.workflows).toHaveLength(1);
expect(result.workspace?.patterns.some((candidate) => candidate.id === pattern.id)).toBe(true);
expect(result.workflow.id).toBe('workflow-handoff-support');
expect(result.workflows).toHaveLength(initialWorkflowCount + 1);
const createdWorkflow = result.workflows.find((workflow) => workflow.name === 'Template Copy');
expect(createdWorkflow).toBeDefined();
expect(result.selectedWorkflowId).toBe(createdWorkflow?.id);
});
test('imports and saves workflows from yaml', async () => {
const workspace = createWorkspaceSeed();
const initialWorkflowCount = workspace.workflows.length;
const service = createService(workspace);
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
const result = await service.importWorkflow(yaml, 'yaml', { save: true });
expect(result.workspace?.workflows).toHaveLength(1);
expect(result.workspace?.workflows).toHaveLength(initialWorkflowCount + 1);
expect(result.workspace?.selectedWorkflowId).toBe('workflow-test');
expect(result.workflow.id).toBe('workflow-test');
});
@@ -8,6 +8,7 @@ import {
type RunTimelineEventRecord,
type SessionRunRecord,
} from '@shared/domain/runTimeline';
import type { WorkflowDefinition } from '@shared/domain/workflow';
mock.module('electron', () => {
const electronMock = {
@@ -200,7 +201,7 @@ describe('AryxAppService workflow checkpointing', () => {
workspaceKind: 'scratchpad',
mode: 'interactive',
messageMode: 'enqueue',
pattern: createPattern(),
workflow: createWorkflow(),
messages: session.messages,
resumeFromCheckpoint,
}),
@@ -230,7 +231,7 @@ describe('AryxAppService workflow checkpointing', () => {
});
function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } {
const pattern = createPattern();
const workflow = createWorkflow();
const run = createSessionRunRecord({
requestId: 'turn-1',
project: {
@@ -239,14 +240,14 @@ function createRunningSession(): { session: SessionRecord; run: SessionRunRecord
},
workingDirectory: 'C:\\scratchpad',
workspaceKind: 'scratchpad',
pattern,
workflow,
triggerMessageId: 'msg-user-1',
startedAt: '2026-04-01T12:00:00.000Z',
});
const session: SessionRecord = {
id: 'session-1',
projectId: SCRATCHPAD_PROJECT_ID,
patternId: pattern.id,
workflowId: workflow.id,
title: 'Checkpoint session',
createdAt: '2026-04-01T12:00:00.000Z',
updatedAt: '2026-04-01T12:00:00.000Z',
@@ -274,23 +275,42 @@ function createRunningSession(): { session: SessionRecord; run: SessionRunRecord
return { session, run };
}
function createPattern() {
function createWorkflow(): WorkflowDefinition {
return {
id: 'pattern-handoff',
id: 'workflow-handoff',
name: 'Checkpointing flow',
description: '',
mode: 'handoff' as const,
availability: 'available' as const,
maxIterations: 4,
agents: [
{
id: 'agent-1',
name: 'Primary',
description: '',
instructions: 'Help with the request.',
model: 'gpt-5.4',
},
],
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' as const,
orchestrationMode: 'handoff' as const,
maxIterations: 4,
},
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
};
+66 -28
View File
@@ -157,22 +157,41 @@ describe('SidecarClient', () => {
requestId: 'turn-1',
sessionId: 'session-1',
projectPath: 'C:\\workspace\\project',
pattern: {
id: 'pattern-1',
workflow: {
id: 'workflow-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',
},
],
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',
},
@@ -253,22 +272,41 @@ describe('SidecarClient', () => {
requestId: 'turn-1',
sessionId: 'session-1',
projectPath: 'C:\\workspace\\project',
pattern: {
id: 'pattern-1',
workflow: {
id: 'workflow-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',
},
],
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',
},
@@ -27,15 +27,15 @@ const { WorkspaceRepository } = await import('@main/persistence/workspaceReposit
function createStoredWorkspace(): WorkspaceState {
const workspace = createWorkspaceSeed();
const scratchpadProject = createScratchpadProject('C:\\legacy\\scratchpad', TIMESTAMP);
const patternId = workspace.patterns[0]?.id;
if (!patternId) {
const workflowId = workspace.workflows[0]?.id;
if (!workflowId) {
throw new Error('Expected workspace seed to include at least one pattern.');
}
const session: SessionRecord = {
id: 'session-scratchpad',
projectId: scratchpadProject.id,
patternId,
workflowId,
title: 'Scratchpad',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
@@ -49,7 +49,7 @@ function createStoredWorkspace(): WorkspaceState {
projects: [scratchpadProject],
sessions: [session],
selectedProjectId: scratchpadProject.id,
selectedPatternId: patternId,
selectedWorkflowId: workflowId,
selectedSessionId: session.id,
};
}