refactor: added UI-only tool result summary to all the tools; move summary generation logic to tool's own ts file

This commit is contained in:
Xiaohan-Tian
2026-06-02 23:13:25 -07:00
parent 4bdd0d5ea7
commit 6b0de74acd
13 changed files with 749 additions and 146 deletions
+165 -4
View File
@@ -5,11 +5,13 @@ import ChatBox from './ChatBox';
import { I18nContext } from '../i18n/I18nProvider';
import type { ResolvedLocaleCode } from '../i18n/types';
import { translate } from '../i18n/translate';
import type { ChatMessage } from '../types/projectTypes';
const {
agentCoreMock,
processUserMessageMock,
processStreamMock,
streamProcessorCallbacks,
} = vi.hoisted(() => ({
agentCoreMock: {
setLLMProvider: vi.fn(),
@@ -25,6 +27,12 @@ const {
},
processUserMessageMock: vi.fn(),
processStreamMock: vi.fn(async () => ''),
streamProcessorCallbacks: {
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined,
onMessageRemove: undefined as ((messageId: string) => void) | undefined,
onProcessingChange: undefined as ((isProcessing: boolean) => void) | undefined,
},
}));
vi.mock('./chat', () => ({
@@ -96,10 +104,17 @@ vi.mock('../util/messageFilter/UserMessageFilter', () => ({
}));
vi.mock('../hooks/useStreamProcessor', () => ({
useStreamProcessor: () => ({
abortController: null,
processStream: processStreamMock,
}),
useStreamProcessor: (options: typeof streamProcessorCallbacks) => {
streamProcessorCallbacks.onMessageAdd = options.onMessageAdd;
streamProcessorCallbacks.onMessageUpdate = options.onMessageUpdate;
streamProcessorCallbacks.onMessageRemove = options.onMessageRemove;
streamProcessorCallbacks.onProcessingChange = options.onProcessingChange;
return {
abortController: null,
processStream: processStreamMock,
};
},
}));
vi.mock('../utils/chatMessageUtils', () => ({
@@ -176,6 +191,10 @@ describe('ChatBox', () => {
processStreamMock.mockClear();
agentCoreMock.compactConversation.mockClear();
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
streamProcessorCallbacks.onMessageAdd = undefined;
streamProcessorCallbacks.onMessageUpdate = undefined;
streamProcessorCallbacks.onMessageRemove = undefined;
streamProcessorCallbacks.onProcessingChange = undefined;
});
it('renders the English assistant title under en_us', () => {
@@ -227,4 +246,146 @@ describe('ChatBox', () => {
expect(screen.queryByText('Task Checklist')).toBeNull();
});
});
it('removes older incomplete todo snapshots before appending a new one', async () => {
processUserMessageMock.mockResolvedValue({
displayUserMessage: false,
sendToLLM: true,
finalMessageForLLM: 'todo prompt',
pseudoAssistantResponse: null,
metadata: null,
});
processStreamMock.mockImplementation(async () => {
streamProcessorCallbacks.onMessageAdd?.({
id: 'todo-1',
role: 'assistant',
content: 'todo 1',
toolName: 'update_todo_list',
toolSuccess: true,
todoSnapshot: [
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
{ id: '2', text: 'Write harmony', status: 'in_progress', updatedAt: 2 },
],
});
streamProcessorCallbacks.onMessageAdd?.({
id: 'todo-2',
role: 'assistant',
content: 'todo 2',
toolName: 'update_todo_list',
toolSuccess: true,
todoSnapshot: [
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 3 },
{ id: '2', text: 'Write bass', status: 'pending', updatedAt: 4 },
],
});
return '';
});
renderWithLocale('en_us');
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
fireEvent.change(input, { target: { value: 'todo cleanup' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
await waitFor(() => {
expect(screen.queryByText('TODO SNAPSHOT: Inspect melody, Write harmony')).toBeNull();
expect(screen.getByText('TODO SNAPSHOT: Inspect melody, Write bass')).toBeTruthy();
});
});
it('preserves completed todo snapshots when a new incomplete snapshot is added', async () => {
processUserMessageMock.mockResolvedValue({
displayUserMessage: false,
sendToLLM: true,
finalMessageForLLM: 'todo prompt',
pseudoAssistantResponse: null,
metadata: null,
});
processStreamMock.mockImplementation(async () => {
streamProcessorCallbacks.onMessageAdd?.({
id: 'todo-complete',
role: 'assistant',
content: 'done snapshot',
toolName: 'update_todo_list',
toolSuccess: true,
todoSnapshot: [
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
{ id: '2', text: 'Write harmony', status: 'completed', updatedAt: 2 },
],
});
streamProcessorCallbacks.onMessageAdd?.({
id: 'todo-active',
role: 'assistant',
content: 'active snapshot',
toolName: 'update_todo_list',
toolSuccess: true,
todoSnapshot: [
{ id: '1', text: 'Mix stems', status: 'completed', updatedAt: 3 },
{ id: '2', text: 'Render bounce', status: 'in_progress', updatedAt: 4 },
],
});
return '';
});
renderWithLocale('en_us');
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
fireEvent.change(input, { target: { value: 'todo preserve' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
await waitFor(() => {
expect(screen.getByText('TODO SNAPSHOT: Inspect melody, Write harmony')).toBeTruthy();
expect(screen.getByText('TODO SNAPSHOT: Mix stems, Render bounce')).toBeTruthy();
});
});
it('does not remove non-todo assistant messages during todo cleanup', async () => {
processUserMessageMock.mockResolvedValue({
displayUserMessage: false,
sendToLLM: true,
finalMessageForLLM: 'todo prompt',
pseudoAssistantResponse: null,
metadata: null,
});
processStreamMock.mockImplementation(async () => {
streamProcessorCallbacks.onMessageAdd?.({
id: 'assistant-note',
role: 'assistant',
content: 'Normal assistant message',
});
streamProcessorCallbacks.onMessageAdd?.({
id: 'todo-1',
role: 'assistant',
content: 'todo 1',
toolName: 'update_todo_list',
toolSuccess: true,
todoSnapshot: [
{ id: '1', text: 'Inspect melody', status: 'pending', updatedAt: 1 },
],
});
streamProcessorCallbacks.onMessageAdd?.({
id: 'todo-2',
role: 'assistant',
content: 'todo 2',
toolName: 'update_todo_list',
toolSuccess: true,
todoSnapshot: [
{ id: '1', text: 'Render bounce', status: 'in_progress', updatedAt: 2 },
],
});
return '';
});
renderWithLocale('en_us');
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
fireEvent.change(input, { target: { value: 'todo cleanup keep assistant' } });
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
await waitFor(() => {
expect(screen.getByText('Normal assistant message')).toBeTruthy();
expect(screen.queryByText('TODO SNAPSHOT: Inspect melody')).toBeNull();
expect(screen.getByText('TODO SNAPSHOT: Render bounce')).toBeTruthy();
});
});
});
+23 -1
View File
@@ -3,6 +3,7 @@ import './ChatBox.css';
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
import { UserMessage, AssistantMessage } from './chat';
import { AgentCore } from '../agent/core/AgentCore';
import { summarizeTodoCounts } from '../agent/core/todo';
import { OpenAICompatibleLLMProvider, type LLMProvider } from '../agent/llm/LLMProvider';
import { LocalBrowserLLMProvider } from '../agent/llm/LocalBrowserLLMProvider';
import { ConfigManager } from '../core/config/ConfigManager';
@@ -23,6 +24,16 @@ import type { ChatMessage } from '../types/projectTypes';
// Module-level guard to avoid duplicate welcome in React StrictMode dev remounts
let hasShownWelcomeOnceInRuntime = false;
const TODO_TOOL_NAME = 'update_todo_list';
const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
if (message.toolName !== TODO_TOOL_NAME || !Array.isArray(message.todoSnapshot)) {
return false;
}
const counts = summarizeTodoCounts(message.todoSnapshot);
return counts.total > 0 && counts.completed === counts.total;
};
/**
* Create the LLM provider from current configuration
@@ -151,7 +162,18 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
}, []);
const handleMessageAdd = useCallback((message: ChatMessage) => {
setMessages(prev => [...prev, message]);
setMessages((prev) => {
if (message.toolName === TODO_TOOL_NAME && Array.isArray(message.todoSnapshot)) {
const preservedMessages = prev.filter((existingMessage) => (
existingMessage.toolName !== TODO_TOOL_NAME
|| !Array.isArray(existingMessage.todoSnapshot)
|| isCompletedTodoSnapshotMessage(existingMessage)
));
return [...preservedMessages, message];
}
return [...prev, message];
});
}, []);
const handleMessageRemove = useCallback((messageId: string) => {