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
@@ -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([