mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
- Implement tests for KeywordDetector including keyword detection, sanitization, and priority handling. - Add tests for SessionStateService covering session validation, loading, saving, and state updates. - Create tests for UserAbortDetector to validate user abort detection logic and pattern matching.
802 lines
28 KiB
TypeScript
802 lines
28 KiB
TypeScript
/**
|
|
* Integration Tests for CCW + OMC Hook Integration
|
|
*
|
|
* Tests the complete hook system including:
|
|
* - Stop Hook with Soft Enforcement
|
|
* - Mode activation via keyword detection
|
|
* - Checkpoint creation and recovery
|
|
* - End-to-end workflow continuation
|
|
* - Mode system integration
|
|
*
|
|
* Notes:
|
|
* - Targets the runtime implementation shipped in `ccw/dist`.
|
|
* - Uses temporary directories for isolation.
|
|
* - Calls services directly (no HTTP server required).
|
|
*/
|
|
|
|
import { after, before, beforeEach, describe, it, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
|
|
// =============================================================================
|
|
// Test Setup
|
|
// =============================================================================
|
|
|
|
const stopHandlerUrl = new URL('../../dist/core/hooks/stop-handler.js', import.meta.url);
|
|
const recoveryHandlerUrl = new URL('../../dist/core/hooks/recovery-handler.js', import.meta.url);
|
|
const modeRegistryUrl = new URL('../../dist/core/services/mode-registry-service.js', import.meta.url);
|
|
const checkpointServiceUrl = new URL('../../dist/core/services/checkpoint-service.js', import.meta.url);
|
|
const contextLimitUrl = new URL('../../dist/core/hooks/context-limit-detector.js', import.meta.url);
|
|
const userAbortUrl = new URL('../../dist/core/hooks/user-abort-detector.js', import.meta.url);
|
|
const keywordDetectorUrl = new URL('../../dist/core/hooks/keyword-detector.js', import.meta.url);
|
|
|
|
// Add cache-busting
|
|
[stopHandlerUrl, recoveryHandlerUrl, modeRegistryUrl, checkpointServiceUrl, contextLimitUrl, userAbortUrl, keywordDetectorUrl].forEach(url => {
|
|
url.searchParams.set('t', String(Date.now()));
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let modules: any = {};
|
|
|
|
const originalEnv = {
|
|
HOME: process.env.HOME,
|
|
USERPROFILE: process.env.USERPROFILE,
|
|
};
|
|
|
|
// =============================================================================
|
|
// Helper Functions
|
|
// =============================================================================
|
|
|
|
async function importModules() {
|
|
modules.StopHandler = (await import(stopHandlerUrl.href)).StopHandler;
|
|
modules.RecoveryHandler = (await import(recoveryHandlerUrl.href)).RecoveryHandler;
|
|
modules.ModeRegistryService = (await import(modeRegistryUrl.href)).ModeRegistryService;
|
|
modules.CheckpointService = (await import(checkpointServiceUrl.href)).CheckpointService;
|
|
modules.isContextLimitStop = (await import(contextLimitUrl.href)).isContextLimitStop;
|
|
modules.isUserAbort = (await import(userAbortUrl.href)).isUserAbort;
|
|
modules.detectKeywords = (await import(keywordDetectorUrl.href)).detectKeywords;
|
|
modules.getPrimaryKeyword = (await import(keywordDetectorUrl.href)).getPrimaryKeyword;
|
|
}
|
|
|
|
function createTestProject(baseDir: string): string {
|
|
const projectDir = join(baseDir, 'project');
|
|
mkdirSync(projectDir, { recursive: true });
|
|
mkdirSync(join(projectDir, '.workflow'), { recursive: true });
|
|
mkdirSync(join(projectDir, '.workflow', 'modes'), { recursive: true });
|
|
mkdirSync(join(projectDir, '.workflow', 'checkpoints'), { recursive: true });
|
|
return projectDir;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Integration Tests
|
|
// =============================================================================
|
|
|
|
describe('CCW + OMC Hook Integration', async () => {
|
|
let homeDir = '';
|
|
let testDir = '';
|
|
let projectDir = '';
|
|
|
|
before(async () => {
|
|
homeDir = mkdtempSync(join(tmpdir(), 'ccw-hooks-home-'));
|
|
testDir = mkdtempSync(join(tmpdir(), 'ccw-hooks-test-'));
|
|
|
|
process.env.HOME = homeDir;
|
|
process.env.USERPROFILE = homeDir;
|
|
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'warn', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
|
|
await importModules();
|
|
projectDir = createTestProject(testDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Clean up project state between tests
|
|
rmSync(join(projectDir, '.workflow'), { recursive: true, force: true });
|
|
mkdirSync(join(projectDir, '.workflow'), { recursive: true });
|
|
mkdirSync(join(projectDir, '.workflow', 'modes'), { recursive: true });
|
|
mkdirSync(join(projectDir, '.workflow', 'checkpoints'), { recursive: true });
|
|
});
|
|
|
|
after(() => {
|
|
mock.restoreAll();
|
|
process.env.HOME = originalEnv.HOME;
|
|
process.env.USERPROFILE = originalEnv.USERPROFILE;
|
|
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
rmSync(homeDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Stop Handler Integration Tests
|
|
// ===========================================================================
|
|
|
|
describe('Stop Handler Integration', () => {
|
|
it('INT-STOP-1: Should always return continue: true (Soft Enforcement)', async () => {
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
// Test various contexts - all should return continue: true
|
|
const contexts = [
|
|
{},
|
|
{ stop_reason: 'unknown' },
|
|
{ active_workflow: true },
|
|
{ active_mode: 'analysis' }
|
|
];
|
|
|
|
for (const context of contexts) {
|
|
const result = await stopHandler.handleStop(context);
|
|
assert.equal(result.continue, true, `Expected continue: true for context ${JSON.stringify(context)}`);
|
|
}
|
|
});
|
|
|
|
it('INT-STOP-2: Should detect context limit and allow stop', async () => {
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const result = await stopHandler.handleStop({
|
|
stop_reason: 'context_limit_reached',
|
|
end_turn_reason: 'max_tokens'
|
|
});
|
|
|
|
assert.equal(result.continue, true);
|
|
assert.equal(result.mode, 'context-limit');
|
|
});
|
|
|
|
it('INT-STOP-3: Should detect user abort and respect intent', async () => {
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const result = await stopHandler.handleStop({
|
|
user_requested: true,
|
|
stop_reason: 'user_cancel'
|
|
});
|
|
|
|
assert.equal(result.continue, true);
|
|
assert.equal(result.mode, 'user-abort');
|
|
});
|
|
|
|
it('INT-STOP-4: Should inject continuation message for active workflow', async () => {
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false,
|
|
workflowContinuationMessage: '[WORKFLOW] Continue working...'
|
|
});
|
|
|
|
const result = await stopHandler.handleStop({
|
|
active_workflow: true,
|
|
session_id: 'test-session-001'
|
|
});
|
|
|
|
assert.equal(result.continue, true);
|
|
assert.equal(result.mode, 'active-workflow');
|
|
assert.ok(result.message);
|
|
assert.ok(result.message.includes('[WORKFLOW]'));
|
|
});
|
|
|
|
it('INT-STOP-5: Should check ModeRegistryService for active modes', async () => {
|
|
const sessionId = 'test-session-002';
|
|
|
|
// First activate a mode
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const activated = modeRegistry.activateMode('autopilot', sessionId);
|
|
assert.equal(activated, true);
|
|
|
|
// Now test stop handler
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const result = await stopHandler.handleStop({
|
|
session_id: sessionId
|
|
});
|
|
|
|
assert.equal(result.continue, true);
|
|
// Should detect active mode
|
|
assert.ok(
|
|
result.mode === 'active-mode' || result.mode === 'none',
|
|
`Expected active-mode or none, got ${result.mode}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Mode System Integration Tests
|
|
// ===========================================================================
|
|
|
|
describe('Mode System Integration', () => {
|
|
it('INT-MODE-1: Should activate and detect modes via ModeRegistryService', async () => {
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'mode-test-001';
|
|
|
|
// Initially no mode active
|
|
assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false);
|
|
|
|
// Activate mode
|
|
const activated = modeRegistry.activateMode('autopilot', sessionId);
|
|
assert.equal(activated, true);
|
|
|
|
// Now should be active
|
|
assert.equal(modeRegistry.isModeActive('autopilot', sessionId), true);
|
|
assert.deepEqual(modeRegistry.getActiveModes(sessionId), ['autopilot']);
|
|
|
|
// Deactivate
|
|
modeRegistry.deactivateMode('autopilot', sessionId);
|
|
assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false);
|
|
});
|
|
|
|
it('INT-MODE-2: Should prevent concurrent exclusive modes', async () => {
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId1 = 'exclusive-test-001';
|
|
const sessionId2 = 'exclusive-test-002';
|
|
|
|
// Activate autopilot (exclusive mode) in session 1
|
|
const activated1 = modeRegistry.activateMode('autopilot', sessionId1);
|
|
assert.equal(activated1, true);
|
|
|
|
// Try to activate swarm (also exclusive) in session 2
|
|
// This should be blocked because autopilot is already active
|
|
const canStart = modeRegistry.canStartMode('swarm', sessionId2);
|
|
assert.equal(canStart.allowed, false);
|
|
assert.equal(canStart.blockedBy, 'autopilot');
|
|
|
|
// Cleanup
|
|
modeRegistry.deactivateMode('autopilot', sessionId1);
|
|
});
|
|
|
|
it('INT-MODE-3: Should clean up stale markers', async () => {
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'stale-test-001';
|
|
|
|
// Activate mode
|
|
modeRegistry.activateMode('autopilot', sessionId);
|
|
|
|
// Create a stale marker (manually set old timestamp)
|
|
const stateFile = join(projectDir, '.workflow', 'modes', 'sessions', sessionId, 'autopilot-state.json');
|
|
if (existsSync(stateFile)) {
|
|
const content = readFileSync(stateFile, 'utf-8');
|
|
const state = JSON.parse(content);
|
|
// Set activation time to 2 hours ago (beyond 1 hour threshold)
|
|
state.activatedAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
|
writeFileSync(stateFile, JSON.stringify(state), 'utf-8');
|
|
}
|
|
|
|
// Run cleanup
|
|
const cleaned = modeRegistry.cleanupStaleMarkers();
|
|
|
|
// Mode should no longer be active
|
|
assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false);
|
|
});
|
|
|
|
it('INT-MODE-4: Should support non-exclusive modes concurrently', async () => {
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'non-exclusive-test-001';
|
|
|
|
// Activate ralph (non-exclusive)
|
|
const ralphOk = modeRegistry.activateMode('ralph', sessionId);
|
|
assert.equal(ralphOk, true);
|
|
|
|
// Activate team (non-exclusive) - should be allowed
|
|
const teamOk = modeRegistry.activateMode('team', sessionId);
|
|
assert.equal(teamOk, true);
|
|
|
|
// Both should be active
|
|
const activeModes = modeRegistry.getActiveModes(sessionId);
|
|
assert.ok(activeModes.includes('ralph'));
|
|
assert.ok(activeModes.includes('team'));
|
|
|
|
// Cleanup
|
|
modeRegistry.deactivateMode('ralph', sessionId);
|
|
modeRegistry.deactivateMode('team', sessionId);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Checkpoint and Recovery Integration Tests
|
|
// ===========================================================================
|
|
|
|
describe('Checkpoint and Recovery Integration', () => {
|
|
it('INT-CHECKPOINT-1: Should create checkpoint via CheckpointService', async () => {
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'checkpoint-test-001';
|
|
|
|
const checkpoint = await checkpointService.createCheckpoint(
|
|
sessionId,
|
|
'compact',
|
|
{
|
|
modeStates: { autopilot: { active: true } },
|
|
workflowState: null,
|
|
memoryContext: null
|
|
}
|
|
);
|
|
|
|
assert.ok(checkpoint.id);
|
|
assert.equal(checkpoint.session_id, sessionId);
|
|
assert.equal(checkpoint.trigger, 'compact');
|
|
assert.ok(checkpoint.mode_states.autopilot?.active);
|
|
|
|
// Save and verify
|
|
const savedId = await checkpointService.saveCheckpoint(checkpoint);
|
|
assert.equal(savedId, checkpoint.id);
|
|
|
|
// Load and verify
|
|
const loaded = await checkpointService.loadCheckpoint(checkpoint.id);
|
|
assert.ok(loaded);
|
|
assert.equal(loaded?.id, checkpoint.id);
|
|
});
|
|
|
|
it('INT-CHECKPOINT-2: Should create checkpoint via RecoveryHandler PreCompact', async () => {
|
|
const recoveryHandler = new modules.RecoveryHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'precompact-test-001';
|
|
|
|
const result = await recoveryHandler.handlePreCompact({
|
|
session_id: sessionId,
|
|
cwd: projectDir,
|
|
hook_event_name: 'PreCompact',
|
|
trigger: 'auto'
|
|
});
|
|
|
|
assert.equal(result.continue, true);
|
|
assert.ok(result.systemMessage);
|
|
|
|
// Verify checkpoint was created
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const checkpoint = await checkpointService.getLatestCheckpoint(sessionId);
|
|
assert.ok(checkpoint);
|
|
assert.equal(checkpoint?.session_id, sessionId);
|
|
});
|
|
|
|
it('INT-CHECKPOINT-3: Should recover session from checkpoint', async () => {
|
|
const recoveryHandler = new modules.RecoveryHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'recovery-test-001';
|
|
|
|
// Create checkpoint first
|
|
await recoveryHandler.handlePreCompact({
|
|
session_id: sessionId,
|
|
cwd: projectDir,
|
|
hook_event_name: 'PreCompact',
|
|
trigger: 'manual'
|
|
});
|
|
|
|
// Now check recovery
|
|
const checkpoint = await recoveryHandler.checkRecovery(sessionId);
|
|
assert.ok(checkpoint);
|
|
|
|
// Format recovery message
|
|
const message = await recoveryHandler.formatRecoveryMessage(checkpoint);
|
|
assert.ok(message);
|
|
assert.ok(message.includes(sessionId));
|
|
});
|
|
|
|
it('INT-CHECKPOINT-4: Should cleanup old checkpoints', async () => {
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
maxCheckpointsPerSession: 3,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'cleanup-test-001';
|
|
|
|
// Create more than max checkpoints
|
|
for (let i = 0; i < 5; i++) {
|
|
const checkpoint = await checkpointService.createCheckpoint(
|
|
sessionId,
|
|
'compact',
|
|
{ modeStates: {}, workflowState: null, memoryContext: null }
|
|
);
|
|
await checkpointService.saveCheckpoint(checkpoint);
|
|
}
|
|
|
|
// Should only have 3 checkpoints
|
|
const checkpoints = await checkpointService.listCheckpoints(sessionId);
|
|
assert.ok(checkpoints.length <= 3);
|
|
});
|
|
|
|
it('INT-CHECKPOINT-5: Should include mode states in checkpoint', async () => {
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const sessionId = 'mode-checkpoint-test-001';
|
|
|
|
// Activate modes
|
|
modeRegistry.activateMode('autopilot', sessionId);
|
|
modeRegistry.activateMode('ralph', sessionId);
|
|
|
|
// Create checkpoint with mode states
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const modeStates: Record<string, { active: boolean }> = {};
|
|
const activeModes = modeRegistry.getActiveModes(sessionId);
|
|
for (const mode of activeModes) {
|
|
modeStates[mode] = { active: true };
|
|
}
|
|
|
|
const checkpoint = await checkpointService.createCheckpoint(
|
|
sessionId,
|
|
'compact',
|
|
{ modeStates: modeStates as any, workflowState: null, memoryContext: null }
|
|
);
|
|
|
|
assert.ok(checkpoint.mode_states.autopilot?.active);
|
|
assert.ok(checkpoint.mode_states.ralph?.active);
|
|
|
|
// Cleanup
|
|
modeRegistry.deactivateMode('autopilot', sessionId);
|
|
modeRegistry.deactivateMode('ralph', sessionId);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Keyword Detection Integration Tests
|
|
// ===========================================================================
|
|
|
|
describe('Keyword Detection Integration', () => {
|
|
it('INT-KEYWORD-1: Should detect mode keywords', async () => {
|
|
const testCases = [
|
|
{ text: 'use autopilot mode', expectedType: 'autopilot' },
|
|
{ text: 'run ultrawork now', expectedType: 'ultrawork' },
|
|
{ text: 'use ulw for this', expectedType: 'ultrawork' },
|
|
{ text: 'start ralph analysis', expectedType: 'ralph' },
|
|
{ text: 'plan this feature', expectedType: 'plan' },
|
|
{ text: 'use tdd approach', expectedType: 'tdd' }
|
|
];
|
|
|
|
for (const tc of testCases) {
|
|
const options = tc.teamEnabled ? { teamEnabled: true } : undefined;
|
|
const keyword = modules.getPrimaryKeyword(tc.text, options);
|
|
assert.ok(keyword, `Expected keyword in "${tc.text}"`);
|
|
assert.equal(keyword.type, tc.expectedType);
|
|
}
|
|
});
|
|
|
|
it('INT-KEYWORD-2: Should not detect keywords in code blocks', async () => {
|
|
const text = 'Here is code:\n```\nautopilot\n```\nNo keyword above';
|
|
const keywords = modules.detectKeywords(text);
|
|
assert.equal(keywords.some((k: any) => k.type === 'autopilot'), false);
|
|
});
|
|
|
|
it('INT-KEYWORD-3: Should handle cancel keyword with highest priority', async () => {
|
|
const text = 'use autopilot and cancelomc';
|
|
const keyword = modules.getPrimaryKeyword(text);
|
|
assert.equal(keyword?.type, 'cancel');
|
|
});
|
|
|
|
it('INT-KEYWORD-4: Should detect delegation keywords', async () => {
|
|
const testCases = [
|
|
{ text: 'ask codex to help', expectedType: 'codex' },
|
|
{ text: 'use gemini for this', expectedType: 'gemini' },
|
|
{ text: 'delegate to gpt', expectedType: 'codex' }
|
|
];
|
|
|
|
for (const tc of testCases) {
|
|
const keywords = modules.detectKeywords(tc.text);
|
|
assert.ok(
|
|
keywords.some((k: any) => k.type === tc.expectedType),
|
|
`Expected ${tc.expectedType} in "${tc.text}"`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// End-to-End Workflow Tests
|
|
// ===========================================================================
|
|
|
|
describe('End-to-End Workflow Integration', () => {
|
|
it('INT-E2E-1: Complete workflow with mode activation and checkpoint', async () => {
|
|
const sessionId = 'e2e-workflow-001';
|
|
|
|
// 1. Create services
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const recoveryHandler = new modules.RecoveryHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
// 2. Activate mode
|
|
const activated = modeRegistry.activateMode('autopilot', sessionId);
|
|
assert.equal(activated, true);
|
|
|
|
// 3. Create checkpoint before compaction
|
|
const precompactResult = await recoveryHandler.handlePreCompact({
|
|
session_id: sessionId,
|
|
cwd: projectDir,
|
|
hook_event_name: 'PreCompact',
|
|
trigger: 'auto'
|
|
});
|
|
|
|
assert.equal(precompactResult.continue, true);
|
|
assert.ok(precompactResult.systemMessage);
|
|
|
|
// 4. Simulate stop during active mode
|
|
const stopResult = await stopHandler.handleStop({
|
|
session_id: sessionId
|
|
});
|
|
|
|
assert.equal(stopResult.continue, true);
|
|
// Should detect active mode (either via registry or context)
|
|
assert.ok(
|
|
['active-mode', 'none'].includes(stopResult.mode || 'none')
|
|
);
|
|
|
|
// 5. Verify recovery is possible
|
|
const checkpoint = await recoveryHandler.checkRecovery(sessionId);
|
|
assert.ok(checkpoint);
|
|
|
|
// 6. Deactivate mode on session end
|
|
modeRegistry.deactivateMode('autopilot', sessionId);
|
|
assert.equal(modeRegistry.isModeActive('autopilot', sessionId), false);
|
|
});
|
|
|
|
it('INT-E2E-2: Recovery workflow restores state correctly', async () => {
|
|
const sessionId = 'e2e-recovery-001';
|
|
|
|
// Setup services
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
// Phase 1: Create state and checkpoint
|
|
modeRegistry.activateMode('ralph', sessionId);
|
|
|
|
const modeStates: Record<string, { active: boolean }> = {};
|
|
for (const mode of modeRegistry.getActiveModes(sessionId)) {
|
|
modeStates[mode] = { active: true };
|
|
}
|
|
|
|
const checkpoint = await checkpointService.createCheckpoint(
|
|
sessionId,
|
|
'compact',
|
|
{ modeStates: modeStates as any, workflowState: null, memoryContext: null }
|
|
);
|
|
|
|
await checkpointService.saveCheckpoint(checkpoint);
|
|
|
|
// Phase 2: Simulate session restart and recovery
|
|
// Clear mode state (simulating new session)
|
|
modeRegistry.deactivateMode('ralph', sessionId);
|
|
assert.equal(modeRegistry.isModeActive('ralph', sessionId), false);
|
|
|
|
// Load checkpoint and restore state
|
|
const loadedCheckpoint = await checkpointService.getLatestCheckpoint(sessionId);
|
|
assert.ok(loadedCheckpoint);
|
|
assert.ok(loadedCheckpoint?.mode_states.ralph?.active);
|
|
|
|
// Re-activate modes from checkpoint
|
|
for (const [mode, state] of Object.entries(loadedCheckpoint?.mode_states || {})) {
|
|
if ((state as any)?.active) {
|
|
modeRegistry.activateMode(mode as any, sessionId);
|
|
}
|
|
}
|
|
|
|
// Verify restoration
|
|
assert.equal(modeRegistry.isModeActive('ralph', sessionId), true);
|
|
|
|
// Cleanup
|
|
modeRegistry.deactivateMode('ralph', sessionId);
|
|
});
|
|
|
|
it('INT-E2E-3: Concurrent PreCompact operations use mutex', async () => {
|
|
const sessionId = 'e2e-mutex-001';
|
|
|
|
const recoveryHandler = new modules.RecoveryHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
// Start two concurrent PreCompact operations
|
|
const [result1, result2] = await Promise.all([
|
|
recoveryHandler.handlePreCompact({
|
|
session_id: sessionId,
|
|
cwd: projectDir,
|
|
hook_event_name: 'PreCompact',
|
|
trigger: 'auto'
|
|
}),
|
|
recoveryHandler.handlePreCompact({
|
|
session_id: sessionId,
|
|
cwd: projectDir,
|
|
hook_event_name: 'PreCompact',
|
|
trigger: 'auto'
|
|
})
|
|
]);
|
|
|
|
// Both should succeed
|
|
assert.equal(result1.continue, true);
|
|
assert.equal(result2.continue, true);
|
|
|
|
// Verify only one checkpoint was created
|
|
const checkpointService = new modules.CheckpointService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const checkpoints = await checkpointService.listCheckpoints(sessionId);
|
|
// Mutex should prevent duplicate checkpoints
|
|
assert.ok(checkpoints.length >= 1);
|
|
});
|
|
|
|
it('INT-E2E-4: Session lifecycle with all hooks', async () => {
|
|
const sessionId = 'e2e-lifecycle-001';
|
|
|
|
const modeRegistry = new modules.ModeRegistryService({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const recoveryHandler = new modules.RecoveryHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
const stopHandler = new modules.StopHandler({
|
|
projectPath: projectDir,
|
|
enableLogging: false
|
|
});
|
|
|
|
// 1. Session start - check for recovery (should be none)
|
|
const initialRecovery = await recoveryHandler.checkRecovery(sessionId);
|
|
assert.equal(initialRecovery, null);
|
|
|
|
// 2. Activate mode
|
|
modeRegistry.activateMode('ultrawork', sessionId);
|
|
|
|
// 3. Detect keywords
|
|
const keywords = modules.detectKeywords('continue with ultrawork');
|
|
assert.ok(keywords.some((k: any) => k.type === 'ultrawork'));
|
|
|
|
// 4. Handle stop with active mode
|
|
const stopResult = await stopHandler.handleStop({
|
|
session_id: sessionId,
|
|
active_mode: 'write'
|
|
});
|
|
|
|
assert.equal(stopResult.continue, true);
|
|
assert.ok(stopResult.mode === 'active-mode' || stopResult.mode === 'none');
|
|
|
|
// 5. PreCompact - create checkpoint
|
|
const precompactResult = await recoveryHandler.handlePreCompact({
|
|
session_id: sessionId,
|
|
cwd: projectDir,
|
|
hook_event_name: 'PreCompact',
|
|
trigger: 'auto'
|
|
});
|
|
|
|
assert.equal(precompactResult.continue, true);
|
|
|
|
// 6. Session end - cleanup
|
|
const activeModes = modeRegistry.getActiveModes(sessionId);
|
|
for (const mode of activeModes) {
|
|
modeRegistry.deactivateMode(mode, sessionId);
|
|
}
|
|
|
|
assert.equal(modeRegistry.isAnyModeActive(sessionId), false);
|
|
|
|
// 7. Verify recovery is available for next session
|
|
const finalRecovery = await recoveryHandler.checkRecovery(sessionId);
|
|
assert.ok(finalRecovery);
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// Context Limit and User Abort Detection Tests
|
|
// ===========================================================================
|
|
|
|
describe('Context Limit and User Abort Detection', () => {
|
|
it('INT-DETECT-1: Should detect context limit stop reasons', async () => {
|
|
const contextLimitCases = [
|
|
{ stop_reason: 'context_limit_reached' },
|
|
{ stop_reason: 'context_window_exceeded' },
|
|
{ end_turn_reason: 'max_tokens' },
|
|
{ stop_reason: 'max_context_exceeded' },
|
|
{ stop_reason: 'token_limit' },
|
|
{ stop_reason: 'conversation_too_long' }
|
|
];
|
|
|
|
for (const context of contextLimitCases) {
|
|
const result = modules.isContextLimitStop(context);
|
|
assert.equal(result, true, `Expected context limit for ${JSON.stringify(context)}`);
|
|
}
|
|
});
|
|
|
|
it('INT-DETECT-2: Should detect user abort', async () => {
|
|
const userAbortCases = [
|
|
{ user_requested: true },
|
|
{ user_requested: true, stop_reason: 'cancel' },
|
|
{ stop_reason: 'user_cancel' }
|
|
];
|
|
|
|
for (const context of userAbortCases) {
|
|
const result = modules.isUserAbort(context);
|
|
assert.equal(result, true, `Expected user abort for ${JSON.stringify(context)}`);
|
|
}
|
|
});
|
|
|
|
it('INT-DETECT-3: Should not false positive on normal stops', async () => {
|
|
const normalCases = [
|
|
{},
|
|
{ stop_reason: 'normal' },
|
|
{ stop_reason: 'tool_use' },
|
|
{ active_workflow: true }
|
|
];
|
|
|
|
for (const context of normalCases) {
|
|
const isContextLimit = modules.isContextLimitStop(context);
|
|
const isUserAbort = modules.isUserAbort(context);
|
|
assert.equal(isContextLimit, false, `Should not detect context limit for ${JSON.stringify(context)}`);
|
|
assert.equal(isUserAbort, false, `Should not detect user abort for ${JSON.stringify(context)}`);
|
|
}
|
|
});
|
|
});
|
|
});
|