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

@@ -1,6 +1,26 @@
/**
* Hooks Routes Module
* Handles all hooks-related API endpoints
*
* ## API Endpoints
*
* ### Active Endpoints
* - POST /api/hook - Main hook endpoint for Claude Code notifications
* - Handles: session-start, context, CLI events, A2UI surfaces
* - POST /api/hook/ccw-exec - Execute CCW CLI commands and parse output
* - GET /api/hooks - Get hooks configuration from global and project settings
* - POST /api/hooks - Save a hook to settings
* - DELETE /api/hooks - Delete a hook from settings
*
* ### Deprecated Endpoints (will be removed in v2.0.0)
* - POST /api/hook/session-context - Use `ccw hook session-context --stdin` instead
* - POST /api/hook/ccw-status - Use /api/hook/ccw-exec with command=parse-status
*
* ## Service Layer
* All endpoints use unified services:
* - HookContextService: Context generation for session-start and per-prompt hooks
* - SessionStateService: Session state tracking and persistence
* - SessionEndService: Background task management for session-end events
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
@@ -235,26 +255,27 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
if (type === 'session-start' || type === 'context') {
try {
const projectPath = url.searchParams.get('path') || initialPath;
const { SessionClusteringService } = await import('../session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
// Use HookContextService for unified context generation
const { HookContextService } = await import('../services/hook-context-service.js');
const contextService = new HookContextService({ projectPath });
const format = url.searchParams.get('format') || 'markdown';
const prompt = typeof extraData.prompt === 'string' ? extraData.prompt : undefined;
// Pass type and prompt to getProgressiveIndex
// session-start: returns recent sessions by time
// context: returns intent-matched sessions based on prompt
const index = await clusteringService.getProgressiveIndex({
type: type as 'session-start' | 'context',
sessionId: resolvedSessionId,
prompt: typeof extraData.prompt === 'string' ? extraData.prompt : undefined // Pass user prompt for intent matching
// Build context using the service
const result = await contextService.buildPromptContext({
sessionId: resolvedSessionId || '',
prompt,
projectId: projectPath
});
// Return context directly
return {
success: true,
type: 'context',
type: result.type,
format,
content: index,
content: result.content,
sessionId: resolvedSessionId
};
} catch (error) {
@@ -336,84 +357,56 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
}
// API: Unified Session Context endpoint (Progressive Disclosure)
// DEPRECATED: Use CLI command `ccw hook session-context --stdin` instead.
// This endpoint now uses file-based state (shared with CLI) for consistency.
// @DEPRECATED - This endpoint is deprecated and will be removed in a future version.
// Migration: Use CLI command `ccw hook session-context --stdin` instead.
// This endpoint now uses HookContextService for consistency with CLI.
// - First prompt: returns cluster-based session overview
// - Subsequent prompts: returns intent-matched sessions based on prompt
if (pathname === '/api/hook/session-context' && req.method === 'POST') {
// Add deprecation warning header
res.setHeader('X-Deprecated', 'true');
res.setHeader('X-Deprecation-Message', 'Use CLI command "ccw hook session-context --stdin" instead. This endpoint will be removed in v2.0.0');
res.setHeader('X-Migration-Guide', 'https://github.com/ccw-project/ccw/blob/main/docs/migration-hooks.md#session-context');
handlePostRequest(req, res, async (body) => {
// Log deprecation warning
console.warn('[DEPRECATED] /api/hook/session-context is deprecated. Use "ccw hook session-context --stdin" instead.');
const { sessionId, prompt } = body as { sessionId?: string; prompt?: string };
if (!sessionId) {
return {
success: true,
content: '',
error: 'sessionId is required'
error: 'sessionId is required',
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
};
}
try {
const projectPath = url.searchParams.get('path') || initialPath;
const { SessionClusteringService } = await import('../session-clustering-service.js');
const clusteringService = new SessionClusteringService(projectPath);
// Use file-based session state (shared with CLI hook.ts)
const sessionStateDir = join(homedir(), '.claude', '.ccw-sessions');
const sessionStateFile = join(sessionStateDir, `session-${sessionId}.json`);
let existingState: { firstLoad: string; loadCount: number; lastPrompt?: string } | null = null;
if (existsSync(sessionStateFile)) {
try {
existingState = JSON.parse(readFileSync(sessionStateFile, 'utf-8'));
} catch {
existingState = null;
}
}
const isFirstPrompt = !existingState;
// Use HookContextService for unified context generation
const { HookContextService } = await import('../services/hook-context-service.js');
const contextService = new HookContextService({ projectPath });
// Update session state (file-based)
const newState = isFirstPrompt
? { firstLoad: new Date().toISOString(), loadCount: 1, lastPrompt: prompt }
: { ...existingState!, loadCount: existingState!.loadCount + 1, lastPrompt: prompt };
if (!existsSync(sessionStateDir)) {
mkdirSync(sessionStateDir, { recursive: true });
}
writeFileSync(sessionStateFile, JSON.stringify(newState, null, 2));
// Determine which type of context to return
let contextType: 'session-start' | 'context';
let content: string;
if (isFirstPrompt) {
// First prompt: return session overview with clusters
contextType = 'session-start';
content = await clusteringService.getProgressiveIndex({
type: 'session-start',
sessionId
});
} else if (prompt && prompt.trim().length > 0) {
// Subsequent prompts with content: return intent-matched sessions
contextType = 'context';
content = await clusteringService.getProgressiveIndex({
type: 'context',
sessionId,
prompt
});
} else {
// Subsequent prompts without content: return minimal context
contextType = 'context';
content = ''; // No context needed for empty prompts
}
// Build context using the service
const result = await contextService.buildPromptContext({
sessionId,
prompt,
projectId: projectPath
});
return {
success: true,
type: contextType,
isFirstPrompt,
loadCount: newState.loadCount,
content,
sessionId
type: result.type,
isFirstPrompt: result.isFirstPrompt,
loadCount: result.state.loadCount,
content: result.content,
sessionId,
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
};
} catch (error) {
console.error('[Hooks] Failed to generate session context:', error);
@@ -421,7 +414,9 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
success: true,
content: '',
sessionId,
error: (error as Error).message
error: (error as Error).message,
_deprecated: true,
_migration: 'Use "ccw hook session-context --stdin"'
};
}
});
@@ -474,8 +469,16 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return true;
}
// API: Parse CCW status.json and return formatted status (fallback)
// API: Parse CCW status.json and return formatted status
// @DEPRECATED - Use /api/hook/ccw-exec with command=parse-status instead.
// This endpoint is kept for backward compatibility but will be removed.
if (pathname === '/api/hook/ccw-status' && req.method === 'POST') {
// Add deprecation warning header
res.setHeader('X-Deprecated', 'true');
res.setHeader('X-Deprecation-Message', 'Use /api/hook/ccw-exec with command=parse-status instead. This endpoint will be removed in v2.0.0');
console.warn('[DEPRECATED] /api/hook/ccw-status is deprecated. Use /api/hook/ccw-exec instead.');
handlePostRequest(req, res, async (body) => {
if (typeof body !== 'object' || body === null) {
return { error: 'Invalid request body', status: 400 };
@@ -487,51 +490,30 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
return { error: 'filePath is required', status: 400 };
}
// Check if this is a CCW status.json file
if (!filePath.includes('status.json') ||
!filePath.match(/\.(ccw|ccw-coordinator|ccw-debug)\//)) {
return { success: false, message: 'Not a CCW status file' };
}
// Delegate to ccw-exec for unified handling
try {
// Read and parse status.json
if (!existsSync(filePath)) {
return { success: false, message: 'Status file not found' };
const result = await executeCliCommand('ccw', ['hook', 'parse-status', filePath]);
if (result.success) {
return {
success: true,
message: result.output,
_deprecated: true,
_migration: 'Use /api/hook/ccw-exec with command=parse-status'
};
} else {
return {
success: false,
error: result.error,
_deprecated: true
};
}
const statusContent = readFileSync(filePath, 'utf8');
const status = JSON.parse(statusContent);
// Extract key information
const sessionId = status.session_id || 'unknown';
const workflow = status.workflow || status.mode || 'unknown';
// Find current command (running or last completed)
let currentCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'running')?.command;
if (!currentCommand) {
const completed = status.command_chain?.filter((cmd: { status: string }) => cmd.status === 'completed');
currentCommand = completed?.[completed.length - 1]?.command || 'unknown';
}
// Find next command (first pending)
const nextCommand = status.command_chain?.find((cmd: { status: string }) => cmd.status === 'pending')?.command || '无';
// Format status message
const message = `📋 CCW Status [${sessionId}] (${workflow}): 当前处于 ${currentCommand},下一个命令 ${nextCommand}`;
return {
success: true,
message,
sessionId,
workflow,
currentCommand,
nextCommand
};
} catch (error) {
console.error('[Hooks] Failed to parse CCW status:', error);
return {
success: false,
error: (error as Error).message
error: (error as Error).message,
_deprecated: true
};
}
});