mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-27 06:58:44 +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';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import {
|
||||
branchSessionRecord,
|
||||
duplicateSessionRecord,
|
||||
querySessions as queryWorkspaceSessions,
|
||||
renameSessionRecord,
|
||||
@@ -703,6 +704,23 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
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> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
|
||||
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
BranchSessionInput,
|
||||
CancelSessionTurnInput,
|
||||
CreateSessionInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
@@ -135,6 +136,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
|
||||
service.duplicateSession(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) =>
|
||||
service.branchSession(input.sessionId, input.messageId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.renameSession, (_event, input: RenameSessionInput) =>
|
||||
service.renameSession(input.sessionId, input.title),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/proj
|
||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
normalizeSessionToolingSelection,
|
||||
normalizeWorkspaceSettings,
|
||||
@@ -81,6 +81,7 @@ export class WorkspaceRepository {
|
||||
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||
const normalizedSession: SessionRecord = {
|
||||
...session,
|
||||
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
|
||||
@@ -47,6 +47,7 @@ const api: ElectronApi = {
|
||||
ipcRenderer.invoke(ipcChannels.updateSessionApprovalSettings, input),
|
||||
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
||||
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
||||
branchSession: (input) => ipcRenderer.invoke(ipcChannels.branchSession, input),
|
||||
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ipcChannels = {
|
||||
updateSessionApprovalSettings: 'sessions:update-approval-settings',
|
||||
createSession: 'sessions:create',
|
||||
duplicateSession: 'sessions:duplicate',
|
||||
branchSession: 'sessions:branch',
|
||||
renameSession: 'sessions:rename',
|
||||
setSessionPinned: 'sessions:set-pinned',
|
||||
setSessionArchived: 'sessions:set-archived',
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface DuplicateSessionInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface BranchSessionInput {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface RenameSessionInput {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
@@ -174,6 +179,7 @@ export interface ElectronApi {
|
||||
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
|
||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface ChatMessageRecord {
|
||||
attachments?: ChatMessageAttachment[];
|
||||
}
|
||||
|
||||
export interface SessionBranchOrigin {
|
||||
sourceSessionId: string;
|
||||
sourceMessageId: string;
|
||||
sourceMessageIndex: number;
|
||||
branchedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -47,6 +54,7 @@ export interface SessionRecord {
|
||||
status: SessionStatus;
|
||||
isPinned?: boolean;
|
||||
isArchived?: boolean;
|
||||
branchOrigin?: SessionBranchOrigin;
|
||||
interactionMode?: InteractionMode;
|
||||
cwd?: string;
|
||||
messages: ChatMessageRecord[];
|
||||
@@ -62,6 +70,38 @@ export interface SessionRecord {
|
||||
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(
|
||||
session: Pick<SessionRecord, 'title' | 'titleSource'>,
|
||||
pattern: PatternDefinition,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
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';
|
||||
|
||||
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,
|
||||
sessionId: string,
|
||||
duplicatedAt: string,
|
||||
createdAt: string,
|
||||
): SessionRecord {
|
||||
return {
|
||||
...session,
|
||||
id: sessionId,
|
||||
title: `${session.title} (Copy)`,
|
||||
titleSource: 'manual',
|
||||
createdAt: duplicatedAt,
|
||||
updatedAt: duplicatedAt,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
status: 'idle',
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
branchOrigin: cloneBranchOrigin(session.branchOrigin),
|
||||
lastError: undefined,
|
||||
sessionModelConfig: session.sessionModelConfig ? { ...session.sessionModelConfig } : undefined,
|
||||
tooling: session.tooling
|
||||
@@ -188,11 +199,60 @@ export function duplicateSessionRecord(
|
||||
: undefined,
|
||||
pendingApproval: undefined,
|
||||
pendingApprovalQueue: undefined,
|
||||
pendingUserInput: undefined,
|
||||
pendingPlanReview: undefined,
|
||||
pendingMcpAuth: undefined,
|
||||
runs: [],
|
||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||
...message,
|
||||
pending: false,
|
||||
})),
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = requireSinglePattern(workspace);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
@@ -176,6 +176,132 @@ describe('session library helpers', () => {
|
||||
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', () => {
|
||||
const workspace = createWorkspace();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user