Files
aryx/tests/shared/sessionLibrary.test.ts
T
David KayaandCopilot 805e369b67 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>
2026-04-06 22:37:00 +02:00

654 lines
20 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import {
branchSessionRecord,
duplicateSessionRecord,
editAndResendSessionRecord,
querySessions,
regenerateSessionRecord,
renameSessionRecord,
setSessionMessagePinnedRecord,
} from '@shared/domain/sessionLibrary';
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 createWorkflow(overrides?: Partial<WorkflowDefinition>): WorkflowDefinition {
return {
id: 'workflow-sequential-review',
name: 'Sequential Review',
description: 'Multi-agent review workflow.',
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,
};
}
function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
return {
id: 'project-alpha',
name: 'alpha',
path: 'C:\\workspace\\alpha',
addedAt: '2026-03-23T00:00:00.000Z',
...overrides,
};
}
function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
return {
id: 'session-1',
projectId: 'project-alpha',
workflowId: 'workflow-sequential-review',
title: 'Investigate Copilot refresh bug',
createdAt: '2026-03-23T00:00:00.000Z',
updatedAt: '2026-03-23T00:05:00.000Z',
status: 'idle',
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Please debug why the Copilot CLI version keeps showing unknown.',
createdAt: '2026-03-23T00:00:00.000Z',
},
],
runs: [],
...overrides,
};
}
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
const workspace: WorkspaceState = {
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
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: [
createSession(),
createSession({
id: 'session-2',
projectId: 'project-scratchpad',
workflowId: 'workflow-single-chat',
title: 'Scratchpad brainstorm',
status: 'running',
updatedAt: '2026-03-23T00:10:00.000Z',
isPinned: true,
messages: [
{
id: 'msg-2',
role: 'assistant',
authorName: 'Primary Agent',
content: 'I am thinking through a scratchpad plan.',
createdAt: '2026-03-23T00:09:00.000Z',
pending: true,
},
],
}),
createSession({
id: 'session-3',
title: 'Archived error session',
status: 'error',
isArchived: true,
updatedAt: '2026-03-23T00:08:00.000Z',
}),
],
selectedProjectId: 'project-alpha',
selectedWorkflowId: 'workflow-sequential-review',
selectedSessionId: 'session-1',
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
...overrides,
};
return {
...workspace,
workflows: overrides?.workflows ?? workspace.workflows,
workflowTemplates: overrides?.workflowTemplates ?? workspace.workflowTemplates,
};
}
describe('session library helpers', () => {
test('renames sessions with trimmed manual titles', () => {
const session = renameSessionRecord(createSession(), ' Incident review ', '2026-03-23T00:06:00.000Z');
expect(session.title).toBe('Incident review');
expect(session.titleSource).toBe('manual');
expect(session.updatedAt).toBe('2026-03-23T00:06:00.000Z');
});
test('duplicates sessions as idle unpinned copies', () => {
const sourceSession = createSession({
status: 'error',
isPinned: true,
isArchived: true,
lastError: 'sidecar crashed',
approvalSettings: {
autoApprovedToolNames: ['git.status'],
},
pendingApproval: {
id: 'approval-1',
kind: 'tool-call',
status: 'pending',
requestedAt: '2026-03-23T00:01:00.000Z',
title: 'Approve tool access',
},
pendingApprovalQueue: [
{
id: 'approval-2',
kind: 'final-response',
status: 'pending',
requestedAt: '2026-03-23T00:02:00.000Z',
title: 'Approve final response',
},
],
messages: [
{
id: 'msg-1',
role: 'assistant',
authorName: 'Reviewer',
content: 'Done.',
createdAt: '2026-03-23T00:00:00.000Z',
pending: true,
promptInvocation: {
id: 'project_customization_prompt_doc_review',
name: 'doc-review',
sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md',
resolvedPrompt: 'Review the docs for missing steps.',
model: 'Claude Sonnet 4.5',
tools: ['view'],
},
},
],
});
const session = duplicateSessionRecord(
sourceSession,
'session-copy',
'2026-03-23T00:07:00.000Z',
);
expect(session).toMatchObject({
id: 'session-copy',
title: 'Investigate Copilot refresh bug (Copy)',
titleSource: 'manual',
status: 'idle',
isPinned: false,
isArchived: false,
lastError: undefined,
createdAt: '2026-03-23T00:07:00.000Z',
updatedAt: '2026-03-23T00:07:00.000Z',
});
expect(session.messages[0]?.pending).toBe(false);
expect(session.messages[0]?.promptInvocation).toEqual({
id: 'project_customization_prompt_doc_review',
name: 'doc-review',
sourcePath: '.github\\prompts\\docs\\doc-review.prompt.md',
resolvedPrompt: 'Review the docs for missing steps.',
model: 'Claude Sonnet 4.5',
tools: ['view'],
});
expect(session.messages[0]?.promptInvocation).not.toBe(sourceSession.messages[0]?.promptInvocation);
expect(session.messages[0]?.promptInvocation?.tools).not.toBe(sourceSession.messages[0]?.promptInvocation?.tools);
expect(session.approvalSettings).toEqual({
autoApprovedToolNames: ['git.status'],
});
expect(session.pendingApproval).toBeUndefined();
expect(session.pendingApprovalQueue).toBeUndefined();
expect(session.runs).toEqual([]);
});
test('pins and unpins messages within a session', () => {
const pinned = setSessionMessagePinnedRecord(createSession(), 'msg-1', true, '2026-03-23T00:06:00.000Z');
expect(pinned.updatedAt).toBe('2026-03-23T00:06:00.000Z');
expect(pinned.messages[0]?.isPinned).toBe(true);
const unpinned = setSessionMessagePinnedRecord(pinned, 'msg-1', false, '2026-03-23T00:07:00.000Z');
expect(unpinned.messages[0]?.isPinned).toBeUndefined();
expect(pinned.messages[0]?.isPinned).toBe(true);
});
test('branches sessions from a user message and retains only the prior transcript', () => {
const sourceSession = createSession({
title: 'Manual branch source',
titleSource: 'manual',
status: 'error',
isPinned: true,
isArchived: true,
lastError: 'sidecar crashed',
approvalSettings: {
autoApprovedToolNames: ['git.status'],
},
pendingApproval: {
id: 'approval-1',
kind: 'tool-call',
status: 'pending',
requestedAt: '2026-03-23T00:01:00.000Z',
title: 'Approve tool access',
},
pendingApprovalQueue: [
{
id: 'approval-2',
kind: 'final-response',
status: 'pending',
requestedAt: '2026-03-23T00:02:00.000Z',
title: 'Approve final response',
},
],
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Investigate the refresh bug.',
createdAt: '2026-03-23T00:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Reviewer',
content: 'I found two likely causes.',
createdAt: '2026-03-23T00:01:00.000Z',
pending: true,
},
{
id: 'msg-3',
role: 'user',
authorName: 'You',
content: 'Try a different approach focused on session state.',
createdAt: '2026-03-23T00:02:00.000Z',
attachments: [
{
type: 'file',
path: 'C:\\workspace\\alpha\\notes.txt',
displayName: 'notes.txt',
},
],
},
{
id: 'msg-4',
role: 'assistant',
authorName: 'Reviewer',
content: 'Here is the alternate plan.',
createdAt: '2026-03-23T00:03:00.000Z',
},
],
});
const branch = branchSessionRecord(
sourceSession,
createWorkflow(),
'session-branch',
'msg-3',
'2026-03-23T00:04:00.000Z',
);
expect(branch).toMatchObject({
id: 'session-branch',
title: 'Manual branch source',
titleSource: 'manual',
status: 'idle',
isPinned: false,
isArchived: false,
lastError: undefined,
createdAt: '2026-03-23T00:04:00.000Z',
updatedAt: '2026-03-23T00:04:00.000Z',
branchOrigin: {
sourceSessionId: 'session-1',
sourceMessageId: 'msg-3',
sourceMessageIndex: 2,
branchedAt: '2026-03-23T00:04:00.000Z',
},
});
expect(branch.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']);
expect(branch.messages[1]?.pending).toBe(false);
expect(branch.messages[2]?.attachments).toEqual(sourceSession.messages[2]?.attachments);
expect(branch.messages[2]?.attachments).not.toBe(sourceSession.messages[2]?.attachments);
expect(branch.messages[1]).not.toBe(sourceSession.messages[1]);
expect(branch.pendingApproval).toBeUndefined();
expect(branch.pendingApprovalQueue).toBeUndefined();
expect(branch.runs).toEqual([]);
expect(sourceSession.messages[1]?.pending).toBe(true);
});
test('rejects branching from non-conversation messages', () => {
const sourceSession = createSession({
messages: [
{
id: 'msg-1',
role: 'system' as 'user',
authorName: 'System',
content: 'System prompt.',
createdAt: '2026-03-23T00:00:00.000Z',
},
],
});
expect(() =>
branchSessionRecord(
sourceSession,
createWorkflow(),
'session-branch',
'msg-1',
'2026-03-23T00:04:00.000Z',
)).toThrow('Only user or assistant messages can be used as a branch point.');
});
test('branches from an assistant message and retains transcript up to that response', () => {
const sourceSession = createSession({
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Investigate the refresh bug.',
createdAt: '2026-03-23T00:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Reviewer',
content: 'I found two likely causes.',
createdAt: '2026-03-23T00:01:00.000Z',
},
{
id: 'msg-3',
role: 'user',
authorName: 'You',
content: 'Try a different approach.',
createdAt: '2026-03-23T00:02:00.000Z',
},
{
id: 'msg-4',
role: 'assistant',
authorName: 'Reviewer',
content: 'Here is the alternate plan.',
createdAt: '2026-03-23T00:03:00.000Z',
},
],
});
const branch = branchSessionRecord(
sourceSession,
createWorkflow(),
'session-branch',
'msg-2',
'2026-03-23T00:05:00.000Z',
);
expect(branch.messages.map((m) => m.id)).toEqual(['msg-1', 'msg-2']);
expect(branch.branchOrigin).toMatchObject({
sourceSessionId: 'session-1',
sourceMessageId: 'msg-2',
sourceMessageIndex: 1,
action: 'branch',
});
});
test('regenerates the last assistant message by truncating back to the prior user turn', () => {
const sourceSession = createSession({
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Investigate the refresh bug.',
createdAt: '2026-03-23T00:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Reviewer',
content: 'I found two likely causes.',
createdAt: '2026-03-23T00:01:00.000Z',
},
{
id: 'msg-3',
role: 'user',
authorName: 'You',
content: 'Try a different approach.',
createdAt: '2026-03-23T00:02:00.000Z',
},
{
id: 'msg-4',
role: 'assistant',
authorName: 'Reviewer',
content: 'Here is the alternate plan.',
createdAt: '2026-03-23T00:03:00.000Z',
pending: true,
},
],
});
const regenerated = regenerateSessionRecord(
sourceSession,
createWorkflow(),
'session-regenerated',
'msg-4',
'2026-03-23T00:06:00.000Z',
);
expect(regenerated.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']);
expect(regenerated.messages[2]?.role).toBe('user');
expect(regenerated.messages[1]?.pending).toBe(false);
expect(regenerated.branchOrigin).toMatchObject({
sourceSessionId: 'session-1',
sourceMessageId: 'msg-4',
sourceMessageIndex: 3,
action: 'regenerate',
});
});
test('rejects regenerating any assistant message except the last one', () => {
const sourceSession = createSession({
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Investigate the refresh bug.',
createdAt: '2026-03-23T00:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Reviewer',
content: 'I found two likely causes.',
createdAt: '2026-03-23T00:01:00.000Z',
},
{
id: 'msg-3',
role: 'user',
authorName: 'You',
content: 'Try a different approach.',
createdAt: '2026-03-23T00:02:00.000Z',
},
],
});
expect(() =>
regenerateSessionRecord(
sourceSession,
createWorkflow(),
'session-regenerated',
'msg-2',
'2026-03-23T00:06:00.000Z',
)).toThrow('Only the last assistant message can be regenerated.');
});
test('edits and resends a user message by creating an implicit branch', () => {
const sourceSession = createSession({
messages: [
{
id: 'msg-1',
role: 'user',
authorName: 'You',
content: 'Investigate the refresh bug.',
createdAt: '2026-03-23T00:00:00.000Z',
},
{
id: 'msg-2',
role: 'assistant',
authorName: 'Reviewer',
content: 'I found two likely causes.',
createdAt: '2026-03-23T00:01:00.000Z',
},
{
id: 'msg-3',
role: 'user',
authorName: 'You',
content: 'Try a different approach.',
createdAt: '2026-03-23T00:02:00.000Z',
promptInvocation: {
id: 'project_customization_prompt_alt_plan',
name: 'alt-plan',
sourcePath: '.github\\prompts\\alt-plan.prompt.md',
resolvedPrompt: 'Try a different approach focused on session state.',
model: 'GPT-5.4',
tools: ['view', 'glob'],
},
attachments: [
{
type: 'file',
path: 'C:\\workspace\\alpha\\notes.txt',
displayName: 'notes.txt',
},
],
},
{
id: 'msg-4',
role: 'assistant',
authorName: 'Reviewer',
content: 'Here is the alternate plan.',
createdAt: '2026-03-23T00:03:00.000Z',
},
],
});
const edited = editAndResendSessionRecord(
sourceSession,
createWorkflow(),
'session-edited',
'msg-3',
'Focus on session state only.',
'2026-03-23T00:06:00.000Z',
[
{
type: 'file',
path: 'C:\\workspace\\alpha\\state-notes.txt',
displayName: 'state-notes.txt',
},
],
);
expect(edited.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']);
expect(edited.messages[2]).toMatchObject({
id: 'msg-3',
role: 'user',
content: 'Focus on session state only.',
promptInvocation: undefined,
attachments: [
{
type: 'file',
path: 'C:\\workspace\\alpha\\state-notes.txt',
displayName: 'state-notes.txt',
},
],
});
expect(edited.messages[2]?.attachments).not.toBe(sourceSession.messages[2]?.attachments);
expect(edited.branchOrigin).toMatchObject({
sourceSessionId: 'session-1',
sourceMessageId: 'msg-3',
sourceMessageIndex: 2,
action: 'edit-and-resend',
});
});
test('searches across session title, messages, projects, and patterns', () => {
const workspace = createWorkspace();
expect(querySessions(workspace, { searchText: 'copilot alpha review' })).toEqual([
{
sessionId: 'session-1',
score: 31,
matchedFields: ['title', 'message', 'project', 'workflow'],
},
]);
});
test('filters archived sessions by default and can include them explicitly', () => {
const workspace = createWorkspace();
expect(querySessions(workspace, { statuses: ['error'] })).toEqual([]);
expect(querySessions(workspace, { statuses: ['error'], includeArchived: true })).toEqual([
{
sessionId: 'session-3',
score: 0,
matchedFields: [],
},
]);
});
test('supports pinned and scratchpad filters while keeping pinned sessions first', () => {
const workspace = createWorkspace({
sessions: [
createSession({ id: 'session-4', title: 'Pinned idle session', isPinned: true, updatedAt: '2026-03-23T00:04:00.000Z' }),
...createWorkspace().sessions,
],
});
expect(querySessions(workspace, { onlyPinned: true })).toEqual([
{
sessionId: 'session-2',
score: 0,
matchedFields: [],
},
{
sessionId: 'session-4',
score: 0,
matchedFields: [],
},
]);
expect(querySessions(workspace, { workspaceKinds: ['scratchpad'] })).toEqual([
{
sessionId: 'session-2',
score: 0,
matchedFields: [],
},
]);
});
});