feat: add agent todo tool and inline todo snapshot cards in chat
This commit is contained in:
@@ -67,4 +67,31 @@ describe('ConversationCompactor', () => {
|
||||
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepends supplemental todo context to the summarization prompt', async () => {
|
||||
const prompts: string[] = [];
|
||||
const provider: LLMProvider = {
|
||||
async *generateStream(messages) {
|
||||
prompts.push(String(messages[0]?.content ?? ''));
|
||||
yield { type: 'text', content: 'summary' };
|
||||
yield { type: 'done', content: '', finishReason: 'stop' };
|
||||
},
|
||||
};
|
||||
const compactor = new ConversationCompactor({
|
||||
provider,
|
||||
systemPrompt: 'compact prompt',
|
||||
supplementalContext: 'Current todo state:\n[>] #1: Review melody',
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
|
||||
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
|
||||
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
|
||||
];
|
||||
|
||||
await compactor.compact(messages, 2);
|
||||
|
||||
expect(prompts[0]).toContain('Current todo state:');
|
||||
expect(prompts[0]).toContain('[>] #1: Review melody');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ConversationCompactorOptions {
|
||||
tools?: OpenAIToolDefinition[];
|
||||
focus?: string;
|
||||
onProgress?: (progress: CompactProgress) => void;
|
||||
supplementalContext?: string;
|
||||
}
|
||||
|
||||
export interface ConversationCompactionResult {
|
||||
@@ -76,12 +77,21 @@ function splitIntoChunks(serializedMessages: string[]): string[] {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildCompactionUserPrompt(chunkText: string, chunkIndex: number, chunkCount: number, focus?: string): string {
|
||||
function buildCompactionUserPrompt(
|
||||
chunkText: string,
|
||||
chunkIndex: number,
|
||||
chunkCount: number,
|
||||
focus?: string,
|
||||
supplementalContext?: string,
|
||||
): string {
|
||||
const focusSection = focus?.trim()
|
||||
? `Focus instruction from the user: ${focus.trim()}\n\n`
|
||||
: '';
|
||||
const contextSection = supplementalContext?.trim()
|
||||
? `${supplementalContext.trim()}\n\n`
|
||||
: '';
|
||||
|
||||
return `${focusSection}Summarize this conversation history chunk for future continuation.
|
||||
return `${focusSection}${contextSection}Summarize this conversation history chunk for future continuation.
|
||||
|
||||
Preserve:
|
||||
- the active goal
|
||||
@@ -103,6 +113,7 @@ export class ConversationCompactor {
|
||||
private readonly tools: OpenAIToolDefinition[];
|
||||
private readonly focus?: string;
|
||||
private readonly onProgress?: (progress: CompactProgress) => void;
|
||||
private readonly supplementalContext?: string;
|
||||
|
||||
constructor(options: ConversationCompactorOptions) {
|
||||
this.provider = options.provider;
|
||||
@@ -110,6 +121,7 @@ export class ConversationCompactor {
|
||||
this.tools = options.tools ?? [];
|
||||
this.focus = options.focus;
|
||||
this.onProgress = options.onProgress;
|
||||
this.supplementalContext = options.supplementalContext;
|
||||
}
|
||||
|
||||
async compact(messages: Message[], tailStartIndex: number): Promise<ConversationCompactionResult> {
|
||||
@@ -174,7 +186,13 @@ export class ConversationCompactor {
|
||||
}
|
||||
|
||||
private async summarizeChunk(chunkText: string, chunkIndex: number, chunkCount: number): Promise<string> {
|
||||
const prompt = buildCompactionUserPrompt(chunkText, chunkIndex, chunkCount, this.focus);
|
||||
const prompt = buildCompactionUserPrompt(
|
||||
chunkText,
|
||||
chunkIndex,
|
||||
chunkCount,
|
||||
this.focus,
|
||||
this.supplementalContext,
|
||||
);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: `compact_user_${chunkIndex}`,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AgentCore } from './AgentCore';
|
||||
import type { LLMProvider } from '../llm/LLMProvider';
|
||||
import type { Message, ToolCall } from './AgentState';
|
||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
refreshProjectState: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./SystemPrompts', () => ({
|
||||
SystemPrompts: {
|
||||
getSystemPromptWithContext: vi.fn(async () => 'system prompt'),
|
||||
},
|
||||
}));
|
||||
|
||||
class ScriptedProvider implements LLMProvider {
|
||||
public calls: Message[][] = [];
|
||||
|
||||
constructor(private readonly scripts: StreamChunk[][]) {}
|
||||
|
||||
async *generateStream(messages: Message[]): AsyncIterableIterator<StreamChunk> {
|
||||
this.calls.push(messages.map(message => ({ ...message })));
|
||||
const script = this.scripts.shift() ?? [{ type: 'done', content: '', finishReason: 'stop' }];
|
||||
for (const chunk of script) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolCall(name: string, args: Record<string, unknown>, id: string): ToolCall {
|
||||
return {
|
||||
id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectChunks(input: string): Promise<StreamChunk[]> {
|
||||
const chunks: StreamChunk[] = [];
|
||||
for await (const chunk of AgentCore.instance().processUserInput(input)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
describe('AgentCore todo integration', () => {
|
||||
beforeEach(() => {
|
||||
AgentCore.instance().clearConversation();
|
||||
AgentCore.instance().setLLMProvider(new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]));
|
||||
});
|
||||
|
||||
it('updates todo state through the update_todo_list tool during the agent loop', async () => {
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{
|
||||
type: 'tool_call',
|
||||
content: '',
|
||||
toolCall: makeToolCall('update_todo_list', {
|
||||
items: [
|
||||
{ id: '1', text: 'Read current music', status: 'completed' },
|
||||
{ id: '2', text: 'Write counter melody', status: 'in_progress' },
|
||||
],
|
||||
}, 'todo_1'),
|
||||
},
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Done' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Plan and update the region in multiple steps.');
|
||||
|
||||
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
|
||||
expect.objectContaining({ id: '1', text: 'Read current music', status: 'completed' }),
|
||||
expect.objectContaining({ id: '2', text: 'Write counter melody', status: 'in_progress' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects a hidden reminder after tool work goes stale with an active checklist', async () => {
|
||||
AgentCore.instance().getAgentState().setTodos([
|
||||
{ id: '1', text: 'Analyze melody', status: 'in_progress', updatedAt: 1 },
|
||||
]);
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('unknown_tool', {}, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('unknown_tool', {}, 'tool_2') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Final reply' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Please analyze and revise this passage.');
|
||||
|
||||
expect(provider.calls[2][provider.calls[2].length - 1]?.content).toContain('Keep the task list current');
|
||||
});
|
||||
|
||||
it('does not inject the reminder for a simple one-shot turn without todos', async () => {
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('unknown_tool', {}, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Final reply' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
expect(provider.calls[1][provider.calls[1].length - 1]?.content).not.toContain('Keep the task list current');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type { ToolCall } from './AgentState';
|
||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { buildTodoContext } from './todo';
|
||||
|
||||
export interface CompactConversationOptions {
|
||||
trigger: 'manual' | 'auto';
|
||||
@@ -26,11 +27,16 @@ export interface CompactConversationResult {
|
||||
*/
|
||||
export class AgentCore {
|
||||
private static _instance: AgentCore | null = null;
|
||||
private static readonly TODO_TOOL_NAME = 'update_todo_list';
|
||||
private static readonly TODO_REMINDER = '<reminder>Keep the task list current. Use update_todo_list for multi-step work, mark one item in_progress before major tool work, and complete items as you finish them.</reminder>';
|
||||
|
||||
private llmProvider: LLMProvider | null = null;
|
||||
private agentState: AgentState;
|
||||
private currentUserMessageId: string | null = null;
|
||||
private currentAssistantMessageId: string | null = null;
|
||||
private todoToolCyclesSinceUpdate = 0;
|
||||
private remindAboutTodosOnNextLoop = false;
|
||||
private currentTurnLikelyMultiStep = false;
|
||||
|
||||
private constructor() {
|
||||
this.agentState = new AgentState();
|
||||
@@ -108,6 +114,7 @@ export class AgentCore {
|
||||
|
||||
// Add user message to state
|
||||
this.currentUserMessageId = this.agentState.addMessage('user', userInput);
|
||||
this.currentTurnLikelyMultiStep = this.isLikelyMultiStepTask(userInput);
|
||||
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(
|
||||
this.llmProvider.getPreferredSystemPromptPath?.(),
|
||||
@@ -120,6 +127,7 @@ export class AgentCore {
|
||||
|
||||
while (continueLoop) {
|
||||
const conversationHistory = this.agentState.getMessages();
|
||||
const turnMessages = this.buildLoopMessages(conversationHistory);
|
||||
|
||||
// Pre-add an empty assistant message that we'll update as we stream
|
||||
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
|
||||
@@ -129,7 +137,7 @@ export class AgentCore {
|
||||
let finishReason = 'stop';
|
||||
let performanceInfo: StreamChunk['performanceInfo'];
|
||||
|
||||
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt, tools)) {
|
||||
for await (const chunk of this.llmProvider.generateStream(turnMessages, systemPrompt, tools)) {
|
||||
if (chunk.type === 'text') {
|
||||
assistantTextContent += chunk.content;
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
|
||||
@@ -143,6 +151,8 @@ export class AgentCore {
|
||||
}
|
||||
|
||||
if (finishReason === 'tool_calls' && accumulatedToolCalls.length > 0) {
|
||||
this.updateTodoReminderState(accumulatedToolCalls);
|
||||
|
||||
// Update assistant message with tool calls
|
||||
this.agentState.updateMessage(
|
||||
this.currentAssistantMessageId,
|
||||
@@ -188,6 +198,10 @@ export class AgentCore {
|
||||
} finally {
|
||||
this.currentUserMessageId = null;
|
||||
this.currentAssistantMessageId = null;
|
||||
this.currentTurnLikelyMultiStep = false;
|
||||
if (this.agentState.getTodos().length === 0) {
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +236,9 @@ export class AgentCore {
|
||||
|
||||
clearConversation(): void {
|
||||
this.agentState.clearMessages();
|
||||
this.todoToolCyclesSinceUpdate = 0;
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
this.currentTurnLikelyMultiStep = false;
|
||||
}
|
||||
|
||||
async shouldCompactBeforeNextTurn(userInput: string): Promise<boolean> {
|
||||
@@ -287,6 +304,7 @@ export class AgentCore {
|
||||
tools: this.getToolDefinitions(),
|
||||
focus: options.focus,
|
||||
onProgress: options.onProgress,
|
||||
supplementalContext: buildTodoContext(this.agentState.getTodos()),
|
||||
});
|
||||
const result = await compactor.compact(messages, tailStartIndex);
|
||||
if (!result.changed) {
|
||||
@@ -348,4 +366,78 @@ export class AgentCore {
|
||||
|
||||
return 90;
|
||||
}
|
||||
|
||||
private buildLoopMessages(conversationHistory: ReturnType<AgentState['getMessages']>): ReturnType<AgentState['getMessages']> {
|
||||
if (!this.shouldInjectTodoReminder()) {
|
||||
return conversationHistory;
|
||||
}
|
||||
|
||||
return [
|
||||
...conversationHistory,
|
||||
{
|
||||
id: `todo_reminder_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: AgentCore.TODO_REMINDER,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private shouldInjectTodoReminder(): boolean {
|
||||
const hasTodos = this.agentState.getTodos().length > 0;
|
||||
return (hasTodos && this.todoToolCyclesSinceUpdate >= 2)
|
||||
|| (!hasTodos && this.remindAboutTodosOnNextLoop && this.currentTurnLikelyMultiStep);
|
||||
}
|
||||
|
||||
private updateTodoReminderState(toolCalls: ToolCall[]): void {
|
||||
const usedTodoTool = toolCalls.some(toolCall => toolCall.function.name === AgentCore.TODO_TOOL_NAME);
|
||||
const hasNonTodoToolCall = toolCalls.some(toolCall => toolCall.function.name !== AgentCore.TODO_TOOL_NAME);
|
||||
const hasTodos = this.agentState.getTodos().length > 0;
|
||||
|
||||
if (usedTodoTool) {
|
||||
this.todoToolCyclesSinceUpdate = 0;
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasNonTodoToolCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTodos) {
|
||||
this.todoToolCyclesSinceUpdate += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentTurnLikelyMultiStep) {
|
||||
this.remindAboutTodosOnNextLoop = true;
|
||||
}
|
||||
}
|
||||
|
||||
private isLikelyMultiStepTask(userInput: string): boolean {
|
||||
const normalized = userInput.toLowerCase();
|
||||
if (/\n\s*[-*]\s|\n\s*\d+\.\s/.test(userInput)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const coordinationKeywords = [
|
||||
'plan',
|
||||
'analyze',
|
||||
'compare',
|
||||
'design',
|
||||
'implement',
|
||||
'refactor',
|
||||
'fix',
|
||||
'update',
|
||||
'multi-step',
|
||||
'todo',
|
||||
'checklist',
|
||||
];
|
||||
|
||||
if (coordinationKeywords.some(keyword => normalized.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return userInput.length >= 120 && /\band\b/.test(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AgentState } from './AgentState';
|
||||
import type { TodoItem } from './todo';
|
||||
|
||||
describe('AgentState compaction helpers', () => {
|
||||
it('preserves the most recent exchange block as the raw tail', () => {
|
||||
@@ -62,4 +63,50 @@ describe('AgentState compaction helpers', () => {
|
||||
expect(state.getMessages()).toHaveLength(0);
|
||||
expect(state.getFullMessages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stores, reads, and clears session-scoped todos', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
const todos: TodoItem[] = [
|
||||
{ id: '1', text: 'Inspect region', status: 'completed', updatedAt: 1 },
|
||||
{ id: '2', text: 'Write notes', status: 'in_progress', activeText: 'Writing notes', updatedAt: 2 },
|
||||
];
|
||||
|
||||
state.setTodos(todos);
|
||||
|
||||
expect(state.getTodos()).toEqual(todos);
|
||||
|
||||
state.clearTodos();
|
||||
|
||||
expect(state.getTodos()).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains todos when compacting message history', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.addMessage('assistant', 'first reply');
|
||||
state.addMessage('user', 'second');
|
||||
state.addMessage('assistant', 'second reply');
|
||||
state.setTodos([
|
||||
{ id: '1', text: 'Keep this task', status: 'in_progress', updatedAt: 1 },
|
||||
]);
|
||||
|
||||
const compacted = state.createCompactedHistory('summary', 2, 'manual');
|
||||
state.replaceMessages(compacted);
|
||||
|
||||
expect(state.getTodos()).toEqual([
|
||||
{ id: '1', text: 'Keep this task', status: 'in_progress', updatedAt: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears todos when the conversation is cleared', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.setTodos([
|
||||
{ id: '1', text: 'Temporary task', status: 'pending', updatedAt: 1 },
|
||||
]);
|
||||
|
||||
state.clearMessages();
|
||||
|
||||
expect(state.getTodos()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Manages the state of an agent conversation
|
||||
*/
|
||||
import type { TodoItem } from './todo';
|
||||
|
||||
/**
|
||||
* Tool call info attached to assistant messages (OpenAI function calling format)
|
||||
@@ -28,8 +29,10 @@ export interface Message {
|
||||
export class AgentState {
|
||||
private messages: Message[] = [];
|
||||
private fullMessages: Message[] = [];
|
||||
private todos: TodoItem[] = [];
|
||||
private conversationId: string;
|
||||
private isWorkingOnTask: boolean = false;
|
||||
private todoListeners: Set<() => void> = new Set();
|
||||
|
||||
constructor(conversationId?: string, isWorkingOnTask: boolean = false) {
|
||||
this.conversationId = conversationId || this.generateConversationId();
|
||||
@@ -143,6 +146,7 @@ export class AgentState {
|
||||
clearMessages(): void {
|
||||
this.messages = [];
|
||||
this.fullMessages = [];
|
||||
this.clearTodos();
|
||||
}
|
||||
|
||||
replaceMessages(messages: Message[]): void {
|
||||
@@ -195,4 +199,34 @@ export class AgentState {
|
||||
setIsWorkingOnTask(isWorkingOnTask: boolean): void {
|
||||
this.isWorkingOnTask = isWorkingOnTask;
|
||||
}
|
||||
|
||||
getTodos(): TodoItem[] {
|
||||
return this.todos.map(todo => ({ ...todo }));
|
||||
}
|
||||
|
||||
setTodos(todos: TodoItem[]): void {
|
||||
this.todos = todos.map(todo => ({ ...todo }));
|
||||
this.notifyTodoListeners();
|
||||
}
|
||||
|
||||
clearTodos(): void {
|
||||
if (this.todos.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.todos = [];
|
||||
this.notifyTodoListeners();
|
||||
}
|
||||
|
||||
subscribeTodoChanges(listener: () => void): () => void {
|
||||
this.todoListeners.add(listener);
|
||||
return () => {
|
||||
this.todoListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyTodoListeners(): void {
|
||||
for (const listener of this.todoListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
text: string;
|
||||
status: TodoStatus;
|
||||
activeText?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TodoInputItem {
|
||||
id?: string;
|
||||
text: string;
|
||||
status: TodoStatus;
|
||||
activeText?: string;
|
||||
}
|
||||
|
||||
const TODO_MARKERS: Record<TodoStatus, string> = {
|
||||
pending: '[ ]',
|
||||
in_progress: '[>]',
|
||||
completed: '[x]',
|
||||
};
|
||||
|
||||
export function validateAndNormalizeTodos(items: TodoInputItem[], now: number = Date.now()): TodoItem[] {
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error('Todo items must be an array');
|
||||
}
|
||||
|
||||
if (items.length > 20) {
|
||||
throw new Error('Max 20 todo items allowed');
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
let inProgressCount = 0;
|
||||
|
||||
return items.map((item, index) => {
|
||||
const id = String(item.id ?? index + 1).trim();
|
||||
const text = String(item.text ?? '').trim();
|
||||
const status = String(item.status ?? '').trim() as TodoStatus;
|
||||
const activeText = typeof item.activeText === 'string' ? item.activeText.trim() : undefined;
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`Todo item ${index + 1}: id is required`);
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
throw new Error(`Todo item ${id}: duplicate id`);
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
if (!text) {
|
||||
throw new Error(`Todo item ${id}: text is required`);
|
||||
}
|
||||
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') {
|
||||
throw new Error(`Todo item ${id}: invalid status '${status}'`);
|
||||
}
|
||||
|
||||
if (status === 'in_progress') {
|
||||
inProgressCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
status,
|
||||
...(activeText ? { activeText } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
}).map((item) => {
|
||||
if (inProgressCount > 1) {
|
||||
throw new Error('Only one todo item can be in_progress at a time');
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTodoList(items: TodoItem[]): string {
|
||||
if (items.length === 0) {
|
||||
return 'No todos.';
|
||||
}
|
||||
|
||||
const lines = items.map((item) => {
|
||||
const label = item.status === 'in_progress' && item.activeText ? item.activeText : item.text;
|
||||
return `${TODO_MARKERS[item.status]} #${item.id}: ${label}`;
|
||||
});
|
||||
const completed = items.filter(item => item.status === 'completed').length;
|
||||
lines.push(`\n(${completed}/${items.length} completed)`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function summarizeTodoCounts(items: TodoItem[]): {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
} {
|
||||
return {
|
||||
total: items.length,
|
||||
completed: items.filter(item => item.status === 'completed').length,
|
||||
inProgress: items.filter(item => item.status === 'in_progress').length,
|
||||
pending: items.filter(item => item.status === 'pending').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTodoContext(items: TodoItem[]): string {
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `Current todo state:\n${renderTodoList(items)}`;
|
||||
}
|
||||
@@ -169,7 +169,8 @@ export class OpenAICompatibleLLMProvider implements LLMProvider {
|
||||
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream) {
|
||||
console.log('LLMProvider: chunk', JSON.stringify(chunk));
|
||||
// Do not delete: leave this commented out for future debugging purpose.
|
||||
// console.log('LLMProvider: chunk', JSON.stringify(chunk));
|
||||
const choice = chunk.choices[0];
|
||||
if (!choice) continue;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ToolParameter {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
description: string;
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
items?: ToolParameter; // For array types
|
||||
properties?: Record<string, ToolParameter>; // For object types
|
||||
}
|
||||
@@ -114,6 +115,10 @@ export abstract class BaseTool {
|
||||
schema.items = this.convertParamToJsonSchema(param.items);
|
||||
}
|
||||
|
||||
if (param.enum) {
|
||||
schema.enum = param.enum;
|
||||
}
|
||||
|
||||
if (param.type === 'object' && param.properties) {
|
||||
const properties: Record<string, unknown> = {};
|
||||
const required: string[] = [];
|
||||
@@ -237,4 +242,4 @@ export abstract class BaseTool {
|
||||
result
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
refreshProjectState: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AgentCore } from '../core/AgentCore';
|
||||
import { UpdateTodoListTool } from './UpdateTodoListTool';
|
||||
|
||||
describe('UpdateTodoListTool', () => {
|
||||
beforeEach(() => {
|
||||
AgentCore.instance().clearConversation();
|
||||
});
|
||||
|
||||
it('accepts a valid full-list replacement and updates agent state', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Inspect current region', status: 'completed' },
|
||||
{ id: '2', text: 'Draft harmony', status: 'in_progress', activeText: 'Drafting harmony' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('(1/2 completed)');
|
||||
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
|
||||
expect.objectContaining({ id: '1', text: 'Inspect current region', status: 'completed' }),
|
||||
expect.objectContaining({ id: '2', text: 'Draft harmony', status: 'in_progress', activeText: 'Drafting harmony' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects empty todo text', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: ' ', status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('text is required');
|
||||
});
|
||||
|
||||
it('rejects invalid statuses', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Task', status: 'active' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain("invalid status 'active'");
|
||||
});
|
||||
|
||||
it('rejects duplicate ids', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Task A', status: 'pending' },
|
||||
{ id: '1', text: 'Task B', status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('duplicate id');
|
||||
});
|
||||
|
||||
it('rejects multiple in-progress items', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Task A', status: 'in_progress' },
|
||||
{ id: '2', text: 'Task B', status: 'in_progress' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Only one todo item can be in_progress');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AgentCore } from '../core/AgentCore';
|
||||
import { renderTodoList, summarizeTodoCounts, validateAndNormalizeTodos, type TodoInputItem } from '../core/todo';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
|
||||
export class UpdateTodoListTool extends BaseTool {
|
||||
readonly name = 'update_todo_list';
|
||||
readonly description = 'Replace the current task checklist for multi-step work and keep progress updated.';
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'The full todo list to keep for the current task.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A single todo item.',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Stable task id.',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'User-visible task description.',
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Current task status.',
|
||||
required: true,
|
||||
enum: ['pending', 'in_progress', 'completed'],
|
||||
},
|
||||
activeText: {
|
||||
type: 'string',
|
||||
description: 'Optional present-tense wording to show while the task is in progress.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const items = (params.items as TodoInputItem[]) ?? [];
|
||||
const todos = validateAndNormalizeTodos(items);
|
||||
AgentCore.instance().getAgentState().setTodos(todos);
|
||||
|
||||
const counts = summarizeTodoCounts(todos);
|
||||
const rendered = renderTodoList(todos);
|
||||
|
||||
return this.createSuccessResult(
|
||||
`${rendered}\n\nTotal: ${counts.total}, in progress: ${counts.inProgress}, pending: ${counts.pending}, completed: ${counts.completed}`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(error instanceof Error ? error.message : 'Failed to update todo list');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import { AddNotesTool } from './AddNotesTool';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { ReadChordProgressionTool } from './ReadChordProgressionTool';
|
||||
import { UpdateTodoListTool } from './UpdateTodoListTool';
|
||||
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, ReadChordProgressionTool };
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, ReadChordProgressionTool, UpdateTodoListTool };
|
||||
|
||||
// Tool registry for easy access
|
||||
export const AVAILABLE_TOOLS = {
|
||||
update_todo_list: UpdateTodoListTool,
|
||||
add_notes: AddNotesTool,
|
||||
remove_notes: RemoveNotesTool,
|
||||
read_music: ReadMusicTool,
|
||||
|
||||
@@ -166,6 +166,97 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card {
|
||||
background: linear-gradient(180deg, #252525 0%, #202020 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card-header h4 {
|
||||
margin: 0;
|
||||
color: #f0f0f0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chatbox-todo-count {
|
||||
color: #8fb8da;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chatbox-todo-active {
|
||||
color: #d7d7d7;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatbox-todo-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chatbox-todo-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
color: #d8d8d8;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-completed .chatbox-todo-text {
|
||||
color: #9ba39f;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-in_progress .chatbox-todo-text {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.chatbox-todo-marker {
|
||||
width: 10px;
|
||||
flex: 0 0 10px;
|
||||
color: #7cc2f1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-completed .chatbox-todo-marker {
|
||||
color: #67c18a;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-pending .chatbox-todo-marker {
|
||||
color: #a7a7a7;
|
||||
}
|
||||
|
||||
.chatbox-todo-status {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chatbox-todo-status.is-success {
|
||||
color: #67c18a;
|
||||
}
|
||||
|
||||
.chatbox-todo-status.is-error {
|
||||
color: #d45a5a;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -15,7 +15,11 @@ const {
|
||||
setLLMProvider: vi.fn(),
|
||||
getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })),
|
||||
abortCurrentRequest: vi.fn(),
|
||||
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
|
||||
getAgentState: vi.fn(() => ({
|
||||
getMessages: vi.fn(() => []),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
})),
|
||||
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
|
||||
shouldCompactBeforeNextTurn: vi.fn(async () => false),
|
||||
},
|
||||
@@ -25,7 +29,17 @@ const {
|
||||
|
||||
vi.mock('./chat', () => ({
|
||||
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
AssistantMessage: ({
|
||||
content,
|
||||
todoSnapshot,
|
||||
}: {
|
||||
content: string;
|
||||
todoSnapshot?: Array<{ text: string }>;
|
||||
}) => (
|
||||
<div>
|
||||
{todoSnapshot ? `TODO SNAPSHOT: ${todoSnapshot.map(todo => todo.text).join(', ')}` : content}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../agent/core/AgentCore', () => ({
|
||||
@@ -205,4 +219,12 @@ describe('ChatBox', () => {
|
||||
expect(screen.getByText('Conversation Compacted')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render a pinned todo checklist from agent state', async () => {
|
||||
renderWithLocale('en_us');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Task Checklist')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,7 +428,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
const localRuntimeMessage = localModelState.runtimeSupport.reason;
|
||||
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
|
||||
|
||||
return (
|
||||
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
||||
<div className="chatbox-header">
|
||||
@@ -526,6 +525,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
performanceInfo={message.performanceInfo}
|
||||
toolName={message.toolName}
|
||||
toolSuccess={message.toolSuccess}
|
||||
todoSnapshot={message.todoSnapshot}
|
||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import AssistantMessage from './AssistantMessage';
|
||||
import type { TodoItem } from '../../agent/core/todo';
|
||||
|
||||
describe('AssistantMessage', () => {
|
||||
afterEach(() => {
|
||||
@@ -110,4 +111,26 @@ describe('AssistantMessage', () => {
|
||||
|
||||
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a structured todo snapshot card instead of markdown content', () => {
|
||||
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 },
|
||||
];
|
||||
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="fallback content"
|
||||
toolName="update_todo_list"
|
||||
toolSuccess={true}
|
||||
todoSnapshot={todoSnapshot}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Agent task checklist snapshot')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task Checklist')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/2 completed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Working on: Writing harmony')).toBeInTheDocument();
|
||||
expect(screen.queryByText('fallback content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,17 @@ import remarkMath from 'remark-math';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
|
||||
import { summarizeTodoCounts } from '../../agent/core/todo';
|
||||
import type { TodoItem } from '../../agent/core/todo';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
onAbort?: () => void;
|
||||
performanceInfo?: PerformanceInfo;
|
||||
toolName?: string;
|
||||
toolSuccess?: boolean;
|
||||
todoSnapshot?: TodoItem[];
|
||||
}
|
||||
|
||||
// Memoized code component to prevent SyntaxHighlighter re-renders
|
||||
@@ -58,7 +63,15 @@ const formatThinkingDuration = (elapsedSeconds: number): string => {
|
||||
return `Thinking for ${minutes}m ${seconds.toString().padStart(2, '0')}s...`;
|
||||
};
|
||||
|
||||
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort, performanceInfo }) => {
|
||||
const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
content,
|
||||
isStreaming,
|
||||
onAbort,
|
||||
performanceInfo,
|
||||
toolName,
|
||||
toolSuccess,
|
||||
todoSnapshot,
|
||||
}) => {
|
||||
const prefillTps = formatTps(performanceInfo?.prefillTps);
|
||||
const generationTps = formatTps(performanceInfo?.generationTps);
|
||||
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
|
||||
@@ -68,6 +81,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
||||
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|
||||
|| content === COMPACTION_DONE_LABEL
|
||||
|| content === COMPACTION_EMPTY_LABEL;
|
||||
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinking) {
|
||||
@@ -89,6 +103,37 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
||||
}, [isThinking]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isTodoSnapshotCard) {
|
||||
const counts = summarizeTodoCounts(todoSnapshot);
|
||||
const activeTodo = todoSnapshot.find(todo => todo.status === 'in_progress') ?? null;
|
||||
|
||||
return (
|
||||
<section className="chatbox-todo-card" aria-label="Agent task checklist snapshot">
|
||||
<div className="chatbox-todo-card-header">
|
||||
<h4>Task Checklist</h4>
|
||||
<span className="chatbox-todo-count">
|
||||
{counts.completed}/{counts.total} completed
|
||||
</span>
|
||||
</div>
|
||||
{activeTodo && (
|
||||
<div className="chatbox-todo-active">
|
||||
Working on: {activeTodo.activeText || activeTodo.text}
|
||||
</div>
|
||||
)}
|
||||
<ul className="chatbox-todo-list">
|
||||
{todoSnapshot.map((todo) => (
|
||||
<li key={todo.id} className={`chatbox-todo-item is-${todo.status}`}>
|
||||
<span className="chatbox-todo-marker" aria-hidden="true">
|
||||
{todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '→' : '•'}
|
||||
</span>
|
||||
<span className="chatbox-todo-text">{todo.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompactionBanner) {
|
||||
return (
|
||||
<div className="message-divider-banner" aria-label={content}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,10 @@ export const enUsMessages: TranslationMessages = {
|
||||
'app.loading': 'Loading ...',
|
||||
'assistant.displayName': 'K.G.Studio Musician Assistant',
|
||||
'assistant.welcomeFallback': 'Welcome to K.G.Studio Musician Assistant.',
|
||||
'chatbox.todo.title': 'Task Checklist',
|
||||
'chatbox.todo.ariaLabel': 'Agent task checklist',
|
||||
'chatbox.todo.count': '{completed}/{total} completed',
|
||||
'chatbox.todo.active': 'Working on: {task}',
|
||||
'mainContent.createTrack': 'Create track',
|
||||
'mainContent.showGlobalTracks': 'Show global tracks',
|
||||
'status.chordGuideCandidate': 'Chord Guide Candidate: {name} - {notes} - {note}',
|
||||
|
||||
@@ -6,6 +6,10 @@ export const zhCnMessages: TranslationMessages = {
|
||||
'app.loading': '加载中...',
|
||||
'assistant.displayName': 'K.G.Studio 音乐创作助手',
|
||||
'assistant.welcomeFallback': '欢迎使用 K.G.Studio 音乐创作助手。',
|
||||
'chatbox.todo.title': '任务清单',
|
||||
'chatbox.todo.ariaLabel': '代理任务清单',
|
||||
'chatbox.todo.count': '已完成 {completed}/{total}',
|
||||
'chatbox.todo.active': '当前进行中:{task}',
|
||||
'mainContent.createTrack': '创建轨道',
|
||||
'mainContent.showGlobalTracks': '显示全局轨道',
|
||||
'status.chordGuideCandidate': '和弦指导候选: {name} - {notes} - {note}',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import type { PerformanceInfo } from '../agent/llm/StreamingTypes';
|
||||
import type { TodoItem } from '../agent/core/todo';
|
||||
|
||||
export interface TimeSignature {
|
||||
numerator: number;
|
||||
@@ -13,6 +14,9 @@ export interface ChatMessage {
|
||||
isStreaming?: boolean;
|
||||
tokenCount?: number;
|
||||
performanceInfo?: PerformanceInfo;
|
||||
toolName?: string;
|
||||
toolSuccess?: boolean;
|
||||
todoSnapshot?: TodoItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user