import { describe, expect, mock, test } from 'bun:test'; import type { RunTurnCommand, WorkflowCheckpointSavedEvent, WorkflowCheckpointResume } from '@shared/contracts/sidecar'; import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; import { createSessionRunRecord, type RunTimelineEventRecord, type SessionRunRecord, } from '@shared/domain/runTimeline'; 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'); describe('AryxAppService workflow checkpointing', () => { test('records workflow checkpoint recovery snapshots from turn-scoped events', async () => { const service = new AryxAppService(); const { session, run } = createRunningSession(); const checkpointEvent: WorkflowCheckpointSavedEvent = { type: 'workflow-checkpoint-saved', requestId: run.requestId, sessionId: session.id, workflowSessionId: run.requestId, checkpointId: 'checkpoint-1', storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1', stepNumber: 2, }; const internals = service as unknown as { workflowCheckpointRecoveries: Map; handleTurnScopedEvent: ( workspace: { sessions: SessionRecord[] }, sessionId: string, event: WorkflowCheckpointSavedEvent, ) => void | Promise; }; await internals.handleTurnScopedEvent({ sessions: [session] }, session.id, checkpointEvent); expect(internals.workflowCheckpointRecoveries.get(run.requestId)).toEqual({ workflowSessionId: run.requestId, checkpointId: 'checkpoint-1', storePath: checkpointEvent.storePath, stepNumber: 2, sessionMessages: session.messages, runEvents: run.events, }); }); test('retries a checkpointed turn with resume metadata after sidecar exit', async () => { const service = new AryxAppService(); const { session, run } = createRunningSession(); const workspace = { sessions: [session] }; const checkpointRecovery = { workflowSessionId: run.requestId, checkpointId: 'checkpoint-7', storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1', stepNumber: 7, sessionMessages: structuredClone(session.messages), runEvents: structuredClone(run.events), }; const invocations: RunTurnCommand[] = []; session.messages.push({ id: 'msg-partial', role: 'assistant', authorName: 'Primary', content: 'Partial output after the checkpoint.', createdAt: '2026-04-01T12:00:05.000Z', pending: true, }); run.events = [ ...run.events, { id: 'run-event-extra', kind: 'message', occurredAt: '2026-04-01T12:00:05.000Z', status: 'running', messageId: 'msg-partial', content: 'Partial output after the checkpoint.', } satisfies RunTimelineEventRecord, ]; session.pendingUserInput = { id: 'user-input-1', status: 'pending', requestedAt: '2026-04-01T12:00:05.000Z', question: 'Need more detail?', choices: ['Yes', 'No'], allowFreeform: true, }; ( service as unknown as { workflowCheckpointRecoveries: Map; sidecar: { runTurn: ( command: RunTurnCommand, ) => Promise; }; persistAndBroadcast: (workspace: unknown) => Promise; emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; runSidecarTurnWithCheckpointRecovery: ( workspace: unknown, session: SessionRecord, requestId: string, createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand, onDelta: () => Promise, onActivity: () => Promise, onApproval: () => Promise, onUserInput: () => Promise, onMcpOAuthRequired: () => Promise, onExitPlanMode: () => Promise, onMessageReclassified: () => Promise, onTurnScopedEvent: () => Promise, ) => Promise; } ).workflowCheckpointRecoveries.set(run.requestId, checkpointRecovery); ( service as unknown as { sidecar: { runTurn: (command: RunTurnCommand) => Promise; }; } ).sidecar = { runTurn: async (command: RunTurnCommand) => { invocations.push(structuredClone(command)); if (invocations.length === 1) { throw new Error('The .NET sidecar exited unexpectedly with code 1.'); } return []; }, }; ( service as unknown as { persistAndBroadcast: (workspace: unknown) => Promise; emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; } ).persistAndBroadcast = async () => undefined; ( service as unknown as { emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void; } ).emitRunUpdated = () => undefined; const result = await ( service as unknown as { runSidecarTurnWithCheckpointRecovery: ( workspace: unknown, session: SessionRecord, requestId: string, createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand, onDelta: () => Promise, onActivity: () => Promise, onApproval: () => Promise, onUserInput: () => Promise, onMcpOAuthRequired: () => Promise, onExitPlanMode: () => Promise, onMessageReclassified: () => Promise, onTurnScopedEvent: () => Promise, ) => Promise; } ).runSidecarTurnWithCheckpointRecovery( workspace, session, run.requestId, (resumeFromCheckpoint?: WorkflowCheckpointResume): RunTurnCommand => ({ type: 'run-turn', requestId: run.requestId, sessionId: session.id, projectPath: 'C:\\scratchpad', workspaceKind: 'scratchpad', mode: 'interactive', messageMode: 'enqueue', pattern: createPattern(), messages: session.messages, resumeFromCheckpoint, }), async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, async () => undefined, ); expect(result).toEqual([]); expect(invocations).toHaveLength(2); expect(invocations[0]?.resumeFromCheckpoint).toBeUndefined(); expect(invocations[1]?.resumeFromCheckpoint).toEqual({ workflowSessionId: run.requestId, checkpointId: 'checkpoint-7', storePath: checkpointRecovery.storePath, }); expect(invocations[1]?.messages).toEqual(checkpointRecovery.sessionMessages); expect(session.messages).toEqual(checkpointRecovery.sessionMessages); expect(session.pendingUserInput).toBeUndefined(); expect(session.runs[0]?.events).toEqual(checkpointRecovery.runEvents); }); }); function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } { const pattern = createPattern(); const run = createSessionRunRecord({ requestId: 'turn-1', project: { id: SCRATCHPAD_PROJECT_ID, path: 'C:\\scratchpad', }, workingDirectory: 'C:\\scratchpad', workspaceKind: 'scratchpad', pattern, triggerMessageId: 'msg-user-1', startedAt: '2026-04-01T12:00:00.000Z', }); const session: SessionRecord = { id: 'session-1', projectId: SCRATCHPAD_PROJECT_ID, patternId: pattern.id, title: 'Checkpoint session', createdAt: '2026-04-01T12:00:00.000Z', updatedAt: '2026-04-01T12:00:00.000Z', status: 'running', messages: [ { id: 'msg-user-1', role: 'user', authorName: 'You', content: 'Continue the workflow.', createdAt: '2026-04-01T12:00:00.000Z', }, { id: 'msg-assistant-1', role: 'assistant', authorName: 'Primary', content: 'Working on it.', createdAt: '2026-04-01T12:00:01.000Z', pending: true, }, ], runs: [run], }; return { session, run }; } function createPattern() { return { id: 'pattern-handoff', name: 'Checkpointing flow', description: '', mode: 'handoff' as const, availability: 'available' as const, maxIterations: 4, agents: [ { id: 'agent-1', name: 'Primary', description: '', instructions: 'Help with the request.', model: 'gpt-5.4', }, ], createdAt: '2026-04-01T00:00:00.000Z', updatedAt: '2026-04-01T00:00:00.000Z', }; }