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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user