mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
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:
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ function createSession(
|
||||
return {
|
||||
id: 'session-1',
|
||||
projectId: 'project-1',
|
||||
patternId: 'pattern-1',
|
||||
workflowId: 'pattern-1',
|
||||
title: 'Test session',
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
type SessionActivityMap,
|
||||
type SessionRequestUsageMap,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
|
||||
describe('session activity helpers', () => {
|
||||
const agents: PatternDefinition['agents'] = [
|
||||
const agents = [
|
||||
{
|
||||
id: 'architect',
|
||||
name: 'Architect',
|
||||
@@ -37,7 +37,14 @@ describe('session activity helpers', () => {
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
];
|
||||
] satisfies Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
model: string;
|
||||
reasoningEffort?: 'high' | 'medium';
|
||||
}>;
|
||||
|
||||
test('stores activity per session and per agent', () => {
|
||||
const architectEvent: SessionEventRecord = {
|
||||
@@ -247,9 +254,9 @@ describe('session activity helpers', () => {
|
||||
projectId: 'project-1',
|
||||
projectPath: 'C:\\workspace\\project',
|
||||
workspaceKind: 'project',
|
||||
patternId: 'pattern-1',
|
||||
patternName: 'Pattern',
|
||||
patternMode: 'single',
|
||||
workflowId: 'workflow-1',
|
||||
workflowName: 'Pattern',
|
||||
workflowMode: 'single',
|
||||
triggerMessageId: 'msg-1',
|
||||
startedAt: '2026-03-23T00:00:00.000Z',
|
||||
completedAt: '2026-03-23T00:00:01.000Z',
|
||||
|
||||
@@ -10,7 +10,6 @@ describe('session workspace helpers', () => {
|
||||
function createWorkspace(): WorkspaceState {
|
||||
return {
|
||||
projects: [],
|
||||
patterns: [],
|
||||
workflows: [],
|
||||
workflowTemplates: [],
|
||||
settings: createWorkspaceSettings(),
|
||||
@@ -18,7 +17,7 @@ describe('session workspace helpers', () => {
|
||||
{
|
||||
id: 'session-1',
|
||||
projectId: 'project-1',
|
||||
patternId: 'pattern-1',
|
||||
workflowId: 'workflow-1',
|
||||
title: 'Session',
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
@@ -196,9 +195,9 @@ describe('session workspace helpers', () => {
|
||||
projectId: 'project-1',
|
||||
projectPath: 'C:\\workspace\\alpha',
|
||||
workspaceKind: 'project',
|
||||
patternId: 'pattern-1',
|
||||
patternName: 'Sequential Review',
|
||||
patternMode: 'sequential',
|
||||
workflowId: 'workflow-1',
|
||||
workflowName: 'Sequential Review',
|
||||
workflowMode: 'sequential',
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
status: 'running',
|
||||
|
||||
+40
-20
@@ -5,10 +5,10 @@ import {
|
||||
buildAvailableModelCatalog,
|
||||
findModel,
|
||||
findModelByReference,
|
||||
normalizePatternModels,
|
||||
normalizeWorkflowModels,
|
||||
resolveReasoningEffort,
|
||||
} from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
|
||||
const availableModels: SidecarModelCapability[] = [
|
||||
{
|
||||
@@ -24,24 +24,43 @@ const availableModels: SidecarModelCapability[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function createPattern(): PatternDefinition {
|
||||
function createWorkflow(): WorkflowDefinition {
|
||||
return {
|
||||
id: 'pattern-1',
|
||||
name: 'Pattern',
|
||||
description: '',
|
||||
mode: 'single',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Primary Agent',
|
||||
description: 'Helpful assistant',
|
||||
instructions: 'Help the user.',
|
||||
model: 'claude-sonnet-4.5',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: 'agent-1',
|
||||
kind: 'agent',
|
||||
label: 'Primary Agent',
|
||||
position: { x: 200, y: 0 },
|
||||
order: 0,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-1',
|
||||
name: 'Primary Agent',
|
||||
description: 'Helpful assistant',
|
||||
instructions: 'Help the user.',
|
||||
model: 'claude-sonnet-4.5',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
},
|
||||
{ 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-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
};
|
||||
@@ -70,12 +89,13 @@ describe('dynamic model catalog', () => {
|
||||
expect(findModelByReference('Claude Sonnet 4.5', catalog)?.id).toBe('claude-sonnet-4.5');
|
||||
});
|
||||
|
||||
test('normalizes pattern agents before runtime execution', () => {
|
||||
const normalized = normalizePatternModels(
|
||||
createPattern(),
|
||||
test('normalizes workflow agent reasoning effort before runtime execution', () => {
|
||||
const normalized = normalizeWorkflowModels(
|
||||
createWorkflow(),
|
||||
buildAvailableModelCatalog(availableModels),
|
||||
);
|
||||
|
||||
expect(normalized.agents[0].reasoningEffort).toBeUndefined();
|
||||
const primaryAgent = normalized.graph.nodes.find((node) => node.kind === 'agent');
|
||||
expect(primaryAgent?.config.kind === 'agent' ? primaryAgent.config.reasoningEffort : undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import {
|
||||
appendRunActivityEvent,
|
||||
completeSessionRunRecord,
|
||||
@@ -12,30 +12,58 @@ import {
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { PendingApprovalRecord } from '@shared/domain/approval';
|
||||
|
||||
function createPattern(): PatternDefinition {
|
||||
function createWorkflow(): WorkflowDefinition {
|
||||
return {
|
||||
id: 'pattern-sequential',
|
||||
id: 'workflow-sequential',
|
||||
name: 'Sequential Trio Review',
|
||||
description: 'Sequential handoff review flow.',
|
||||
mode: 'sequential',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-writer',
|
||||
name: 'Writer',
|
||||
description: 'Writes the draft.',
|
||||
instructions: 'Write.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
{
|
||||
id: 'agent-reviewer',
|
||||
name: 'Reviewer',
|
||||
description: 'Reviews the draft.',
|
||||
instructions: 'Review.',
|
||||
model: 'claude-sonnet-4.5',
|
||||
},
|
||||
],
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: 'agent-writer',
|
||||
kind: 'agent',
|
||||
label: 'Writer',
|
||||
position: { x: 200, y: 0 },
|
||||
order: 0,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-writer',
|
||||
name: 'Writer',
|
||||
description: 'Writes the draft.',
|
||||
instructions: 'Write.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'agent-reviewer',
|
||||
kind: 'agent',
|
||||
label: 'Reviewer',
|
||||
position: { x: 400, y: 0 },
|
||||
order: 1,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-reviewer',
|
||||
name: 'Reviewer',
|
||||
description: 'Reviews the draft.',
|
||||
instructions: 'Review.',
|
||||
model: 'claude-sonnet-4.5',
|
||||
},
|
||||
},
|
||||
{ id: 'end', kind: 'end', label: 'End', position: { x: 600, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-start-writer', source: 'start', target: 'agent-writer', kind: 'direct' },
|
||||
{ id: 'edge-writer-reviewer', source: 'agent-writer', target: 'agent-reviewer', kind: 'direct' },
|
||||
{ id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
orchestrationMode: 'sequential',
|
||||
maxIterations: 1,
|
||||
},
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
};
|
||||
@@ -57,7 +85,7 @@ describe('run timeline helpers', () => {
|
||||
project: createProject(),
|
||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
workflow: createWorkflow(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
preRunGitBaselineFiles: [
|
||||
@@ -73,9 +101,9 @@ describe('run timeline helpers', () => {
|
||||
projectId: 'project-1',
|
||||
projectPath: 'C:\\workspace\\alpha',
|
||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||
patternId: 'pattern-sequential',
|
||||
patternName: 'Sequential Trio Review',
|
||||
patternMode: 'sequential',
|
||||
workflowId: 'workflow-sequential',
|
||||
workflowName: 'Sequential Trio Review',
|
||||
workflowMode: 'sequential',
|
||||
triggerMessageId: 'msg-user-1',
|
||||
status: 'running',
|
||||
});
|
||||
@@ -112,7 +140,7 @@ describe('run timeline helpers', () => {
|
||||
requestId: 'turn-1',
|
||||
project: createProject(),
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
workflow: createWorkflow(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
});
|
||||
@@ -159,7 +187,7 @@ describe('run timeline helpers', () => {
|
||||
requestId: 'turn-1',
|
||||
project: createProject(),
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
workflow: createWorkflow(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
}),
|
||||
@@ -187,7 +215,7 @@ describe('run timeline helpers', () => {
|
||||
requestId: 'turn-1',
|
||||
project: createProject(),
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
workflow: createWorkflow(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
});
|
||||
@@ -242,7 +270,7 @@ describe('run timeline helpers', () => {
|
||||
requestId: 'turn-1',
|
||||
project: createProject(),
|
||||
workspaceKind: 'project',
|
||||
pattern: createPattern(),
|
||||
workflow: createWorkflow(),
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-03-23T00:00:01.000Z',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||
import {
|
||||
applySessionApprovalSettings,
|
||||
applySessionModelConfig,
|
||||
@@ -12,42 +12,70 @@ import {
|
||||
type SessionRecord,
|
||||
} from '@shared/domain/session';
|
||||
|
||||
function createPattern(): PatternDefinition {
|
||||
function createWorkflow(): WorkflowDefinition {
|
||||
return {
|
||||
id: 'pattern-single',
|
||||
id: 'workflow-single',
|
||||
name: '1-on-1 Copilot Chat',
|
||||
description: 'Single agent chat',
|
||||
mode: 'single',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-primary',
|
||||
name: 'Primary Agent',
|
||||
description: 'Helpful assistant',
|
||||
instructions: 'Help the user.',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
{
|
||||
id: 'agent-secondary',
|
||||
name: 'Secondary Agent',
|
||||
description: 'Unused here',
|
||||
instructions: 'Review.',
|
||||
model: 'claude-sonnet-4.5',
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: 'agent-primary-node',
|
||||
kind: 'agent',
|
||||
label: 'Primary Agent',
|
||||
position: { x: 200, y: 0 },
|
||||
order: 0,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-primary',
|
||||
name: 'Primary Agent',
|
||||
description: 'Helpful assistant',
|
||||
instructions: 'Help the user.',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'agent-secondary-node',
|
||||
kind: 'agent',
|
||||
label: 'Secondary Agent',
|
||||
position: { x: 400, y: 0 },
|
||||
order: 1,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-secondary',
|
||||
name: 'Secondary Agent',
|
||||
description: 'Unused here',
|
||||
instructions: 'Review.',
|
||||
model: 'claude-sonnet-4.5',
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
},
|
||||
{ id: 'end', kind: 'end', label: 'End', position: { x: 600, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-start-primary', source: 'start', target: 'agent-primary-node', kind: 'direct' },
|
||||
{ id: 'edge-primary-secondary', source: 'agent-primary-node', target: 'agent-secondary-node', kind: 'direct' },
|
||||
{ id: 'edge-secondary-end', source: 'agent-secondary-node', target: 'end', kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
orchestrationMode: 'single',
|
||||
maxIterations: 1,
|
||||
},
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
return {
|
||||
id: 'session-1',
|
||||
projectId: 'project-scratchpad',
|
||||
patternId: 'pattern-single',
|
||||
return {
|
||||
id: 'session-1',
|
||||
projectId: 'project-scratchpad',
|
||||
workflowId: 'workflow-single',
|
||||
title: 'Scratchpad',
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
@@ -60,13 +88,13 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
|
||||
describe('session model config helpers', () => {
|
||||
test('captures the initial model settings from the primary agent', () => {
|
||||
expect(createSessionModelConfig(createPattern())).toEqual({
|
||||
expect(createSessionModelConfig(createWorkflow())).toEqual({
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves persisted session overrides over the pattern defaults', () => {
|
||||
test('resolves persisted session overrides over the workflow defaults', () => {
|
||||
const config = resolveSessionModelConfig(
|
||||
createSession({
|
||||
sessionModelConfig: {
|
||||
@@ -74,7 +102,7 @@ describe('session model config helpers', () => {
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
}),
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
);
|
||||
|
||||
expect(config).toEqual({
|
||||
@@ -84,9 +112,9 @@ describe('session model config helpers', () => {
|
||||
});
|
||||
|
||||
test('applies session model settings only to the primary agent', () => {
|
||||
const pattern = createPattern();
|
||||
const workflow = createWorkflow();
|
||||
const updated = applySessionModelConfig(
|
||||
pattern,
|
||||
workflow,
|
||||
createSession({
|
||||
sessionModelConfig: {
|
||||
model: 'gpt-5.4-mini',
|
||||
@@ -95,22 +123,25 @@ describe('session model config helpers', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(updated.agents[0].model).toBe('gpt-5.4-mini');
|
||||
expect(updated.agents[0].reasoningEffort).toBe('low');
|
||||
expect(updated.agents[1]).toEqual(pattern.agents[1]);
|
||||
const primaryAgent = updated.graph.nodes[1];
|
||||
const secondaryAgent = updated.graph.nodes[2];
|
||||
const originalSecondaryAgent = workflow.graph.nodes[2];
|
||||
expect(primaryAgent?.config.kind === 'agent' ? primaryAgent.config.model : undefined).toBe('gpt-5.4-mini');
|
||||
expect(primaryAgent?.config.kind === 'agent' ? primaryAgent.config.reasoningEffort : undefined).toBe('low');
|
||||
expect(secondaryAgent).toEqual(originalSecondaryAgent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session title helpers', () => {
|
||||
test('keeps a manual title instead of recomputing it from the first user message', () => {
|
||||
const pattern = createPattern();
|
||||
const workflow = createWorkflow();
|
||||
const session = createSession({
|
||||
title: 'Release readiness review',
|
||||
titleSource: 'manual',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSessionTitle(session, pattern, [
|
||||
resolveSessionTitle(session, workflow, [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
@@ -123,11 +154,11 @@ describe('session title helpers', () => {
|
||||
});
|
||||
|
||||
test('builds auto titles from markdown-heavy first messages', () => {
|
||||
const pattern = createPattern();
|
||||
const workflow = createWorkflow();
|
||||
const session = createSession();
|
||||
|
||||
expect(
|
||||
resolveSessionTitle(session, pattern, [
|
||||
resolveSessionTitle(session, workflow, [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
@@ -164,25 +195,28 @@ describe('session tooling helpers', () => {
|
||||
});
|
||||
|
||||
describe('session approval helpers', () => {
|
||||
test('normalizes session approval overrides and applies them over pattern defaults', () => {
|
||||
const pattern = {
|
||||
...createPattern(),
|
||||
approvalPolicy: {
|
||||
rules: [{ kind: 'tool-call' as const }],
|
||||
autoApprovedToolNames: ['git.status'],
|
||||
test('normalizes session approval overrides and applies them over workflow defaults', () => {
|
||||
const workflow = {
|
||||
...createWorkflow(),
|
||||
settings: {
|
||||
...createWorkflow().settings,
|
||||
approvalPolicy: {
|
||||
rules: [{ kind: 'tool-call' as const }],
|
||||
autoApprovedToolNames: ['git.status'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveSessionApprovalSettings(createSession())).toBeUndefined();
|
||||
expect(
|
||||
applySessionApprovalSettings(
|
||||
pattern,
|
||||
createSession({
|
||||
applySessionApprovalSettings(
|
||||
workflow,
|
||||
createSession({
|
||||
approvalSettings: {
|
||||
autoApprovedToolNames: ['git.diff', ' git.diff '],
|
||||
},
|
||||
}),
|
||||
).approvalPolicy,
|
||||
).settings.approvalPolicy,
|
||||
).toEqual({
|
||||
rules: [{ kind: 'tool-call' }],
|
||||
autoApprovedToolNames: ['git.diff'],
|
||||
|
||||
@@ -9,30 +9,49 @@ import {
|
||||
renameSessionRecord,
|
||||
setSessionMessagePinnedRecord,
|
||||
} from '@shared/domain/sessionLibrary';
|
||||
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 { createWorkspaceSettings } from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
function createPattern(overrides?: Partial<PatternDefinition>): PatternDefinition {
|
||||
function createWorkflow(overrides?: Partial<WorkflowDefinition>): WorkflowDefinition {
|
||||
return {
|
||||
id: 'pattern-sequential-review',
|
||||
id: 'workflow-sequential-review',
|
||||
name: 'Sequential Review',
|
||||
description: 'Multi-agent review workflow.',
|
||||
mode: 'sequential',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-analyst',
|
||||
name: 'Analyst',
|
||||
description: 'Reviews the request and finds issues.',
|
||||
instructions: 'Analyze the request.',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||
{
|
||||
id: 'agent-analyst-node',
|
||||
kind: 'agent',
|
||||
label: 'Analyst',
|
||||
position: { x: 200, y: 0 },
|
||||
order: 0,
|
||||
config: {
|
||||
kind: 'agent',
|
||||
id: 'agent-analyst',
|
||||
name: 'Analyst',
|
||||
description: 'Reviews the request and finds issues.',
|
||||
instructions: 'Analyze the request.',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
},
|
||||
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-start-agent', source: 'start', target: 'agent-analyst-node', kind: 'direct' },
|
||||
{ id: 'edge-agent-end', source: 'agent-analyst-node', target: 'end', kind: 'direct' },
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
checkpointing: { enabled: false },
|
||||
executionMode: 'off-thread',
|
||||
orchestrationMode: 'sequential',
|
||||
maxIterations: 1,
|
||||
},
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||
...overrides,
|
||||
@@ -53,7 +72,7 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
return {
|
||||
id: 'session-1',
|
||||
projectId: 'project-alpha',
|
||||
patternId: 'pattern-sequential-review',
|
||||
workflowId: 'workflow-sequential-review',
|
||||
title: 'Investigate Copilot refresh bug',
|
||||
createdAt: '2026-03-23T00:00:00.000Z',
|
||||
updatedAt: '2026-03-23T00:05:00.000Z',
|
||||
@@ -75,8 +94,7 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
||||
const workspace: WorkspaceState = {
|
||||
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
||||
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
|
||||
workflows: [],
|
||||
workflows: [createWorkflow(), createWorkflow({ id: 'workflow-single-chat', name: '1-on-1 Copilot Chat', settings: { checkpointing: { enabled: false }, executionMode: 'off-thread', orchestrationMode: 'single', maxIterations: 1 } })],
|
||||
workflowTemplates: [],
|
||||
settings: createWorkspaceSettings(),
|
||||
sessions: [
|
||||
@@ -84,7 +102,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
||||
createSession({
|
||||
id: 'session-2',
|
||||
projectId: 'project-scratchpad',
|
||||
patternId: 'pattern-single-chat',
|
||||
workflowId: 'workflow-single-chat',
|
||||
title: 'Scratchpad brainstorm',
|
||||
status: 'running',
|
||||
updatedAt: '2026-03-23T00:10:00.000Z',
|
||||
@@ -109,7 +127,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
||||
}),
|
||||
],
|
||||
selectedProjectId: 'project-alpha',
|
||||
selectedPatternId: 'pattern-sequential-review',
|
||||
selectedWorkflowId: 'workflow-sequential-review',
|
||||
selectedSessionId: 'session-1',
|
||||
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
|
||||
...overrides,
|
||||
@@ -291,7 +309,7 @@ describe('session library helpers', () => {
|
||||
|
||||
const branch = branchSessionRecord(
|
||||
sourceSession,
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
'session-branch',
|
||||
'msg-3',
|
||||
'2026-03-23T00:04:00.000Z',
|
||||
@@ -341,7 +359,7 @@ describe('session library helpers', () => {
|
||||
expect(() =>
|
||||
branchSessionRecord(
|
||||
sourceSession,
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
'session-branch',
|
||||
'msg-1',
|
||||
'2026-03-23T00:04:00.000Z',
|
||||
@@ -384,7 +402,7 @@ describe('session library helpers', () => {
|
||||
|
||||
const branch = branchSessionRecord(
|
||||
sourceSession,
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
'session-branch',
|
||||
'msg-2',
|
||||
'2026-03-23T00:05:00.000Z',
|
||||
@@ -436,7 +454,7 @@ describe('session library helpers', () => {
|
||||
|
||||
const regenerated = regenerateSessionRecord(
|
||||
sourceSession,
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
'session-regenerated',
|
||||
'msg-4',
|
||||
'2026-03-23T00:06:00.000Z',
|
||||
@@ -483,7 +501,7 @@ describe('session library helpers', () => {
|
||||
expect(() =>
|
||||
regenerateSessionRecord(
|
||||
sourceSession,
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
'session-regenerated',
|
||||
'msg-2',
|
||||
'2026-03-23T00:06:00.000Z',
|
||||
@@ -541,7 +559,7 @@ describe('session library helpers', () => {
|
||||
|
||||
const edited = editAndResendSessionRecord(
|
||||
sourceSession,
|
||||
createPattern(),
|
||||
createWorkflow(),
|
||||
'session-edited',
|
||||
'msg-3',
|
||||
'Focus on session state only.',
|
||||
@@ -585,7 +603,7 @@ describe('session library helpers', () => {
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
score: 31,
|
||||
matchedFields: ['title', 'message', 'project', 'pattern'],
|
||||
matchedFields: ['title', 'message', 'project', 'workflow'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildWorkflowExecutionPattern,
|
||||
buildWorkflowExecutionDefinition,
|
||||
validateWorkflowDefinition,
|
||||
type WorkflowDefinition,
|
||||
} from '@shared/domain/workflow';
|
||||
@@ -245,16 +245,16 @@ describe('workflow validation', () => {
|
||||
expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true);
|
||||
});
|
||||
|
||||
test('builds a synthetic execution pattern from workflow agents', () => {
|
||||
const pattern = buildWorkflowExecutionPattern(createWorkflow());
|
||||
test('builds an execution definition from workflow agents', () => {
|
||||
const execution = buildWorkflowExecutionDefinition(createWorkflow());
|
||||
|
||||
expect(pattern.id).toBe('workflow-1');
|
||||
expect(pattern.agents).toHaveLength(1);
|
||||
expect(pattern.agents[0]?.name).toBe('Primary Agent');
|
||||
expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']);
|
||||
expect(execution.id).toBe('workflow-1');
|
||||
expect(execution.agents).toHaveLength(1);
|
||||
expect(execution.agents[0]?.name).toBe('Primary Agent');
|
||||
expect(execution.orchestrationMode).toBe('single');
|
||||
});
|
||||
|
||||
test('includes referenced sub-workflow agents when building execution patterns', () => {
|
||||
test('includes referenced sub-workflow agents when building execution definitions', () => {
|
||||
const childWorkflow = createReferencedSubWorkflow();
|
||||
const workflow = createWorkflow();
|
||||
workflow.graph.nodes[1] = {
|
||||
@@ -270,12 +270,12 @@ describe('workflow validation', () => {
|
||||
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
||||
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
||||
|
||||
const pattern = buildWorkflowExecutionPattern(workflow, {
|
||||
resolveWorkflow: (workflowId) => workflowId === childWorkflow.id ? childWorkflow : undefined,
|
||||
const execution = buildWorkflowExecutionDefinition(workflow, {
|
||||
resolveWorkflow: (workflowId: string) => workflowId === childWorkflow.id ? childWorkflow : undefined,
|
||||
});
|
||||
|
||||
expect(pattern.mode).toBe('single');
|
||||
expect(pattern.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
|
||||
expect(execution.orchestrationMode).toBe('single');
|
||||
expect(execution.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
|
||||
});
|
||||
|
||||
test('accepts simple property and expression conditions', () => {
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||
import {
|
||||
exportWorkflowDefinition,
|
||||
importWorkflowDefinition,
|
||||
} from '@shared/domain/workflowSerialization';
|
||||
import {
|
||||
buildWorkflowFromPattern,
|
||||
createBuiltinWorkflowTemplates,
|
||||
} from '@shared/domain/workflowTemplate';
|
||||
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
||||
|
||||
const TIMESTAMP = '2026-04-05T00:00:00.000Z';
|
||||
|
||||
function requirePattern(mode: 'sequential' | 'concurrent' | 'handoff' | 'group-chat') {
|
||||
const pattern = createBuiltinPatterns(TIMESTAMP).find((candidate) => candidate.mode === mode);
|
||||
if (!pattern) {
|
||||
throw new Error(`Expected built-in ${mode} pattern.`);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
describe('workflow templates', () => {
|
||||
test('builds a valid sequential workflow from a pattern', () => {
|
||||
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
|
||||
|
||||
expect(workflow.id).toBe('workflow-sequential-review');
|
||||
expect(workflow.graph.nodes.map((node) => node.kind)).toEqual(['start', 'agent', 'agent', 'agent', 'end']);
|
||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}:${edge.kind}`)).toEqual([
|
||||
'start->agent-node-agent-sequential-analyst:direct',
|
||||
'agent-node-agent-sequential-analyst->agent-node-agent-sequential-builder:direct',
|
||||
'agent-node-agent-sequential-builder->agent-node-agent-sequential-reviewer:direct',
|
||||
'agent-node-agent-sequential-reviewer->end:direct',
|
||||
]);
|
||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
test('builds a valid concurrent workflow from a pattern', () => {
|
||||
const workflow = buildWorkflowFromPattern(requirePattern('concurrent'));
|
||||
|
||||
expect(workflow.graph.edges.filter((edge) => edge.kind === 'fan-out')).toHaveLength(3);
|
||||
expect(workflow.graph.edges.filter((edge) => edge.kind === 'fan-in')).toHaveLength(3);
|
||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
test('builds a valid handoff workflow from a pattern graph', () => {
|
||||
const workflow = buildWorkflowFromPattern(requirePattern('handoff'));
|
||||
|
||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain('start->agent-node-agent-handoff-triage');
|
||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain(
|
||||
'agent-node-agent-handoff-triage->agent-node-agent-handoff-ux',
|
||||
);
|
||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain(
|
||||
'agent-node-agent-handoff-runtime->agent-node-agent-handoff-triage',
|
||||
);
|
||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
test('builds a group-chat workflow with a loop approximation', () => {
|
||||
const workflow = buildWorkflowFromPattern(requirePattern('group-chat'));
|
||||
const loopEdge = workflow.graph.edges.find((edge) =>
|
||||
edge.source === 'agent-node-agent-group-reviewer' && edge.target === 'agent-node-agent-group-writer');
|
||||
const entryEdge = workflow.graph.edges.find((edge) =>
|
||||
edge.source === 'start' && edge.target === 'agent-node-agent-group-writer');
|
||||
const exitEdge = workflow.graph.edges.find((edge) =>
|
||||
edge.source === 'agent-node-agent-group-reviewer' && edge.target === 'end');
|
||||
|
||||
expect(loopEdge).toEqual(expect.objectContaining({
|
||||
source: 'agent-node-agent-group-reviewer',
|
||||
target: 'agent-node-agent-group-writer',
|
||||
isLoop: true,
|
||||
maxIterations: 5,
|
||||
condition: { type: 'always' },
|
||||
}));
|
||||
expect(entryEdge?.isLoop).not.toBe(true);
|
||||
expect(exitEdge?.isLoop).not.toBe(true);
|
||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
||||
});
|
||||
|
||||
test('creates 8 hand-crafted builtin workflow templates', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
|
||||
expect(templates).toHaveLength(8);
|
||||
expect(templates.map((template) => template.id)).toEqual([
|
||||
'workflow-template-code-review',
|
||||
'workflow-template-research-summarize',
|
||||
'workflow-template-customer-support',
|
||||
'workflow-template-content-creation',
|
||||
'workflow-template-multi-agent-debate',
|
||||
'workflow-template-data-processing',
|
||||
'workflow-template-approval',
|
||||
'workflow-template-nested-orchestrator',
|
||||
]);
|
||||
expect(templates.every((template) => template.source === 'builtin')).toBe(true);
|
||||
});
|
||||
|
||||
test('builtin templates span all categories', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
const categories = new Set(templates.map((template) => template.category));
|
||||
|
||||
expect(categories).toContain('orchestration');
|
||||
expect(categories).toContain('data-pipeline');
|
||||
expect(categories).toContain('human-in-loop');
|
||||
});
|
||||
|
||||
test('builtin templates produce valid workflows', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
|
||||
for (const template of templates) {
|
||||
const issues = validateWorkflowDefinition(template.workflow);
|
||||
expect(issues.filter((issue) => issue.level === 'error')).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('builtin templates use the provided timestamp', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
|
||||
for (const template of templates) {
|
||||
expect(template.createdAt).toBe(TIMESTAMP);
|
||||
expect(template.updatedAt).toBe(TIMESTAMP);
|
||||
}
|
||||
});
|
||||
|
||||
test('research-summarize template uses fan-out and fan-in edges', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
const research = templates.find((t) => t.id === 'workflow-template-research-summarize')!;
|
||||
|
||||
expect(research.workflow.graph.edges.filter((e) => e.kind === 'fan-out')).toHaveLength(3);
|
||||
expect(research.workflow.graph.edges.filter((e) => e.kind === 'fan-in')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('content-creation template has a loop edge with maxIterations', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
const content = templates.find((t) => t.id === 'workflow-template-content-creation')!;
|
||||
const loopEdge = content.workflow.graph.edges.find((e) => e.isLoop && e.source === 'editor' && e.target === 'writer');
|
||||
|
||||
expect(loopEdge).toBeDefined();
|
||||
expect(loopEdge!.maxIterations).toBe(3);
|
||||
expect(loopEdge!.source).toBe('editor');
|
||||
expect(loopEdge!.target).toBe('writer');
|
||||
});
|
||||
|
||||
test('data-processing template includes invoke-function nodes', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
const dataProc = templates.find((t) => t.id === 'workflow-template-data-processing')!;
|
||||
const funcNodes = dataProc.workflow.graph.nodes.filter((n) => n.kind === 'invoke-function');
|
||||
|
||||
expect(funcNodes).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('approval template includes a request-port node', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
const approval = templates.find((t) => t.id === 'workflow-template-approval')!;
|
||||
const portNode = approval.workflow.graph.nodes.find((n) => n.kind === 'request-port');
|
||||
|
||||
expect(portNode).toBeDefined();
|
||||
expect(portNode!.config).toEqual(expect.objectContaining({
|
||||
kind: 'request-port',
|
||||
portId: 'review',
|
||||
requestType: 'ReviewRequest',
|
||||
responseType: 'ReviewDecision',
|
||||
}));
|
||||
});
|
||||
|
||||
test('nested-orchestrator template includes a sub-workflow node', () => {
|
||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
||||
const nested = templates.find((t) => t.id === 'workflow-template-nested-orchestrator')!;
|
||||
const subNode = nested.workflow.graph.nodes.find((n) => n.kind === 'sub-workflow');
|
||||
|
||||
expect(subNode).toBeDefined();
|
||||
expect(subNode!.config).toEqual(expect.objectContaining({ kind: 'sub-workflow' }));
|
||||
});
|
||||
|
||||
test('round trips workflow yaml import and export', () => {
|
||||
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
|
||||
const exported = exportWorkflowDefinition(workflow, 'yaml');
|
||||
const imported = importWorkflowDefinition(exported.content, 'yaml');
|
||||
|
||||
expect(exported.format).toBe('yaml');
|
||||
expect(imported).toEqual(workflow);
|
||||
});
|
||||
|
||||
test('exports mermaid flowcharts with expected edges', () => {
|
||||
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
|
||||
const exported = exportWorkflowDefinition(workflow, 'mermaid');
|
||||
|
||||
expect(exported.content.startsWith('flowchart LR')).toBe(true);
|
||||
expect(exported.content).toContain('-->');
|
||||
expect(exported.content).toContain('n0 --> n1');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { createWorkspaceSeed } from '@shared/domain/workspace';
|
||||
|
||||
describe('workspace seed', () => {
|
||||
test('starts empty and seeds built-in patterns and workflow templates with a shared timestamp', () => {
|
||||
test('starts empty and seeds built-in workflows and workflow templates with a shared timestamp', () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
|
||||
expect(workspace.projects).toEqual([]);
|
||||
@@ -19,16 +19,15 @@ describe('workspace seed', () => {
|
||||
},
|
||||
});
|
||||
expect(workspace.selectedProjectId).toBeUndefined();
|
||||
expect(workspace.selectedPatternId).toBeUndefined();
|
||||
expect(workspace.selectedWorkflowId).toBeUndefined();
|
||||
expect(workspace.selectedSessionId).toBeUndefined();
|
||||
|
||||
expect(workspace.patterns.map((pattern) => pattern.mode)).toEqual([
|
||||
expect(workspace.workflows.map((workflow) => workflow.settings.orchestrationMode)).toEqual([
|
||||
'single',
|
||||
'sequential',
|
||||
'concurrent',
|
||||
'handoff',
|
||||
'group-chat',
|
||||
'magentic',
|
||||
]);
|
||||
expect(workspace.workflowTemplates.map((template) => template.id)).toEqual([
|
||||
'workflow-template-code-review',
|
||||
@@ -41,16 +40,12 @@ describe('workspace seed', () => {
|
||||
'workflow-template-nested-orchestrator',
|
||||
]);
|
||||
|
||||
for (const pattern of workspace.patterns) {
|
||||
expect(pattern.createdAt).toBe(workspace.lastUpdatedAt);
|
||||
expect(pattern.updatedAt).toBe(workspace.lastUpdatedAt);
|
||||
expect(pattern.approvalPolicy?.rules).toContainEqual({ kind: 'tool-call' });
|
||||
for (const workflow of workspace.workflows) {
|
||||
expect(workflow.createdAt).toBe(workspace.lastUpdatedAt);
|
||||
expect(workflow.updatedAt).toBe(workspace.lastUpdatedAt);
|
||||
expect(workflow.settings.approvalPolicy).toBeUndefined();
|
||||
}
|
||||
|
||||
const magentic = workspace.patterns.find((pattern) => pattern.mode === 'magentic');
|
||||
|
||||
expect(magentic?.availability).toBe('unavailable');
|
||||
expect(magentic?.unavailabilityReason).toContain('unsupported');
|
||||
for (const template of workspace.workflowTemplates) {
|
||||
expect(template.createdAt).toBe(workspace.lastUpdatedAt);
|
||||
expect(template.updatedAt).toBe(workspace.lastUpdatedAt);
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
resolvePatternAgent,
|
||||
resolvePatternAgents,
|
||||
findWorkspaceAgentUsages,
|
||||
normalizeWorkspaceAgentDefinition,
|
||||
type WorkspaceAgentDefinition,
|
||||
} from '@shared/domain/workspaceAgent';
|
||||
import type { PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
||||
|
||||
const TIMESTAMP = '2026-04-01T00:00:00.000Z';
|
||||
|
||||
function makeWorkspaceAgent(overrides: Partial<WorkspaceAgentDefinition> = {}): WorkspaceAgentDefinition {
|
||||
return {
|
||||
id: 'wa-1',
|
||||
name: 'Code Reviewer',
|
||||
description: 'Reviews code for quality',
|
||||
instructions: 'Review all code carefully',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInlineAgent(overrides: Partial<PatternAgentDefinition> = {}): PatternAgentDefinition {
|
||||
return {
|
||||
id: 'agent-1',
|
||||
name: 'Inline Agent',
|
||||
description: 'An inline agent',
|
||||
instructions: 'Do stuff',
|
||||
model: 'claude-sonnet-4',
|
||||
reasoningEffort: 'medium',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLinkedAgent(overrides: Partial<PatternAgentDefinition> = {}): PatternAgentDefinition {
|
||||
return {
|
||||
id: 'agent-linked',
|
||||
name: 'Code Reviewer',
|
||||
description: 'Reviews code for quality',
|
||||
instructions: 'Review all code carefully',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
workspaceAgentId: 'wa-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePattern(agents: PatternAgentDefinition[], overrides: Partial<PatternDefinition> = {}): PatternDefinition {
|
||||
return {
|
||||
id: 'pattern-1',
|
||||
name: 'Test Pattern',
|
||||
description: '',
|
||||
mode: 'sequential',
|
||||
availability: 'available',
|
||||
maxIterations: 10,
|
||||
agents,
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolvePatternAgent', () => {
|
||||
const workspaceAgents = [makeWorkspaceAgent()];
|
||||
|
||||
test('returns inline agent unchanged', () => {
|
||||
const agent = makeInlineAgent();
|
||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
||||
expect(resolved).toEqual(agent);
|
||||
});
|
||||
|
||||
test('resolves linked agent from workspace agent base', () => {
|
||||
const agent = makeLinkedAgent();
|
||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
||||
expect(resolved.name).toBe('Code Reviewer');
|
||||
expect(resolved.model).toBe('gpt-5.4');
|
||||
expect(resolved.instructions).toBe('Review all code carefully');
|
||||
expect(resolved.workspaceAgentId).toBe('wa-1');
|
||||
expect(resolved.id).toBe('agent-linked');
|
||||
});
|
||||
|
||||
test('applies per-pattern overrides on top of workspace agent', () => {
|
||||
const agent = makeLinkedAgent({
|
||||
overrides: { model: 'claude-opus-4', instructions: 'Override instructions' },
|
||||
});
|
||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
||||
expect(resolved.model).toBe('claude-opus-4');
|
||||
expect(resolved.instructions).toBe('Override instructions');
|
||||
expect(resolved.name).toBe('Code Reviewer');
|
||||
expect(resolved.description).toBe('Reviews code for quality');
|
||||
});
|
||||
|
||||
test('falls back to inline fields when workspace agent is missing', () => {
|
||||
const agent = makeLinkedAgent({ workspaceAgentId: 'nonexistent' });
|
||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
||||
expect(resolved).toEqual(agent);
|
||||
});
|
||||
|
||||
test('partial overrides only replace specified fields', () => {
|
||||
const agent = makeLinkedAgent({
|
||||
overrides: { name: 'Custom Name' },
|
||||
});
|
||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
||||
expect(resolved.name).toBe('Custom Name');
|
||||
expect(resolved.model).toBe('gpt-5.4');
|
||||
expect(resolved.reasoningEffort).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePatternAgents', () => {
|
||||
const workspaceAgents = [makeWorkspaceAgent()];
|
||||
|
||||
test('resolves all agents in a pattern', () => {
|
||||
const pattern = makePattern([
|
||||
makeInlineAgent(),
|
||||
makeLinkedAgent(),
|
||||
]);
|
||||
const resolved = resolvePatternAgents(pattern, workspaceAgents);
|
||||
expect(resolved.agents[0].name).toBe('Inline Agent');
|
||||
expect(resolved.agents[1].name).toBe('Code Reviewer');
|
||||
expect(resolved.agents[1].workspaceAgentId).toBe('wa-1');
|
||||
});
|
||||
|
||||
test('preserves pattern metadata', () => {
|
||||
const pattern = makePattern([makeInlineAgent()], { id: 'p-custom', name: 'Custom' });
|
||||
const resolved = resolvePatternAgents(pattern, workspaceAgents);
|
||||
expect(resolved.id).toBe('p-custom');
|
||||
expect(resolved.name).toBe('Custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWorkspaceAgentUsages', () => {
|
||||
test('finds patterns referencing a workspace agent', () => {
|
||||
const patterns = [
|
||||
makePattern([makeLinkedAgent()], { id: 'p1', name: 'Pattern 1' }),
|
||||
makePattern([makeInlineAgent()], { id: 'p2', name: 'Pattern 2' }),
|
||||
makePattern(
|
||||
[makeInlineAgent(), makeLinkedAgent({ id: 'agent-linked-2' })],
|
||||
{ id: 'p3', name: 'Pattern 3' },
|
||||
),
|
||||
];
|
||||
const usages = findWorkspaceAgentUsages('wa-1', patterns);
|
||||
expect(usages).toHaveLength(2);
|
||||
expect(usages[0].patternId).toBe('p1');
|
||||
expect(usages[1].patternId).toBe('p3');
|
||||
});
|
||||
|
||||
test('returns empty when no patterns reference the agent', () => {
|
||||
const patterns = [makePattern([makeInlineAgent()])];
|
||||
const usages = findWorkspaceAgentUsages('wa-1', patterns);
|
||||
expect(usages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeWorkspaceAgentDefinition', () => {
|
||||
test('trims string fields', () => {
|
||||
const agent = makeWorkspaceAgent({
|
||||
name: ' Code Reviewer ',
|
||||
description: ' Reviews code ',
|
||||
instructions: ' Review carefully ',
|
||||
model: ' gpt-5.4 ',
|
||||
});
|
||||
const normalized = normalizeWorkspaceAgentDefinition(agent);
|
||||
expect(normalized.name).toBe('Code Reviewer');
|
||||
expect(normalized.description).toBe('Reviews code');
|
||||
expect(normalized.instructions).toBe('Review carefully');
|
||||
expect(normalized.model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
test('preserves non-string fields', () => {
|
||||
const agent = makeWorkspaceAgent({ reasoningEffort: 'xhigh' });
|
||||
const normalized = normalizeWorkspaceAgentDefinition(agent);
|
||||
expect(normalized.reasoningEffort).toBe('xhigh');
|
||||
expect(normalized.id).toBe('wa-1');
|
||||
expect(normalized.createdAt).toBe(TIMESTAMP);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user