mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 12:48:48 +02:00
feat: add message actions backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+244
-99
@@ -8,12 +8,13 @@ import type {
|
|||||||
AgentActivityEvent,
|
AgentActivityEvent,
|
||||||
ApprovalRequestedEvent,
|
ApprovalRequestedEvent,
|
||||||
ExitPlanModeRequestedEvent,
|
ExitPlanModeRequestedEvent,
|
||||||
|
MessageMode,
|
||||||
McpOauthRequiredEvent,
|
McpOauthRequiredEvent,
|
||||||
RunTurnCustomAgentConfig,
|
RunTurnCustomAgentConfig,
|
||||||
RunTurnToolingConfig,
|
RunTurnToolingConfig,
|
||||||
SidecarCapabilities,
|
SidecarCapabilities,
|
||||||
TurnDeltaEvent,
|
|
||||||
UserInputRequestedEvent,
|
UserInputRequestedEvent,
|
||||||
|
TurnDeltaEvent,
|
||||||
} from '@shared/contracts/sidecar';
|
} from '@shared/contracts/sidecar';
|
||||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||||
import {
|
import {
|
||||||
@@ -64,13 +65,17 @@ import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project'
|
|||||||
import {
|
import {
|
||||||
branchSessionRecord,
|
branchSessionRecord,
|
||||||
duplicateSessionRecord,
|
duplicateSessionRecord,
|
||||||
|
editAndResendSessionRecord,
|
||||||
querySessions as queryWorkspaceSessions,
|
querySessions as queryWorkspaceSessions,
|
||||||
|
regenerateSessionRecord,
|
||||||
renameSessionRecord,
|
renameSessionRecord,
|
||||||
|
setSessionMessagePinnedRecord,
|
||||||
type QuerySessionsInput,
|
type QuerySessionsInput,
|
||||||
type SessionQueryResult,
|
type SessionQueryResult,
|
||||||
} from '@shared/domain/sessionLibrary';
|
} from '@shared/domain/sessionLibrary';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
|
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
|
||||||
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import {
|
import {
|
||||||
applySessionApprovalSettings,
|
applySessionApprovalSettings,
|
||||||
applySessionModelConfig,
|
applySessionModelConfig,
|
||||||
@@ -721,6 +726,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setSessionMessagePinned(sessionId: string, messageId: string, isPinned: boolean): Promise<WorkspaceState> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
const updated = setSessionMessagePinnedRecord(session, messageId, isPinned, nowIso());
|
||||||
|
|
||||||
|
Object.assign(session, updated);
|
||||||
|
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);
|
||||||
@@ -779,11 +793,117 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async regenerateSessionMessage(sessionId: string, messageId: string): Promise<void> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
if (session.status === 'running') {
|
||||||
|
throw new Error('Wait for the current response or approval checkpoint to finish before regenerating a message.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
|
await this.buildEffectivePattern(pattern, session),
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
|
const occurredAt = nowIso();
|
||||||
|
const regeneratedSession = regenerateSessionRecord(
|
||||||
|
session,
|
||||||
|
effectivePattern,
|
||||||
|
createId('session'),
|
||||||
|
messageId,
|
||||||
|
occurredAt,
|
||||||
|
);
|
||||||
|
if (isScratchpadProject(regeneratedSession.projectId)) {
|
||||||
|
regeneratedSession.cwd = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureScratchpadSessionDirectory(regeneratedSession);
|
||||||
|
workspace.sessions.unshift(regeneratedSession);
|
||||||
|
workspace.selectedProjectId = regeneratedSession.projectId;
|
||||||
|
workspace.selectedPatternId = regeneratedSession.patternId;
|
||||||
|
workspace.selectedSessionId = regeneratedSession.id;
|
||||||
|
|
||||||
|
const triggerMessage = regeneratedSession.messages.at(-1);
|
||||||
|
if (!triggerMessage || triggerMessage.role !== 'user') {
|
||||||
|
throw new Error('Regenerated session is missing the user message needed to replay the turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runPreparedSessionTurn(workspace, regeneratedSession, project, effectivePattern, projectInstructions, {
|
||||||
|
occurredAt,
|
||||||
|
requestId: createId('turn'),
|
||||||
|
triggerMessageId: triggerMessage.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async editAndResendSessionMessage(
|
||||||
|
sessionId: string,
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
attachments?: ChatMessageAttachment[],
|
||||||
|
): Promise<void> {
|
||||||
|
const workspace = await this.loadWorkspace();
|
||||||
|
const session = this.requireSession(workspace, sessionId);
|
||||||
|
if (session.status === 'running') {
|
||||||
|
throw new Error('Wait for the current response or approval checkpoint to finish before editing and resending a message.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMessage = session.messages.find((message) => message.id === messageId);
|
||||||
|
if (!sourceMessage) {
|
||||||
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preparedContent = prepareChatMessageContent(content);
|
||||||
|
if (!preparedContent) {
|
||||||
|
throw new Error('Message content is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
|
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||||
|
await this.buildEffectivePattern(pattern, session),
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
|
const occurredAt = nowIso();
|
||||||
|
const nextAttachments = attachments === undefined ? sourceMessage.attachments : attachments;
|
||||||
|
const editedSession = editAndResendSessionRecord(
|
||||||
|
session,
|
||||||
|
effectivePattern,
|
||||||
|
createId('session'),
|
||||||
|
messageId,
|
||||||
|
preparedContent,
|
||||||
|
occurredAt,
|
||||||
|
nextAttachments,
|
||||||
|
);
|
||||||
|
if (isScratchpadProject(editedSession.projectId)) {
|
||||||
|
editedSession.cwd = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureScratchpadSessionDirectory(editedSession);
|
||||||
|
workspace.sessions.unshift(editedSession);
|
||||||
|
workspace.selectedProjectId = editedSession.projectId;
|
||||||
|
workspace.selectedPatternId = editedSession.patternId;
|
||||||
|
workspace.selectedSessionId = editedSession.id;
|
||||||
|
|
||||||
|
const triggerMessage = editedSession.messages.at(-1);
|
||||||
|
if (!triggerMessage || triggerMessage.role !== 'user') {
|
||||||
|
throw new Error('Edited session is missing the user message needed to replay the turn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runPreparedSessionTurn(workspace, editedSession, project, effectivePattern, projectInstructions, {
|
||||||
|
occurredAt,
|
||||||
|
requestId: createId('turn'),
|
||||||
|
triggerMessageId: triggerMessage.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async sendSessionMessage(
|
async sendSessionMessage(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: import('@shared/domain/attachment').ChatMessageAttachment[],
|
attachments?: ChatMessageAttachment[],
|
||||||
messageMode?: import('@shared/contracts/sidecar').MessageMode,
|
messageMode?: MessageMode,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
@@ -806,7 +926,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestId = createId('turn');
|
const requestId = createId('turn');
|
||||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
|
||||||
const occurredAt = nowIso();
|
const occurredAt = nowIso();
|
||||||
const userMessageId = createId('msg');
|
const userMessageId = createId('msg');
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
@@ -817,103 +936,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
createdAt: occurredAt,
|
createdAt: occurredAt,
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
});
|
});
|
||||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, {
|
||||||
session.status = 'running';
|
|
||||||
session.lastError = undefined;
|
|
||||||
session.pendingPlanReview = undefined;
|
|
||||||
session.pendingMcpAuth = undefined;
|
|
||||||
session.updatedAt = occurredAt;
|
|
||||||
session.runs = [
|
|
||||||
createSessionRunRecord({
|
|
||||||
requestId,
|
|
||||||
project,
|
|
||||||
workspaceKind,
|
|
||||||
pattern: effectivePattern,
|
|
||||||
triggerMessageId: userMessageId,
|
|
||||||
startedAt: occurredAt,
|
|
||||||
}),
|
|
||||||
...session.runs,
|
|
||||||
];
|
|
||||||
|
|
||||||
await this.persistAndBroadcast(workspace);
|
|
||||||
this.emitSessionEvent({
|
|
||||||
sessionId: session.id,
|
|
||||||
kind: 'status',
|
|
||||||
status: 'running',
|
|
||||||
occurredAt,
|
occurredAt,
|
||||||
|
requestId,
|
||||||
|
triggerMessageId: userMessageId,
|
||||||
|
messageMode,
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
const responseMessages = await this.sidecar.runTurn(
|
|
||||||
{
|
|
||||||
type: 'run-turn',
|
|
||||||
requestId,
|
|
||||||
sessionId: session.id,
|
|
||||||
projectPath: session.cwd ?? project.path,
|
|
||||||
workspaceKind,
|
|
||||||
mode: session.interactionMode ?? 'interactive',
|
|
||||||
messageMode,
|
|
||||||
projectInstructions,
|
|
||||||
pattern: effectivePattern,
|
|
||||||
messages: session.messages,
|
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
|
||||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.applyAgentActivity(workspace, session.id, requestId, event);
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) =>
|
|
||||||
this.sidecar.resolveApproval(event.approvalId, decision, alwaysApprove));
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
|
|
||||||
this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform));
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.handleMcpOAuthRequired(workspace, session.id, event);
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
|
||||||
},
|
|
||||||
async (event) => {
|
|
||||||
await this.handleTurnScopedEvent(workspace, session.id, event);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
|
||||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
|
||||||
await this.persistAndBroadcast(workspace);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof TurnCancelledError) {
|
|
||||||
this.finalizeCancelledTurn(workspace, session, requestId);
|
|
||||||
await this.persistAndBroadcast(workspace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedAt = nowIso();
|
|
||||||
session.status = 'error';
|
|
||||||
session.lastError = error instanceof Error ? error.message : String(error);
|
|
||||||
session.updatedAt = failedAt;
|
|
||||||
|
|
||||||
const failedRun = this.updateSessionRun(session, requestId, (run) =>
|
|
||||||
failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
|
|
||||||
|
|
||||||
this.emitSessionEvent({
|
|
||||||
sessionId: session.id,
|
|
||||||
kind: 'error',
|
|
||||||
occurredAt: failedAt,
|
|
||||||
error: session.lastError,
|
|
||||||
});
|
|
||||||
if (failedRun) {
|
|
||||||
this.emitRunUpdated(session.id, failedAt, failedRun);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.persistAndBroadcast(workspace);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelSessionTurn(sessionId: string): Promise<void> {
|
async cancelSessionTurn(sessionId: string): Promise<void> {
|
||||||
@@ -1256,6 +1285,122 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async runPreparedSessionTurn(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
session: SessionRecord,
|
||||||
|
project: ProjectRecord,
|
||||||
|
effectivePattern: PatternDefinition,
|
||||||
|
projectInstructions: string | undefined,
|
||||||
|
options: {
|
||||||
|
occurredAt: string;
|
||||||
|
requestId: string;
|
||||||
|
triggerMessageId: string;
|
||||||
|
messageMode?: MessageMode;
|
||||||
|
attachments?: ChatMessageAttachment[];
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||||
|
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||||
|
|
||||||
|
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||||
|
session.status = 'running';
|
||||||
|
session.lastError = undefined;
|
||||||
|
session.pendingPlanReview = undefined;
|
||||||
|
session.pendingMcpAuth = undefined;
|
||||||
|
session.updatedAt = occurredAt;
|
||||||
|
session.runs = [
|
||||||
|
createSessionRunRecord({
|
||||||
|
requestId,
|
||||||
|
project,
|
||||||
|
workspaceKind,
|
||||||
|
pattern: effectivePattern,
|
||||||
|
triggerMessageId,
|
||||||
|
startedAt: occurredAt,
|
||||||
|
}),
|
||||||
|
...session.runs,
|
||||||
|
];
|
||||||
|
|
||||||
|
await this.persistAndBroadcast(workspace);
|
||||||
|
this.emitSessionEvent({
|
||||||
|
sessionId: session.id,
|
||||||
|
kind: 'status',
|
||||||
|
status: 'running',
|
||||||
|
occurredAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const responseMessages = await this.sidecar.runTurn(
|
||||||
|
{
|
||||||
|
type: 'run-turn',
|
||||||
|
requestId,
|
||||||
|
sessionId: session.id,
|
||||||
|
projectPath: session.cwd ?? project.path,
|
||||||
|
workspaceKind,
|
||||||
|
mode: session.interactionMode ?? 'interactive',
|
||||||
|
messageMode,
|
||||||
|
projectInstructions,
|
||||||
|
pattern: effectivePattern,
|
||||||
|
messages: session.messages,
|
||||||
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
|
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.applyAgentActivity(workspace, session.id, requestId, event);
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) =>
|
||||||
|
this.sidecar.resolveApproval(event.approvalId, decision, alwaysApprove));
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
|
||||||
|
this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform));
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.handleMcpOAuthRequired(workspace, session.id, event);
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
await this.handleTurnScopedEvent(workspace, session.id, event);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||||
|
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||||
|
await this.persistAndBroadcast(workspace);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TurnCancelledError) {
|
||||||
|
this.finalizeCancelledTurn(workspace, session, requestId);
|
||||||
|
await this.persistAndBroadcast(workspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedAt = nowIso();
|
||||||
|
session.status = 'error';
|
||||||
|
session.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
session.updatedAt = failedAt;
|
||||||
|
|
||||||
|
const failedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||||
|
failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
|
||||||
|
|
||||||
|
this.emitSessionEvent({
|
||||||
|
sessionId: session.id,
|
||||||
|
kind: 'error',
|
||||||
|
occurredAt: failedAt,
|
||||||
|
error: session.lastError,
|
||||||
|
});
|
||||||
|
if (failedRun) {
|
||||||
|
this.emitRunUpdated(session.id, failedAt, failedRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistAndBroadcast(workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateSessionTooling(
|
async updateSessionTooling(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
enabledMcpServerIds: string[],
|
enabledMcpServerIds: string[],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
DismissSessionMcpAuthInput,
|
DismissSessionMcpAuthInput,
|
||||||
DismissSessionPlanReviewInput,
|
DismissSessionPlanReviewInput,
|
||||||
DeleteSessionInput,
|
DeleteSessionInput,
|
||||||
|
EditAndResendSessionMessageInput,
|
||||||
|
RegenerateSessionMessageInput,
|
||||||
StartSessionMcpAuthInput,
|
StartSessionMcpAuthInput,
|
||||||
DuplicateSessionInput,
|
DuplicateSessionInput,
|
||||||
RenameSessionInput,
|
RenameSessionInput,
|
||||||
@@ -26,6 +28,7 @@ import type {
|
|||||||
SetProjectAgentProfileEnabledInput,
|
SetProjectAgentProfileEnabledInput,
|
||||||
SetSessionArchivedInput,
|
SetSessionArchivedInput,
|
||||||
SetSessionInteractionModeInput,
|
SetSessionInteractionModeInput,
|
||||||
|
SetSessionMessagePinnedInput,
|
||||||
SetSessionPinnedInput,
|
SetSessionPinnedInput,
|
||||||
SetTerminalHeightInput,
|
SetTerminalHeightInput,
|
||||||
ResizeTerminalInput,
|
ResizeTerminalInput,
|
||||||
@@ -149,6 +152,9 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) =>
|
ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) =>
|
||||||
service.branchSession(input.sessionId, input.messageId),
|
service.branchSession(input.sessionId, input.messageId),
|
||||||
);
|
);
|
||||||
|
ipcMain.handle(ipcChannels.setSessionMessagePinned, (_event, input: SetSessionMessagePinnedInput) =>
|
||||||
|
service.setSessionMessagePinned(input.sessionId, input.messageId, input.isPinned),
|
||||||
|
);
|
||||||
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),
|
||||||
);
|
);
|
||||||
@@ -161,6 +167,12 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
|
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
|
||||||
service.deleteSession(input.sessionId),
|
service.deleteSession(input.sessionId),
|
||||||
);
|
);
|
||||||
|
ipcMain.handle(ipcChannels.regenerateSessionMessage, (_event, input: RegenerateSessionMessageInput) =>
|
||||||
|
service.regenerateSessionMessage(input.sessionId, input.messageId),
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.editAndResendSessionMessage, (_event, input: EditAndResendSessionMessageInput) =>
|
||||||
|
service.editAndResendSessionMessage(input.sessionId, input.messageId, input.content, input.attachments),
|
||||||
|
);
|
||||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||||
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
|
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,10 +50,13 @@ const api: ElectronApi = {
|
|||||||
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),
|
branchSession: (input) => ipcRenderer.invoke(ipcChannels.branchSession, input),
|
||||||
|
setSessionMessagePinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionMessagePinned, 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),
|
||||||
deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input),
|
deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input),
|
||||||
|
regenerateSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.regenerateSessionMessage, input),
|
||||||
|
editAndResendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.editAndResendSessionMessage, input),
|
||||||
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
||||||
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
|
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
|
||||||
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
||||||
|
|||||||
@@ -34,10 +34,13 @@ export const ipcChannels = {
|
|||||||
createSession: 'sessions:create',
|
createSession: 'sessions:create',
|
||||||
duplicateSession: 'sessions:duplicate',
|
duplicateSession: 'sessions:duplicate',
|
||||||
branchSession: 'sessions:branch',
|
branchSession: 'sessions:branch',
|
||||||
|
setSessionMessagePinned: 'sessions:set-message-pinned',
|
||||||
renameSession: 'sessions:rename',
|
renameSession: 'sessions:rename',
|
||||||
setSessionPinned: 'sessions:set-pinned',
|
setSessionPinned: 'sessions:set-pinned',
|
||||||
setSessionArchived: 'sessions:set-archived',
|
setSessionArchived: 'sessions:set-archived',
|
||||||
deleteSession: 'sessions:delete',
|
deleteSession: 'sessions:delete',
|
||||||
|
regenerateSessionMessage: 'sessions:regenerate-message',
|
||||||
|
editAndResendSessionMessage: 'sessions:edit-and-resend-message',
|
||||||
sendSessionMessage: 'sessions:send-message',
|
sendSessionMessage: 'sessions:send-message',
|
||||||
cancelSessionTurn: 'sessions:cancel-turn',
|
cancelSessionTurn: 'sessions:cancel-turn',
|
||||||
resolveSessionApproval: 'sessions:resolve-approval',
|
resolveSessionApproval: 'sessions:resolve-approval',
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ export interface BranchSessionInput {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetSessionMessagePinnedInput {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
isPinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateSessionMessageInput {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditAndResendSessionMessageInput {
|
||||||
|
sessionId: string;
|
||||||
|
messageId: string;
|
||||||
|
content: string;
|
||||||
|
attachments?: ChatMessageAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenameSessionInput {
|
export interface RenameSessionInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -198,10 +216,13 @@ export interface ElectronApi {
|
|||||||
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>;
|
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
|
||||||
|
setSessionMessagePinned(input: SetSessionMessagePinnedInput): 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>;
|
||||||
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
|
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
|
||||||
|
regenerateSessionMessage(input: RegenerateSessionMessageInput): Promise<void>;
|
||||||
|
editAndResendSessionMessage(input: EditAndResendSessionMessageInput): Promise<void>;
|
||||||
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
||||||
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
|
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
|
||||||
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { InteractionMode } from '@shared/contracts/sidecar';
|
|||||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||||
export type SessionTitleSource = 'auto' | 'manual';
|
export type SessionTitleSource = 'auto' | 'manual';
|
||||||
|
export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend';
|
||||||
|
|
||||||
export interface SessionModelConfig {
|
export interface SessionModelConfig {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -32,6 +33,7 @@ export interface ChatMessageRecord {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isPinned?: boolean;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,7 @@ export interface SessionBranchOrigin {
|
|||||||
sourceMessageId: string;
|
sourceMessageId: string;
|
||||||
sourceMessageIndex: number;
|
sourceMessageIndex: number;
|
||||||
branchedAt: string;
|
branchedAt: string;
|
||||||
|
action?: SessionBranchOriginAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionRecord {
|
export interface SessionRecord {
|
||||||
@@ -82,6 +85,10 @@ export function normalizeSessionBranchOrigin(
|
|||||||
const sourceMessageId = normalizeOptionalString(branchOrigin?.sourceMessageId);
|
const sourceMessageId = normalizeOptionalString(branchOrigin?.sourceMessageId);
|
||||||
const branchedAt = normalizeOptionalString(branchOrigin?.branchedAt);
|
const branchedAt = normalizeOptionalString(branchOrigin?.branchedAt);
|
||||||
const sourceMessageIndex = branchOrigin?.sourceMessageIndex;
|
const sourceMessageIndex = branchOrigin?.sourceMessageIndex;
|
||||||
|
const action = branchOrigin?.action;
|
||||||
|
const normalizedAction = action === 'branch' || action === 'regenerate' || action === 'edit-and-resend'
|
||||||
|
? action
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!sourceSessionId
|
!sourceSessionId
|
||||||
@@ -99,6 +106,7 @@ export function normalizeSessionBranchOrigin(
|
|||||||
sourceMessageId,
|
sourceMessageId,
|
||||||
sourceMessageIndex,
|
sourceMessageIndex,
|
||||||
branchedAt,
|
branchedAt,
|
||||||
|
action: normalizedAction,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
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 { resolveSessionTitle, type ChatMessageRecord, type SessionBranchOrigin, type SessionRecord, type SessionStatus } from '@shared/domain/session';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
|
import {
|
||||||
|
resolveSessionTitle,
|
||||||
|
type ChatMessageRecord,
|
||||||
|
type SessionBranchOrigin,
|
||||||
|
type SessionBranchOriginAction,
|
||||||
|
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';
|
||||||
@@ -162,11 +170,41 @@ function cloneBranchOrigin(branchOrigin?: SessionBranchOrigin): SessionBranchOri
|
|||||||
return branchOrigin ? { ...branchOrigin } : undefined;
|
return branchOrigin ? { ...branchOrigin } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneAttachments(attachments?: ChatMessageAttachment[]): ChatMessageAttachment[] | undefined {
|
||||||
|
const cloned = attachments?.map((attachment) => ({ ...attachment }));
|
||||||
|
return cloned && cloned.length > 0 ? cloned : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
|
function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
pending: false,
|
pending: false,
|
||||||
attachments: message.attachments?.map((attachment) => ({ ...attachment })),
|
attachments: cloneAttachments(message.attachments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMessageIndex(session: SessionRecord, messageId: string): number {
|
||||||
|
const sourceMessageIndex = session.messages.findIndex((message) => message.id === messageId);
|
||||||
|
if (sourceMessageIndex < 0) {
|
||||||
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceMessageIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBranchOrigin(
|
||||||
|
session: SessionRecord,
|
||||||
|
messageId: string,
|
||||||
|
sourceMessageIndex: number,
|
||||||
|
branchedAt: string,
|
||||||
|
action: SessionBranchOriginAction,
|
||||||
|
): SessionBranchOrigin {
|
||||||
|
return {
|
||||||
|
sourceSessionId: session.id,
|
||||||
|
sourceMessageId: messageId,
|
||||||
|
sourceMessageIndex,
|
||||||
|
branchedAt,
|
||||||
|
action,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,11 +265,7 @@ export function branchSessionRecord(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
branchedAt: string,
|
branchedAt: string,
|
||||||
): SessionRecord {
|
): SessionRecord {
|
||||||
const sourceMessageIndex = session.messages.findIndex((message) => message.id === messageId);
|
const sourceMessageIndex = requireMessageIndex(session, messageId);
|
||||||
if (sourceMessageIndex < 0) {
|
|
||||||
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceMessage = session.messages[sourceMessageIndex];
|
const sourceMessage = session.messages[sourceMessageIndex];
|
||||||
if (!sourceMessage) {
|
if (!sourceMessage) {
|
||||||
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
@@ -247,12 +281,117 @@ export function branchSessionRecord(
|
|||||||
...createDerivedSessionRecord(session, sessionId, branchedAt),
|
...createDerivedSessionRecord(session, sessionId, branchedAt),
|
||||||
title: resolveSessionTitle(session, pattern, branchedMessages),
|
title: resolveSessionTitle(session, pattern, branchedMessages),
|
||||||
messages: branchedMessages,
|
messages: branchedMessages,
|
||||||
branchOrigin: {
|
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, branchedAt, 'branch'),
|
||||||
sourceSessionId: session.id,
|
};
|
||||||
sourceMessageId: messageId,
|
}
|
||||||
sourceMessageIndex,
|
|
||||||
branchedAt,
|
export function setSessionMessagePinnedRecord(
|
||||||
},
|
session: SessionRecord,
|
||||||
|
messageId: string,
|
||||||
|
isPinned: boolean,
|
||||||
|
updatedAt: string,
|
||||||
|
): SessionRecord {
|
||||||
|
const sourceMessageIndex = requireMessageIndex(session, messageId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
updatedAt,
|
||||||
|
messages: session.messages.map((message, index) => {
|
||||||
|
if (index !== sourceMessageIndex) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPinned) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
isPinned: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
isPinned: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateSessionRecord(
|
||||||
|
session: SessionRecord,
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
sessionId: string,
|
||||||
|
messageId: string,
|
||||||
|
regeneratedAt: string,
|
||||||
|
): SessionRecord {
|
||||||
|
const sourceMessageIndex = requireMessageIndex(session, messageId);
|
||||||
|
const sourceMessage = session.messages[sourceMessageIndex];
|
||||||
|
if (!sourceMessage) {
|
||||||
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceMessage.role !== 'assistant') {
|
||||||
|
throw new Error('Only assistant messages can be regenerated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceMessageIndex !== session.messages.length - 1) {
|
||||||
|
throw new Error('Only the last assistant message can be regenerated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorUserMessageIndex = session.messages
|
||||||
|
.slice(0, sourceMessageIndex)
|
||||||
|
.map((message, index) => ({ message, index }))
|
||||||
|
.filter((candidate) => candidate.message.role === 'user')
|
||||||
|
.at(-1)?.index;
|
||||||
|
|
||||||
|
if (priorUserMessageIndex === undefined) {
|
||||||
|
throw new Error('Assistant message cannot be regenerated because no prior user message exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const regeneratedMessages = session.messages
|
||||||
|
.slice(0, priorUserMessageIndex + 1)
|
||||||
|
.map(cloneChatMessageRecord);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createDerivedSessionRecord(session, sessionId, regeneratedAt),
|
||||||
|
title: resolveSessionTitle(session, pattern, regeneratedMessages),
|
||||||
|
messages: regeneratedMessages,
|
||||||
|
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, regeneratedAt, 'regenerate'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editAndResendSessionRecord(
|
||||||
|
session: SessionRecord,
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
sessionId: string,
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
editedAt: string,
|
||||||
|
attachments?: ChatMessageAttachment[],
|
||||||
|
): SessionRecord {
|
||||||
|
const sourceMessageIndex = requireMessageIndex(session, messageId);
|
||||||
|
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 edited and resent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const editedMessages = session.messages.slice(0, sourceMessageIndex + 1).map(cloneChatMessageRecord);
|
||||||
|
const editedMessage = editedMessages[sourceMessageIndex];
|
||||||
|
if (!editedMessage) {
|
||||||
|
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
editedMessage.content = content;
|
||||||
|
editedMessage.attachments = cloneAttachments(attachments);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...createDerivedSessionRecord(session, sessionId, editedAt),
|
||||||
|
title: resolveSessionTitle(session, pattern, editedMessages),
|
||||||
|
messages: editedMessages,
|
||||||
|
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, editedAt, 'edit-and-resend'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
|
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||||
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
|
const TIMESTAMP = '2026-03-29T00:00:00.000Z';
|
||||||
|
|
||||||
|
mock.module('electron', () => {
|
||||||
|
const electronMock = {
|
||||||
|
app: {
|
||||||
|
isPackaged: false,
|
||||||
|
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||||
|
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
openPath: async () => '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...electronMock,
|
||||||
|
default: electronMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module('keytar', () => ({
|
||||||
|
default: {
|
||||||
|
getPassword: async () => null,
|
||||||
|
setPassword: async () => undefined,
|
||||||
|
deletePassword: async () => false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { AryxAppService } = await import('@main/AryxAppService');
|
||||||
|
|
||||||
|
function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
||||||
|
return {
|
||||||
|
id: 'project-alpha',
|
||||||
|
name: 'alpha',
|
||||||
|
path: 'C:\\workspace\\alpha',
|
||||||
|
addedAt: TIMESTAMP,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
|
return {
|
||||||
|
id: 'session-alpha',
|
||||||
|
projectId,
|
||||||
|
patternId,
|
||||||
|
title: 'Alpha session',
|
||||||
|
createdAt: TIMESTAMP,
|
||||||
|
updatedAt: TIMESTAMP,
|
||||||
|
status: 'idle',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
role: 'user',
|
||||||
|
authorName: 'You',
|
||||||
|
content: 'Investigate the refresh bug.',
|
||||||
|
createdAt: '2026-03-29T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-2',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Here is the first answer.',
|
||||||
|
createdAt: '2026-03-29T00:01:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-3',
|
||||||
|
role: 'user',
|
||||||
|
authorName: 'You',
|
||||||
|
content: 'Try again with more focus on session state.',
|
||||||
|
createdAt: '2026-03-29T00:02:00.000Z',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
path: 'C:\\workspace\\alpha\\notes.txt',
|
||||||
|
displayName: 'notes.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-4',
|
||||||
|
role: 'assistant',
|
||||||
|
authorName: 'Primary Agent',
|
||||||
|
content: 'Here is the revised answer.',
|
||||||
|
createdAt: '2026-03-29T00:03:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
runs: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFixture(): {
|
||||||
|
workspace: WorkspaceState;
|
||||||
|
pattern: PatternDefinition;
|
||||||
|
project: ProjectRecord;
|
||||||
|
session: SessionRecord;
|
||||||
|
} {
|
||||||
|
const workspace = createWorkspaceSeed();
|
||||||
|
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
||||||
|
if (!pattern) {
|
||||||
|
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = createProject();
|
||||||
|
const session = createSession(project.id, pattern.id);
|
||||||
|
|
||||||
|
workspace.projects = [project];
|
||||||
|
workspace.sessions = [session];
|
||||||
|
workspace.selectedProjectId = project.id;
|
||||||
|
workspace.selectedPatternId = pattern.id;
|
||||||
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
|
return { workspace, pattern, project, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
workspace: WorkspaceState,
|
||||||
|
pattern: PatternDefinition,
|
||||||
|
options?: {
|
||||||
|
captureRunTurn?: (command: RunTurnCommand) => void;
|
||||||
|
},
|
||||||
|
): InstanceType<typeof AryxAppService> {
|
||||||
|
const service = new AryxAppService();
|
||||||
|
const internals = service as unknown as Record<string, unknown>;
|
||||||
|
internals.loadWorkspace = async () => workspace;
|
||||||
|
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
||||||
|
internals.buildEffectivePattern = async () => pattern;
|
||||||
|
internals.awaitFinalResponseApproval = async () => undefined;
|
||||||
|
internals.finalizeTurn = () => undefined;
|
||||||
|
internals.emitSessionEvent = () => undefined;
|
||||||
|
internals.pruneUnavailableApprovalTools = async () => false;
|
||||||
|
internals.pruneUnavailableSessionToolingSelections = () => false;
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
sidecar: {
|
||||||
|
runTurn: (command: RunTurnCommand) => Promise<[]>;
|
||||||
|
resolveApproval: () => Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).sidecar = {
|
||||||
|
runTurn: async (command) => {
|
||||||
|
options?.captureRunTurn?.(command);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
resolveApproval: async () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AryxAppService message actions', () => {
|
||||||
|
test('setSessionMessagePinned persists pinned message state', async () => {
|
||||||
|
const { workspace, pattern, session } = createFixture();
|
||||||
|
const service = createService(workspace, pattern);
|
||||||
|
|
||||||
|
const updated = await service.setSessionMessagePinned(session.id, 'msg-2', true);
|
||||||
|
|
||||||
|
expect(updated.sessions[0]?.messages[1]?.isPinned).toBe(true);
|
||||||
|
expect(session.messages[1]?.isPinned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regenerateSessionMessage creates a replay branch from the prior user turn', async () => {
|
||||||
|
const { workspace, pattern, session } = createFixture();
|
||||||
|
let command: RunTurnCommand | undefined;
|
||||||
|
const service = createService(workspace, pattern, {
|
||||||
|
captureRunTurn: (capturedCommand) => {
|
||||||
|
command = capturedCommand;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.regenerateSessionMessage(session.id, 'msg-4');
|
||||||
|
|
||||||
|
const regenerated = workspace.sessions[0];
|
||||||
|
if (!regenerated) {
|
||||||
|
throw new Error('Expected regenerateSessionMessage to prepend a new session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(regenerated.id).not.toBe(session.id);
|
||||||
|
expect(regenerated.branchOrigin).toMatchObject({
|
||||||
|
sourceSessionId: session.id,
|
||||||
|
sourceMessageId: 'msg-4',
|
||||||
|
sourceMessageIndex: 3,
|
||||||
|
action: 'regenerate',
|
||||||
|
});
|
||||||
|
expect(regenerated.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']);
|
||||||
|
expect(workspace.selectedSessionId).toBe(regenerated.id);
|
||||||
|
expect(command?.sessionId).toBe(regenerated.id);
|
||||||
|
expect(command?.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editAndResendSessionMessage creates an implicit branch with the edited prompt', async () => {
|
||||||
|
const { workspace, pattern, session } = createFixture();
|
||||||
|
let command: RunTurnCommand | undefined;
|
||||||
|
const service = createService(workspace, pattern, {
|
||||||
|
captureRunTurn: (capturedCommand) => {
|
||||||
|
command = capturedCommand;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.editAndResendSessionMessage(session.id, 'msg-3', ' Focus only on session state. ');
|
||||||
|
|
||||||
|
const edited = workspace.sessions[0];
|
||||||
|
if (!edited) {
|
||||||
|
throw new Error('Expected editAndResendSessionMessage to prepend a new session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(edited.id).not.toBe(session.id);
|
||||||
|
expect(edited.branchOrigin).toMatchObject({
|
||||||
|
sourceSessionId: session.id,
|
||||||
|
sourceMessageId: 'msg-3',
|
||||||
|
sourceMessageIndex: 2,
|
||||||
|
action: 'edit-and-resend',
|
||||||
|
});
|
||||||
|
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 only on session state. ',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
path: 'C:\\workspace\\alpha\\notes.txt',
|
||||||
|
displayName: 'notes.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(edited.messages[2]?.attachments).not.toBe(session.messages[2]?.attachments);
|
||||||
|
expect(workspace.selectedSessionId).toBe(edited.id);
|
||||||
|
expect(command?.sessionId).toBe(edited.id);
|
||||||
|
expect(command?.messages[2]?.content).toBe(' Focus only on session state. ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { branchSessionRecord, querySessions, duplicateSessionRecord, renameSessionRecord } from '@shared/domain/sessionLibrary';
|
import {
|
||||||
|
branchSessionRecord,
|
||||||
|
duplicateSessionRecord,
|
||||||
|
editAndResendSessionRecord,
|
||||||
|
querySessions,
|
||||||
|
regenerateSessionRecord,
|
||||||
|
renameSessionRecord,
|
||||||
|
setSessionMessagePinnedRecord,
|
||||||
|
} 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 +184,17 @@ describe('session library helpers', () => {
|
|||||||
expect(session.runs).toEqual([]);
|
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', () => {
|
test('branches sessions from a user message and retains only the prior transcript', () => {
|
||||||
const sourceSession = createSession({
|
const sourceSession = createSession({
|
||||||
title: 'Manual branch source',
|
title: 'Manual branch source',
|
||||||
@@ -349,6 +368,177 @@ describe('session library helpers', () => {
|
|||||||
sourceSessionId: 'session-1',
|
sourceSessionId: 'session-1',
|
||||||
sourceMessageId: 'msg-2',
|
sourceMessageId: 'msg-2',
|
||||||
sourceMessageIndex: 1,
|
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',
|
||||||
|
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.',
|
||||||
|
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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user