From d88ce0f00c87953d29f1320eb24ba4a5adffcc1b Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 22:59:56 +0200 Subject: [PATCH] feat: add message actions backend Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/AryxAppService.ts | 343 ++++++++++++++------ src/main/ipc/registerIpcHandlers.ts | 12 + src/preload/index.ts | 3 + src/shared/contracts/channels.ts | 3 + src/shared/contracts/ipc.ts | 21 ++ src/shared/domain/session.ts | 8 + src/shared/domain/sessionLibrary.ts | 165 +++++++++- tests/main/appServiceMessageActions.test.ts | 243 ++++++++++++++ tests/shared/sessionLibrary.test.ts | 192 ++++++++++- 9 files changed, 877 insertions(+), 113 deletions(-) create mode 100644 tests/main/appServiceMessageActions.test.ts diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 6530616..7357e9a 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -8,12 +8,13 @@ import type { AgentActivityEvent, ApprovalRequestedEvent, ExitPlanModeRequestedEvent, + MessageMode, McpOauthRequiredEvent, RunTurnCustomAgentConfig, RunTurnToolingConfig, SidecarCapabilities, - TurnDeltaEvent, UserInputRequestedEvent, + TurnDeltaEvent, } from '@shared/contracts/sidecar'; import type { TurnScopedEvent } from '@main/sidecar/runTurnPending'; import { @@ -64,13 +65,17 @@ import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project' import { branchSessionRecord, duplicateSessionRecord, + editAndResendSessionRecord, querySessions as queryWorkspaceSessions, + regenerateSessionRecord, renameSessionRecord, + setSessionMessagePinnedRecord, type QuerySessionsInput, type SessionQueryResult, } from '@shared/domain/sessionLibrary'; import type { SessionEventRecord } from '@shared/domain/event'; import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal'; +import type { ChatMessageAttachment } from '@shared/domain/attachment'; import { applySessionApprovalSettings, applySessionModelConfig, @@ -721,6 +726,15 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setSessionMessagePinned(sessionId: string, messageId: string, isPinned: boolean): Promise { + 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 { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); @@ -779,11 +793,117 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async regenerateSessionMessage(sessionId: string, messageId: string): Promise { + 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 { + 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( sessionId: string, content: string, - attachments?: import('@shared/domain/attachment').ChatMessageAttachment[], - messageMode?: import('@shared/contracts/sidecar').MessageMode, + attachments?: ChatMessageAttachment[], + messageMode?: MessageMode, ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); @@ -806,7 +926,6 @@ export class AryxAppService extends EventEmitter { } const requestId = createId('turn'); - const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; const occurredAt = nowIso(); const userMessageId = createId('msg'); session.messages.push({ @@ -817,103 +936,13 @@ export class AryxAppService extends EventEmitter { createdAt: occurredAt, attachments: attachments?.length ? attachments : undefined, }); - 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: userMessageId, - startedAt: occurredAt, - }), - ...session.runs, - ]; - - await this.persistAndBroadcast(workspace); - this.emitSessionEvent({ - sessionId: session.id, - kind: 'status', - status: 'running', + await this.runPreparedSessionTurn(workspace, session, project, effectivePattern, projectInstructions, { 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 { @@ -1256,6 +1285,122 @@ export class AryxAppService extends EventEmitter { } } + 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 { + 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( sessionId: string, enabledMcpServerIds: string[], diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index c80735d..4491831 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -9,6 +9,8 @@ import type { DismissSessionMcpAuthInput, DismissSessionPlanReviewInput, DeleteSessionInput, + EditAndResendSessionMessageInput, + RegenerateSessionMessageInput, StartSessionMcpAuthInput, DuplicateSessionInput, RenameSessionInput, @@ -26,6 +28,7 @@ import type { SetProjectAgentProfileEnabledInput, SetSessionArchivedInput, SetSessionInteractionModeInput, + SetSessionMessagePinnedInput, SetSessionPinnedInput, SetTerminalHeightInput, ResizeTerminalInput, @@ -149,6 +152,9 @@ export function registerIpcHandlers( ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) => 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) => service.renameSession(input.sessionId, input.title), ); @@ -161,6 +167,12 @@ export function registerIpcHandlers( ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) => 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) => service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode), ); diff --git a/src/preload/index.ts b/src/preload/index.ts index 35701a0..1f8e1cb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -50,10 +50,13 @@ const api: ElectronApi = { createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input), duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input), branchSession: (input) => ipcRenderer.invoke(ipcChannels.branchSession, input), + setSessionMessagePinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionMessagePinned, input), renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input), setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input), setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, 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), cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 4bd8071..51ca33a 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -34,10 +34,13 @@ export const ipcChannels = { createSession: 'sessions:create', duplicateSession: 'sessions:duplicate', branchSession: 'sessions:branch', + setSessionMessagePinned: 'sessions:set-message-pinned', renameSession: 'sessions:rename', setSessionPinned: 'sessions:set-pinned', setSessionArchived: 'sessions:set-archived', deleteSession: 'sessions:delete', + regenerateSessionMessage: 'sessions:regenerate-message', + editAndResendSessionMessage: 'sessions:edit-and-resend-message', sendSessionMessage: 'sessions:send-message', cancelSessionTurn: 'sessions:cancel-turn', resolveSessionApproval: 'sessions:resolve-approval', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index c0568cf..51c0ea6 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -63,6 +63,24 @@ export interface BranchSessionInput { 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 { sessionId: string; title: string; @@ -198,10 +216,13 @@ export interface ElectronApi { createSession(input: CreateSessionInput): Promise; duplicateSession(input: DuplicateSessionInput): Promise; branchSession(input: BranchSessionInput): Promise; + setSessionMessagePinned(input: SetSessionMessagePinnedInput): Promise; renameSession(input: RenameSessionInput): Promise; setSessionPinned(input: SetSessionPinnedInput): Promise; setSessionArchived(input: SetSessionArchivedInput): Promise; deleteSession(input: DeleteSessionInput): Promise; + regenerateSessionMessage(input: RegenerateSessionMessageInput): Promise; + editAndResendSessionMessage(input: EditAndResendSessionMessageInput): Promise; sendSessionMessage(input: SendSessionMessageInput): Promise; cancelSessionTurn(input: CancelSessionTurnInput): Promise; resolveSessionApproval(input: ResolveSessionApprovalInput): Promise; diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 243fae5..3e1b0ac 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -20,6 +20,7 @@ import type { InteractionMode } from '@shared/contracts/sidecar'; export type ChatRole = 'system' | 'user' | 'assistant'; export type SessionStatus = 'idle' | 'running' | 'error'; export type SessionTitleSource = 'auto' | 'manual'; +export type SessionBranchOriginAction = 'branch' | 'regenerate' | 'edit-and-resend'; export interface SessionModelConfig { model: string; @@ -32,6 +33,7 @@ export interface ChatMessageRecord { authorName: string; content: string; createdAt: string; + isPinned?: boolean; pending?: boolean; attachments?: ChatMessageAttachment[]; } @@ -41,6 +43,7 @@ export interface SessionBranchOrigin { sourceMessageId: string; sourceMessageIndex: number; branchedAt: string; + action?: SessionBranchOriginAction; } export interface SessionRecord { @@ -82,6 +85,10 @@ export function normalizeSessionBranchOrigin( const sourceMessageId = normalizeOptionalString(branchOrigin?.sourceMessageId); const branchedAt = normalizeOptionalString(branchOrigin?.branchedAt); const sourceMessageIndex = branchOrigin?.sourceMessageIndex; + const action = branchOrigin?.action; + const normalizedAction = action === 'branch' || action === 'regenerate' || action === 'edit-and-resend' + ? action + : undefined; if ( !sourceSessionId @@ -99,6 +106,7 @@ export function normalizeSessionBranchOrigin( sourceMessageId, sourceMessageIndex, branchedAt, + action: normalizedAction, }; } diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index 43e2e2f..1a7f7c2 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -1,6 +1,14 @@ import type { PatternDefinition } from '@shared/domain/pattern'; 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'; export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern'; @@ -162,11 +170,41 @@ function cloneBranchOrigin(branchOrigin?: SessionBranchOrigin): SessionBranchOri 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 { return { ...message, 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, 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 sourceMessageIndex = requireMessageIndex(session, messageId); const sourceMessage = session.messages[sourceMessageIndex]; if (!sourceMessage) { throw new Error(`Message ${messageId} not found in session ${session.id}.`); @@ -247,12 +281,117 @@ export function branchSessionRecord( ...createDerivedSessionRecord(session, sessionId, branchedAt), title: resolveSessionTitle(session, pattern, branchedMessages), messages: branchedMessages, - branchOrigin: { - sourceSessionId: session.id, - sourceMessageId: messageId, - sourceMessageIndex, - branchedAt, - }, + branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, branchedAt, 'branch'), + }; +} + +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'), }; } diff --git a/tests/main/appServiceMessageActions.test.ts b/tests/main/appServiceMessageActions.test.ts new file mode 100644 index 0000000..8f3e02d --- /dev/null +++ b/tests/main/appServiceMessageActions.test.ts @@ -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 { + return { + id: 'project-alpha', + name: 'alpha', + path: 'C:\\workspace\\alpha', + addedAt: TIMESTAMP, + ...overrides, + }; +} + +function createSession(projectId: string, patternId: string, overrides?: Partial): 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 { + const service = new AryxAppService(); + const internals = service as unknown as Record; + 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; + }; + } + ).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. '); + }); +}); diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts index 1c1168a..4e7b2a0 100644 --- a/tests/shared/sessionLibrary.test.ts +++ b/tests/shared/sessionLibrary.test.ts @@ -1,6 +1,14 @@ 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 { ProjectRecord } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; @@ -176,6 +184,17 @@ describe('session library helpers', () => { expect(session.runs).toEqual([]); }); + test('pins and unpins messages within a session', () => { + const pinned = setSessionMessagePinnedRecord(createSession(), 'msg-1', true, '2026-03-23T00:06:00.000Z'); + + expect(pinned.updatedAt).toBe('2026-03-23T00:06:00.000Z'); + expect(pinned.messages[0]?.isPinned).toBe(true); + + const unpinned = setSessionMessagePinnedRecord(pinned, 'msg-1', false, '2026-03-23T00:07:00.000Z'); + expect(unpinned.messages[0]?.isPinned).toBeUndefined(); + expect(pinned.messages[0]?.isPinned).toBe(true); + }); + test('branches sessions from a user message and retains only the prior transcript', () => { const sourceSession = createSession({ title: 'Manual branch source', @@ -349,6 +368,177 @@ describe('session library helpers', () => { sourceSessionId: 'session-1', sourceMessageId: 'msg-2', sourceMessageIndex: 1, + action: 'branch', + }); + }); + + test('regenerates the last assistant message by truncating back to the prior user turn', () => { + const sourceSession = createSession({ + messages: [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Investigate the refresh bug.', + createdAt: '2026-03-23T00:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + authorName: 'Reviewer', + content: 'I found two likely causes.', + createdAt: '2026-03-23T00:01:00.000Z', + }, + { + id: 'msg-3', + role: 'user', + authorName: 'You', + content: 'Try a different approach.', + createdAt: '2026-03-23T00:02:00.000Z', + }, + { + id: 'msg-4', + role: 'assistant', + authorName: 'Reviewer', + content: 'Here is the alternate plan.', + createdAt: '2026-03-23T00:03:00.000Z', + pending: true, + }, + ], + }); + + const regenerated = regenerateSessionRecord( + sourceSession, + createPattern(), + 'session-regenerated', + 'msg-4', + '2026-03-23T00:06:00.000Z', + ); + + expect(regenerated.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2', 'msg-3']); + expect(regenerated.messages[2]?.role).toBe('user'); + expect(regenerated.messages[1]?.pending).toBe(false); + expect(regenerated.branchOrigin).toMatchObject({ + sourceSessionId: 'session-1', + sourceMessageId: 'msg-4', + sourceMessageIndex: 3, + action: 'regenerate', + }); + }); + + test('rejects regenerating any assistant message except the last one', () => { + const sourceSession = createSession({ + messages: [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Investigate the refresh bug.', + createdAt: '2026-03-23T00:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + authorName: 'Reviewer', + content: 'I found two likely causes.', + createdAt: '2026-03-23T00:01:00.000Z', + }, + { + id: 'msg-3', + role: 'user', + authorName: 'You', + content: 'Try a different approach.', + createdAt: '2026-03-23T00:02:00.000Z', + }, + ], + }); + + expect(() => + regenerateSessionRecord( + sourceSession, + createPattern(), + 'session-regenerated', + 'msg-2', + '2026-03-23T00:06:00.000Z', + )).toThrow('Only the last assistant message can be regenerated.'); + }); + + test('edits and resends a user message by creating an implicit branch', () => { + const sourceSession = createSession({ + messages: [ + { + id: 'msg-1', + role: 'user', + authorName: 'You', + content: 'Investigate the refresh bug.', + createdAt: '2026-03-23T00:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + authorName: 'Reviewer', + content: 'I found two likely causes.', + createdAt: '2026-03-23T00:01:00.000Z', + }, + { + id: 'msg-3', + role: 'user', + authorName: 'You', + content: 'Try a different approach.', + createdAt: '2026-03-23T00:02:00.000Z', + 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', }); });