feat: Implement phases 6 to 9 of the review cycle fix process, including discovery, batching, parallel planning, execution, and completion

- Added Phase 6: Fix Discovery & Batching with intelligent grouping and batching of findings.
- Added Phase 7: Fix Parallel Planning to launch planning agents for concurrent analysis and aggregation of partial plans.
- Added Phase 8: Fix Execution for stage-based execution of fixes with conservative test verification.
- Added Phase 9: Fix Completion to aggregate results, generate summary reports, and handle session completion.
- Introduced new frontend components: ResizeHandle for draggable resizing of sidebar panels and useResizablePanel hook for managing panel sizes with localStorage persistence.
- Added PowerShell script for checking TypeScript errors in source code, excluding test files.
This commit is contained in:
catlog22
2026-02-07 19:28:33 +08:00
parent ba5f4eba84
commit d43696d756
90 changed files with 8462 additions and 616 deletions

View File

@@ -59,7 +59,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockData);
@@ -71,12 +71,12 @@ describe('Chart Hooks Integration Tests', () => {
mockApi.get.mockResolvedValue({ data: mockData });
const { result } = renderHook(
() => useWorkflowStatusCounts({ projectPath: '/test/workspace' }),
() => useWorkflowStatusCounts({ projectPath: '/test/workspace' } as any),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(mockApi.get).toHaveBeenCalledWith('/api/session-status-counts', {
@@ -90,7 +90,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
expect((result.current as any).isError).toBe(true);
});
expect(result.current.error).toBeDefined();
@@ -102,13 +102,13 @@ describe('Chart Hooks Integration Tests', () => {
mockApi.get.mockResolvedValue({ data: mockData });
const { result: result1 } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => expect(result1.current.isSuccess).toBe(true));
await waitFor(() => expect((result1.current as any).isSuccess).toBe(true));
// Second render should use cache
const { result: result2 } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => {
expect(result2.current.isSuccess).toBe(true);
expect((result2.current as any).isSuccess).toBe(true);
});
// API should only be called once (cached)
@@ -122,7 +122,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await waitFor(() => expect((result.current as any).isSuccess).toBe(true));
// Refetch
await result.current.refetch();
@@ -143,7 +143,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useActivityTimeline(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockData);
@@ -159,10 +159,10 @@ describe('Chart Hooks Integration Tests', () => {
end: new Date('2026-01-31'),
};
const { result } = renderHook(() => useActivityTimeline(dateRange), { wrapper });
const { result } = renderHook(() => (useActivityTimeline as any)(dateRange), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(mockApi.get).toHaveBeenCalledWith('/api/activity-timeline', {
@@ -179,7 +179,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useActivityTimeline(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
@@ -190,12 +190,12 @@ describe('Chart Hooks Integration Tests', () => {
mockApi.get.mockResolvedValue({ data: mockData });
const { result } = renderHook(
() => useActivityTimeline(undefined, '/test/workspace'),
() => (useActivityTimeline as any)(undefined, '/test/workspace'),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(mockApi.get).toHaveBeenCalledWith('/api/activity-timeline', {
@@ -210,11 +210,11 @@ describe('Chart Hooks Integration Tests', () => {
mockApi.get.mockResolvedValueOnce({ data: mockData1 });
const { result, rerender } = renderHook(
({ workspace }: { workspace?: string }) => useActivityTimeline(undefined, workspace),
({ workspace }: { workspace?: string }) => (useActivityTimeline as any)(undefined, workspace),
{ wrapper, initialProps: { workspace: '/workspace1' } }
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await waitFor(() => expect((result.current as any).isSuccess).toBe(true));
expect(result.current.data).toEqual(mockData1);
// Change workspace
@@ -242,7 +242,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useTaskTypeCounts(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockData);
@@ -254,12 +254,12 @@ describe('Chart Hooks Integration Tests', () => {
mockApi.get.mockResolvedValue({ data: mockData });
const { result } = renderHook(
() => useTaskTypeCounts({ projectPath: '/test/workspace' }),
() => useTaskTypeCounts({ projectPath: '/test/workspace' } as any),
{ wrapper }
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(mockApi.get).toHaveBeenCalledWith('/api/task-type-counts', {
@@ -278,7 +278,7 @@ describe('Chart Hooks Integration Tests', () => {
const { result } = renderHook(() => useTaskTypeCounts(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockData);
@@ -294,7 +294,7 @@ describe('Chart Hooks Integration Tests', () => {
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect((result.current as any).isSuccess).toBe(true);
});
// Data should be fresh for 30s
@@ -318,9 +318,9 @@ describe('Chart Hooks Integration Tests', () => {
const { result: result3 } = renderHook(() => useTaskTypeCounts(), { wrapper });
await waitFor(() => {
expect(result1.current.isSuccess).toBe(true);
expect(result2.current.isSuccess).toBe(true);
expect(result3.current.isSuccess).toBe(true);
expect((result1.current as any).isSuccess).toBe(true);
expect((result2.current as any).isSuccess).toBe(true);
expect((result3.current as any).isSuccess).toBe(true);
});
expect(mockApi.get).toHaveBeenCalledTimes(3);
@@ -343,9 +343,9 @@ describe('Chart Hooks Integration Tests', () => {
const { result: result3 } = renderHook(() => useTaskTypeCounts(), { wrapper });
await waitFor(() => {
expect(result1.current.isError).toBe(true);
expect(result2.current.isSuccess).toBe(true);
expect(result3.current.isSuccess).toBe(true);
expect((result1.current as any).isError).toBe(true);
expect((result2.current as any).isSuccess).toBe(true);
expect((result3.current as any).isSuccess).toBe(true);
});
});
@@ -355,13 +355,13 @@ describe('Chart Hooks Integration Tests', () => {
// First component
const { result: result1 } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => expect(result1.current.isSuccess).toBe(true));
await waitFor(() => expect((result1.current as any).isSuccess).toBe(true));
// Second component should use cache
const { result: result2 } = renderHook(() => useWorkflowStatusCounts(), { wrapper });
await waitFor(() => {
expect(result2.current.isSuccess).toBe(true);
expect((result2.current as any).isSuccess).toBe(true);
});
// Only one API call

View File

@@ -183,7 +183,7 @@ describe('useCodexLens Hook', () => {
describe('useCodexLensModels', () => {
it('should fetch and filter models by type', async () => {
vi.mocked(api.fetchCodexLensModels).mockResolvedValue(mockModelsData);
vi.mocked(api.fetchCodexLensModels).mockResolvedValue(mockModelsData as any);
const { result } = renderHook(() => useCodexLensModels(), { wrapper });
@@ -203,7 +203,7 @@ describe('useCodexLens Hook', () => {
settings: { SETTING1: 'setting1' },
raw: 'KEY1=value1\nKEY2=value2',
};
vi.mocked(api.fetchCodexLensEnv).mockResolvedValue(mockEnv);
vi.mocked(api.fetchCodexLensEnv).mockResolvedValue(mockEnv as any);
const { result } = renderHook(() => useCodexLensEnv(), { wrapper });
@@ -225,8 +225,8 @@ describe('useCodexLens Hook', () => {
],
selected_device_id: 0,
};
vi.mocked(api.fetchCodexLensGpuDetect).mockResolvedValue(mockDetect);
vi.mocked(api.fetchCodexLensGpuList).mockResolvedValue(mockList);
vi.mocked(api.fetchCodexLensGpuDetect).mockResolvedValue(mockDetect as any);
vi.mocked(api.fetchCodexLensGpuList).mockResolvedValue(mockList as any);
const { result } = renderHook(() => useCodexLensGpu(), { wrapper });
@@ -366,13 +366,13 @@ describe('useCodexLens Hook', () => {
env: { KEY1: 'newvalue' },
settings: {},
raw: 'KEY1=newvalue',
});
} as any);
const { result } = renderHook(() => useUpdateCodexLensEnv(), { wrapper });
const updateResult = await result.current.updateEnv({
raw: 'KEY1=newvalue',
});
} as any);
expect(api.updateCodexLensEnv).toHaveBeenCalledWith({ raw: 'KEY1=newvalue' });
expect(updateResult.success).toBe(true);

View File

@@ -1000,7 +1000,7 @@ export function useCodexLensIndexingStatus(): UseCodexLensIndexingStatusReturn {
queryKey: codexLensKeys.indexingStatus(),
queryFn: checkCodexLensIndexingStatus,
staleTime: STALE_TIME_SHORT,
refetchInterval: (data) => (data?.inProgress ? 2000 : false), // Poll every 2s when indexing
refetchInterval: (query) => ((query.state.data as any)?.inProgress ? 2000 : false), // Poll every 2s when indexing
retry: false,
});

View File

@@ -67,7 +67,7 @@ describe('useIssueQueue', () => {
grouped_items: { 'parallel-group': ['task1', 'task2'] },
};
vi.mocked(api.fetchIssueQueue).mockResolvedValue(mockQueue);
vi.mocked(api.fetchIssueQueue).mockResolvedValue(mockQueue as any);
const { result } = renderHook(() => useIssueQueue(), {
wrapper: createWrapper(),
@@ -192,7 +192,7 @@ describe('useIssueDiscovery', () => {
vi.mocked(api.fetchDiscoveries).mockResolvedValue([
{ id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 2, created_at: '2024-01-01T00:00:00Z' },
]);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings as any);
const { result } = renderHook(() => useIssueDiscovery(), {
wrapper: createWrapper(),
@@ -228,7 +228,7 @@ describe('useIssueDiscovery', () => {
vi.mocked(api.fetchDiscoveries).mockResolvedValue([
{ id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 2, created_at: '2024-01-01T00:00:00Z' },
]);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings as any);
const { result } = renderHook(() => useIssueDiscovery(), {
wrapper: createWrapper(),
@@ -264,7 +264,7 @@ describe('useIssueDiscovery', () => {
vi.mocked(api.fetchDiscoveries).mockResolvedValue([
{ id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 2, created_at: '2024-01-01T00:00:00Z' },
]);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings as any);
const { result } = renderHook(() => useIssueDiscovery(), {
wrapper: createWrapper(),
@@ -299,7 +299,7 @@ describe('useIssueDiscovery', () => {
vi.mocked(api.fetchDiscoveries).mockResolvedValue([
{ id: '1', name: 'Session 1', status: 'completed' as const, progress: 100, findings_count: 1, created_at: '2024-01-01T00:00:00Z' },
]);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings);
vi.mocked(api.fetchDiscoveryFindings).mockResolvedValue(mockFindings as any);
const { result } = renderHook(() => useIssueDiscovery(), {
wrapper: createWrapper(),

View File

@@ -56,6 +56,10 @@ export function useLocale(): UseLocaleReturn {
* Hook to format i18n messages with the current locale
* @returns A formatMessage function for translating message IDs
*
* Supports both string and react-intl descriptor formats:
* - formatMessage('home.title')
* - formatMessage({ id: 'home.title' })
*
* @example
* ```tsx
* const formatMessage = useFormatMessage();
@@ -63,12 +67,13 @@ export function useLocale(): UseLocaleReturn {
* ```
*/
export function useFormatMessage(): (
id: string,
idOrDescriptor: string | { id: string; defaultMessage?: string },
values?: Record<string, string | number | boolean | Date | null | undefined>
) => string {
// Use useMemo to avoid recreating the function on each render
return useMemo(() => {
return (id: string, values?: Record<string, string | number | boolean | Date | null | undefined>) => {
return (idOrDescriptor: string | { id: string; defaultMessage?: string }, values?: Record<string, string | number | boolean | Date | null | undefined>) => {
const id = typeof idOrDescriptor === 'string' ? idOrDescriptor : idOrDescriptor.id;
return formatMessage(id, values);
};
}, []);

View File

@@ -298,7 +298,7 @@ export function usePrefetchSessions() {
return (filter?: SessionsFilter) => {
queryClient.prefetchQuery({
queryKey: sessionsKeys.list(filter),
queryFn: fetchSessions,
queryFn: () => fetchSessions(),
staleTime: STALE_TIME,
});
};

View File

@@ -14,6 +14,7 @@ import {
type ExecutionLog,
} from '../types/execution';
import { SurfaceUpdateSchema } from '../packages/a2ui-runtime/core/A2UITypes';
import type { ToolCallKind } from '../types/toolCall';
// Constants
const RECONNECT_DELAY_BASE = 1000; // 1 second
@@ -42,6 +43,15 @@ function getStoreState() {
addLog: execution.addLog,
completeExecution: execution.completeExecution,
currentExecution: execution.currentExecution,
// Tool call actions
startToolCall: execution.startToolCall,
updateToolCall: execution.updateToolCall,
completeToolCall: execution.completeToolCall,
toggleToolCallExpanded: execution.toggleToolCallExpanded,
// Tool call getters
getToolCallsForNode: execution.getToolCallsForNode,
// Node output actions
addNodeOutput: execution.addNodeOutput,
// Flow store
updateNode: flow.updateNode,
// CLI stream store
@@ -60,6 +70,61 @@ export interface UseWebSocketReturn {
reconnect: () => void;
}
// ========== Tool Call Parsing Helpers ==========
/**
* Parse tool call metadata from content
* Expected format: "[Tool] toolName(args)"
*/
function parseToolCallMetadata(content: string): { toolName: string; args: string } | null {
// Handle string content
if (typeof content === 'string') {
const match = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
if (match) {
return { toolName: match[1], args: match[2] || '' };
}
}
// Handle object content with toolName field
try {
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
if (parsed && typeof parsed === 'object' && 'toolName' in parsed) {
return {
toolName: String(parsed.toolName),
args: parsed.parameters ? JSON.stringify(parsed.parameters) : '',
};
}
} catch {
// Not valid JSON, return null
}
return null;
}
/**
* Infer tool call kind from tool name
*/
function inferToolCallKind(toolName: string): ToolCallKind {
const name = toolName.toLowerCase();
if (name === 'exec_command' || name === 'execute') return 'execute';
if (name === 'apply_patch' || name === 'patch') return 'patch';
if (name === 'web_search' || name === 'exa_search') return 'web_search';
if (name.startsWith('mcp_') || name.includes('mcp')) return 'mcp_tool';
if (name.includes('file') || name.includes('read') || name.includes('write')) return 'file_operation';
if (name.includes('think') || name.includes('reason')) return 'thinking';
// Default to execute
return 'execute';
}
/**
* Generate unique tool call ID
*/
function generateToolCallId(): string {
return `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
const { enabled = true, onMessage } = options;
@@ -105,7 +170,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
const unitContent = unit?.content || outputData;
const unitType = unit?.type || chunkType;
// Special handling for tool_call type
// Convert content to string for display
let content: string;
if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) {
// Format tool_call display
@@ -114,7 +179,49 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
content = typeof unitContent === 'string' ? unitContent : JSON.stringify(unitContent);
}
// Split by lines and add each line to store
// ========== Tool Call Processing ==========
// Parse and start new tool call if this is a tool_call type
if (unitType === 'tool_call') {
const metadata = parseToolCallMetadata(content);
if (metadata) {
const callId = generateToolCallId();
const currentNodeId = stores.currentExecution?.currentNodeId;
if (currentNodeId) {
stores.startToolCall(currentNodeId, callId, {
kind: inferToolCallKind(metadata.toolName),
description: metadata.args
? `${metadata.toolName}(${metadata.args})`
: metadata.toolName,
});
// Also add to node output for streaming display
stores.addNodeOutput(currentNodeId, {
type: 'tool_call',
content,
timestamp: Date.now(),
});
}
}
}
// ========== Stream Processing ==========
// Update tool call output buffer if we have an active tool call for this node
const currentNodeId = stores.currentExecution?.currentNodeId;
if (currentNodeId && (unitType === 'stdout' || unitType === 'stderr')) {
const toolCalls = stores.getToolCallsForNode?.(currentNodeId);
const activeCall = toolCalls?.find(c => c.status === 'executing');
if (activeCall) {
stores.updateToolCall(currentNodeId, activeCall.callId, {
outputChunk: content,
stream: unitType === 'stderr' ? 'stderr' : 'stdout',
});
}
}
// ========== Legacy CLI Stream Output ==========
// Split by lines and add each line to cliStreamStore
const lines = content.split('\n');
lines.forEach((line: string) => {
// Add non-empty lines, or single line if that's all we have