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

This commit is contained in:
Xiaohan-Tian
2026-06-02 21:52:44 -07:00
parent c1d9f4c9fb
commit 5aacf01457
24 changed files with 995 additions and 28 deletions
@@ -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');
});
});
+21 -3
View File
@@ -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}`,
+134
View File
@@ -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');
});
});
+93 -1
View File
@@ -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);
}
}
+47
View File
@@ -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([]);
});
});
+34
View File
@@ -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();
}
}
}
+110
View File
@@ -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)}`;
}
+2 -1
View File
@@ -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;
+6 -1
View File
@@ -16,6 +16,7 @@ export interface ToolParameter {
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
description: string;
required?: boolean;
enum?: string[];
items?: ToolParameter; // For array types
properties?: Record<string, ToolParameter>; // For object types
}
@@ -114,6 +115,10 @@ export abstract class BaseTool {
schema.items = this.convertParamToJsonSchema(param.items);
}
if (param.enum) {
schema.enum = param.enum;
}
if (param.type === 'object' && param.properties) {
const properties: Record<string, unknown> = {};
const required: string[] = [];
@@ -237,4 +242,4 @@ export abstract class BaseTool {
result
};
}
}
}
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../stores/projectStore', () => ({
useProjectStore: {
getState: () => ({
refreshProjectState: vi.fn(),
}),
},
}));
import { AgentCore } from '../core/AgentCore';
import { UpdateTodoListTool } from './UpdateTodoListTool';
describe('UpdateTodoListTool', () => {
beforeEach(() => {
AgentCore.instance().clearConversation();
});
it('accepts a valid full-list replacement and updates agent state', async () => {
const tool = new UpdateTodoListTool();
const result = await tool.execute({
items: [
{ id: '1', text: 'Inspect current region', status: 'completed' },
{ id: '2', text: 'Draft harmony', status: 'in_progress', activeText: 'Drafting harmony' },
],
});
expect(result.success).toBe(true);
expect(result.result).toContain('(1/2 completed)');
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
expect.objectContaining({ id: '1', text: 'Inspect current region', status: 'completed' }),
expect.objectContaining({ id: '2', text: 'Draft harmony', status: 'in_progress', activeText: 'Drafting harmony' }),
]);
});
it('rejects empty todo text', async () => {
const tool = new UpdateTodoListTool();
const result = await tool.execute({
items: [
{ id: '1', text: ' ', status: 'pending' },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('text is required');
});
it('rejects invalid statuses', async () => {
const tool = new UpdateTodoListTool();
const result = await tool.execute({
items: [
{ id: '1', text: 'Task', status: 'active' },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain("invalid status 'active'");
});
it('rejects duplicate ids', async () => {
const tool = new UpdateTodoListTool();
const result = await tool.execute({
items: [
{ id: '1', text: 'Task A', status: 'pending' },
{ id: '1', text: 'Task B', status: 'pending' },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('duplicate id');
});
it('rejects multiple in-progress items', async () => {
const tool = new UpdateTodoListTool();
const result = await tool.execute({
items: [
{ id: '1', text: 'Task A', status: 'in_progress' },
{ id: '2', text: 'Task B', status: 'in_progress' },
],
});
expect(result.success).toBe(false);
expect(result.result).toContain('Only one todo item can be in_progress');
});
});
+60
View File
@@ -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');
}
}
}
+3 -1
View File
@@ -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,
+91
View File
@@ -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;
+24 -2
View File
@@ -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();
});
});
});
+3 -1
View File
@@ -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();
});
});
+46 -1
View File
@@ -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}>
+129 -1
View File
@@ -1,5 +1,6 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { TodoItem } from '../agent/core/todo';
vi.mock('../agent/core/AgentCore', () => ({
AgentCore: {
@@ -16,7 +17,7 @@ vi.mock('../utils/chatMessageUtils', () => ({
tokenCount: 0
}),
createMessage: (role: 'user' | 'assistant', content: string) => ({
id: `${role}-message`,
id: `${role}-${content}`,
role,
content
})
@@ -89,4 +90,131 @@ describe('useStreamProcessor', () => {
expect(processingChanges.at(-1)).toBe(false);
});
it('suppresses update_todo_list tool-call messages and emits structured todo snapshots', async () => {
const todoSnapshot: TodoItem[] = [
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
{ id: '2', text: 'Write harmony', status: 'in_progress', activeText: 'Writing harmony', updatedAt: 2 },
];
vi.spyOn(AgentCore, 'instance').mockReturnValue({
getAgentState: () => ({
getTodos: () => todoSnapshot,
}),
processUserInput: async function* () {
yield {
type: 'tool_call',
content: '',
toolCall: {
id: 'todo-call-1',
type: 'function',
function: {
name: 'update_todo_list',
arguments: JSON.stringify({ items: [] }),
},
},
};
yield {
type: 'tool_result',
content: '',
toolResult: {
name: 'update_todo_list',
success: true,
result: 'todo fallback content',
},
};
yield { type: 'done', content: '' };
},
} as unknown as AgentCore);
const messages = new Map<string, ChatMessage>();
const { result } = renderHook(() => useStreamProcessor({
onMessageAdd: (message) => {
messages.set(message.id, message);
},
onMessageUpdate: (messageId, updater) => {
const current = messages.get(messageId);
if (!current) {
throw new Error(`Missing message ${messageId}`);
}
messages.set(messageId, updater(current));
},
onMessageRemove: (messageId) => {
messages.delete(messageId);
},
onProcessingChange: () => undefined,
}));
await act(async () => {
await result.current.processStream('todo prompt');
});
const addedMessages = [...messages.values()];
expect(addedMessages.some(message => message.content.includes('Calling tool: update_todo_list'))).toBe(false);
expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(true);
const todoMessage = addedMessages.find(message => message.toolName === 'update_todo_list');
expect(todoMessage?.toolSuccess).toBe(true);
expect(todoMessage?.todoSnapshot).toEqual(todoSnapshot);
expect(todoMessage?.content).toBe('todo fallback content');
});
it('continues to show generic tool-call messages for non-todo tools', async () => {
vi.spyOn(AgentCore, 'instance').mockReturnValue({
getAgentState: () => ({
getTodos: () => [],
}),
processUserInput: async function* () {
yield {
type: 'tool_call',
content: '',
toolCall: {
id: 'read-call-1',
type: 'function',
function: {
name: 'read_music',
arguments: JSON.stringify({}),
},
},
};
yield {
type: 'tool_result',
content: '',
toolResult: {
name: 'read_music',
success: true,
result: 'music data',
},
};
yield { type: 'done', content: '' };
},
} as unknown as AgentCore);
const messages = new Map<string, ChatMessage>();
const { result } = renderHook(() => useStreamProcessor({
onMessageAdd: (message) => {
messages.set(message.id, message);
},
onMessageUpdate: (messageId, updater) => {
const current = messages.get(messageId);
if (!current) {
throw new Error(`Missing message ${messageId}`);
}
messages.set(messageId, updater(current));
},
onMessageRemove: (messageId) => {
messages.delete(messageId);
},
onProcessingChange: () => undefined,
}));
await act(async () => {
await result.current.processStream('read prompt');
});
const addedMessages = [...messages.values()];
expect(addedMessages.some(message => message.content.includes('Calling tool: read_music'))).toBe(true);
expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(false);
});
});
+13 -2
View File
@@ -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
View File
@@ -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}',
+4
View File
@@ -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}',
+4
View File
@@ -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[];
}
/**