feat: add agent todo tool and inline todo snapshot cards in chat
This commit is contained in:
@@ -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.
|
||||
|
||||
====
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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