From 5aacf0145762b851bdb925dbbfd3a8142c7e78d0 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:52:44 -0700 Subject: [PATCH] feat: add agent todo tool and inline todo snapshot cards in chat --- public/prompts/system.md | 24 ++-- public/prompts/system_compact.md | 17 ++- .../compact/ConversationCompactor.test.ts | 27 ++++ src/agent/compact/ConversationCompactor.ts | 24 +++- src/agent/core/AgentCore.test.ts | 134 ++++++++++++++++++ src/agent/core/AgentCore.ts | 94 +++++++++++- src/agent/core/AgentState.test.ts | 47 ++++++ src/agent/core/AgentState.ts | 34 +++++ src/agent/core/todo.ts | 110 ++++++++++++++ src/agent/llm/LLMProvider.ts | 3 +- src/agent/tools/BaseTool.ts | 7 +- src/agent/tools/UpdateTodoListTool.test.ts | 90 ++++++++++++ src/agent/tools/UpdateTodoListTool.ts | 60 ++++++++ src/agent/tools/index.ts | 4 +- src/components/ChatBox.css | 91 ++++++++++++ src/components/ChatBox.test.tsx | 26 +++- src/components/ChatBox.tsx | 4 +- src/components/chat/AssistantMessage.test.tsx | 23 +++ src/components/chat/AssistantMessage.tsx | 47 +++++- src/hooks/useStreamProcessor.test.ts | 130 ++++++++++++++++- src/hooks/useStreamProcessor.ts | 15 +- src/i18n/messages/en_us.ts | 4 + src/i18n/messages/zh_cn.ts | 4 + src/types/projectTypes.ts | 4 + 24 files changed, 995 insertions(+), 28 deletions(-) create mode 100644 src/agent/core/AgentCore.test.ts create mode 100644 src/agent/core/todo.ts create mode 100644 src/agent/tools/UpdateTodoListTool.test.ts create mode 100644 src/agent/tools/UpdateTodoListTool.ts diff --git a/public/prompts/system.md b/public/prompts/system.md index 993834f..5fa1342 100644 --- a/public/prompts/system.md +++ b/public/prompts/system.md @@ -21,6 +21,9 @@ You have access to tools for reading and editing music. Tools are invoked via na ## read_music Read existing musical content from the project. The output is in ABC notation. If there are multiple tracks, all tracks are returned as separate ABC notation sections, with track names (e.g., "Melody", "Bass", "Chords") providing arrangement context. +## update_todo_list +Replace the current task checklist for multi-step work. Use it to keep a concise list of pending, in-progress, and completed tasks visible to the user while you work. + ## remove_notes Remove notes from a given beat range in the current region. @@ -32,10 +35,12 @@ To create a melodic line, use sequential `start` values for each note. To create # Tool Use Guidelines 1. Assess what information you already have and what you need before choosing a tool. -2. Choose the most appropriate tool for the current step. If you need to understand existing music, use `read_music` first. -3. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result. -4. If a required parameter cannot be determined from context, ask the user instead of guessing. -5. Proceed step-by-step. Each action should build on confirmed results from previous steps. +2. For multi-step tasks, user-provided checklists, or work that will likely require 3 or more actions, use `update_todo_list` before major tool work begins. Keep exactly one item `in_progress` while you are actively working on it, and mark items `completed` when done. +3. Do not create a todo list for simple one-shot answers or single-tool actions that do not need progress tracking. +4. Choose the most appropriate tool for the current step. If you need to understand existing music, use `read_music` first. +5. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result. +6. If a required parameter cannot be determined from context, ask the user instead of guessing. +7. Proceed step-by-step. Each action should build on confirmed results from previous steps. ==== @@ -108,11 +113,12 @@ OBJECTIVE You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically. 1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order. -2. Work through these goals sequentially, utilizing available tools as necessary. Each goal should correspond to a distinct step in your problem-solving process. -3. Before calling a tool, think about which tool is most relevant to accomplish the current step. Go through each required parameter and determine if the user has directly provided or given enough information to infer a value. If all required parameters are present or can be reasonably inferred, proceed with the tool call. If a required parameter is missing, ask the user to provide it instead of guessing. -4. Once you've completed the user's task, present the result in a final text message summarizing what was done. -5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. -6. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first check the key signature, time signature, and existing notes in the current region, then think about which progression would best suit the user's needs as well as the melody, then convert the chord progression into an actual list of chords based on the key signature, and finally organize the notes into a list and use the `add_notes` tool to add the notes to the current region based on the time signature to set the start beat and length of each note. +2. If the work is non-trivial, reflect those goals in `update_todo_list` and keep the checklist current while you work. +3. Work through these goals sequentially, utilizing available tools as necessary. Each goal should correspond to a distinct step in your problem-solving process. +4. Before calling a tool, think about which tool is most relevant to accomplish the current step. Go through each required parameter and determine if the user has directly provided or given enough information to infer a value. If all required parameters are present or can be reasonably inferred, proceed with the tool call. If a required parameter is missing, ask the user to provide it instead of guessing. +5. Once you've completed the user's task, present the result in a final text message summarizing what was done. +6. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance. +7. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first check the key signature, time signature, and existing notes in the current region, then think about which progression would best suit the user's needs as well as the melody, then convert the chord progression into an actual list of chords based on the key signature, and finally organize the notes into a list and use the `add_notes` tool to add the notes to the current region based on the time signature to set the start beat and length of each note. ==== diff --git a/public/prompts/system_compact.md b/public/prompts/system_compact.md index 95682cd..a146247 100644 --- a/public/prompts/system_compact.md +++ b/public/prompts/system_compact.md @@ -16,6 +16,9 @@ TOOLS ## read_music Reads existing music in ABC notation. +## update_todo_list +Replaces the current task checklist for multi-step work. + ## remove_notes Removes notes from a beat range. @@ -37,6 +40,9 @@ TOOL RULES - Check tool results before continuing. - Do not assume success. - If information is missing, ask the user. +- For multi-step tasks, user checklists, or work likely to need 3 or more actions, use `update_todo_list` before major tool work. +- When using `update_todo_list`, keep exactly one item `in_progress` and mark items `completed` when done. +- Do not create a todo list for simple one-shot answers or single-tool tasks. - Use `read_music` before editing when musical context is needed. - Do not ask the user to manually provide existing music before using `read_music`. @@ -93,11 +99,12 @@ Focus mainly on the current region. WORKFLOW 1. Understand the task -2. Read music if needed -3. Plan musical changes -4. Edit step-by-step with tools -5. Verify results -6. Return a concise summary +2. If the work is non-trivial, create or update a checklist with `update_todo_list` +3. Read music if needed +4. Plan musical changes +5. Edit step-by-step with tools +6. Verify results and keep the checklist current +7. Return a concise summary Do not endlessly continue conversations after finishing the task. diff --git a/src/agent/compact/ConversationCompactor.test.ts b/src/agent/compact/ConversationCompactor.test.ts index 2ac0e0e..412d353 100644 --- a/src/agent/compact/ConversationCompactor.test.ts +++ b/src/agent/compact/ConversationCompactor.test.ts @@ -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'); + }); }); diff --git a/src/agent/compact/ConversationCompactor.ts b/src/agent/compact/ConversationCompactor.ts index 4dcc99c..4b9bf19 100644 --- a/src/agent/compact/ConversationCompactor.ts +++ b/src/agent/compact/ConversationCompactor.ts @@ -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 { @@ -174,7 +186,13 @@ export class ConversationCompactor { } private async summarizeChunk(chunkText: string, chunkIndex: number, chunkCount: number): Promise { - 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}`, diff --git a/src/agent/core/AgentCore.test.ts b/src/agent/core/AgentCore.test.ts new file mode 100644 index 0000000..68bad75 --- /dev/null +++ b/src/agent/core/AgentCore.test.ts @@ -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 { + 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, id: string): ToolCall { + return { + id, + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, + }; +} + +async function collectChunks(input: string): Promise { + 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'); + }); +}); diff --git a/src/agent/core/AgentCore.ts b/src/agent/core/AgentCore.ts index f6830cd..509cab2 100644 --- a/src/agent/core/AgentCore.ts +++ b/src/agent/core/AgentCore.ts @@ -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 = '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.'; 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 { @@ -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): ReturnType { + 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); + } } diff --git a/src/agent/core/AgentState.test.ts b/src/agent/core/AgentState.test.ts index 548f6d2..1503682 100644 --- a/src/agent/core/AgentState.test.ts +++ b/src/agent/core/AgentState.test.ts @@ -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([]); + }); }); diff --git a/src/agent/core/AgentState.ts b/src/agent/core/AgentState.ts index 0e2c4b6..2e9400a 100644 --- a/src/agent/core/AgentState.ts +++ b/src/agent/core/AgentState.ts @@ -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(); + } + } } diff --git a/src/agent/core/todo.ts b/src/agent/core/todo.ts new file mode 100644 index 0000000..269383b --- /dev/null +++ b/src/agent/core/todo.ts @@ -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 = { + 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(); + 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)}`; +} diff --git a/src/agent/llm/LLMProvider.ts b/src/agent/llm/LLMProvider.ts index 8b4bb14..bca9437 100644 --- a/src/agent/llm/LLMProvider.ts +++ b/src/agent/llm/LLMProvider.ts @@ -169,7 +169,8 @@ export class OpenAICompatibleLLMProvider implements LLMProvider { const toolCallAccumulator = new Map(); 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; diff --git a/src/agent/tools/BaseTool.ts b/src/agent/tools/BaseTool.ts index 7aa5d94..494cbb6 100644 --- a/src/agent/tools/BaseTool.ts +++ b/src/agent/tools/BaseTool.ts @@ -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; // 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 = {}; const required: string[] = []; @@ -237,4 +242,4 @@ export abstract class BaseTool { result }; } -} \ No newline at end of file +} diff --git a/src/agent/tools/UpdateTodoListTool.test.ts b/src/agent/tools/UpdateTodoListTool.test.ts new file mode 100644 index 0000000..aefcc3c --- /dev/null +++ b/src/agent/tools/UpdateTodoListTool.test.ts @@ -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'); + }); +}); diff --git a/src/agent/tools/UpdateTodoListTool.ts b/src/agent/tools/UpdateTodoListTool.ts new file mode 100644 index 0000000..e33f062 --- /dev/null +++ b/src/agent/tools/UpdateTodoListTool.ts @@ -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 = { + 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): Promise { + 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'); + } + } +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 0ba4263..9651d20 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -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, diff --git a/src/components/ChatBox.css b/src/components/ChatBox.css index 0d0a9bb..65a3f3f 100644 --- a/src/components/ChatBox.css +++ b/src/components/ChatBox.css @@ -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; diff --git a/src/components/ChatBox.test.tsx b/src/components/ChatBox.test.tsx index 66b0edd..159b3a6 100644 --- a/src/components/ChatBox.test.tsx +++ b/src/components/ChatBox.test.tsx @@ -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 }) =>
{content}
, - AssistantMessage: ({ content }: { content: string }) =>
{content}
, + AssistantMessage: ({ + content, + todoSnapshot, + }: { + content: string; + todoSnapshot?: Array<{ text: string }>; + }) => ( +
+ {todoSnapshot ? `TODO SNAPSHOT: ${todoSnapshot.map(todo => todo.text).join(', ')}` : content} +
+ ), })); 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(); + }); + }); }); diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 19f0582..e849c6a 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -428,7 +428,6 @@ const ChatBox: React.FC = ({ isVisible }) => { const localRuntimeMessage = localModelState.runtimeSupport.reason; const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; - return (
@@ -526,6 +525,9 @@ const ChatBox: React.FC = ({ isVisible }) => { content={message.content} isStreaming={message.isStreaming} performanceInfo={message.performanceInfo} + toolName={message.toolName} + toolSuccess={message.toolSuccess} + todoSnapshot={message.todoSnapshot} onAbort={message.isStreaming ? handleAbort : undefined} /> ) diff --git a/src/components/chat/AssistantMessage.test.tsx b/src/components/chat/AssistantMessage.test.tsx index 8939df1..04a8e14 100644 --- a/src/components/chat/AssistantMessage.test.tsx +++ b/src/components/chat/AssistantMessage.test.tsx @@ -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( + + ); + + 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(); + }); }); diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index b3a77b0..7a8e532 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -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 = ({ content, isStreaming, onAbort, performanceInfo }) => { +const AssistantMessage: React.FC = ({ + 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 = ({ 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 = ({ content, isStreamin }, [isThinking]); const renderContent = () => { + if (isTodoSnapshotCard) { + const counts = summarizeTodoCounts(todoSnapshot); + const activeTodo = todoSnapshot.find(todo => todo.status === 'in_progress') ?? null; + + return ( +
+
+

Task Checklist

+ + {counts.completed}/{counts.total} completed + +
+ {activeTodo && ( +
+ Working on: {activeTodo.activeText || activeTodo.text} +
+ )} +
    + {todoSnapshot.map((todo) => ( +
  • + + {todo.text} +
  • + ))} +
+
+ ); + } + if (isCompactionBanner) { return (
diff --git a/src/hooks/useStreamProcessor.test.ts b/src/hooks/useStreamProcessor.test.ts index 8685d2d..5e6c8ba 100644 --- a/src/hooks/useStreamProcessor.test.ts +++ b/src/hooks/useStreamProcessor.test.ts @@ -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(); + + 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(); + + 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); + }); }); diff --git a/src/hooks/useStreamProcessor.ts b/src/hooks/useStreamProcessor.ts index 24b2d09..285afe4 100644 --- a/src/hooks/useStreamProcessor.ts +++ b/src/hooks/useStreamProcessor.ts @@ -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 diff --git a/src/i18n/messages/en_us.ts b/src/i18n/messages/en_us.ts index 56dacfc..4aa0be3 100644 --- a/src/i18n/messages/en_us.ts +++ b/src/i18n/messages/en_us.ts @@ -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}', diff --git a/src/i18n/messages/zh_cn.ts b/src/i18n/messages/zh_cn.ts index bc51cdc..1231208 100644 --- a/src/i18n/messages/zh_cn.ts +++ b/src/i18n/messages/zh_cn.ts @@ -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}', diff --git a/src/types/projectTypes.ts b/src/types/projectTypes.ts index b94f5b4..aaeb91f 100644 --- a/src/types/projectTypes.ts +++ b/src/types/projectTypes.ts @@ -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[]; } /**