mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-10 05:38:45 +02:00
feat: add backend session branching support
Add the backend contract and session-domain support for 'Branch from here': - new branchSession IPC method and sessions:branch channel - branchOrigin metadata on SessionRecord - session branching helper that truncates the transcript at a chosen user message and clears runtime state - AryxAppService branching flow with scratchpad directory support - persistence normalization and regression coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -62,6 +62,7 @@ import {
|
|||||||
} from '@shared/domain/approval';
|
} from '@shared/domain/approval';
|
||||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||||
import {
|
import {
|
||||||
|
branchSessionRecord,
|
||||||
duplicateSessionRecord,
|
duplicateSessionRecord,
|
||||||
querySessions as queryWorkspaceSessions,
|
querySessions as queryWorkspaceSessions,
|
||||||
renameSessionRecord,
|
renameSessionRecord,
|
||||||
@@ -703,6 +704,23 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async branchSession(sessionId: string, messageId: string): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
|
const branch = branchSessionRecord(session, pattern, createId('session'), messageId, nowIso());
|
||||||
|
if (isScratchpadProject(branch.projectId)) {
|
||||||
|
branch.cwd = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureScratchpadSessionDirectory(branch);
|
||||||
|
workspace.sessions.unshift(branch);
|
||||||
|
workspace.selectedProjectId = branch.projectId;
|
||||||
|
workspace.selectedPatternId = branch.patternId;
|
||||||
|
workspace.selectedSessionId = branch.id;
|
||||||
|
return this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
async renameSession(sessionId: string, title: string): Promise<WorkspaceState> {
|
async renameSession(sessionId: string, title: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
|
|||||||
|
|
||||||
import { ipcChannels } from '@shared/contracts/channels';
|
import { ipcChannels } from '@shared/contracts/channels';
|
||||||
import type {
|
import type {
|
||||||
|
BranchSessionInput,
|
||||||
CancelSessionTurnInput,
|
CancelSessionTurnInput,
|
||||||
CreateSessionInput,
|
CreateSessionInput,
|
||||||
DismissSessionMcpAuthInput,
|
DismissSessionMcpAuthInput,
|
||||||
@@ -135,6 +136,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
|||||||
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
||||||
service.duplicateSession(input.sessionId),
|
service.duplicateSession(input.sessionId),
|
||||||
);
|
);
|
||||||
|
ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) =>
|
||||||
|
service.branchSession(input.sessionId, input.messageId),
|
||||||
|
);
|
||||||
ipcMain.handle(ipcChannels.renameSession, (_event, input: RenameSessionInput) =>
|
ipcMain.handle(ipcChannels.renameSession, (_event, input: RenameSessionInput) =>
|
||||||
service.renameSession(input.sessionId, input.title),
|
service.renameSession(input.sessionId, input.title),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/proj
|
|||||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
|
||||||
import {
|
import {
|
||||||
normalizeSessionToolingSelection,
|
normalizeSessionToolingSelection,
|
||||||
normalizeWorkspaceSettings,
|
normalizeWorkspaceSettings,
|
||||||
@@ -81,6 +81,7 @@ export class WorkspaceRepository {
|
|||||||
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||||
const normalizedSession: SessionRecord = {
|
const normalizedSession: SessionRecord = {
|
||||||
...session,
|
...session,
|
||||||
|
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||||
runs: normalizeSessionRunRecords(session.runs),
|
runs: normalizeSessionRunRecords(session.runs),
|
||||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const api: ElectronApi = {
|
|||||||
ipcRenderer.invoke(ipcChannels.updateSessionApprovalSettings, input),
|
ipcRenderer.invoke(ipcChannels.updateSessionApprovalSettings, input),
|
||||||
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
||||||
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
||||||
|
branchSession: (input) => ipcRenderer.invoke(ipcChannels.branchSession, input),
|
||||||
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
||||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ipcChannels = {
|
|||||||
updateSessionApprovalSettings: 'sessions:update-approval-settings',
|
updateSessionApprovalSettings: 'sessions:update-approval-settings',
|
||||||
createSession: 'sessions:create',
|
createSession: 'sessions:create',
|
||||||
duplicateSession: 'sessions:duplicate',
|
duplicateSession: 'sessions:duplicate',
|
||||||
|
branchSession: 'sessions:branch',
|
||||||
renameSession: 'sessions:rename',
|
renameSession: 'sessions:rename',
|
||||||
setSessionPinned: 'sessions:set-pinned',
|
setSessionPinned: 'sessions:set-pinned',
|
||||||
setSessionArchived: 'sessions:set-archived',
|
setSessionArchived: 'sessions:set-archived',
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export interface DuplicateSessionInput {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BranchSessionInput {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenameSessionInput {
|
export interface RenameSessionInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -174,6 +179,7 @@ export interface ElectronApi {
|
|||||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||||
|
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||||
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
||||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export interface ChatMessageRecord {
|
|||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionBranchOrigin {
|
||||||
|
sourceSessionId: string;
|
||||||
|
sourceMessageId: string;
|
||||||
|
sourceMessageIndex: number;
|
||||||
|
branchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionRecord {
|
export interface SessionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -47,6 +54,7 @@ export interface SessionRecord {
|
|||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
|
branchOrigin?: SessionBranchOrigin;
|
||||||
interactionMode?: InteractionMode;
|
interactionMode?: InteractionMode;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
@@ -62,6 +70,38 @@ export interface SessionRecord {
|
|||||||
runs: SessionRunRecord[];
|
runs: SessionRunRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value?: string): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSessionBranchOrigin(
|
||||||
|
branchOrigin?: Partial<SessionBranchOrigin>,
|
||||||
|
): SessionBranchOrigin | undefined {
|
||||||
|
const sourceSessionId = normalizeOptionalString(branchOrigin?.sourceSessionId);
|
||||||
|
const sourceMessageId = normalizeOptionalString(branchOrigin?.sourceMessageId);
|
||||||
|
const branchedAt = normalizeOptionalString(branchOrigin?.branchedAt);
|
||||||
|
const sourceMessageIndex = branchOrigin?.sourceMessageIndex;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!sourceSessionId
|
||||||
|
|| !sourceMessageId
|
||||||
|
|| !branchedAt
|
||||||
|
|| typeof sourceMessageIndex !== 'number'
|
||||||
|
|| !Number.isInteger(sourceMessageIndex)
|
||||||
|
|| sourceMessageIndex < 0
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceSessionId,
|
||||||
|
sourceMessageId,
|
||||||
|
sourceMessageIndex,
|
||||||
|
branchedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveSessionTitle(
|
export function resolveSessionTitle(
|
||||||
session: Pick<SessionRecord, 'title' | 'titleSource'>,
|
session: Pick<SessionRecord, 'title' | 'titleSource'>,
|
||||||
pattern: PatternDefinition,
|
pattern: PatternDefinition,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||||
import type { ChatMessageRecord, SessionRecord, SessionStatus } from '@shared/domain/session';
|
import { resolveSessionTitle, type ChatMessageRecord, type SessionBranchOrigin, type SessionRecord, type SessionStatus } from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
|
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
|
||||||
@@ -158,21 +158,32 @@ export function renameSessionRecord(session: SessionRecord, title: string, updat
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function duplicateSessionRecord(
|
function cloneBranchOrigin(branchOrigin?: SessionBranchOrigin): SessionBranchOrigin | undefined {
|
||||||
|
return branchOrigin ? { ...branchOrigin } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
pending: false,
|
||||||
|
attachments: message.attachments?.map((attachment) => ({ ...attachment })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDerivedSessionRecord(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
duplicatedAt: string,
|
createdAt: string,
|
||||||
): SessionRecord {
|
): SessionRecord {
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
title: `${session.title} (Copy)`,
|
createdAt,
|
||||||
titleSource: 'manual',
|
updatedAt: createdAt,
|
||||||
createdAt: duplicatedAt,
|
|
||||||
updatedAt: duplicatedAt,
|
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
|
branchOrigin: cloneBranchOrigin(session.branchOrigin),
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
sessionModelConfig: session.sessionModelConfig ? { ...session.sessionModelConfig } : undefined,
|
sessionModelConfig: session.sessionModelConfig ? { ...session.sessionModelConfig } : undefined,
|
||||||
tooling: session.tooling
|
tooling: session.tooling
|
||||||
@@ -188,11 +199,60 @@ export function duplicateSessionRecord(
|
|||||||
: undefined,
|
: undefined,
|
||||||
pendingApproval: undefined,
|
pendingApproval: undefined,
|
||||||
pendingApprovalQueue: undefined,
|
pendingApprovalQueue: undefined,
|
||||||
|
pendingUserInput: undefined,
|
||||||
|
pendingPlanReview: undefined,
|
||||||
|
pendingMcpAuth: undefined,
|
||||||
runs: [],
|
runs: [],
|
||||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
messages: [],
|
||||||
...message,
|
};
|
||||||
pending: false,
|
}
|
||||||
})),
|
|
||||||
|
export function duplicateSessionRecord(
|
||||||
|
session: SessionRecord,
|
||||||
|
sessionId: string,
|
||||||
|
duplicatedAt: string,
|
||||||
|
): SessionRecord {
|
||||||
|
return {
|
||||||
|
...createDerivedSessionRecord(session, sessionId, duplicatedAt),
|
||||||
|
title: `${session.title} (Copy)`,
|
||||||
|
titleSource: 'manual',
|
||||||
|
messages: session.messages.map(cloneChatMessageRecord),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function branchSessionRecord(
|
||||||
|
session: SessionRecord,
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
sessionId: string,
|
||||||
|
messageId: string,
|
||||||
|
branchedAt: string,
|
||||||
|
): SessionRecord {
|
||||||
|
const sourceMessageIndex = session.messages.findIndex((message) => message.id === messageId);
|
||||||
|
if (sourceMessageIndex < 0) {
|
||||||
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMessage = session.messages[sourceMessageIndex];
|
||||||
|
if (!sourceMessage) {
|
||||||
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceMessage.role !== 'user') {
|
||||||
|
throw new Error('Only user messages can be used as a branch point.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchedMessages = session.messages.slice(0, sourceMessageIndex + 1).map(cloneChatMessageRecord);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createDerivedSessionRecord(session, sessionId, branchedAt),
|
||||||
|
title: resolveSessionTitle(session, pattern, branchedMessages),
|
||||||
|
messages: branchedMessages,
|
||||||
|
branchOrigin: {
|
||||||
|
sourceSessionId: session.id,
|
||||||
|
sourceMessageId: messageId,
|
||||||
|
sourceMessageIndex,
|
||||||
|
branchedAt,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,73 @@ describe('AryxAppService scratchpad directories', () => {
|
|||||||
expect(await pathExists(duplicate.cwd!)).toBe(true);
|
expect(await pathExists(duplicate.cwd!)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('creates a new directory when branching a scratchpad session', async () => {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
const pattern = requireSinglePattern(workspace);
|
||||||
|
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
|
||||||
|
const originalDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-original');
|
||||||
|
const originalSession = createScratchpadSession(pattern.id, {
|
||||||
|
id: 'session-original',
|
||||||
|
cwd: originalDirectory,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
role: 'user',
|
||||||
|
authorName: 'You',
|
||||||
|
content: 'Draft a plan.',
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-2',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Here is the first approach.',
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-3',
|
||||||
|
role: 'user',
|
||||||
|
authorName: 'You',
|
||||||
|
content: 'Try again from here with a stricter checklist.',
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-4',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Here is the revised approach.',
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
workspace.projects = [scratchpadProject];
|
||||||
|
workspace.sessions = [originalSession];
|
||||||
|
workspace.selectedProjectId = scratchpadProject.id;
|
||||||
|
workspace.selectedPatternId = pattern.id;
|
||||||
|
workspace.selectedSessionId = originalSession.id;
|
||||||
|
|
||||||
|
const service = createService(workspace);
|
||||||
|
|
||||||
|
const result = await service.branchSession(originalSession.id, 'msg-3');
|
||||||
|
const branch = result.sessions[0];
|
||||||
|
if (!branch) {
|
||||||
|
throw new Error('Expected branchSession to prepend the branch.');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(branch.id).not.toBe(originalSession.id);
|
||||||
|
expect(branch.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', branch.id));
|
||||||
|
expect(branch.cwd).not.toBe(originalSession.cwd);
|
||||||
|
expect(branch.branchOrigin).toMatchObject({
|
||||||
|
sourceSessionId: originalSession.id,
|
||||||
|
sourceMessageId: 'msg-3',
|
||||||
|
sourceMessageIndex: 2,
|
||||||
|
});
|
||||||
|
expect(branch.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']);
|
||||||
|
expect(result.selectedSessionId).toBe(branch.id);
|
||||||
|
expect(await pathExists(branch.cwd!)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('removes the scratchpad directory when deleting a session', async () => {
|
test('removes the scratchpad directory when deleting a session', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = requireSinglePattern(workspace);
|
const pattern = requireSinglePattern(workspace);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { querySessions, duplicateSessionRecord, renameSessionRecord } from '@shared/domain/sessionLibrary';
|
import { branchSessionRecord, querySessions, duplicateSessionRecord, renameSessionRecord } from '@shared/domain/sessionLibrary';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
@@ -176,6 +176,132 @@ describe('session library helpers', () => {
|
|||||||
expect(session.runs).toEqual([]);
|
expect(session.runs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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-user messages', () => {
|
||||||
|
const sourceSession = createSession({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Reviewer',
|
||||||
|
content: 'I can help with that.',
|
||||||
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
branchSessionRecord(
|
||||||
|
sourceSession,
|
||||||
|
createPattern(),
|
||||||
|
'session-branch',
|
||||||
|
'msg-1',
|
||||||
|
'2026-03-23T00:04:00.000Z',
|
||||||
|
)).toThrow('Only user messages can be used as a branch point.');
|
||||||
|
});
|
||||||
|
|
||||||
test('searches across session title, messages, projects, and patterns', () => {
|
test('searches across session title, messages, projects, and patterns', () => {
|
||||||
const workspace = createWorkspace();
|
const workspace = createWorkspace();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user