diff --git a/apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx b/apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx new file mode 100644 index 0000000000..4167533953 --- /dev/null +++ b/apps/frontend/src/renderer/components/task-detail/SubtaskActionList.tsx @@ -0,0 +1,505 @@ +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ChevronDown, + ChevronRight, + FileText, + Search, + FolderSearch, + Pencil, + FileCode, + Terminal, + Wrench, + CheckCircle2, + XCircle, + Info, + AlertTriangle, + Sparkles, + Layers, + FileEdit, + Eye, + FolderOpen, +} from 'lucide-react'; +import { Badge } from '../ui/badge'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { cn } from '../../lib/utils'; +import type { ScoredAction } from '../../lib/actionScoring'; +import { groupActionsBySubphase, getScoreReasonKeys } from '../../lib/actionScoring'; + +interface SubtaskActionListProps { + /** Scored actions to display (already filtered to top N) */ + actions: ScoredAction[]; + /** Maximum number of actions to show (for display purposes) */ + maxActions?: number; + /** Whether to show subphase grouping headers */ + showSubphaseGrouping?: boolean; + /** Optional callback when "View all logs" is clicked */ + onViewAllLogs?: () => void; + /** Files that were modified (edited/written) during this subtask */ + modifiedFiles?: string[]; + /** Files that were read (but not modified) during this subtask */ + readFiles?: string[]; +} + +/** + * Get tool info including icon, label key for i18n, and color styling + * Mirrors the pattern from TaskLogs.tsx + */ +function getToolInfo(toolName: string) { + switch (toolName?.toLowerCase()) { + case 'read': + return { icon: FileText, labelKey: 'detail.tools.reading', color: 'text-blue-500 bg-blue-500/10' }; + case 'glob': + return { icon: FolderSearch, labelKey: 'detail.tools.searchingFiles', color: 'text-amber-500 bg-amber-500/10' }; + case 'grep': + return { icon: Search, labelKey: 'detail.tools.searchingCode', color: 'text-green-500 bg-green-500/10' }; + case 'edit': + return { icon: Pencil, labelKey: 'detail.tools.editing', color: 'text-purple-500 bg-purple-500/10' }; + case 'write': + return { icon: FileCode, labelKey: 'detail.tools.writing', color: 'text-cyan-500 bg-cyan-500/10' }; + case 'bash': + return { icon: Terminal, labelKey: 'detail.tools.running', color: 'text-orange-500 bg-orange-500/10' }; + default: + // For unknown tools, use the tool name directly as label (not translated) + return { icon: Wrench, labelKey: null, fallbackLabel: toolName || 'Action', color: 'text-muted-foreground bg-muted' }; + } +} + +/** + * Format timestamp into a human-readable time + */ +function formatTime(timestamp: string): string { + try { + const date = new Date(timestamp); + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } +} + +/** + * Get entry type styling + */ +function getEntryTypeStyles(type: string): { bgColor: string; textColor: string; borderColor: string; Icon: typeof XCircle | null } { + switch (type) { + case 'error': + return { bgColor: 'bg-destructive/10', textColor: 'text-destructive', borderColor: 'border-destructive/30', Icon: XCircle }; + case 'success': + return { bgColor: 'bg-success/10', textColor: 'text-success', borderColor: 'border-success/30', Icon: CheckCircle2 }; + case 'info': + return { bgColor: 'bg-info/10', textColor: 'text-info', borderColor: 'border-info/30', Icon: Info }; + default: + return { bgColor: 'bg-secondary/30', textColor: 'text-muted-foreground', borderColor: 'border-border/50', Icon: null }; + } +} + +/** + * Individual scored action item + */ +interface ScoredActionItemProps { + scoredAction: ScoredAction; + showScore?: boolean; +} + +function ScoredActionItem({ scoredAction, showScore = true }: ScoredActionItemProps) { + const { t } = useTranslation('tasks'); + const [isExpanded, setIsExpanded] = useState(false); + const { action, score, scoreBreakdown } = scoredAction; + const hasDetail = Boolean(action.detail); + const entryStyles = getEntryTypeStyles(action.type); + + // Determine if this is a tool action or a regular entry + const isToolAction = action.type === 'tool_start' || action.type === 'tool_end'; + const toolInfo = action.tool_name ? getToolInfo(action.tool_name) : null; + + // Render tool action + if (isToolAction && toolInfo) { + const { icon: ToolIcon, labelKey, color } = toolInfo; + const label = labelKey ? t(labelKey) : ('fallbackLabel' in toolInfo ? toolInfo.fallbackLabel : t('detail.tools.action')); + const isStart = action.type === 'tool_start'; + + return ( +
+
+
+ + {label} + {isStart && action.tool_input && ( + + {action.tool_input} + + )} + {!isStart && ( + + )} +
+ {showScore && score > 0 && ( + + )} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+              {action.detail}
+            
+
+ )} +
+ ); + } + + // Render typed entry (error, success, info, text) + const { bgColor, textColor, borderColor, Icon } = entryStyles; + + return ( +
+
+ {Icon && } + + {formatTime(action.timestamp)} + + {action.content} + {action.subphase && ( + + {action.subphase} + + )} + {showScore && score > 0 && ( + + )} + {hasDetail && ( + + )} +
+ {hasDetail && isExpanded && ( +
+
+            {action.detail}
+          
+
+ )} +
+ ); +} + +/** + * Score badge with tooltip showing breakdown + */ +interface ScoreBadgeProps { + score: number; + scoreBreakdown: { + error: number; + decision: number; + fileChange: number; + timeAnomaly: number; + novelty: number; + }; +} + +function ScoreBadge({ score, scoreBreakdown }: ScoreBadgeProps) { + const { t } = useTranslation('tasks'); + // Use Pick type - no need for type assertion + const reasonKeys = getScoreReasonKeys({ scoreBreakdown }); + const translatedReasons = reasonKeys.map(key => t(key)).join(', '); + + return ( + + + = 40 ? 'border-destructive/50 text-destructive bg-destructive/5' : + score >= 25 ? 'border-amber-500/50 text-amber-500 bg-amber-500/5' : + 'border-info/50 text-info bg-info/5' + )} + > + + {score} + + + +
+

{t('detail.scoring.relevanceScore')}: {score}

+

{translatedReasons}

+
+
+
+ ); +} + +/** + * Subphase group header + */ +interface SubphaseGroupProps { + subphase: string; + actions: ScoredAction[]; + defaultExpanded?: boolean; +} + +function SubphaseGroup({ subphase, actions, defaultExpanded = true }: SubphaseGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + + + + + +
+ {actions.map((scoredAction, idx) => ( + + ))} +
+
+
+ ); +} + +/** + * Get filename from a path (handles both Unix and Windows separators) + */ +function getFilenameFromPath(path: string): string { + const parts = path.split(/[\\/]/); + return parts[parts.length - 1] || path; +} + +/** + * Files Section Component + * Displays modified and read files in a compact format + */ +interface FilesSectionProps { + modifiedFiles?: string[]; + readFiles?: string[]; +} + +function FilesSection({ modifiedFiles = [], readFiles = [] }: FilesSectionProps) { + const { t } = useTranslation('tasks'); + const hasFiles = modifiedFiles.length > 0 || readFiles.length > 0; + + if (!hasFiles) { + return null; + } + + return ( +
+
+ + {t('detail.actions.filesTouched')} +
+
+ {/* Modified Files */} + {modifiedFiles.length > 0 && ( +
+
+ + {t('detail.actions.modified')} +
+
+ {modifiedFiles.map((file) => ( + + + + + {getFilenameFromPath(file)} + + + + {file} + + + ))} +
+
+ )} + {/* Read Files */} + {readFiles.length > 0 && ( +
+
+ + {t('detail.actions.read')} +
+
+ {readFiles.map((file) => ( + + + + + {getFilenameFromPath(file)} + + + + {file} + + + ))} +
+
+ )} +
+
+ ); +} + +/** + * SubtaskActionList Component + * + * Displays the top N most relevant actions for a subtask, grouped by subphase. + * Uses cognitive science-based scoring to highlight the most important actions. + */ +export function SubtaskActionList({ + actions, + maxActions = 5, + showSubphaseGrouping = true, + onViewAllLogs, + modifiedFiles, + readFiles, +}: SubtaskActionListProps) { + const { t } = useTranslation('tasks'); + + // Limit to maxActions and group by subphase + const displayActions = useMemo(() => { + return actions.slice(0, maxActions); + }, [actions, maxActions]); + + const groupedActions = useMemo(() => { + if (!showSubphaseGrouping) return null; + return groupActionsBySubphase(displayActions); + }, [displayActions, showSubphaseGrouping]); + + // Empty state + if (actions.length === 0) { + return ( +
+ +

{t('detail.actions.noActionsRecorded')}

+
+ ); + } + + return ( +
+ {/* Files Section - shows modified and read files */} + + + {/* Header with action count */} +
+ + + {t('detail.actions.topActions', { displayed: displayActions.length, total: actions.length })} + + {onViewAllLogs && ( + + )} +
+ + {/* Actions list */} +
+ {showSubphaseGrouping && groupedActions ? ( + // Grouped by subphase + Array.from(groupedActions.entries()).map(([subphase, subphaseActions]) => ( + + )) + ) : ( + // Flat list + displayActions.map((scoredAction, idx) => ( + + )) + )} +
+ + {/* Show warning if many actions were filtered */} + {actions.length > 100 && ( +
+ + {t('detail.actions.showingTopRelevant', { displayed: displayActions.length, total: actions.length })} +
+ )} +
+ ); +} + +export default SubtaskActionList; diff --git a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx index 3b41168a2b..a86a7449f6 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskDetailModal.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { useToast } from '../../hooks/use-toast'; @@ -213,6 +214,11 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, onOpenChange(false); }; + // Memoized callback for navigating to logs tab from subtasks + const handleViewAllLogs = useCallback(() => { + state.setActiveTab('logs'); + }, [state.setActiveTab]); + // Helper function to get status badge variant const getStatusBadgeVariant = (status: string, isStuck: boolean) => { if (isStuck) return 'warning'; @@ -539,7 +545,11 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals, {/* Subtasks Tab */} - + {/* Logs Tab */} diff --git a/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx b/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx index b7355c39cf..2c4b655614 100644 --- a/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx +++ b/apps/frontend/src/renderer/components/task-detail/TaskSubtasks.tsx @@ -1,12 +1,27 @@ -import { CheckCircle2, Clock, XCircle, AlertCircle, ListChecks, FileCode } from 'lucide-react'; +import { useState, useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, Clock, XCircle, AlertCircle, ListChecks, FileCode, ChevronDown, ChevronRight, Sparkles, ArrowDownWideNarrow, ListOrdered } from 'lucide-react'; import { Badge } from '../ui/badge'; import { ScrollArea } from '../ui/scroll-area'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../ui/collapsible'; import { cn, calculateProgress } from '../../lib/utils'; -import type { Task } from '../../../shared/types'; +import { filterTopActions, calculateSubtaskRelevanceScores, sortSubtasksByRelevance, getImportantFiles } from '../../lib/actionScoring'; +import { SubtaskActionList } from './SubtaskActionList'; +import type { Task, TaskLogs, TaskLogEntry } from '../../../shared/types'; + +/** Subtask type extracted from Task */ +type Subtask = Task['subtasks'][number]; + +/** Sort mode for subtasks */ +type SortMode = 'default' | 'relevance'; interface TaskSubtasksProps { task: Task; + /** Phase logs containing actions - used to show top 5 actions per subtask */ + phaseLogs?: TaskLogs | null; + /** Optional callback when "View all logs" is clicked from a subtask */ + onViewAllLogs?: () => void; } function getSubtaskStatusIcon(status: string) { @@ -22,97 +37,320 @@ function getSubtaskStatusIcon(status: string) { } } -export function TaskSubtasks({ task }: TaskSubtasksProps) { +/** + * Extract all log entries from phase logs into a flat array + */ +function getAllEntriesFromPhaseLogs(phaseLogs: TaskLogs): TaskLogEntry[] { + const entries: TaskLogEntry[] = []; + + // Collect entries from all phases + for (const phase of ['planning', 'coding', 'validation'] as const) { + const phaseLog = phaseLogs.phases[phase]; + if (phaseLog && phaseLog.entries) { + entries.push(...phaseLog.entries); + } + } + + return entries; +} + +/** + * Get action count for a subtask from phase logs + */ +function getSubtaskActionCount(phaseLogs: TaskLogs | null | undefined, subtaskId: string): number { + if (!phaseLogs) return 0; + + const allEntries = getAllEntriesFromPhaseLogs(phaseLogs); + return allEntries.filter(entry => entry.subtask_id === subtaskId).length; +} + +export function TaskSubtasks({ task, phaseLogs, onViewAllLogs }: TaskSubtasksProps) { + const { t } = useTranslation('tasks'); const progress = calculateProgress(task.subtasks); + // Track which subtasks are expanded (store by subtask id) + const [expandedSubtasks, setExpandedSubtasks] = useState>(new Set()); + + // Sort mode for subtasks + const [sortMode, setSortMode] = useState('default'); + + // Toggle expansion state for a subtask + const toggleSubtask = useCallback((subtaskId: string) => { + setExpandedSubtasks(prev => { + const next = new Set(prev); + if (next.has(subtaskId)) { + next.delete(subtaskId); + } else { + next.add(subtaskId); + } + return next; + }); + }, []); + + // Toggle sort mode + const toggleSortMode = useCallback(() => { + setSortMode(prev => prev === 'default' ? 'relevance' : 'default'); + }, []); + + // Memoize all entries from phase logs + const allEntries = useMemo(() => { + if (!phaseLogs) return []; + return getAllEntriesFromPhaseLogs(phaseLogs); + }, [phaseLogs]); + + // Memoize scored actions per subtask (only compute when expanded) + const getSubtaskActions = useCallback((subtaskId: string) => { + if (allEntries.length === 0) return []; + return filterTopActions(allEntries, 5, subtaskId); + }, [allEntries]); + + // Calculate relevance scores for all subtasks (memoized) + const relevanceScores = useMemo(() => { + if (allEntries.length === 0) return new Map(); + const subtaskIds = task.subtasks.map(s => s.id); + return calculateSubtaskRelevanceScores(allEntries, subtaskIds); + }, [allEntries, task.subtasks]); + + // Get sorted subtasks based on sort mode + const sortedSubtasks = useMemo(() => { + if (sortMode === 'default') { + return task.subtasks; + } + // Sort by relevance + const subtaskIds = task.subtasks.map(s => s.id); + const sortedIds = sortSubtasksByRelevance(subtaskIds, relevanceScores); + // Map sorted IDs back to subtask objects with proper type guard + return sortedIds + .map(id => task.subtasks.find(s => s.id === id)) + .filter((s): s is Subtask => s != null); + }, [task.subtasks, sortMode, relevanceScores]); + + // Precompute original indices (1-based) for O(1) lookup instead of O(n) findIndex per subtask + const originalIndexMap = useMemo(() => { + const map = new Map(); + task.subtasks.forEach((s, idx) => map.set(s.id, idx + 1)); + return map; + }, [task.subtasks]); + return (
{task.subtasks.length === 0 ? (
-

No subtasks defined

+

{t('detail.subtasks.noSubtasks')}

- Implementation subtasks will appear here after planning + {t('detail.subtasks.noSubtasksDescription')}

) : ( <> - {/* Progress summary */} + {/* Progress summary with sort toggle */}
- {task.subtasks.filter(c => c.status === 'completed').length} of {task.subtasks.length} completed - {progress}% -
- {task.subtasks.map((subtask, index) => ( -
{t('detail.subtasks.completedCount', { completed: task.subtasks.filter(c => c.status === 'completed').length, total: task.subtasks.length })} +
+ {/* Sort toggle button */} + {allEntries.length > 0 && ( + + + + + +

+ {sortMode === 'relevance' + ? t('detail.subtasks.sortTooltipRelevance') + : t('detail.subtasks.sortTooltipOrder')} +

+
+
)} - > -
- {getSubtaskStatusIcon(subtask.status)} -
-
- - #{index + 1} - - - - - {subtask.id} - - - -

{subtask.id}

-
-
-
- - -

- {subtask.description} -

-
- {subtask.description && subtask.description.length > 80 && ( - -

{subtask.description}

-
- )} -
- {subtask.files && subtask.files.length > 0 && ( -
- {subtask.files.map((file) => ( - + {progress}% +
+
+ {sortedSubtasks.map((subtask, index) => { + const isExpanded = expandedSubtasks.has(subtask.id); + const actionCount = getSubtaskActionCount(phaseLogs, subtask.id); + const hasActions = actionCount > 0; + // Only compute scored actions when expanded (memoized via useCallback) + const scoredActions = isExpanded ? getSubtaskActions(subtask.id) : []; + // Get files touched during this subtask (only when expanded) + const importantFiles = isExpanded && hasActions + ? getImportantFiles(allEntries, subtask.id, 5) + : { modified: [], read: [] }; + // Get relevance score for this subtask + const relevanceScore = relevanceScores.get(subtask.id); + // Original index (1-based) for display - O(1) lookup from precomputed map + const originalIndex = originalIndexMap.get(subtask.id) ?? 0; + + return ( + toggleSubtask(subtask.id)} + > +
+ {/* Subtask Header - always visible */} +
+
+ {getSubtaskStatusIcon(subtask.status)} +
+
+ + #{originalIndex} + + {/* Show relevance score when sorting by relevance */} + {sortMode === 'relevance' && relevanceScore && relevanceScore.totalScore > 0 && ( + + + + + {Math.round(relevanceScore.totalScore)} + + + +
+

{t('detail.scoring.relevanceScore')}: {Math.round(relevanceScore.totalScore)}

+

+ {t('detail.scoring.actionsCount', { count: relevanceScore.actionCount })} •{' '} + {t('detail.scoring.averageScore', { value: Math.round(relevanceScore.averageScore) })} •{' '} + {t('detail.scoring.topScore', { value: Math.round(relevanceScore.topScore) })} +

+ {relevanceScore.hasErrors && ( +

{t('detail.subtasks.containsErrors')}

+ )} +
+
+
+ )} + + + + {subtask.id} + + + +

{subtask.id}

+
+
+
+ - - - {file.split('/').pop()} - +

+ {subtask.description} +

- - {file} - + {subtask.description && subtask.description.length > 80 && ( + +

{subtask.description}

+
+ )}
- ))} + {subtask.files && subtask.files.length > 0 && ( +
+ {subtask.files.map((file) => ( + + + + + {file.split('/').pop()} + + + + {file} + + + ))} +
+ )} +
+ + {/* Expand/Collapse trigger - only show when there are actions */} + {hasActions && ( + + + + )}
- )} +
+ + {/* Expandable Action Section */} + +
+
+ +
+
+
-
-
- ))} + + ); + })} )}
diff --git a/apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts b/apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts new file mode 100644 index 0000000000..9aa9636f64 --- /dev/null +++ b/apps/frontend/src/renderer/lib/__tests__/actionScoring.test.ts @@ -0,0 +1,1191 @@ +/** + * Unit tests for Action Scoring Algorithm + * + * Tests weighted scoring algorithm for intelligent subtask action summarization. + * Verifies: + * - Scoring weights (Error=40, Warning=20, Decision=25, FileChange=20, TimeAnomaly=10, Novelty=5) + * - Top 5 filtering functionality + * - Edge cases (empty arrays, fewer than 5 actions, missing metadata) + * - Tiebreaker logic (earlier actions win when scores equal) + * - Performance (<100ms for 1000 actions) + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + SCORING_WEIGHTS, + DEFAULT_TOP_N, + scoreAction, + filterTopActions, + groupActionsBySubphase, + getScoreReason, + isErrorAction, + isWarningAction, + isDecisionAction, + isFileChangeAction, + isNovelty, + countFilesChanged, + calculateSubtaskRelevance, + calculateSubtaskRelevanceScores, + sortSubtasksByRelevance, + extractFilesFromAction, + extractFilesFromActions, + getFilesSummary, + getImportantFiles, + type ScoredAction, + type ScoringContext, + type SubtaskRelevanceScore, + type ExtractedFile, + type FilesSummary, +} from '../actionScoring'; +import type { TaskLogEntry, TaskLogEntryType, TaskLogPhase } from '../../../shared/types/task'; + +/** + * Helper to create a test TaskLogEntry with sensible defaults + */ +function createTestAction(overrides: Partial = {}): TaskLogEntry { + return { + timestamp: new Date().toISOString(), + type: 'text' as TaskLogEntryType, + content: 'Test action content', + phase: 'coding' as TaskLogPhase, + ...overrides, + }; +} + +/** + * Helper to create a fresh scoring context + */ +function createScoringContext(): ScoringContext { + return { + averageDuration: undefined, + seenToolTypes: new Set(), + }; +} + +describe('Action Scoring Algorithm', () => { + describe('SCORING_WEIGHTS constants', () => { + it('should have correct weight values based on cognitive science principles', () => { + expect(SCORING_WEIGHTS.ERROR).toBe(40); + expect(SCORING_WEIGHTS.WARNING).toBe(20); + expect(SCORING_WEIGHTS.DECISION).toBe(25); + expect(SCORING_WEIGHTS.FILE_CHANGE).toBe(20); + expect(SCORING_WEIGHTS.TIME_ANOMALY).toBe(10); + expect(SCORING_WEIGHTS.NOVELTY).toBe(5); + }); + + it('should have DEFAULT_TOP_N set to 5', () => { + expect(DEFAULT_TOP_N).toBe(5); + }); + }); + + describe('isErrorAction', () => { + it('should detect error type entries', () => { + const action = createTestAction({ type: 'error' }); + expect(isErrorAction(action)).toBe(true); + }); + + it('should detect error keywords in content', () => { + const errorKeywords = ['error', 'failed', 'failure', 'exception', 'crash']; + + errorKeywords.forEach((keyword) => { + const action = createTestAction({ content: `Something ${keyword} here` }); + expect(isErrorAction(action)).toBe(true); + }); + }); + + it('should return false for non-error actions', () => { + const action = createTestAction({ type: 'text', content: 'All good' }); + expect(isErrorAction(action)).toBe(false); + }); + + it('should handle missing content gracefully', () => { + const action = createTestAction({ content: undefined }); + expect(isErrorAction(action)).toBe(false); + }); + }); + + describe('isWarningAction', () => { + it('should detect warning keywords in content', () => { + const warningKeywords = ['warning', 'deprecated', 'caution']; + + warningKeywords.forEach((keyword) => { + const action = createTestAction({ content: `Something ${keyword} here` }); + expect(isWarningAction(action)).toBe(true); + }); + }); + + it('should return false for non-warning actions', () => { + const action = createTestAction({ content: 'Normal message' }); + expect(isWarningAction(action)).toBe(false); + }); + }); + + describe('isDecisionAction', () => { + it('should detect decision-related tool names', () => { + const decisionTools = ['Edit', 'Write', 'Create', 'Bash', 'Delete', 'Rename']; + + decisionTools.forEach((tool) => { + const action = createTestAction({ tool_name: tool }); + expect(isDecisionAction(action)).toBe(true); + }); + }); + + it('should detect decision keywords in content', () => { + const decisionKeywords = [ + 'decided', + 'choosing', + 'selected', + 'implementing', + 'creating', + 'adding', + 'modifying', + ]; + + decisionKeywords.forEach((keyword) => { + const action = createTestAction({ content: `${keyword} a new feature` }); + expect(isDecisionAction(action)).toBe(true); + }); + }); + + it('should return false for non-decision actions', () => { + const action = createTestAction({ tool_name: 'Read', content: 'Just reading' }); + expect(isDecisionAction(action)).toBe(false); + }); + }); + + describe('isFileChangeAction', () => { + it('should detect file change tool names', () => { + const fileChangeTools = ['Edit', 'Write', 'Create', 'Delete', 'Rename', 'Move', 'NotebookEdit']; + + fileChangeTools.forEach((tool) => { + const action = createTestAction({ tool_name: tool }); + expect(isFileChangeAction(action)).toBe(true); + }); + }); + + it('should detect file change keywords in content', () => { + const fileChangeKeywords = ['wrote', 'edited', 'created', 'modified', 'deleted']; + + fileChangeKeywords.forEach((keyword) => { + const action = createTestAction({ content: `${keyword} file.ts` }); + expect(isFileChangeAction(action)).toBe(true); + }); + }); + + it('should return false for non-file-change actions', () => { + const action = createTestAction({ tool_name: 'Read', content: 'Reading content' }); + expect(isFileChangeAction(action)).toBe(false); + }); + }); + + describe('countFilesChanged', () => { + it('should count file paths in tool input', () => { + const action = createTestAction({ + tool_input: '/src/component.tsx', + detail: 'Modified file', + }); + expect(countFilesChanged(action)).toBe(1); + }); + + it('should count multiple file paths', () => { + const action = createTestAction({ + tool_input: '/src/component.tsx', + detail: 'Also changed /src/utils.ts and /src/types.ts', + }); + expect(countFilesChanged(action)).toBe(3); + }); + + it('should cap at 3 files for scoring purposes', () => { + const action = createTestAction({ + detail: + '/a.ts /b.ts /c.ts /d.ts /e.ts /f.ts', + }); + expect(countFilesChanged(action)).toBe(3); + }); + + it('should handle missing tool_input and detail', () => { + const action = createTestAction({}); + expect(countFilesChanged(action)).toBe(0); + }); + }); + + describe('isNovelty', () => { + it('should return true for first occurrence of tool type', () => { + const action = createTestAction({ tool_name: 'NewTool' }); + const seenTypes = new Set(); + + expect(isNovelty(action, seenTypes)).toBe(true); + }); + + it('should return false for repeated tool type', () => { + const action = createTestAction({ tool_name: 'Edit' }); + const seenTypes = new Set(['edit']); + + expect(isNovelty(action, seenTypes)).toBe(false); + }); + + it('should return false for actions without tool_name', () => { + const action = createTestAction({ tool_name: undefined }); + const seenTypes = new Set(); + + expect(isNovelty(action, seenTypes)).toBe(false); + }); + }); + + describe('scoreAction', () => { + let context: ScoringContext; + + beforeEach(() => { + context = createScoringContext(); + }); + + it('should score error actions with ERROR weight (40)', () => { + const action = createTestAction({ type: 'error', content: 'Test error' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.error).toBe(SCORING_WEIGHTS.ERROR); + expect(scored.score).toBeGreaterThanOrEqual(SCORING_WEIGHTS.ERROR); + }); + + it('should score warning actions with WARNING weight (20)', () => { + const action = createTestAction({ content: 'Warning: deprecated method' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.error).toBe(SCORING_WEIGHTS.WARNING); + }); + + it('should score decision actions with DECISION weight (25)', () => { + const action = createTestAction({ tool_name: 'Edit' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.decision).toBe(SCORING_WEIGHTS.DECISION); + }); + + it('should score file change actions with FILE_CHANGE weight (20)', () => { + const action = createTestAction({ + tool_name: 'Write', + tool_input: '/src/file.ts', + }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.fileChange).toBeGreaterThanOrEqual(SCORING_WEIGHTS.FILE_CHANGE); + }); + + it('should score novel tool types with NOVELTY weight (5)', () => { + const action = createTestAction({ tool_name: 'NewTool' }); + const scored = scoreAction(action, 0, context); + + expect(scored.scoreBreakdown.novelty).toBe(SCORING_WEIGHTS.NOVELTY); + }); + + it('should track seen tool types for novelty scoring', () => { + const action1 = createTestAction({ tool_name: 'Edit' }); + const action2 = createTestAction({ tool_name: 'Edit' }); + + scoreAction(action1, 0, context); + const scored2 = scoreAction(action2, 1, context); + + expect(scored2.scoreBreakdown.novelty).toBe(0); + }); + + it('should combine multiple scoring criteria', () => { + const action = createTestAction({ + type: 'error', + tool_name: 'Edit', + content: 'Error while implementing feature', + tool_input: '/src/file.ts', + }); + + const scored = scoreAction(action, 0, context); + + // Should have error (40) + decision (25) + file change (20+) + novelty (5) + expect(scored.score).toBeGreaterThan( + SCORING_WEIGHTS.ERROR + SCORING_WEIGHTS.DECISION + ); + }); + + it('should include original index for tiebreaking', () => { + const action = createTestAction({}); + const scored = scoreAction(action, 42, context); + + expect(scored.index).toBe(42); + }); + + it('should include the original action in result', () => { + const action = createTestAction({ content: 'Original content' }); + const scored = scoreAction(action, 0, context); + + expect(scored.action).toBe(action); + expect(scored.action.content).toBe('Original content'); + }); + }); + + describe('filterTopActions', () => { + it('should return empty array for null/undefined input', () => { + expect(filterTopActions(null as unknown as TaskLogEntry[])).toEqual([]); + expect(filterTopActions(undefined as unknown as TaskLogEntry[])).toEqual([]); + }); + + it('should return empty array for empty input', () => { + expect(filterTopActions([])).toEqual([]); + }); + + it('should return all actions when fewer than N', () => { + const actions = [ + createTestAction({ content: 'Action 1' }), + createTestAction({ content: 'Action 2' }), + createTestAction({ content: 'Action 3' }), + ]; + + const result = filterTopActions(actions, 5); + expect(result).toHaveLength(3); + }); + + it('should return exactly N actions when more than N exist', () => { + const actions = Array.from({ length: 20 }, (_, i) => + createTestAction({ content: `Action ${i}` }) + ); + + const result = filterTopActions(actions, 5); + expect(result).toHaveLength(5); + }); + + it('should prioritize error actions over normal actions', () => { + const actions = [ + createTestAction({ content: 'Normal action 1' }), + createTestAction({ content: 'Normal action 2' }), + createTestAction({ type: 'error', content: 'Error occurred' }), + createTestAction({ content: 'Normal action 3' }), + createTestAction({ content: 'Normal action 4' }), + ]; + + const result = filterTopActions(actions, 3); + + // Error action should be first (highest score) + expect(result[0].action.type).toBe('error'); + }); + + it('should prioritize file changes over simple text actions', () => { + const actions = [ + createTestAction({ type: 'text', content: 'Just text' }), + createTestAction({ tool_name: 'Write', tool_input: '/src/file.ts' }), + createTestAction({ type: 'text', content: 'More text' }), + ]; + + const result = filterTopActions(actions, 2); + + // Write action should score higher + const writeAction = result.find((r) => r.action.tool_name === 'Write'); + expect(writeAction).toBeDefined(); + }); + + it('should use index as tiebreaker when scores are equal', () => { + // Create 10 identical actions (same score) + const actions = Array.from({ length: 10 }, (_, i) => + createTestAction({ content: `Identical action`, tool_name: undefined }) + ); + + const result = filterTopActions(actions, 5); + + // Earlier actions should win tiebreaker - exact original order preserved + const resultIndices = result.map(scored => scored.index); + expect(resultIndices).toEqual([0, 1, 2, 3, 4]); + }); + + it('should filter by subtask_id when provided', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Subtask 1 action' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Subtask 2 action' }), + createTestAction({ subtask_id: 'subtask-1', content: 'Another subtask 1 action' }), + ]; + + const result = filterTopActions(actions, 5, 'subtask-1'); + + expect(result).toHaveLength(2); + result.forEach((scored) => { + expect(scored.action.subtask_id).toBe('subtask-1'); + }); + }); + + it('should return sorted results by score (descending)', () => { + const actions = [ + createTestAction({ content: 'Low score text' }), + createTestAction({ type: 'error', content: 'High score error' }), + createTestAction({ tool_name: 'Edit', content: 'Medium score edit' }), + ]; + + const result = filterTopActions(actions, 3); + + // Verify descending order + for (let i = 1; i < result.length; i++) { + expect(result[i].score).toBeLessThanOrEqual(result[i - 1].score); + } + }); + }); + + describe('groupActionsBySubphase', () => { + it('should group actions by subphase', () => { + const context = createScoringContext(); + const scoredActions: ScoredAction[] = [ + scoreAction(createTestAction({ subphase: 'Planning' }), 0, context), + scoreAction(createTestAction({ subphase: 'Implementation' }), 1, context), + scoreAction(createTestAction({ subphase: 'Planning' }), 2, context), + scoreAction(createTestAction({ subphase: 'Testing' }), 3, context), + ]; + + const groups = groupActionsBySubphase(scoredActions); + + expect(groups.size).toBe(3); + expect(groups.get('Planning')).toHaveLength(2); + expect(groups.get('Implementation')).toHaveLength(1); + expect(groups.get('Testing')).toHaveLength(1); + }); + + it('should use "Other" for actions without subphase', () => { + const context = createScoringContext(); + const scoredActions: ScoredAction[] = [ + scoreAction(createTestAction({ subphase: undefined }), 0, context), + scoreAction(createTestAction({ subphase: 'Planning' }), 1, context), + ]; + + const groups = groupActionsBySubphase(scoredActions); + + expect(groups.get('Other')).toHaveLength(1); + expect(groups.get('Planning')).toHaveLength(1); + }); + + it('should handle empty array', () => { + const groups = groupActionsBySubphase([]); + expect(groups.size).toBe(0); + }); + }); + + describe('getScoreReason', () => { + it('should return "Error detected" for error actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ type: 'error' }), 0, context); + + expect(getScoreReason(scored)).toContain('Error detected'); + }); + + it('should return "Warning detected" for warning actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ content: 'Warning: deprecated' }), 0, context); + + expect(getScoreReason(scored)).toContain('Warning detected'); + }); + + it('should return "Key decision" for decision actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ tool_name: 'Edit' }), 0, context); + + expect(getScoreReason(scored)).toContain('Key decision'); + }); + + it('should return "File modification" for file change actions', () => { + const context = createScoringContext(); + const scored = scoreAction( + createTestAction({ tool_name: 'Write', tool_input: '/src/file.ts' }), + 0, + context + ); + + expect(getScoreReason(scored)).toContain('File modification'); + }); + + it('should return "New action type" for novel actions', () => { + const context = createScoringContext(); + const scored = scoreAction(createTestAction({ tool_name: 'BrandNewTool' }), 0, context); + + expect(getScoreReason(scored)).toContain('New action type'); + }); + + it('should return "Standard action" for low-score actions', () => { + const context = createScoringContext(); + context.seenToolTypes.add('read'); // Mark Read as seen + const scored = scoreAction( + createTestAction({ tool_name: 'Read', content: 'Just reading' }), + 0, + context + ); + + expect(getScoreReason(scored)).toBe('Standard action'); + }); + + it('should combine multiple reasons', () => { + const context = createScoringContext(); + const scored = scoreAction( + createTestAction({ + type: 'error', + tool_name: 'Edit', + }), + 0, + context + ); + + const reason = getScoreReason(scored); + expect(reason).toContain('Error detected'); + expect(reason).toContain('Key decision'); + }); + }); + + describe('Edge Cases', () => { + it('should handle actions with all undefined optional fields', () => { + const action: TaskLogEntry = { + timestamp: new Date().toISOString(), + type: 'text', + content: 'Minimal action', + phase: 'coding', + }; + + const context = createScoringContext(); + const scored = scoreAction(action, 0, context); + + expect(scored.score).toBeGreaterThanOrEqual(0); + expect(scored.action).toBe(action); + }); + + it('should handle actions with empty string content', () => { + const action = createTestAction({ content: '' }); + const context = createScoringContext(); + const scored = scoreAction(action, 0, context); + + expect(scored.score).toBeGreaterThanOrEqual(0); + }); + + it('should handle all actions having the same score', () => { + const actions = Array.from({ length: 10 }, () => + createTestAction({ content: 'Same content' }) + ); + + const result = filterTopActions(actions, 5); + + expect(result).toHaveLength(5); + // All should have same score + const scores = result.map((r) => r.score); + expect(new Set(scores).size).toBe(1); + }); + + it('should handle single action', () => { + const actions = [createTestAction({ content: 'Only action' })]; + const result = filterTopActions(actions, 5); + + expect(result).toHaveLength(1); + }); + + it('should handle N=0 (request for zero actions)', () => { + const actions = [createTestAction({ content: 'Action' })]; + const result = filterTopActions(actions, 0); + + expect(result).toHaveLength(0); + }); + + it('should handle negative N gracefully', () => { + const actions = [createTestAction({ content: 'Action' })]; + const result = filterTopActions(actions, -1); + + expect(result).toHaveLength(0); + }); + }); + + describe('Performance', () => { + // CI environments are often slower; use higher thresholds to avoid flaky tests + const isCI = process.env.CI === 'true' || process.env.CI === '1'; + const ciMultiplier = isCI ? 5 : 1; // 5x slower thresholds in CI + + it('should process 1000 actions in reasonable time', () => { + const actions: TaskLogEntry[] = Array.from({ length: 1000 }, (_, i) => { + // Create variety of action types + const types: TaskLogEntryType[] = ['text', 'tool_start', 'tool_end', 'error', 'success']; + const tools = ['Edit', 'Write', 'Read', 'Bash', 'Glob', 'Grep', undefined]; + + return createTestAction({ + type: types[i % types.length], + tool_name: tools[i % tools.length], + content: `Action ${i} with some content for variety`, + subtask_id: `subtask-${i % 5}`, + subphase: i % 3 === 0 ? 'Planning' : i % 3 === 1 ? 'Implementation' : 'Testing', + }); + }); + + const startTime = performance.now(); + const result = filterTopActions(actions, 5); + const endTime = performance.now(); + + const duration = endTime - startTime; + const threshold = 100 * ciMultiplier; + + expect(result).toHaveLength(5); + expect(duration).toBeLessThan(threshold); + }); + + it('should process 5000 actions in reasonable time', () => { + const actions: TaskLogEntry[] = Array.from({ length: 5000 }, (_, i) => + createTestAction({ + content: `Action ${i}`, + tool_name: i % 10 === 0 ? 'Edit' : undefined, + }) + ); + + const startTime = performance.now(); + const result = filterTopActions(actions, 5); + const endTime = performance.now(); + + const duration = endTime - startTime; + const threshold = 500 * ciMultiplier; + + expect(result).toHaveLength(5); + expect(duration).toBeLessThan(threshold); + }); + + it('should not cause memory issues with large datasets', () => { + // Create large dataset + const actions: TaskLogEntry[] = Array.from({ length: 1000 }, (_, i) => + createTestAction({ content: `Action ${i}`.repeat(100) }) // Large content + ); + + // Should complete without throwing + expect(() => { + const result = filterTopActions(actions, 5); + expect(result).toHaveLength(5); + }).not.toThrow(); + }); + }); + + describe('Integration scenarios', () => { + it('should correctly rank a realistic mix of actions', () => { + const actions = [ + createTestAction({ type: 'text', content: 'Reading file structure' }), + createTestAction({ tool_name: 'Read', content: 'Read package.json' }), + createTestAction({ type: 'error', content: 'Failed to parse JSON' }), + createTestAction({ tool_name: 'Edit', content: 'Fixed JSON syntax', tool_input: '/package.json' }), + createTestAction({ content: 'warning: deprecated package' }), + createTestAction({ tool_name: 'Bash', content: 'npm install' }), + createTestAction({ type: 'success', content: 'Installation complete' }), + createTestAction({ tool_name: 'Write', content: 'Creating new file', tool_input: '/src/new-file.ts' }), + createTestAction({ type: 'text', content: 'Analysis complete' }), + createTestAction({ tool_name: 'Read', content: 'Checking results' }), + ]; + + const result = filterTopActions(actions, 5); + + // Error should be in top 5 + const hasError = result.some((r) => r.action.type === 'error'); + expect(hasError).toBe(true); + + // File modifications should be in top 5 + const hasFileChange = result.some( + (r) => r.action.tool_name === 'Edit' || r.action.tool_name === 'Write' + ); + expect(hasFileChange).toBe(true); + }); + + it('should handle subtask-specific filtering with scoring', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error in subtask 1' }), + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error in subtask 2' }), + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Fixed issue' }), + createTestAction({ subtask_id: 'subtask-2', tool_name: 'Edit', content: 'Fixed other issue' }), + ]; + + const subtask1Result = filterTopActions(actions, 5, 'subtask-1'); + const subtask2Result = filterTopActions(actions, 5, 'subtask-2'); + + expect(subtask1Result).toHaveLength(2); + expect(subtask2Result).toHaveLength(2); + + subtask1Result.forEach((r) => expect(r.action.subtask_id).toBe('subtask-1')); + subtask2Result.forEach((r) => expect(r.action.subtask_id).toBe('subtask-2')); + }); + }); + + describe('Subtask Relevance Scoring', () => { + describe('calculateSubtaskRelevance', () => { + it('should return zero scores for subtask with no actions', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Action 1' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-2'); + + expect(result.subtaskId).toBe('subtask-2'); + expect(result.totalScore).toBe(0); + expect(result.actionCount).toBe(0); + expect(result.averageScore).toBe(0); + expect(result.hasErrors).toBe(false); + expect(result.hasDecisions).toBe(false); + expect(result.topScore).toBe(0); + }); + + it('should calculate correct scores for subtask with actions', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error occurred' }), + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Fixed issue' }), + createTestAction({ subtask_id: 'subtask-1', content: 'Some text' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.subtaskId).toBe('subtask-1'); + expect(result.actionCount).toBe(3); + expect(result.totalScore).toBeGreaterThan(0); + expect(result.hasErrors).toBe(true); + expect(result.hasDecisions).toBe(true); + expect(result.topScore).toBeGreaterThanOrEqual(SCORING_WEIGHTS.ERROR); + }); + + it('should detect subtasks with errors', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error occurred' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.hasErrors).toBe(true); + }); + + it('should detect subtasks without errors', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Some text' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.hasErrors).toBe(false); + }); + + it('should detect subtasks with decisions', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Editing file' }), + ]; + + const result = calculateSubtaskRelevance(actions, 'subtask-1'); + + expect(result.hasDecisions).toBe(true); + }); + }); + + describe('calculateSubtaskRelevanceScores', () => { + it('should calculate scores for multiple subtasks', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error' }), + createTestAction({ subtask_id: 'subtask-2', tool_name: 'Edit', content: 'Edit' }), + createTestAction({ subtask_id: 'subtask-3', content: 'Text' }), + ]; + + const result = calculateSubtaskRelevanceScores(actions, ['subtask-1', 'subtask-2', 'subtask-3']); + + expect(result.size).toBe(3); + expect(result.get('subtask-1')?.hasErrors).toBe(true); + expect(result.get('subtask-2')?.hasDecisions).toBe(true); + expect(result.get('subtask-3')?.hasErrors).toBe(false); + }); + + it('should return empty map for empty subtask list', () => { + const actions = [createTestAction({ subtask_id: 'subtask-1' })]; + const result = calculateSubtaskRelevanceScores(actions, []); + + expect(result.size).toBe(0); + }); + }); + + describe('sortSubtasksByRelevance', () => { + it('should sort subtasks by relevance score (highest first)', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Text only' }), + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error occurred' }), + createTestAction({ subtask_id: 'subtask-3', tool_name: 'Edit', content: 'Edit' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2', 'subtask-3']; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-2 should be first (has error, highest priority) + expect(sorted[0]).toBe('subtask-2'); + }); + + it('should prioritize subtasks with errors', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Edit', content: 'Edit', tool_input: '/file.ts' }), + createTestAction({ subtask_id: 'subtask-1', tool_name: 'Write', content: 'Write', tool_input: '/file2.ts' }), + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-2 should be first due to error even though subtask-1 has more actions + expect(sorted[0]).toBe('subtask-2'); + }); + + it('should use action count as secondary sort when scores are similar', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', content: 'Text 1' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Text 2' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Text 3' }), + createTestAction({ subtask_id: 'subtask-2', content: 'Text 4' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-2 should be first (more actions) + expect(sorted[0]).toBe('subtask-2'); + }); + + it('should handle empty relevance scores gracefully', () => { + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = new Map(); + + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // Should return same order when no scores available + expect(sorted).toEqual(subtaskIds); + }); + + it('should handle partial relevance scores', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-1', type: 'error', content: 'Error' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const scores = calculateSubtaskRelevanceScores(actions, ['subtask-1']); // Only score subtask-1 + const sorted = sortSubtasksByRelevance(subtaskIds, scores); + + // subtask-1 should be first (has score), subtask-2 at end (no score) + expect(sorted[0]).toBe('subtask-1'); + }); + + it('should not mutate original array', () => { + const actions = [ + createTestAction({ subtask_id: 'subtask-2', type: 'error', content: 'Error' }), + ]; + + const subtaskIds = ['subtask-1', 'subtask-2']; + const originalOrder = [...subtaskIds]; + const scores = calculateSubtaskRelevanceScores(actions, subtaskIds); + + sortSubtasksByRelevance(subtaskIds, scores); + + expect(subtaskIds).toEqual(originalOrder); + }); + }); + }); + + /** + * File Extraction Tests + */ + describe('File Extraction', () => { + describe('extractFilesFromAction', () => { + it('should extract file path from Read tool action', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/components/App.tsx', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/components/App.tsx'); + expect(files[0].filename).toBe('App.tsx'); + expect(files[0].operation).toBe('read'); + }); + + it('should extract file path from Edit tool action', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/lib/utils.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/lib/utils.ts'); + expect(files[0].operation).toBe('edit'); + }); + + it('should extract file path from Write tool action', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Write', + tool_input: '/src/new-file.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/new-file.ts'); + expect(files[0].operation).toBe('write'); + }); + + it('should skip tool_end actions to avoid duplicates', () => { + const action = createTestAction({ + type: 'tool_end', + tool_name: 'Read', + tool_input: '/src/file.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(0); + }); + + it('should skip non-tool actions', () => { + const action = createTestAction({ + type: 'text', + content: 'Some text about /src/file.ts', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(0); + }); + + it('should handle relative paths', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: './src/components/Button.tsx', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('./src/components/Button.tsx'); + expect(files[0].filename).toBe('Button.tsx'); + }); + + it('should extract file from detail when tool_input is empty', () => { + const action = createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '', + detail: 'Editing /src/config.json', + }); + + const files = extractFilesFromAction(action); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/config.json'); + }); + }); + + describe('extractFilesFromActions', () => { + it('should extract files from multiple actions', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file1.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/file2.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Write', + tool_input: '/src/file3.ts', + }), + ]; + + const files = extractFilesFromActions(actions); + + expect(files).toHaveLength(3); + expect(files.map(f => f.path)).toEqual([ + '/src/file1.ts', + '/src/file2.ts', + '/src/file3.ts', + ]); + }); + + it('should skip Grep and Glob tools (search operations)', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Grep', + tool_input: 'searchPattern', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Glob', + tool_input: '**/*.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + ]; + + const files = extractFilesFromActions(actions); + + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/src/file.ts'); + }); + }); + + describe('getFilesSummary', () => { + it('should categorize files by operation type', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/read-only.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/modified.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Write', + tool_input: '/src/written.ts', + }), + ]; + + const summary = getFilesSummary(actions); + + expect(summary.modifiedFiles).toContain('/src/modified.ts'); + expect(summary.modifiedFiles).toContain('/src/written.ts'); + expect(summary.readFiles).toContain('/src/read-only.ts'); + }); + + it('should not include read file in readFiles if it was also modified', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/file.ts', + }), + ]; + + const summary = getFilesSummary(actions); + + expect(summary.modifiedFiles).toContain('/src/file.ts'); + expect(summary.readFiles).not.toContain('/src/file.ts'); + }); + + it('should filter by subtask_id when specified', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/subtask1-file.ts', + subtask_id: 'subtask-1', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/subtask2-file.ts', + subtask_id: 'subtask-2', + }), + ]; + + const summary = getFilesSummary(actions, 'subtask-1'); + + expect(summary.uniqueFiles).toHaveLength(1); + expect(summary.uniqueFiles).toContain('/src/subtask1-file.ts'); + expect(summary.uniqueFiles).not.toContain('/src/subtask2-file.ts'); + }); + + it('should return unique file paths', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/file.ts', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/file.ts', + }), + ]; + + const summary = getFilesSummary(actions); + + expect(summary.uniqueFiles).toHaveLength(1); + expect(summary.modifiedFiles).toHaveLength(1); + }); + }); + + describe('getImportantFiles', () => { + it('should prioritize modified files over read files', () => { + const actions = [ + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/read1.ts', + subtask_id: 'subtask-1', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Read', + tool_input: '/src/read2.ts', + subtask_id: 'subtask-1', + }), + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: '/src/edited.ts', + subtask_id: 'subtask-1', + }), + ]; + + const result = getImportantFiles(actions, 'subtask-1', 2); + + // Modified file should come first + expect(result.modified).toContain('/src/edited.ts'); + // Read files fill remaining slots + expect(result.read).toHaveLength(1); + }); + + it('should limit to maxFiles parameter', () => { + const actions = Array.from({ length: 10 }, (_, i) => + createTestAction({ + type: 'tool_start', + tool_name: 'Edit', + tool_input: `/src/file${i}.ts`, + subtask_id: 'subtask-1', + }) + ); + + const result = getImportantFiles(actions, 'subtask-1', 3); + + expect(result.modified).toHaveLength(3); + }); + + it('should return empty arrays when no files found', () => { + const actions = [ + createTestAction({ + type: 'text', + content: 'Some text', + subtask_id: 'subtask-1', + }), + ]; + + const result = getImportantFiles(actions, 'subtask-1'); + + expect(result.modified).toHaveLength(0); + expect(result.read).toHaveLength(0); + }); + }); + }); +}); diff --git a/apps/frontend/src/renderer/lib/actionScoring.ts b/apps/frontend/src/renderer/lib/actionScoring.ts new file mode 100644 index 0000000000..99a7832222 --- /dev/null +++ b/apps/frontend/src/renderer/lib/actionScoring.ts @@ -0,0 +1,880 @@ +/** + * Action Scoring Algorithm for Intelligent Subtask Action Summarization + * + * Implements a weighted scoring algorithm based on cognitive science principles + * to identify the most relevant actions from potentially 1000+ entries. + * + * Scoring Weights (research-backed): + * - Error: 40 - Flow blockers demand immediate attention (Csikszentmihalyi) + * - Decision: 25 - Mental model building (Justin Sung's conceptual frameworks) + * - FileChange: 20 - Concrete anchors for active recall + * - TimeAnomaly: 10 - Attention signals for complexity (Kahneman's System 1/2) + * - Novelty: 5 - Learning moments, first exposures (Bjork's desirable difficulties) + */ + +import type { TaskLogEntry, TaskLogEntryType } from '@shared/types/task'; + +/** Scoring weight constants based on cognitive science principles */ +export const SCORING_WEIGHTS = { + ERROR: 40, + WARNING: 20, + DECISION: 25, + FILE_CHANGE: 20, + TIME_ANOMALY: 10, + NOVELTY: 5, +} as const; + +/** Default number of top actions to return */ +export const DEFAULT_TOP_N = 5; + +/** Time anomaly threshold multiplier (2x average duration) */ +export const TIME_ANOMALY_THRESHOLD = 2; + +/** + * Scored action with computed relevance score + */ +export interface ScoredAction { + action: TaskLogEntry; + score: number; + index: number; // Original index for tiebreaking + scoreBreakdown: ScoreBreakdown; +} + +/** + * Breakdown of how an action's score was calculated + */ +export interface ScoreBreakdown { + error: number; + decision: number; + fileChange: number; + timeAnomaly: number; + novelty: number; +} + +/** + * Context for scoring actions (e.g., average duration, seen types) + */ +export interface ScoringContext { + averageDuration?: number; + seenToolTypes: Set; +} + +/** + * Tool names that indicate decision-making actions + * These represent key architectural or implementation choices + */ +const DECISION_TOOL_NAMES = [ + 'edit', + 'write', + 'create', + 'bash', // Commands can represent decisions + 'delete', + 'rename', + 'move', + 'refactor', +]; + +/** + * Tool names that indicate file changes + */ +const FILE_CHANGE_TOOL_NAMES = [ + 'edit', + 'write', + 'create', + 'delete', + 'rename', + 'move', + 'notebookedit', +]; + +/** + * Entry types that indicate errors or warnings + */ +const ERROR_TYPES: TaskLogEntryType[] = ['error']; + +/** + * Check if an action represents an error + */ +export function isErrorAction(action: TaskLogEntry): boolean { + // Check entry type + if (ERROR_TYPES.includes(action.type)) { + return true; + } + + // Check content for error keywords + const content = action.content?.toLowerCase() ?? ''; + const hasErrorKeywords = + content.includes('error') || + content.includes('failed') || + content.includes('failure') || + content.includes('exception') || + content.includes('crash'); + + return hasErrorKeywords; +} + +/** + * Check if an action represents a warning + */ +export function isWarningAction(action: TaskLogEntry): boolean { + const content = action.content?.toLowerCase() ?? ''; + return ( + content.includes('warning') || + content.includes('deprecated') || + content.includes('caution') + ); +} + +/** + * Check if an action represents a decision point + * Decision points are key moments that affect the implementation direction + */ +export function isDecisionAction(action: TaskLogEntry): boolean { + // Tool-based decision detection + if (action.tool_name) { + const toolLower = action.tool_name.toLowerCase(); + if (DECISION_TOOL_NAMES.some((name) => toolLower.includes(name))) { + return true; + } + } + + // Content-based decision detection + const content = action.content?.toLowerCase() ?? ''; + const hasDecisionKeywords = + content.includes('decided') || + content.includes('choosing') || + content.includes('selected') || + content.includes('implementing') || + content.includes('creating') || + content.includes('adding') || + content.includes('modifying'); + + return hasDecisionKeywords; +} + +/** + * Check if an action involves file changes + */ +export function isFileChangeAction(action: TaskLogEntry): boolean { + // Tool-based file change detection + if (action.tool_name) { + const toolLower = action.tool_name.toLowerCase(); + if (FILE_CHANGE_TOOL_NAMES.some((name) => toolLower.includes(name))) { + return true; + } + } + + // Content-based file change detection + const content = action.content?.toLowerCase() ?? ''; + return ( + content.includes('wrote') || + content.includes('edited') || + content.includes('created') || + content.includes('modified') || + content.includes('deleted') + ); +} + +/** + * Count the number of files affected by an action + * Returns a rough estimate based on content analysis + */ +export function countFilesChanged(action: TaskLogEntry): number { + // Check tool input for file paths + const input = action.tool_input ?? ''; + const detail = action.detail ?? ''; + const combined = input + detail; + + // Count file path patterns (simplified heuristic) + const filePatterns = combined.match(/[/\\][\w\-.]+\.[a-z]{1,4}/gi) ?? []; + + // Cap at 3 for scoring purposes (to prevent outliers) + return Math.min(filePatterns.length, 3); +} + +/** + * Parse duration from action timestamp or detail + * Returns undefined if duration cannot be determined + * + * TODO: Duration parsing is not implemented because TaskLogEntry does not include + * duration data. When duration tracking is added to the type, implement this function + * to enable TIME_ANOMALY scoring. Until then, isTimeAnomaly() will always return false. + */ +export function parseDuration(_action: TaskLogEntry): number | undefined { + return undefined; +} + +/** + * Calculate average duration from a list of actions + */ +export function calculateAverageDuration(actions: TaskLogEntry[]): number | undefined { + const durations = actions + .map(parseDuration) + .filter((d): d is number => d !== undefined); + + if (durations.length === 0) { + return undefined; + } + + return durations.reduce((sum, d) => sum + d, 0) / durations.length; +} + +/** + * Check if action duration is anomalous (significantly longer than average) + */ +export function isTimeAnomaly(action: TaskLogEntry, averageDuration?: number): boolean { + const duration = parseDuration(action); + + // Return false if either value is undefined + // Note: parseDuration currently always returns undefined (see TODO in that function) + // Once duration tracking is implemented in TaskLogEntry, this function will work correctly + if (averageDuration === undefined || duration === undefined) { + return false; + } + + return duration > averageDuration * TIME_ANOMALY_THRESHOLD; +} + +/** + * Check if this is the first occurrence of a tool type + */ +export function isNovelty(action: TaskLogEntry, seenTypes: Set): boolean { + if (!action.tool_name) { + return false; + } + + const toolType = action.tool_name.toLowerCase(); + return !seenTypes.has(toolType); +} + +/** + * Score a single action based on cognitive science principles + * + * @param action - The action to score + * @param index - Original index in the actions array (for tiebreaking) + * @param context - Scoring context with average duration and seen types + * @returns ScoredAction with computed score and breakdown + */ +export function scoreAction( + action: TaskLogEntry, + index: number, + context: ScoringContext +): ScoredAction { + const breakdown: ScoreBreakdown = { + error: 0, + decision: 0, + fileChange: 0, + timeAnomaly: 0, + novelty: 0, + }; + + // Error/warning signals (cognitive priority) + if (isErrorAction(action)) { + breakdown.error = SCORING_WEIGHTS.ERROR; + } else if (isWarningAction(action)) { + breakdown.error = SCORING_WEIGHTS.WARNING; + } + + // Decision points (mental model building) + if (isDecisionAction(action)) { + breakdown.decision = SCORING_WEIGHTS.DECISION; + } + + // File changes (concrete anchors) + if (isFileChangeAction(action)) { + const filesChanged = countFilesChanged(action); + // Scale by number of files changed, cap at 3x base weight + breakdown.fileChange = SCORING_WEIGHTS.FILE_CHANGE * Math.max(1, filesChanged); + } + + // Time anomalies (attention signals) + if (isTimeAnomaly(action, context.averageDuration)) { + breakdown.timeAnomaly = SCORING_WEIGHTS.TIME_ANOMALY; + } + + // Novelty (learning moments) + if (isNovelty(action, context.seenToolTypes)) { + breakdown.novelty = SCORING_WEIGHTS.NOVELTY; + // Track this type as seen + if (action.tool_name) { + context.seenToolTypes.add(action.tool_name.toLowerCase()); + } + } + + const score = + breakdown.error + + breakdown.decision + + breakdown.fileChange + + breakdown.timeAnomaly + + breakdown.novelty; + + return { + action, + score, + index, + scoreBreakdown: breakdown, + }; +} + +/** + * Compare function for sorting scored actions + * Primary: descending by score + * Secondary: ascending by index (earlier actions win ties) + */ +function compareScoredActions(a: ScoredAction, b: ScoredAction): number { + // Higher score comes first + if (b.score !== a.score) { + return b.score - a.score; + } + // For equal scores, earlier action wins (ascending index) + return a.index - b.index; +} + +/** + * Filter and return the top N most relevant actions + * + * @param actions - Array of all actions to filter + * @param n - Number of top actions to return (default: 5) + * @param subtaskId - Optional subtask ID to filter by + * @returns Array of scored actions, sorted by relevance + */ +export function filterTopActions( + actions: TaskLogEntry[], + n: number = DEFAULT_TOP_N, + subtaskId?: string +): ScoredAction[] { + // Handle empty arrays or invalid n + if (!actions || actions.length === 0 || n <= 0) { + return []; + } + + // Filter by subtask if specified + const filteredActions = subtaskId + ? actions.filter((a) => a.subtask_id === subtaskId) + : actions; + + if (filteredActions.length === 0) { + return []; + } + + // Create scoring context (consolidated - same logic for all array sizes) + const context: ScoringContext = { + averageDuration: calculateAverageDuration(filteredActions), + seenToolTypes: new Set(), + }; + + // Score all actions + const scoredActions = filteredActions.map((action, index) => + scoreAction(action, index, context) + ); + + // Sort by score (descending) with tiebreaker + scoredActions.sort(compareScoredActions); + + // Return top N (or all if fewer than n) + return scoredActions.slice(0, Math.min(n, scoredActions.length)); +} + +/** + * Group actions by subphase for hierarchical display + * + * @param actions - Array of scored actions + * @returns Map of subphase -> actions + */ +export function groupActionsBySubphase( + actions: ScoredAction[] +): Map { + const groups = new Map(); + + for (const action of actions) { + const subphase = action.action.subphase ?? 'Other'; + const existing = groups.get(subphase) ?? []; + existing.push(action); + groups.set(subphase, existing); + } + + return groups; +} + +/** + * Get translation keys for why an action scored highly + * + * @param scoredAction - Object containing at least the scoreBreakdown field + * @returns Array of i18n translation keys (use t() to translate in UI components) + */ +export function getScoreReasonKeys( + scoredAction: Pick +): string[] { + const reasonKeys: string[] = []; + const { scoreBreakdown } = scoredAction; + + if (scoreBreakdown.error > 0) { + if (scoreBreakdown.error >= SCORING_WEIGHTS.ERROR) { + reasonKeys.push('detail.scoring.errorDetected'); + } else { + reasonKeys.push('detail.scoring.warningDetected'); + } + } + + if (scoreBreakdown.decision > 0) { + reasonKeys.push('detail.scoring.keyDecision'); + } + + if (scoreBreakdown.fileChange > 0) { + reasonKeys.push('detail.scoring.fileModification'); + } + + if (scoreBreakdown.timeAnomaly > 0) { + reasonKeys.push('detail.scoring.longDuration'); + } + + if (scoreBreakdown.novelty > 0) { + reasonKeys.push('detail.scoring.newActionType'); + } + + return reasonKeys.length > 0 ? reasonKeys : ['detail.scoring.standardAction']; +} + +/** + * Get a human-readable description of why an action scored highly + * Returns hardcoded English strings for backwards compatibility. + * For i18n support, use getScoreReasonKeys() instead. + * + * @deprecated Use getScoreReasonKeys() for i18n support + * @param scoredAction - Object containing at least the scoreBreakdown field + * @returns String describing the scoring reasons (English only) + */ +export function getScoreReason( + scoredAction: Pick +): string { + const reasons: string[] = []; + const { scoreBreakdown } = scoredAction; + + if (scoreBreakdown.error > 0) { + if (scoreBreakdown.error >= SCORING_WEIGHTS.ERROR) { + reasons.push('Error detected'); + } else { + reasons.push('Warning detected'); + } + } + + if (scoreBreakdown.decision > 0) { + reasons.push('Key decision'); + } + + if (scoreBreakdown.fileChange > 0) { + reasons.push('File modification'); + } + + if (scoreBreakdown.timeAnomaly > 0) { + reasons.push('Long duration'); + } + + if (scoreBreakdown.novelty > 0) { + reasons.push('New action type'); + } + + return reasons.length > 0 ? reasons.join(', ') : 'Standard action'; +} + +/** + * Subtask relevance score with aggregate data + */ +export interface SubtaskRelevanceScore { + subtaskId: string; + totalScore: number; + actionCount: number; + averageScore: number; + hasErrors: boolean; + hasDecisions: boolean; + topScore: number; // Highest single action score +} + +/** + * Calculate the relevance score for a subtask based on its actions + * + * The relevance score is a combination of: + * - Total aggregate score of all actions + * - Top action score (to prioritize subtasks with critical actions) + * - Presence of errors (higher priority) + * + * @param actions - All actions from phase logs + * @param subtaskId - The subtask ID to calculate relevance for + * @returns SubtaskRelevanceScore with aggregated scoring data + */ +export function calculateSubtaskRelevance( + actions: TaskLogEntry[], + subtaskId: string +): SubtaskRelevanceScore { + const subtaskActions = actions.filter((a) => a.subtask_id === subtaskId); + + if (subtaskActions.length === 0) { + return { + subtaskId, + totalScore: 0, + actionCount: 0, + averageScore: 0, + hasErrors: false, + hasDecisions: false, + topScore: 0, + }; + } + + // Score all subtask actions + const context: ScoringContext = { + averageDuration: calculateAverageDuration(subtaskActions), + seenToolTypes: new Set(), + }; + + const scoredActions = subtaskActions.map((action, index) => + scoreAction(action, index, context) + ); + + // Aggregate scores + const totalScore = scoredActions.reduce((sum, sa) => sum + sa.score, 0); + const topScore = Math.max(...scoredActions.map((sa) => sa.score)); + const hasErrors = scoredActions.some((sa) => sa.scoreBreakdown.error >= SCORING_WEIGHTS.ERROR); + const hasDecisions = scoredActions.some((sa) => sa.scoreBreakdown.decision > 0); + + return { + subtaskId, + totalScore, + actionCount: subtaskActions.length, + averageScore: totalScore / subtaskActions.length, + hasErrors, + hasDecisions, + topScore, + }; +} + +/** + * Calculate relevance scores for multiple subtasks + * + * @param actions - All actions from phase logs + * @param subtaskIds - Array of subtask IDs to calculate relevance for + * @returns Map of subtask ID to relevance score + */ +export function calculateSubtaskRelevanceScores( + actions: TaskLogEntry[], + subtaskIds: string[] +): Map { + const scores = new Map(); + + for (const subtaskId of subtaskIds) { + scores.set(subtaskId, calculateSubtaskRelevance(actions, subtaskId)); + } + + return scores; +} + +/** + * Sort subtasks by relevance score + * + * Primary sort: By composite relevance score (errors weighted heavily) + * Secondary sort: By action count (more actions = more activity) + * Tertiary sort: By original order (for stable sorting) + * + * @param subtaskIds - Array of subtask IDs + * @param relevanceScores - Map of subtask ID to relevance score + * @returns Sorted array of subtask IDs (most relevant first) + */ +export function sortSubtasksByRelevance( + subtaskIds: string[], + relevanceScores: Map +): string[] { + // Pre-compute index map for O(1) lookups instead of O(n) indexOf calls + const indexMap = new Map(subtaskIds.map((id, idx) => [id, idx])); + + return [...subtaskIds].sort((a, b) => { + const scoreA = relevanceScores.get(a); + const scoreB = relevanceScores.get(b); + + // Handle missing scores (put at end) + if (!scoreA && !scoreB) return 0; + if (!scoreA) return 1; + if (!scoreB) return -1; + + // Composite score: prioritize errors, then top score, then total + const compositeA = + (scoreA.hasErrors ? 1000 : 0) + scoreA.topScore * 10 + scoreA.totalScore; + const compositeB = + (scoreB.hasErrors ? 1000 : 0) + scoreB.topScore * 10 + scoreB.totalScore; + + if (compositeB !== compositeA) { + return compositeB - compositeA; // Descending + } + + // Secondary: action count (more activity = more relevant) + if (scoreB.actionCount !== scoreA.actionCount) { + return scoreB.actionCount - scoreA.actionCount; + } + + // Tertiary: stable sort by original index (O(1) lookup via index map) + return (indexMap.get(a) ?? 0) - (indexMap.get(b) ?? 0); + }); +} + +/** + * File operation type for categorizing file interactions + */ +export type FileOperationType = 'read' | 'edit' | 'write' | 'delete' | 'search' | 'bash'; + +/** + * Extracted file information from log actions + */ +export interface ExtractedFile { + path: string; + filename: string; + operation: FileOperationType; + timestamp: string; + toolName?: string; +} + +/** + * Summary of files touched during a subtask + */ +export interface FilesSummary { + files: ExtractedFile[]; + uniqueFiles: string[]; + byOperation: Map; + modifiedFiles: string[]; // Files that were edited/written (most important) + readFiles: string[]; // Files that were only read +} + +/** + * Extract file path from tool_input string + * Handles various formats: direct paths, JSON inputs, etc. + * + * NOTE: This is a best-effort extraction for UI display purposes only. + * Limitations: + * - Returns only the first match found + * - May miss paths with unusual characters or deeply nested structures + * - Extension matching is limited to 10 chars (covers most common extensions) + * - Windows paths support is basic (backslashes in simple patterns) + */ +function extractFilePathFromInput(input: string): string | null { + if (!input) return null; + + // Trim whitespace + const trimmed = input.trim(); + + // Direct file path (starts with / or ./ or Windows drive letter) + if (trimmed.startsWith('/') || trimmed.startsWith('./') || /^[A-Za-z]:\\/.test(trimmed)) { + // Extract just the path part (before any space or newline) + const pathMatch = trimmed.match(/^([^\s\n]+)/); + if (pathMatch) { + return pathMatch[1]; + } + } + + // Try to parse as JSON and extract file_path or path + try { + const parsed = JSON.parse(trimmed); + if (parsed.file_path) return parsed.file_path; + if (parsed.path) return parsed.path; + if (parsed.filename) return parsed.filename; + } catch { + // Not JSON, try regex patterns + } + + // Look for file path patterns in the string + // Extension length up to 10 chars to cover .typescript, .properties, etc. + // Note: Quoted patterns come first to correctly extract paths without quotes + // Note: No /g flag - we use exec() and only need the first match + const pathPatterns = [ + // Paths in double quotes (Unix or Windows) - checked first to extract without quotes + /"([^"]+\.[a-zA-Z]{1,10})"/, + // Paths in single quotes (Unix or Windows) + /'([^']+\.[a-zA-Z]{1,10})'/, + // Unix absolute paths (e.g., /home/user/file.ts) + /\/[\w\-./]+\.[a-zA-Z]{1,10}/, + // Unix relative paths (e.g., ./src/file.ts) + /\.\/[\w\-./]+\.[a-zA-Z]{1,10}/, + // Windows absolute paths (e.g., C:\Users\file.ts) + /[A-Za-z]:\\[\w\-.\\]+\.[a-zA-Z]{1,10}/, + // Windows relative paths (e.g., .\src\file.ts) + /\.\\[\w\-.\\]+\.[a-zA-Z]{1,10}/, + ]; + + for (const pattern of pathPatterns) { + const match = pattern.exec(trimmed); + if (match) { + // Prefer captured group (match[1]) for quoted patterns, otherwise use full match (match[0]) + return match[1] ?? match[0]; + } + } + + return null; +} + +/** + * Get filename from a path (handles both Unix and Windows separators) + */ +function getFilename(path: string): string { + // Split on both forward slash and backslash to handle Unix and Windows paths + const parts = path.split(/[/\\]/); + return parts[parts.length - 1] || path; +} + +/** + * Map tool name to operation type + */ +function getOperationType(toolName: string | undefined): FileOperationType { + if (!toolName) return 'bash'; + + const tool = toolName.toLowerCase(); + + if (tool === 'read') return 'read'; + if (tool === 'edit' || tool === 'notebookedit') return 'edit'; + if (tool === 'write') return 'write'; + if (tool === 'delete') return 'delete'; + if (tool === 'grep' || tool === 'glob') return 'search'; + if (tool === 'bash') return 'bash'; + + // Default for unknown tools + return 'bash'; +} + +/** + * Extract files from a single action + */ +export function extractFilesFromAction(action: TaskLogEntry): ExtractedFile[] { + const files: ExtractedFile[] = []; + + // Only process tool actions + if (action.type !== 'tool_start' && action.type !== 'tool_end') { + return files; + } + + // Skip tool_end to avoid duplicates + if (action.type === 'tool_end') { + return files; + } + + const operation = getOperationType(action.tool_name); + + // Skip search operations for now (they don't represent specific files) + if (operation === 'search') { + return files; + } + + // Try to extract file path from tool_input + if (action.tool_input) { + const filePath = extractFilePathFromInput(action.tool_input); + if (filePath) { + files.push({ + path: filePath, + filename: getFilename(filePath), + operation, + timestamp: action.timestamp, + toolName: action.tool_name, + }); + } + } + + // Also check detail field for additional file references + if (action.detail && files.length === 0) { + const filePath = extractFilePathFromInput(action.detail); + if (filePath) { + files.push({ + path: filePath, + filename: getFilename(filePath), + operation, + timestamp: action.timestamp, + toolName: action.tool_name, + }); + } + } + + return files; +} + +/** + * Extract all files from a list of actions + */ +export function extractFilesFromActions(actions: TaskLogEntry[]): ExtractedFile[] { + const files: ExtractedFile[] = []; + + for (const action of actions) { + const extracted = extractFilesFromAction(action); + files.push(...extracted); + } + + return files; +} + +/** + * Get a summary of files touched during a subtask + */ +export function getFilesSummary(actions: TaskLogEntry[], subtaskId?: string): FilesSummary { + // Filter by subtask if specified + const filteredActions = subtaskId + ? actions.filter(a => a.subtask_id === subtaskId) + : actions; + + // Extract all files + const files = extractFilesFromActions(filteredActions); + + // Group by operation type + const byOperation = new Map(); + for (const file of files) { + const existing = byOperation.get(file.operation) ?? []; + existing.push(file); + byOperation.set(file.operation, existing); + } + + // Get unique file paths + const uniqueFilesSet = new Set(); + for (const file of files) { + uniqueFilesSet.add(file.path); + } + const uniqueFiles = Array.from(uniqueFilesSet); + + // Determine modified vs read-only files + const modifiedFilesSet = new Set(); + const allFilesSet = new Set(); + + for (const file of files) { + allFilesSet.add(file.path); + if (file.operation === 'edit' || file.operation === 'write' || file.operation === 'delete') { + modifiedFilesSet.add(file.path); + } + } + + // Read-only files are those that were read but never modified + const readFilesSet = new Set(); + for (const file of files) { + if (file.operation === 'read' && !modifiedFilesSet.has(file.path)) { + readFilesSet.add(file.path); + } + } + + return { + files, + uniqueFiles, + byOperation, + modifiedFiles: Array.from(modifiedFilesSet), + readFiles: Array.from(readFilesSet), + }; +} + +/** + * Get the most important files for a subtask (modified files first, then read files) + * Limited to a maximum number for display purposes + */ +export function getImportantFiles( + actions: TaskLogEntry[], + subtaskId: string, + maxFiles: number = 5 +): { modified: string[]; read: string[] } { + const summary = getFilesSummary(actions, subtaskId); + + // Prioritize modified files + const modified = summary.modifiedFiles.slice(0, maxFiles); + + // Fill remaining slots with read-only files + const remainingSlots = Math.max(0, maxFiles - modified.length); + const read = summary.readFiles.slice(0, remainingSlots); + + return { modified, read }; +} diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index 88f2401562..3eaafe997c 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -100,6 +100,52 @@ "qa": "QA" } }, + "detail": { + "subtasks": { + "noSubtasks": "No subtasks defined", + "noSubtasksDescription": "Implementation subtasks will appear here after planning", + "completedCount": "{{completed}} of {{total}} completed", + "sortByRelevance": "Relevance", + "sortByOrder": "Order", + "sortTooltipRelevance": "Sorted by relevance (errors and key actions first)", + "sortTooltipOrder": "Click to sort by relevance score", + "containsErrors": "Contains errors" + }, + "actions": { + "show": "Show", + "hide": "Hide", + "noActionsRecorded": "No actions recorded", + "topActions": "Top {{displayed}} of {{total}} actions", + "viewAllLogs": "View all logs", + "showingTopRelevant": "Showing top {{displayed}} most relevant from {{total}} total actions", + "filesTouched": "Files Touched", + "modified": "Modified", + "read": "Read" + }, + "tools": { + "reading": "Reading", + "searchingFiles": "Searching files", + "searchingCode": "Searching code", + "editing": "Editing", + "writing": "Writing", + "running": "Running", + "action": "Action" + }, + "scoring": { + "errorDetected": "Error detected", + "warningDetected": "Warning detected", + "keyDecision": "Key decision", + "fileModification": "File modification", + "longDuration": "Long duration", + "newActionType": "New action type", + "standardAction": "Standard action", + "relevanceScore": "Relevance Score", + "actionsCount_one": "{{count}} action", + "actionsCount_other": "{{count}} actions", + "averageScore": "Avg: {{value}}", + "topScore": "Top: {{value}}" + } + }, "files": { "title": "Files", "tab": "Files", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 613d50509b..b69b1e6b57 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -100,6 +100,52 @@ "qa": "QA" } }, + "detail": { + "subtasks": { + "noSubtasks": "Aucune sous-tâche définie", + "noSubtasksDescription": "Les sous-tâches d'implémentation apparaîtront ici après la planification", + "completedCount": "{{completed}} sur {{total}} terminées", + "sortByRelevance": "Pertinence", + "sortByOrder": "Ordre", + "sortTooltipRelevance": "Trié par pertinence (erreurs et actions clés en premier)", + "sortTooltipOrder": "Cliquez pour trier par score de pertinence", + "containsErrors": "Contient des erreurs" + }, + "actions": { + "show": "Afficher", + "hide": "Masquer", + "noActionsRecorded": "Aucune action enregistrée", + "topActions": "Top {{displayed}} sur {{total}} actions", + "viewAllLogs": "Voir tous les logs", + "showingTopRelevant": "Affichage des {{displayed}} plus pertinentes sur {{total}} actions totales", + "filesTouched": "Fichiers touchés", + "modified": "Modifiés", + "read": "Lus" + }, + "tools": { + "reading": "Lecture", + "searchingFiles": "Recherche de fichiers", + "searchingCode": "Recherche de code", + "editing": "Édition", + "writing": "Écriture", + "running": "Exécution", + "action": "Action" + }, + "scoring": { + "errorDetected": "Erreur détectée", + "warningDetected": "Avertissement détecté", + "keyDecision": "Décision clé", + "fileModification": "Modification de fichier", + "longDuration": "Durée longue", + "newActionType": "Nouveau type d'action", + "standardAction": "Action standard", + "relevanceScore": "Score de pertinence", + "actionsCount_one": "{{count}} action", + "actionsCount_other": "{{count}} actions", + "averageScore": "Moy: {{value}}", + "topScore": "Max: {{value}}" + } + }, "files": { "title": "Fichiers", "tab": "Fichiers",