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 && (
+
+ )}
+
+ );
+ }
+
+ // 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 && (
+
+ )}
+
+ );
+}
+
+/**
+ * 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",