Deduplicate Assistant tool progress updates

This commit is contained in:
rcourtman
2026-06-08 05:09:11 +01:00
parent 6555e172f2
commit 37bb716e89
2 changed files with 136 additions and 1 deletions
@@ -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' }));
@@ -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