feat: add message actions backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 22:59:56 +02:00
co-authored by Copilot
parent 15071fdc47
commit d88ce0f00c
9 changed files with 877 additions and 113 deletions
+244 -99
View File
@@ -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<AppServiceEvents> {
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> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
@@ -779,11 +793,117 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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(
sessionId: string,
content: string,
attachments?: import('@shared/domain/attachment').ChatMessageAttachment[],
messageMode?: import('@shared/contracts/sidecar').MessageMode,
attachments?: ChatMessageAttachment[],
messageMode?: MessageMode,
): Promise<void> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
@@ -806,7 +926,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
}
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<AppServiceEvents> {
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<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(
sessionId: string,
enabledMcpServerIds: string[],
+12
View File
@@ -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),
);
+3
View File
@@ -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),
+3
View File
@@ -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',
+21
View File
@@ -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<WorkspaceState>;
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
setSessionMessagePinned(input: SetSessionMessagePinnedInput): Promise<WorkspaceState>;
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
regenerateSessionMessage(input: RegenerateSessionMessageInput): Promise<void>;
editAndResendSessionMessage(input: EditAndResendSessionMessageInput): Promise<void>;
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
+8
View File
@@ -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,
};
}
+152 -13
View File
@@ -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'),
};
}