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:
catlog22
2026-02-18 21:48:56 +08:00
parent 65762af254
commit 46d4b4edfd
23 changed files with 6992 additions and 329 deletions

View File

@@ -4,15 +4,13 @@
*/
import chalk from 'chalk';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync } from 'fs';
interface HookOptions {
stdin?: boolean;
sessionId?: string;
prompt?: string;
type?: 'session-start' | 'context' | 'session-end';
type?: 'session-start' | 'context' | 'session-end' | 'stop' | 'pre-compact';
path?: string;
}
@@ -21,12 +19,18 @@ interface HookData {
prompt?: string;
cwd?: string;
tool_input?: Record<string, unknown>;
}
interface SessionState {
firstLoad: string;
loadCount: number;
lastPrompt?: string;
user_prompt?: string; // For UserPromptSubmit hook
// Stop context fields
stop_reason?: string;
stopReason?: string;
end_turn_reason?: string;
endTurnReason?: string;
user_requested?: boolean;
userRequested?: boolean;
active_mode?: 'analysis' | 'write' | 'review' | 'auto';
activeMode?: 'analysis' | 'write' | 'review' | 'auto';
active_workflow?: boolean;
activeWorkflow?: boolean;
}
/**
@@ -52,42 +56,6 @@ async function readStdin(): Promise<string> {
});
}
/**
* Get session state file path
* Uses ~/.claude/.ccw-sessions/ for reliable persistence across sessions
*/
function getSessionStateFile(sessionId: string): string {
const stateDir = join(homedir(), '.claude', '.ccw-sessions');
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
return join(stateDir, `session-${sessionId}.json`);
}
/**
* Load session state from file
*/
function loadSessionState(sessionId: string): SessionState | null {
const stateFile = getSessionStateFile(sessionId);
if (!existsSync(stateFile)) {
return null;
}
try {
const content = readFileSync(stateFile, 'utf-8');
return JSON.parse(content) as SessionState;
} catch {
return null;
}
}
/**
* Save session state to file
*/
function saveSessionState(sessionId: string, state: SessionState): void {
const stateFile = getSessionStateFile(sessionId);
writeFileSync(stateFile, JSON.stringify(state, null, 2));
}
/**
* Get project path from hook data or current working directory
*/
@@ -95,27 +63,10 @@ function getProjectPath(hookCwd?: string): string {
return hookCwd || process.cwd();
}
/**
* Check if UnifiedContextBuilder is available (embedder dependencies present).
* Returns the builder instance or null if not available.
*/
async function tryCreateContextBuilder(projectPath: string): Promise<any | null> {
try {
const { isUnifiedEmbedderAvailable } = await import('../core/unified-vector-index.js');
if (!isUnifiedEmbedderAvailable()) {
return null;
}
const { UnifiedContextBuilder } = await import('../core/unified-context-builder.js');
return new UnifiedContextBuilder(projectPath);
} catch {
return null;
}
}
/**
* Session context action - provides progressive context loading
*
* Uses UnifiedContextBuilder when available (embedder present):
* Uses HookContextService for unified context generation:
* - session-start: MEMORY.md summary + clusters + hot entities + patterns
* - per-prompt: vector search across all memory categories
*
@@ -153,71 +104,59 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
try {
const projectPath = getProjectPath(hookCwd);
// Load existing session state
const existingState = loadSessionState(sessionId);
const isFirstPrompt = !existingState;
// Check for recovery on session-start
const isFirstPrompt = !prompt || prompt.trim() === '';
let recoveryMessage = '';
// Update session state
const newState: SessionState = isFirstPrompt
? {
firstLoad: new Date().toISOString(),
loadCount: 1,
lastPrompt: prompt
if (isFirstPrompt && sessionId) {
try {
const { RecoveryHandler } = await import('../core/hooks/recovery-handler.js');
const recoveryHandler = new RecoveryHandler({
projectPath,
enableLogging: !stdin
});
const checkpoint = await recoveryHandler.checkRecovery(sessionId);
if (checkpoint) {
recoveryMessage = await recoveryHandler.formatRecoveryMessage(checkpoint);
if (!stdin) {
console.log(chalk.yellow('Recovery checkpoint found!'));
}
}
} catch (recoveryError) {
// Recovery check failure should not affect session start
if (!stdin) {
console.log(chalk.yellow(`Recovery check warning: ${(recoveryError as Error).message}`));
}
: {
...existingState,
loadCount: existingState.loadCount + 1,
lastPrompt: prompt
};
saveSessionState(sessionId, newState);
// Determine context type and generate content
let contextType: 'session-start' | 'context';
let content = '';
// Try UnifiedContextBuilder first; fall back to getProgressiveIndex
const contextBuilder = await tryCreateContextBuilder(projectPath);
if (contextBuilder) {
// Use UnifiedContextBuilder
if (isFirstPrompt) {
contextType = 'session-start';
content = await contextBuilder.buildSessionStartContext();
} else if (prompt && prompt.trim().length > 0) {
contextType = 'context';
content = await contextBuilder.buildPromptContext(prompt);
} else {
contextType = 'context';
content = '';
}
} else {
// Fallback: use legacy SessionClusteringService.getProgressiveIndex()
const { SessionClusteringService } = await import('../core/session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
if (isFirstPrompt) {
contextType = 'session-start';
content = await clusteringService.getProgressiveIndex({
type: 'session-start',
sessionId
});
} else if (prompt && prompt.trim().length > 0) {
contextType = 'context';
content = await clusteringService.getProgressiveIndex({
type: 'context',
sessionId,
prompt
});
} else {
contextType = 'context';
content = '';
}
}
// Use HookContextService for unified context generation
const { HookContextService } = await import('../core/services/hook-context-service.js');
const contextService = new HookContextService({ projectPath });
// Build context using the service
const result = await contextService.buildPromptContext({
sessionId,
prompt,
projectId: projectPath
});
const content = result.content;
const contextType = result.type;
const loadCount = result.state.loadCount;
const isAdvanced = await contextService.isAdvancedContextAvailable();
if (stdin) {
// For hooks: output content directly to stdout
if (content) {
// Include recovery message if available
if (recoveryMessage) {
process.stdout.write(recoveryMessage);
if (content) {
process.stdout.write('\n\n');
process.stdout.write(content);
}
} else if (content) {
process.stdout.write(content);
}
process.exit(0);
@@ -229,9 +168,17 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
console.log(chalk.cyan('Session ID:'), sessionId);
console.log(chalk.cyan('Type:'), contextType);
console.log(chalk.cyan('First Prompt:'), isFirstPrompt ? 'Yes' : 'No');
console.log(chalk.cyan('Load Count:'), newState.loadCount);
console.log(chalk.cyan('Builder:'), contextBuilder ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)');
console.log(chalk.cyan('Load Count:'), loadCount);
console.log(chalk.cyan('Builder:'), isAdvanced ? 'UnifiedContextBuilder' : 'Legacy (getProgressiveIndex)');
if (recoveryMessage) {
console.log(chalk.cyan('Recovery:'), 'Checkpoint found');
}
console.log(chalk.gray('─'.repeat(40)));
if (recoveryMessage) {
console.log(chalk.yellow('Recovery Message:'));
console.log(recoveryMessage);
console.log();
}
if (content) {
console.log(content);
} else {
@@ -250,10 +197,10 @@ async function sessionContextAction(options: HookOptions): Promise<void> {
/**
* Session end action - triggers async background tasks for memory maintenance.
*
* Tasks executed:
* 1. Incremental vector embedding (index new/updated content)
* 2. Incremental clustering (cluster unclustered sessions)
* 3. Heat score updates (recalculate entity heat scores)
* Uses SessionEndService for unified task management:
* - Incremental vector embedding (index new/updated content)
* - Incremental clustering (cluster unclustered sessions)
* - Heat score updates (recalculate entity heat scores)
*
* All tasks run best-effort; failures are logged but do not affect exit code.
*/
@@ -283,33 +230,58 @@ async function sessionEndAction(options: HookOptions): Promise<void> {
try {
const projectPath = getProjectPath(hookCwd);
const contextBuilder = await tryCreateContextBuilder(projectPath);
if (!contextBuilder) {
// UnifiedContextBuilder not available - skip session-end tasks
// Clean up mode states for this session
try {
const { ModeRegistryService } = await import('../core/services/mode-registry-service.js');
const modeRegistry = new ModeRegistryService({
projectPath,
enableLogging: !stdin
});
// Get active modes for this session and deactivate them
const activeModes = modeRegistry.getActiveModes(sessionId);
for (const mode of activeModes) {
modeRegistry.deactivateMode(mode, sessionId);
if (!stdin) {
console.log(chalk.gray(` Deactivated mode: ${mode}`));
}
}
} catch (modeError) {
// Mode cleanup failure should not affect session end
if (!stdin) {
console.log(chalk.gray('(UnifiedContextBuilder not available, skipping session-end tasks)'));
console.log(chalk.yellow(` Mode cleanup warning: ${(modeError as Error).message}`));
}
}
// Use SessionEndService for unified task management
const { createSessionEndService } = await import('../core/services/session-end-service.js');
const sessionEndService = await createSessionEndService(projectPath, sessionId, !stdin);
const registeredTasks = sessionEndService.getRegisteredTasks();
if (registeredTasks.length === 0) {
// No tasks available - skip session-end tasks
if (!stdin) {
console.log(chalk.gray('(No session-end tasks available)'));
}
process.exit(0);
}
const tasks: Array<{ name: string; execute: () => Promise<void> }> = contextBuilder.buildSessionEndTasks(sessionId);
if (!stdin) {
console.log(chalk.green(`Session End: executing ${tasks.length} background tasks...`));
console.log(chalk.green(`Session End: executing ${registeredTasks.length} background tasks...`));
}
// Execute all tasks concurrently (best-effort)
const results = await Promise.allSettled(
tasks.map((task: { name: string; execute: () => Promise<void> }) => task.execute())
);
// Execute all tasks
const summary = await sessionEndService.executeEndTasks(sessionId);
if (!stdin) {
for (let i = 0; i < tasks.length; i++) {
const status = results[i].status === 'fulfilled' ? 'OK' : 'FAIL';
const color = status === 'OK' ? chalk.green : chalk.yellow;
console.log(color(` [${status}] ${tasks[i].name}`));
for (const result of summary.results) {
const status = result.success ? 'OK' : 'FAIL';
const color = result.success ? chalk.green : chalk.yellow;
console.log(color(` [${status}] ${result.type} (${result.duration}ms)`));
}
console.log(chalk.gray(`Total: ${summary.successful}/${summary.totalTasks} tasks completed in ${summary.totalDuration}ms`));
}
process.exit(0);
@@ -322,6 +294,105 @@ async function sessionEndAction(options: HookOptions): Promise<void> {
}
}
/**
* Stop action - handles Stop hook events with Soft Enforcement
*
* Uses StopHandler for priority-based stop handling:
* 1. context-limit: Always allow stop (deadlock prevention)
* 2. user-abort: Respect user intent
* 3. active-workflow: Inject continuation message
* 4. active-mode: Inject continuation message
*
* Returns { continue: true, message?: string } - never blocks stops.
*/
async function stopAction(options: HookOptions): Promise<void> {
let { stdin, sessionId } = options;
let hookCwd: string | undefined;
let hookData: HookData = {};
// If --stdin flag is set, read from stdin (Claude Code hook format)
if (stdin) {
try {
const stdinData = await readStdin();
if (stdinData) {
hookData = JSON.parse(stdinData) as HookData;
sessionId = hookData.session_id || sessionId;
hookCwd = hookData.cwd;
}
} catch {
// Silently continue if stdin parsing fails
}
}
try {
const projectPath = getProjectPath(hookCwd);
// Import StopHandler dynamically to avoid circular dependencies
const { StopHandler } = await import('../core/hooks/stop-handler.js');
const stopHandler = new StopHandler({
enableLogging: !stdin,
projectPath // Pass projectPath for ModeRegistryService integration
});
// Build stop context from hook data
const stopContext = {
session_id: sessionId,
sessionId: sessionId,
project_path: projectPath,
projectPath: projectPath,
stop_reason: hookData.stop_reason,
stopReason: hookData.stopReason,
end_turn_reason: hookData.end_turn_reason,
endTurnReason: hookData.endTurnReason,
user_requested: hookData.user_requested,
userRequested: hookData.userRequested,
active_mode: hookData.active_mode,
activeMode: hookData.activeMode,
active_workflow: hookData.active_workflow,
activeWorkflow: hookData.activeWorkflow
};
// Handle the stop event
const result = await stopHandler.handleStop(stopContext);
if (stdin) {
// For hooks: output JSON result to stdout
const output: { continue: true; message?: string } = {
continue: true
};
if (result.message) {
output.message = result.message;
}
process.stdout.write(JSON.stringify(output));
process.exit(0);
}
// Interactive mode: show detailed output
console.log(chalk.green('Stop Handler'));
console.log(chalk.gray('─'.repeat(40)));
console.log(chalk.cyan('Mode:'), result.mode || 'none');
console.log(chalk.cyan('Continue:'), result.continue);
if (result.message) {
console.log(chalk.yellow('Message:'));
console.log(result.message);
}
if (result.metadata) {
console.log(chalk.gray('─'.repeat(40)));
console.log(chalk.cyan('Metadata:'));
console.log(JSON.stringify(result.metadata, null, 2));
}
process.exit(0);
} catch (error) {
if (stdin) {
// Silent failure for hooks - always allow stop
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Parse CCW status.json and output formatted status
*/
@@ -375,6 +446,238 @@ async function parseStatusAction(options: HookOptions): Promise<void> {
}
}
/**
* Keyword detection and mode activation action
*
* Detects magic keywords in user prompts and activates corresponding modes.
* Called from UserPromptSubmit hook.
*/
async function keywordAction(options: HookOptions): Promise<void> {
let { stdin, sessionId, prompt } = options;
let hookCwd: string | undefined;
if (stdin) {
try {
const stdinData = await readStdin();
if (stdinData) {
const hookData = JSON.parse(stdinData) as HookData;
sessionId = hookData.session_id || sessionId;
hookCwd = hookData.cwd;
// Support both 'prompt' and 'user_prompt' fields
prompt = hookData.prompt || hookData.user_prompt || prompt;
}
} catch {
// Silently continue if stdin parsing fails
}
}
if (!prompt) {
// No prompt to analyze - just exit silently
if (stdin) {
process.exit(0);
}
console.error(chalk.red('Error: --prompt is required'));
process.exit(1);
}
try {
const projectPath = getProjectPath(hookCwd);
// Import keyword detector
const { getPrimaryKeyword, getAllKeywords, KEYWORD_PATTERNS } = await import('../core/hooks/keyword-detector.js');
// Detect keywords in prompt
const primaryKeyword = getPrimaryKeyword(prompt);
if (!primaryKeyword) {
// No keywords detected - exit silently for hooks
if (stdin) {
process.exit(0);
}
console.log(chalk.gray('No mode keywords detected'));
process.exit(0);
}
// Map keyword type to execution mode
const keywordToModeMap: Record<string, string> = {
'autopilot': 'autopilot',
'ralph': 'ralph',
'ultrawork': 'ultrawork',
'swarm': 'swarm',
'pipeline': 'pipeline',
'team': 'team',
'ultrapilot': 'team', // ultrapilot maps to team
'ultraqa': 'ultraqa'
};
const executionMode = keywordToModeMap[primaryKeyword.type];
if (!executionMode) {
// Keyword not mapped to execution mode (e.g., 'cancel', 'codex', 'gemini')
if (stdin) {
process.exit(0);
}
console.log(chalk.gray(`Keyword "${primaryKeyword.keyword}" detected but no execution mode mapped`));
process.exit(0);
}
// Generate sessionId if not provided
const effectiveSessionId = sessionId || `mode-${Date.now()}`;
// Import ModeRegistryService and activate mode
const { ModeRegistryService } = await import('../core/services/mode-registry-service.js');
const modeRegistry = new ModeRegistryService({
projectPath,
enableLogging: !stdin
});
// Check if mode can be started
const canStart = modeRegistry.canStartMode(executionMode as any, effectiveSessionId);
if (!canStart.allowed) {
if (stdin) {
// For hooks: just output a warning message
const output = {
continue: true,
systemMessage: `[MODE ACTIVATION BLOCKED] ${canStart.message}`
};
process.stdout.write(JSON.stringify(output));
process.exit(0);
}
console.log(chalk.yellow(`Cannot activate mode: ${canStart.message}`));
process.exit(0);
}
// Activate the mode
const activated = modeRegistry.activateMode(
executionMode as any,
effectiveSessionId,
{ prompt, keyword: primaryKeyword.keyword }
);
if (stdin) {
// For hooks: output activation result
const output = {
continue: true,
systemMessage: activated
? `[MODE ACTIVATED] ${executionMode.toUpperCase()} mode activated for this session. Keyword detected: "${primaryKeyword.keyword}"`
: `[MODE ACTIVATION FAILED] Could not activate ${executionMode} mode`
};
process.stdout.write(JSON.stringify(output));
process.exit(0);
}
// Interactive mode: show detailed output
console.log(chalk.green('Keyword Detection'));
console.log(chalk.gray('-'.repeat(40)));
console.log(chalk.cyan('Detected Keyword:'), primaryKeyword.keyword);
console.log(chalk.cyan('Type:'), primaryKeyword.type);
console.log(chalk.cyan('Position:'), primaryKeyword.position);
console.log(chalk.cyan('Execution Mode:'), executionMode);
console.log(chalk.cyan('Session ID:'), effectiveSessionId);
console.log(chalk.cyan('Activated:'), activated ? 'Yes' : 'No');
process.exit(0);
} catch (error) {
if (stdin) {
// Silent failure for hooks
process.exit(0);
}
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* PreCompact action - handles PreCompact hook events
*
* Creates a checkpoint before context compaction to preserve state.
* Uses RecoveryHandler with mutex to prevent concurrent compaction.
*
* Returns { continue: true, systemMessage?: string } - checkpoint summary.
*/
async function preCompactAction(options: HookOptions): Promise<void> {
let { stdin, sessionId } = options;
let hookCwd: string | undefined;
let trigger: 'manual' | 'auto' = 'auto';
if (stdin) {
try {
const stdinData = await readStdin();
if (stdinData) {
const hookData = JSON.parse(stdinData) as HookData & {
trigger?: 'manual' | 'auto';
transcript_path?: string;
permission_mode?: string;
hook_event_name?: string;
};
sessionId = hookData.session_id || sessionId;
hookCwd = hookData.cwd;
trigger = hookData.trigger || 'auto';
}
} catch {
// Silently continue if stdin parsing fails
}
}
if (!sessionId) {
if (!stdin) {
console.error(chalk.red('Error: --session-id is required'));
}
// For hooks, use a default session ID
sessionId = `compact-${Date.now()}`;
}
try {
const projectPath = getProjectPath(hookCwd);
// Import RecoveryHandler dynamically
const { RecoveryHandler } = await import('../core/hooks/recovery-handler.js');
const recoveryHandler = new RecoveryHandler({
projectPath,
enableLogging: !stdin
});
// Handle PreCompact with mutex protection
const result = await recoveryHandler.handlePreCompact({
session_id: sessionId,
cwd: projectPath,
hook_event_name: 'PreCompact',
trigger
});
if (stdin) {
// For hooks: output JSON result to stdout
const output: { continue: boolean; systemMessage?: string } = {
continue: result.continue
};
if (result.systemMessage) {
output.systemMessage = result.systemMessage;
}
process.stdout.write(JSON.stringify(output));
process.exit(0);
}
// Interactive mode: show detailed output
console.log(chalk.green('PreCompact Handler'));
console.log(chalk.gray('─'.repeat(40)));
console.log(chalk.cyan('Session ID:'), sessionId);
console.log(chalk.cyan('Trigger:'), trigger);
console.log(chalk.cyan('Continue:'), result.continue);
if (result.systemMessage) {
console.log(chalk.yellow('System Message:'));
console.log(result.systemMessage);
}
process.exit(0);
} catch (error) {
if (stdin) {
// Don't block compaction on error
process.stdout.write(JSON.stringify({ continue: true }));
process.exit(0);
}
console.error(chalk.red(`Error: ${(error as Error).message}`));
process.exit(1);
}
}
/**
* Notify dashboard action - send notification to running ccw view server
*/
@@ -424,6 +727,9 @@ ${chalk.bold('SUBCOMMANDS')}
parse-status Parse CCW status.json and display current/next command
session-context Progressive session context loading (replaces curl/bash hook)
session-end Trigger background memory maintenance tasks
stop Handle Stop hook events with Soft Enforcement
keyword Detect mode keywords in prompts and activate modes
pre-compact Handle PreCompact hook events (checkpoint creation)
notify Send notification to ccw view dashboard
${chalk.bold('OPTIONS')}
@@ -442,10 +748,32 @@ ${chalk.bold('EXAMPLES')}
${chalk.gray('# Interactive usage:')}
ccw hook session-context --session-id abc123
${chalk.gray('# Handle Stop hook events:')}
ccw hook stop --stdin
${chalk.gray('# Notify dashboard:')}
ccw hook notify --stdin
${chalk.gray('# Detect mode keywords:')}
ccw hook keyword --stdin --prompt "use autopilot to implement auth"
${chalk.gray('# Handle PreCompact events:')}
ccw hook pre-compact --stdin
${chalk.bold('HOOK CONFIGURATION')}
${chalk.gray('Add to .claude/settings.json for Stop hook:')}
{
"hooks": {
"Stop": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "ccw hook stop --stdin"
}]
}]
}
}
${chalk.gray('Add to .claude/settings.json for status tracking:')}
{
"hooks": {
@@ -479,6 +807,16 @@ export async function hookCommand(
case 'session-end':
await sessionEndAction(options);
break;
case 'stop':
await stopAction(options);
break;
case 'keyword':
await keywordAction(options);
break;
case 'pre-compact':
case 'precompact':
await preCompactAction(options);
break;
case 'notify':
await notifyAction(options);
break;