Files
aryx/tests/main/appServiceWorkflowCheckpointing.test.ts
T

298 lines
9.4 KiB
TypeScript

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<string, unknown>;
handleTurnScopedEvent: (
workspace: { sessions: SessionRecord[] },
sessionId: string,
event: WorkflowCheckpointSavedEvent,
) => void | Promise<void>;
};
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<string, unknown>;
sidecar: {
runTurn: (
command: RunTurnCommand,
) => Promise<ChatMessageRecord[]>;
};
persistAndBroadcast: (workspace: unknown) => Promise<void>;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
runSidecarTurnWithCheckpointRecovery: (
workspace: unknown,
session: SessionRecord,
requestId: string,
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
onDelta: () => Promise<void>,
onActivity: () => Promise<void>,
onApproval: () => Promise<void>,
onUserInput: () => Promise<void>,
onMcpOAuthRequired: () => Promise<void>,
onExitPlanMode: () => Promise<void>,
onMessageReclassified: () => Promise<void>,
onTurnScopedEvent: () => Promise<void>,
) => Promise<ChatMessageRecord[]>;
}
).workflowCheckpointRecoveries.set(run.requestId, checkpointRecovery);
(
service as unknown as {
sidecar: {
runTurn: (command: RunTurnCommand) => Promise<ChatMessageRecord[]>;
};
}
).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<void>;
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<void>,
onActivity: () => Promise<void>,
onApproval: () => Promise<void>,
onUserInput: () => Promise<void>,
onMcpOAuthRequired: () => Promise<void>,
onExitPlanMode: () => Promise<void>,
onMessageReclassified: () => Promise<void>,
onTurnScopedEvent: () => Promise<void>,
) => Promise<ChatMessageRecord[]>;
}
).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',
};
}