feat: add agent todo tool and inline todo snapshot cards in chat
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
|
||||
const TODO_TOOL_NAME = 'update_todo_list';
|
||||
|
||||
interface StreamProcessorOptions {
|
||||
onMessageUpdate: (messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
|
||||
onMessageAdd: (message: ChatMessage) => void;
|
||||
@@ -82,6 +84,9 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
||||
|
||||
// Show tool call in UI
|
||||
const toolName = chunk.toolCall.function.name;
|
||||
if (toolName === TODO_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
let argsDisplay = '';
|
||||
try {
|
||||
const args = JSON.parse(chunk.toolCall.function.arguments);
|
||||
@@ -94,8 +99,14 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
||||
} else if (chunk.type === 'tool_result' && chunk.toolResult) {
|
||||
// Show tool result in UI
|
||||
const { name, success, result } = chunk.toolResult;
|
||||
const icon = success ? '✅' : '❌';
|
||||
const toolResultMsg = createMessage('assistant', `${icon} **${name}**\n\n └── ${result}`);
|
||||
const toolResultMsg = name === TODO_TOOL_NAME
|
||||
? {
|
||||
...createMessage('assistant', result),
|
||||
toolName: name,
|
||||
toolSuccess: success,
|
||||
todoSnapshot: AgentCore.instance().getAgentState().getTodos().map(todo => ({ ...todo })),
|
||||
}
|
||||
: createMessage('assistant', `${success ? '✅' : '❌'} **${name}**\n\n └── ${result}`);
|
||||
onMessageAdd(toolResultMsg);
|
||||
|
||||
// Reset for the next LLM turn in the agentic loop
|
||||
|
||||
Reference in New Issue
Block a user