mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Add comprehensive tests for keyword detection, session state management, and user abort detection
- 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.
This commit is contained in:
148
ccw/tests/context-limit-detector.test.ts
Normal file
148
ccw/tests/context-limit-detector.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Tests for ContextLimitDetector
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isContextLimitStop,
|
||||
getMatchingContextPattern,
|
||||
getAllMatchingContextPatterns,
|
||||
CONTEXT_LIMIT_PATTERNS
|
||||
} from '../src/core/hooks/context-limit-detector.js';
|
||||
import type { StopContext } from '../src/core/hooks/context-limit-detector.js';
|
||||
|
||||
describe('isContextLimitStop', () => {
|
||||
it('should return false for undefined context', () => {
|
||||
expect(isContextLimitStop(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect context_limit pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'context_limit' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect context_window pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'context_window_exceeded' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect token_limit pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'token_limit_reached' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect max_tokens pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'max_tokens' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect conversation_too_long pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'conversation_too_long' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect pattern in end_turn_reason', () => {
|
||||
const context: StopContext = { end_turn_reason: 'context_exceeded' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect pattern in camelCase endTurnReason', () => {
|
||||
const context: StopContext = { endTurnReason: 'context_full' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect pattern in camelCase stopReason', () => {
|
||||
const context: StopContext = { stopReason: 'input_too_long' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const context: StopContext = { stop_reason: 'CONTEXT_LIMIT' };
|
||||
expect(isContextLimitStop(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-context-limit reasons', () => {
|
||||
const context: StopContext = { stop_reason: 'end_turn' };
|
||||
expect(isContextLimitStop(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty reasons', () => {
|
||||
const context: StopContext = { stop_reason: '' };
|
||||
expect(isContextLimitStop(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unrelated patterns', () => {
|
||||
const contexts: StopContext[] = [
|
||||
{ stop_reason: 'user_cancel' },
|
||||
{ stop_reason: 'max_response_time' },
|
||||
{ stop_reason: 'complete' }
|
||||
];
|
||||
|
||||
contexts.forEach(context => {
|
||||
expect(isContextLimitStop(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingContextPattern', () => {
|
||||
it('should return null for undefined context', () => {
|
||||
expect(getMatchingContextPattern(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the matching pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'context_limit_reached' };
|
||||
expect(getMatchingContextPattern(context)).toBe('context_limit');
|
||||
});
|
||||
|
||||
it('should return the first matching pattern when multiple match', () => {
|
||||
const context: StopContext = { stop_reason: 'context_window_token_limit' };
|
||||
// 'context_window' should match first (appears earlier in array)
|
||||
const pattern = getMatchingContextPattern(context);
|
||||
expect(pattern).toBe('context_window');
|
||||
});
|
||||
|
||||
it('should return null when no pattern matches', () => {
|
||||
const context: StopContext = { stop_reason: 'some_other_reason' };
|
||||
expect(getMatchingContextPattern(context)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllMatchingContextPatterns', () => {
|
||||
it('should return empty array for undefined context', () => {
|
||||
expect(getAllMatchingContextPatterns(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all matching patterns', () => {
|
||||
const context: StopContext = {
|
||||
stop_reason: 'context_window_token_limit',
|
||||
end_turn_reason: 'max_tokens_exceeded'
|
||||
};
|
||||
const patterns = getAllMatchingContextPatterns(context);
|
||||
|
||||
expect(patterns).toContain('context_window');
|
||||
expect(patterns).toContain('token_limit');
|
||||
expect(patterns).toContain('max_tokens');
|
||||
expect(patterns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should return empty array when no patterns match', () => {
|
||||
const context: StopContext = { stop_reason: 'complete' };
|
||||
expect(getAllMatchingContextPatterns(context)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTEXT_LIMIT_PATTERNS', () => {
|
||||
it('should contain expected patterns', () => {
|
||||
expect(CONTEXT_LIMIT_PATTERNS).toContain('context_limit');
|
||||
expect(CONTEXT_LIMIT_PATTERNS).toContain('context_window');
|
||||
expect(CONTEXT_LIMIT_PATTERNS).toContain('token_limit');
|
||||
expect(CONTEXT_LIMIT_PATTERNS).toContain('max_tokens');
|
||||
expect(CONTEXT_LIMIT_PATTERNS).toContain('conversation_too_long');
|
||||
});
|
||||
|
||||
it('should be readonly array', () => {
|
||||
// TypeScript enforces this at compile time
|
||||
// This test just verifies the constant exists
|
||||
expect(Array.isArray(CONTEXT_LIMIT_PATTERNS)).toBe(true);
|
||||
});
|
||||
});
|
||||
801
ccw/tests/integration/hooks-integration.test.ts
Normal file
801
ccw/tests/integration/hooks-integration.test.ts
Normal file
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* 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)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
307
ccw/tests/keyword-detector.test.ts
Normal file
307
ccw/tests/keyword-detector.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Tests for KeywordDetector
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectKeywords,
|
||||
hasKeyword,
|
||||
getAllKeywords,
|
||||
getPrimaryKeyword,
|
||||
getKeywordType,
|
||||
hasKeywordType,
|
||||
sanitizeText,
|
||||
removeCodeBlocks,
|
||||
KEYWORD_PATTERNS,
|
||||
KEYWORD_PRIORITY
|
||||
} from '../src/core/hooks/keyword-detector.js';
|
||||
import type { KeywordType, DetectedKeyword } from '../src/core/hooks/keyword-detector.js';
|
||||
|
||||
describe('removeCodeBlocks', () => {
|
||||
it('should remove fenced code blocks with ```', () => {
|
||||
const text = 'Hello ```code here``` world';
|
||||
expect(removeCodeBlocks(text)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should remove fenced code blocks with ~~~', () => {
|
||||
const text = 'Hello ~~~code here~~~ world';
|
||||
expect(removeCodeBlocks(text)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should remove inline code with backticks', () => {
|
||||
const text = 'Use the `forEach` method';
|
||||
expect(removeCodeBlocks(text)).toBe('Use the method');
|
||||
});
|
||||
|
||||
it('should handle multiline code blocks', () => {
|
||||
const text = 'Start\n```\nline1\nline2\n```\nEnd';
|
||||
expect(removeCodeBlocks(text)).toBe('Start\n\nEnd');
|
||||
});
|
||||
|
||||
it('should handle multiple code blocks', () => {
|
||||
const text = 'Use `a` and `b` and ```c``` too';
|
||||
expect(removeCodeBlocks(text)).toBe('Use and and too');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeText', () => {
|
||||
it('should remove XML tag blocks', () => {
|
||||
const text = 'Hello <tag>content</tag> world';
|
||||
expect(sanitizeText(text)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should remove self-closing XML tags', () => {
|
||||
const text = 'Hello <br/> world';
|
||||
expect(sanitizeText(text)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should remove URLs', () => {
|
||||
const text = 'Visit https://example.com for more';
|
||||
expect(sanitizeText(text)).toBe('Visit for more');
|
||||
});
|
||||
|
||||
it('should remove file paths with ./', () => {
|
||||
const text = 'Check ./src/file.ts for details';
|
||||
expect(sanitizeText(text)).toBe('Check for details');
|
||||
});
|
||||
|
||||
it('should remove file paths with /', () => {
|
||||
const text = 'Edit /home/user/file.ts';
|
||||
expect(sanitizeText(text)).toBe('Edit ');
|
||||
});
|
||||
|
||||
it('should remove code blocks', () => {
|
||||
const text = 'See ```code``` below';
|
||||
expect(sanitizeText(text)).toBe('See below');
|
||||
});
|
||||
|
||||
it('should handle complex text', () => {
|
||||
const text = 'Use <config>api_key</config> from https://example.com and check ./config.ts';
|
||||
const sanitized = sanitizeText(text);
|
||||
expect(sanitized).not.toContain('<config>');
|
||||
expect(sanitized).not.toContain('https://');
|
||||
expect(sanitized).not.toContain('./config.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectKeywords', () => {
|
||||
describe('basic detection', () => {
|
||||
it('should detect "autopilot" keyword', () => {
|
||||
const keywords = detectKeywords('use autopilot mode');
|
||||
expect(keywords.some(k => k.type === 'autopilot')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "ultrawork" keyword', () => {
|
||||
const keywords = detectKeywords('run ultrawork now');
|
||||
expect(keywords.some(k => k.type === 'ultrawork')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "ultrawork" alias "ulw"', () => {
|
||||
const keywords = detectKeywords('use ulw for this');
|
||||
expect(keywords.some(k => k.type === 'ultrawork')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "plan this" keyword', () => {
|
||||
const keywords = detectKeywords('please plan this feature');
|
||||
expect(keywords.some(k => k.type === 'plan')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "tdd" keyword', () => {
|
||||
const keywords = detectKeywords('use tdd approach');
|
||||
expect(keywords.some(k => k.type === 'tdd')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "ultrathink" keyword', () => {
|
||||
const keywords = detectKeywords('ultrathink about this');
|
||||
expect(keywords.some(k => k.type === 'ultrathink')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "deepsearch" keyword', () => {
|
||||
const keywords = detectKeywords('deepsearch for the answer');
|
||||
expect(keywords.some(k => k.type === 'deepsearch')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel keyword', () => {
|
||||
it('should detect "cancelomc" keyword', () => {
|
||||
const keywords = detectKeywords('cancelomc');
|
||||
expect(keywords.some(k => k.type === 'cancel')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "stopomc" keyword', () => {
|
||||
const keywords = detectKeywords('stopomc');
|
||||
expect(keywords.some(k => k.type === 'cancel')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegation keywords', () => {
|
||||
it('should detect "ask codex" keyword', () => {
|
||||
const keywords = detectKeywords('ask codex to help');
|
||||
expect(keywords.some(k => k.type === 'codex')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "use gemini" keyword', () => {
|
||||
const keywords = detectKeywords('use gemini for this');
|
||||
expect(keywords.some(k => k.type === 'gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "delegate to gpt" keyword', () => {
|
||||
const keywords = detectKeywords('delegate to gpt');
|
||||
expect(keywords.some(k => k.type === 'codex')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('case insensitivity', () => {
|
||||
it('should be case-insensitive', () => {
|
||||
const keywords = detectKeywords('AUTOPILOT and ULTRAWORK');
|
||||
expect(keywords.some(k => k.type === 'autopilot')).toBe(true);
|
||||
expect(keywords.some(k => k.type === 'ultrawork')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('team feature flag', () => {
|
||||
it('should skip team keywords when teamEnabled is false', () => {
|
||||
const keywords = detectKeywords('use team mode', { teamEnabled: false });
|
||||
expect(keywords.some(k => k.type === 'team')).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect team keywords when teamEnabled is true', () => {
|
||||
const keywords = detectKeywords('use team mode', { teamEnabled: true });
|
||||
expect(keywords.some(k => k.type === 'team')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('code block filtering', () => {
|
||||
it('should not detect keywords in code blocks', () => {
|
||||
const text = 'Do this:\n```\nautopilot\n```\nnot in code';
|
||||
const keywords = detectKeywords(text);
|
||||
expect(keywords.some(k => k.type === 'autopilot')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect keywords in inline code', () => {
|
||||
const text = 'Use the `autopilot` function';
|
||||
const keywords = detectKeywords(text);
|
||||
expect(keywords.some(k => k.type === 'autopilot')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returned metadata', () => {
|
||||
it('should include keyword position', () => {
|
||||
const keywords = detectKeywords('please use autopilot now');
|
||||
const autopilot = keywords.find(k => k.type === 'autopilot');
|
||||
expect(autopilot?.position).toBe(11); // 'autopilot' starts at position 11
|
||||
});
|
||||
|
||||
it('should include matched keyword string', () => {
|
||||
const keywords = detectKeywords('use AUTOPILOT');
|
||||
const autopilot = keywords.find(k => k.type === 'autopilot');
|
||||
expect(autopilot?.keyword).toBe('AUTOPILOT');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasKeyword', () => {
|
||||
it('should return true when keyword is present', () => {
|
||||
expect(hasKeyword('use autopilot')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no keyword is present', () => {
|
||||
expect(hasKeyword('hello world')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllKeywords', () => {
|
||||
describe('conflict resolution', () => {
|
||||
it('should return only "cancel" when cancel is present', () => {
|
||||
const types = getAllKeywords('cancelomc and autopilot');
|
||||
expect(types).toEqual(['cancel']);
|
||||
});
|
||||
|
||||
it('should remove "autopilot" when "team" is present', () => {
|
||||
const types = getAllKeywords('use autopilot and team mode', { teamEnabled: true });
|
||||
expect(types).not.toContain('autopilot');
|
||||
expect(types).toContain('team');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no keywords', () => {
|
||||
expect(getAllKeywords('hello world')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return keywords in priority order', () => {
|
||||
const types = getAllKeywords('use plan this and tdd');
|
||||
// 'plan' has priority 9, 'tdd' has priority 10
|
||||
expect(types).toEqual(['plan', 'tdd']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrimaryKeyword', () => {
|
||||
it('should return null when no keywords', () => {
|
||||
expect(getPrimaryKeyword('hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return highest priority keyword', () => {
|
||||
const keyword = getPrimaryKeyword('use plan this and tdd');
|
||||
expect(keyword?.type).toBe('plan');
|
||||
});
|
||||
|
||||
it('should return cancel as primary when present', () => {
|
||||
const keyword = getPrimaryKeyword('use autopilot and cancelomc');
|
||||
expect(keyword?.type).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should include original keyword metadata', () => {
|
||||
const keyword = getPrimaryKeyword('USE AUTOPILOT');
|
||||
expect(keyword?.keyword).toBe('AUTOPILOT');
|
||||
expect(keyword?.position).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getKeywordType', () => {
|
||||
it('should return type for valid keyword', () => {
|
||||
expect(getKeywordType('autopilot')).toBe('autopilot');
|
||||
});
|
||||
|
||||
it('should return null for invalid keyword', () => {
|
||||
expect(getKeywordType('notakeyword')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasKeywordType', () => {
|
||||
it('should return true for present keyword type', () => {
|
||||
expect(hasKeywordType('use autopilot', 'autopilot')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for absent keyword type', () => {
|
||||
expect(hasKeywordType('hello world', 'autopilot')).toBe(false);
|
||||
});
|
||||
|
||||
it('should sanitize text before checking', () => {
|
||||
expect(hasKeywordType('use `autopilot` in code', 'autopilot')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KEYWORD_PRIORITY', () => {
|
||||
it('should have cancel as highest priority', () => {
|
||||
expect(KEYWORD_PRIORITY[0]).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should have gemini as lowest priority', () => {
|
||||
expect(KEYWORD_PRIORITY[KEYWORD_PRIORITY.length - 1]).toBe('gemini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('KEYWORD_PATTERNS', () => {
|
||||
it('should have patterns for all keyword types', () => {
|
||||
const types: KeywordType[] = [
|
||||
'cancel', 'ralph', 'autopilot', 'ultrapilot', 'team', 'ultrawork',
|
||||
'swarm', 'pipeline', 'ralplan', 'plan', 'tdd',
|
||||
'ultrathink', 'deepsearch', 'analyze', 'codex', 'gemini'
|
||||
];
|
||||
|
||||
types.forEach(type => {
|
||||
expect(KEYWORD_PATTERNS[type]).toBeDefined();
|
||||
expect(KEYWORD_PATTERNS[type] instanceof RegExp).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
ccw/tests/session-state-service.test.ts
Normal file
268
ccw/tests/session-state-service.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Tests for SessionStateService
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
validateSessionId,
|
||||
getSessionStatePath,
|
||||
loadSessionState,
|
||||
saveSessionState,
|
||||
clearSessionState,
|
||||
updateSessionState,
|
||||
incrementSessionLoad,
|
||||
SessionStateService,
|
||||
type SessionState
|
||||
} from '../src/core/services/session-state-service.js';
|
||||
import { existsSync, rmSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
describe('validateSessionId', () => {
|
||||
it('should accept valid session IDs', () => {
|
||||
expect(validateSessionId('abc123')).toBe(true);
|
||||
expect(validateSessionId('session-123')).toBe(true);
|
||||
expect(validateSessionId('test_session')).toBe(true);
|
||||
expect(validateSessionId('a')).toBe(true);
|
||||
expect(validateSessionId('ABC-123_XYZ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid session IDs', () => {
|
||||
expect(validateSessionId('')).toBe(false);
|
||||
expect(validateSessionId('.hidden')).toBe(false);
|
||||
expect(validateSessionId('-starts-with-dash')).toBe(false);
|
||||
expect(validateSessionId('../../../etc')).toBe(false);
|
||||
expect(validateSessionId('has spaces')).toBe(false);
|
||||
expect(validateSessionId('has/slash')).toBe(false);
|
||||
expect(validateSessionId('has\\backslash')).toBe(false);
|
||||
expect(validateSessionId('has.dot')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string inputs', () => {
|
||||
expect(validateSessionId(null as any)).toBe(false);
|
||||
expect(validateSessionId(undefined as any)).toBe(false);
|
||||
expect(validateSessionId(123 as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionStatePath', () => {
|
||||
const testSessionId = 'test-session-123';
|
||||
|
||||
describe('global storage (default)', () => {
|
||||
it('should return path in global state directory', () => {
|
||||
const path = getSessionStatePath(testSessionId);
|
||||
expect(path).toContain('.claude');
|
||||
expect(path).toContain('.ccw-sessions');
|
||||
expect(path).toContain(`session-${testSessionId}.json`);
|
||||
});
|
||||
|
||||
it('should throw error for invalid session ID', () => {
|
||||
expect(() => getSessionStatePath('../../../etc')).toThrow('Invalid session ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-scoped storage', () => {
|
||||
const projectPath = '/tmp/test-project';
|
||||
|
||||
it('should return path in project session directory', () => {
|
||||
const path = getSessionStatePath(testSessionId, {
|
||||
storageType: 'session-scoped',
|
||||
projectPath
|
||||
});
|
||||
expect(path).toContain('.workflow');
|
||||
expect(path).toContain('sessions');
|
||||
expect(path).toContain(testSessionId);
|
||||
expect(path).toContain('state.json');
|
||||
});
|
||||
|
||||
it('should throw error when projectPath is missing', () => {
|
||||
expect(() => getSessionStatePath(testSessionId, { storageType: 'session-scoped' }))
|
||||
.toThrow('projectPath is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSessionState / saveSessionState', () => {
|
||||
const testSessionId = 'test-load-save-session';
|
||||
const testState: SessionState = {
|
||||
firstLoad: '2025-01-01T00:00:00.000Z',
|
||||
loadCount: 5,
|
||||
lastPrompt: 'test prompt'
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup
|
||||
try {
|
||||
clearSessionState(testSessionId);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for non-existent session', () => {
|
||||
const state = loadSessionState('non-existent-session-xyz');
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
|
||||
it('should save and load session state', () => {
|
||||
saveSessionState(testSessionId, testState);
|
||||
const loaded = loadSessionState(testSessionId);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.firstLoad).toBe(testState.firstLoad);
|
||||
expect(loaded!.loadCount).toBe(testState.loadCount);
|
||||
expect(loaded!.lastPrompt).toBe(testState.lastPrompt);
|
||||
});
|
||||
|
||||
it('should return null for invalid session ID', () => {
|
||||
expect(loadSessionState('../../../etc')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle state without optional fields', () => {
|
||||
const minimalState: SessionState = {
|
||||
firstLoad: '2025-01-01T00:00:00.000Z',
|
||||
loadCount: 1
|
||||
};
|
||||
|
||||
saveSessionState(testSessionId, minimalState);
|
||||
const loaded = loadSessionState(testSessionId);
|
||||
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.lastPrompt).toBeUndefined();
|
||||
expect(loaded!.activeMode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSessionState', () => {
|
||||
const testSessionId = 'test-clear-session';
|
||||
|
||||
it('should clear existing session state', () => {
|
||||
saveSessionState(testSessionId, {
|
||||
firstLoad: new Date().toISOString(),
|
||||
loadCount: 1
|
||||
});
|
||||
|
||||
expect(loadSessionState(testSessionId)).not.toBeNull();
|
||||
|
||||
const result = clearSessionState(testSessionId);
|
||||
expect(result).toBe(true);
|
||||
expect(loadSessionState(testSessionId)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', () => {
|
||||
const result = clearSessionState('non-existent-session-xyz');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid session ID', () => {
|
||||
expect(clearSessionState('../../../etc')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSessionState', () => {
|
||||
const testSessionId = 'test-update-session';
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
clearSessionState(testSessionId);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should create new state if none exists', () => {
|
||||
const state = updateSessionState(testSessionId, { loadCount: 1 });
|
||||
|
||||
expect(state.firstLoad).toBeDefined();
|
||||
expect(state.loadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should merge updates with existing state', () => {
|
||||
saveSessionState(testSessionId, {
|
||||
firstLoad: '2025-01-01T00:00:00.000Z',
|
||||
loadCount: 5,
|
||||
lastPrompt: 'old prompt'
|
||||
});
|
||||
|
||||
const state = updateSessionState(testSessionId, {
|
||||
loadCount: 6,
|
||||
lastPrompt: 'new prompt'
|
||||
});
|
||||
|
||||
expect(state.firstLoad).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(state.loadCount).toBe(6);
|
||||
expect(state.lastPrompt).toBe('new prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementSessionLoad', () => {
|
||||
const testSessionId = 'test-increment-session';
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
clearSessionState(testSessionId);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should create new state on first load', () => {
|
||||
const result = incrementSessionLoad(testSessionId, 'first prompt');
|
||||
|
||||
expect(result.isFirstPrompt).toBe(true);
|
||||
expect(result.state.loadCount).toBe(1);
|
||||
expect(result.state.lastPrompt).toBe('first prompt');
|
||||
});
|
||||
|
||||
it('should increment load count on subsequent loads', () => {
|
||||
incrementSessionLoad(testSessionId, 'first prompt');
|
||||
const result = incrementSessionLoad(testSessionId, 'second prompt');
|
||||
|
||||
expect(result.isFirstPrompt).toBe(false);
|
||||
expect(result.state.loadCount).toBe(2);
|
||||
expect(result.state.lastPrompt).toBe('second prompt');
|
||||
});
|
||||
|
||||
it('should preserve prompt when not provided', () => {
|
||||
incrementSessionLoad(testSessionId, 'first prompt');
|
||||
const result = incrementSessionLoad(testSessionId);
|
||||
|
||||
expect(result.state.lastPrompt).toBe('first prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionStateService class', () => {
|
||||
const testSessionId = 'test-service-class-session';
|
||||
let service: SessionStateService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new SessionStateService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
service.clear(testSessionId);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should provide object-oriented interface', () => {
|
||||
const result = service.incrementLoad(testSessionId, 'test prompt');
|
||||
|
||||
expect(result.isFirstPrompt).toBe(true);
|
||||
expect(service.getLoadCount(testSessionId)).toBe(1);
|
||||
expect(service.isFirstLoad(testSessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support update method', () => {
|
||||
service.save(testSessionId, {
|
||||
firstLoad: new Date().toISOString(),
|
||||
loadCount: 1
|
||||
});
|
||||
|
||||
const state = service.update(testSessionId, { activeMode: 'write' });
|
||||
expect(state.activeMode).toBe('write');
|
||||
expect(state.loadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
228
ccw/tests/user-abort-detector.test.ts
Normal file
228
ccw/tests/user-abort-detector.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Tests for UserAbortDetector
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isUserAbort,
|
||||
getMatchingAbortPattern,
|
||||
getAllMatchingAbortPatterns,
|
||||
shouldAllowContinuation,
|
||||
USER_ABORT_EXACT_PATTERNS,
|
||||
USER_ABORT_SUBSTRING_PATTERNS
|
||||
} from '../src/core/hooks/user-abort-detector.js';
|
||||
import type { StopContext } from '../src/core/hooks/context-limit-detector.js';
|
||||
|
||||
describe('isUserAbort', () => {
|
||||
describe('user_requested flag', () => {
|
||||
it('should detect user_requested true (snake_case)', () => {
|
||||
const context: StopContext = { user_requested: true };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect userRequested true (camelCase)', () => {
|
||||
const context: StopContext = { userRequested: true };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not treat user_requested false as abort', () => {
|
||||
const context: StopContext = { user_requested: false };
|
||||
expect(isUserAbort(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exact patterns', () => {
|
||||
it('should detect "aborted" exactly', () => {
|
||||
const context: StopContext = { stop_reason: 'aborted' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "abort" exactly', () => {
|
||||
const context: StopContext = { stop_reason: 'abort' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "cancel" exactly', () => {
|
||||
const context: StopContext = { stop_reason: 'cancel' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "interrupt" exactly', () => {
|
||||
const context: StopContext = { stop_reason: 'interrupt' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect exact patterns as substrings', () => {
|
||||
// This tests that "cancel" doesn't match "cancelled_order"
|
||||
const context: StopContext = { stop_reason: 'cancelled_order' };
|
||||
expect(isUserAbort(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect "abort" in "aborted_request"', () => {
|
||||
// This tests exact match behavior
|
||||
const context: StopContext = { stop_reason: 'aborted_request' };
|
||||
expect(isUserAbort(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('substring patterns', () => {
|
||||
it('should detect "user_cancel"', () => {
|
||||
const context: StopContext = { stop_reason: 'user_cancel' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "user_interrupt"', () => {
|
||||
const context: StopContext = { stop_reason: 'user_interrupt' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "ctrl_c"', () => {
|
||||
const context: StopContext = { stop_reason: 'ctrl_c' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "manual_stop"', () => {
|
||||
const context: StopContext = { stop_reason: 'manual_stop' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect substring patterns within larger strings', () => {
|
||||
const context: StopContext = { stop_reason: 'user_cancelled_by_client' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('camelCase support', () => {
|
||||
it('should detect patterns in stopReason (camelCase)', () => {
|
||||
const context: StopContext = { stopReason: 'user_cancel' };
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('case insensitivity', () => {
|
||||
it('should be case-insensitive', () => {
|
||||
const contexts: StopContext[] = [
|
||||
{ stop_reason: 'ABORTED' },
|
||||
{ stop_reason: 'Abort' },
|
||||
{ stop_reason: 'USER_CANCEL' },
|
||||
{ stop_reason: 'User_Interrupt' }
|
||||
];
|
||||
|
||||
contexts.forEach(context => {
|
||||
expect(isUserAbort(context)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-abort cases', () => {
|
||||
it('should return false for undefined context', () => {
|
||||
expect(isUserAbort(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty reason', () => {
|
||||
const context: StopContext = { stop_reason: '' };
|
||||
expect(isUserAbort(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-abort reasons', () => {
|
||||
const contexts: StopContext[] = [
|
||||
{ stop_reason: 'end_turn' },
|
||||
{ stop_reason: 'complete' },
|
||||
{ stop_reason: 'context_limit' },
|
||||
{ stop_reason: 'max_tokens' }
|
||||
];
|
||||
|
||||
contexts.forEach(context => {
|
||||
expect(isUserAbort(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingAbortPattern', () => {
|
||||
it('should return null for undefined context', () => {
|
||||
expect(getMatchingAbortPattern(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return "user_requested" for user_requested flag', () => {
|
||||
const context: StopContext = { user_requested: true };
|
||||
expect(getMatchingAbortPattern(context)).toBe('user_requested');
|
||||
});
|
||||
|
||||
it('should return the exact matching pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'cancel' };
|
||||
expect(getMatchingAbortPattern(context)).toBe('cancel');
|
||||
});
|
||||
|
||||
it('should return the substring matching pattern', () => {
|
||||
const context: StopContext = { stop_reason: 'user_cancel' };
|
||||
expect(getMatchingAbortPattern(context)).toBe('user_cancel');
|
||||
});
|
||||
|
||||
it('should return null when no pattern matches', () => {
|
||||
const context: StopContext = { stop_reason: 'complete' };
|
||||
expect(getMatchingAbortPattern(context)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllMatchingAbortPatterns', () => {
|
||||
it('should return empty array for undefined context', () => {
|
||||
expect(getAllMatchingAbortPatterns(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all matching patterns', () => {
|
||||
const context: StopContext = {
|
||||
user_requested: true,
|
||||
stop_reason: 'user_cancel'
|
||||
};
|
||||
const patterns = getAllMatchingAbortPatterns(context);
|
||||
|
||||
expect(patterns).toContain('user_requested');
|
||||
expect(patterns).toContain('user_cancel');
|
||||
});
|
||||
|
||||
it('should deduplicate patterns', () => {
|
||||
const context: StopContext = { stop_reason: 'cancel' };
|
||||
const patterns = getAllMatchingAbortPatterns(context);
|
||||
|
||||
// Should only have one 'cancel' entry
|
||||
expect(patterns.filter(p => p === 'cancel')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAllowContinuation', () => {
|
||||
it('should return true for undefined context', () => {
|
||||
expect(shouldAllowContinuation(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for non-abort context', () => {
|
||||
const context: StopContext = { stop_reason: 'complete' };
|
||||
expect(shouldAllowContinuation(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for user abort', () => {
|
||||
const context: StopContext = { user_requested: true };
|
||||
expect(shouldAllowContinuation(context)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for cancel reason', () => {
|
||||
const context: StopContext = { stop_reason: 'cancel' };
|
||||
expect(shouldAllowContinuation(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern exports', () => {
|
||||
it('should export exact patterns', () => {
|
||||
expect(USER_ABORT_EXACT_PATTERNS).toContain('aborted');
|
||||
expect(USER_ABORT_EXACT_PATTERNS).toContain('abort');
|
||||
expect(USER_ABORT_EXACT_PATTERNS).toContain('cancel');
|
||||
expect(USER_ABORT_EXACT_PATTERNS).toContain('interrupt');
|
||||
});
|
||||
|
||||
it('should export substring patterns', () => {
|
||||
expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('user_cancel');
|
||||
expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('user_interrupt');
|
||||
expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('ctrl_c');
|
||||
expect(USER_ABORT_SUBSTRING_PATTERNS).toContain('manual_stop');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user