diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 642681338..a3c2a7bdf 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -2133,6 +2133,120 @@ describe('useChat', () => { dispose(); }); + it('ignores duplicate tool_progress events without refreshing live activity', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + + await chat.sendMessage('hi'); + const fire = getFireEvent(); + + fire({ type: 'tool_start', data: { id: 'tool-1', name: 'pulse_read', input: '{}' } }); + vi.setSystemTime(10_100); + fire({ + type: 'tool_progress', + data: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + raw_input: '{"command":"ls /dev"}', + phase: 'running', + message: 'Running command.', + }, + }); + + let assistant = chat.messages().find((m) => m.role === 'assistant')!; + const firstPendingTool = assistant.pendingTools![0]; + const firstPendingToolEvent = assistant.streamEvents?.find((e) => e.type === 'pending_tool'); + expect(firstPendingTool.updatedAt).toBe(10_100); + expect(firstPendingToolEvent?.updatedAt).toBe(10_000); + expect(firstPendingToolEvent?.pendingTool?.updatedAt).toBe(10_100); + + vi.setSystemTime(20_000); + fire({ + type: 'tool_progress', + data: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + raw_input: '{"command":"ls /dev"}', + phase: 'running', + message: 'Running command.', + }, + }); + + assistant = chat.messages().find((m) => m.role === 'assistant')!; + const duplicatePendingToolEvents = assistant.streamEvents?.filter( + (e) => e.type === 'pending_tool', + ); + expect(assistant.pendingTools).toHaveLength(1); + expect(assistant.pendingTools![0]).toBe(firstPendingTool); + expect(assistant.pendingTools![0].updatedAt).toBe(10_100); + expect(duplicatePendingToolEvents).toHaveLength(1); + expect(duplicatePendingToolEvents![0]).toBe(firstPendingToolEvent); + expect(duplicatePendingToolEvents![0].updatedAt).toBe(10_000); + expect(duplicatePendingToolEvents![0].pendingTool?.updatedAt).toBe(10_100); + dispose(); + }); + + it('keeps changed tool_progress updates as fresh live activity', async () => { + vi.useFakeTimers(); + vi.setSystemTime(30_000); + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + + await chat.sendMessage('hi'); + const fire = getFireEvent(); + + fire({ type: 'tool_start', data: { id: 'tool-1', name: 'pulse_read', input: '{}' } }); + vi.setSystemTime(30_100); + fire({ + type: 'tool_progress', + data: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + raw_input: '{"command":"ls /dev"}', + phase: 'running', + message: 'Running command.', + }, + }); + vi.setSystemTime(30_500); + fire({ + type: 'tool_progress', + data: { + id: 'tool-1', + name: 'pulse_read', + input: '{}', + raw_input: '{"command":"ls /dev"}', + phase: 'running', + message: 'Reading output.', + }, + }); + + const assistant = chat.messages().find((m) => m.role === 'assistant')!; + const pendingToolEvents = assistant.streamEvents?.filter((e) => e.type === 'pending_tool'); + expect(assistant.pendingTools).toHaveLength(1); + expect(assistant.pendingTools![0]).toMatchObject({ + id: 'tool-1', + status: 'running', + progress: 'Reading output.', + updatedAt: 30_500, + }); + expect(pendingToolEvents).toHaveLength(1); + expect(pendingToolEvents![0]).toMatchObject({ + toolId: 'tool-1', + updatedAt: 30_000, + pendingTool: expect.objectContaining({ + id: 'tool-1', + progress: 'Reading output.', + updatedAt: 30_500, + }), + }); + dispose(); + }); + it('deduplicates repeated tool_start events without regressing live progress', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 91540b8ee..739aca88b 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -639,6 +639,14 @@ export function useChat(options: UseChatOptions = {}) { return 'running'; }; + const hasSamePendingToolActivity = (current: PendingTool, next: PendingTool): boolean => + current.id === next.id && + current.name === next.name && + current.input === next.input && + current.rawInput === next.rawInput && + current.status === next.status && + current.progress === next.progress; + const replacePendingToolStreamEvents = ( events: StreamDisplayEvent[], resolvedTool: PendingTool, @@ -847,6 +855,7 @@ export function useChat(options: UseChatOptions = {}) { let resolvedTool: PendingTool | undefined; let replacedTool = false; + let activityChanged = false; const updatedPendingTools: PendingTool[] = []; for (const tool of pendingTools) { @@ -858,7 +867,14 @@ export function useChat(options: UseChatOptions = {}) { continue; } replacedTool = true; - resolvedTool = mergeTool(tool); + const mergedTool = mergeTool(tool); + if (hasSamePendingToolActivity(tool, mergedTool)) { + resolvedTool = tool; + updatedPendingTools.push(tool); + continue; + } + activityChanged = true; + resolvedTool = mergedTool; updatedPendingTools.push(resolvedTool); } @@ -873,9 +889,14 @@ export function useChat(options: UseChatOptions = {}) { startedAt: now, updatedAt: now, }; + activityChanged = true; updatedPendingTools.push(resolvedTool); } + if (!activityChanged && resolvedTool) { + return msg; + } + return { ...msg, streamEvents: resolvedTool