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 { 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 { return { id: 'project-alpha', name: 'alpha', path: 'C:\\workspace\\alpha', addedAt: '2026-03-23T00:00:00.000Z', ...overrides, }; } function createSession(overrides?: Partial): 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 { 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: [], }, ]); }); });