feat: add agent todo tool and inline todo snapshot cards in chat

This commit is contained in:
Xiaohan-Tian
2026-06-02 21:52:44 -07:00
parent c1d9f4c9fb
commit 5aacf01457
24 changed files with 995 additions and 28 deletions
+129 -1
View File
@@ -1,5 +1,6 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { TodoItem } from '../agent/core/todo';
vi.mock('../agent/core/AgentCore', () => ({
AgentCore: {
@@ -16,7 +17,7 @@ vi.mock('../utils/chatMessageUtils', () => ({
tokenCount: 0
}),
createMessage: (role: 'user' | 'assistant', content: string) => ({
id: `${role}-message`,
id: `${role}-${content}`,
role,
content
})
@@ -89,4 +90,131 @@ describe('useStreamProcessor', () => {
expect(processingChanges.at(-1)).toBe(false);
});
it('suppresses update_todo_list tool-call messages and emits structured todo snapshots', async () => {
const todoSnapshot: TodoItem[] = [
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
{ id: '2', text: 'Write harmony', status: 'in_progress', activeText: 'Writing harmony', updatedAt: 2 },
];
vi.spyOn(AgentCore, 'instance').mockReturnValue({
getAgentState: () => ({
getTodos: () => todoSnapshot,
}),
processUserInput: async function* () {
yield {
type: 'tool_call',
content: '',
toolCall: {
id: 'todo-call-1',
type: 'function',
function: {
name: 'update_todo_list',
arguments: JSON.stringify({ items: [] }),
},
},
};
yield {
type: 'tool_result',
content: '',
toolResult: {
name: 'update_todo_list',
success: true,
result: 'todo fallback content',
},
};
yield { type: 'done', content: '' };
},
} as unknown as AgentCore);
const messages = new Map<string, ChatMessage>();
const { result } = renderHook(() => useStreamProcessor({
onMessageAdd: (message) => {
messages.set(message.id, message);
},
onMessageUpdate: (messageId, updater) => {
const current = messages.get(messageId);
if (!current) {
throw new Error(`Missing message ${messageId}`);
}
messages.set(messageId, updater(current));
},
onMessageRemove: (messageId) => {
messages.delete(messageId);
},
onProcessingChange: () => undefined,
}));
await act(async () => {
await result.current.processStream('todo prompt');
});
const addedMessages = [...messages.values()];
expect(addedMessages.some(message => message.content.includes('Calling tool: update_todo_list'))).toBe(false);
expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(true);
const todoMessage = addedMessages.find(message => message.toolName === 'update_todo_list');
expect(todoMessage?.toolSuccess).toBe(true);
expect(todoMessage?.todoSnapshot).toEqual(todoSnapshot);
expect(todoMessage?.content).toBe('todo fallback content');
});
it('continues to show generic tool-call messages for non-todo tools', async () => {
vi.spyOn(AgentCore, 'instance').mockReturnValue({
getAgentState: () => ({
getTodos: () => [],
}),
processUserInput: async function* () {
yield {
type: 'tool_call',
content: '',
toolCall: {
id: 'read-call-1',
type: 'function',
function: {
name: 'read_music',
arguments: JSON.stringify({}),
},
},
};
yield {
type: 'tool_result',
content: '',
toolResult: {
name: 'read_music',
success: true,
result: 'music data',
},
};
yield { type: 'done', content: '' };
},
} as unknown as AgentCore);
const messages = new Map<string, ChatMessage>();
const { result } = renderHook(() => useStreamProcessor({
onMessageAdd: (message) => {
messages.set(message.id, message);
},
onMessageUpdate: (messageId, updater) => {
const current = messages.get(messageId);
if (!current) {
throw new Error(`Missing message ${messageId}`);
}
messages.set(messageId, updater(current));
},
onMessageRemove: (messageId) => {
messages.delete(messageId);
},
onProcessingChange: () => undefined,
}));
await act(async () => {
await result.current.processStream('read prompt');
});
const addedMessages = [...messages.values()];
expect(addedMessages.some(message => message.content.includes('Calling tool: read_music'))).toBe(true);
expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(false);
});
});