Files
aryx/tests/shared/sessionLibrary.test.ts
T
David KayaandCopilot 4971dcf9fc feat(workflows): add phase 5 backend templates and export support
- add shared workflow template and serialization helpers for YAML, Mermaid, and DOT export
- add pattern-to-workflow upgrade and template application flows in AryxAppService
- persist built-in and custom workflow templates in workspace state and expose new IPC/preload methods
- cover the new backend slice with shared and app-service tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:11:59 +02:00

636 lines
19 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import {
branchSessionRecord,
duplicateSessionRecord,
editAndResendSessionRecord,
querySessions,
regenerateSessionRecord,
renameSessionRecord,
setSessionMessagePinnedRecord,
} from '@shared/domain/sessionLibrary';
import type { PatternDefinition } from '@shared/domain/pattern';
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 {
return {
id: 'pattern-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',
},
],
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',
patternId: 'pattern-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' })],
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
workflows: [],
workflowTemplates: [],
settings: createWorkspaceSettings(),
sessions: [
createSession(),
createSession({
id: 'session-2',
projectId: 'project-scratchpad',
patternId: 'pattern-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',
selectedPatternId: 'pattern-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,
createPattern(),
'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,
createPattern(),
'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,
createPattern(),
'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,
createPattern(),
'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,
createPattern(),
'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,
createPattern(),
'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', 'pattern'],
},
]);
});
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: [],
},
]);
});
});