refactor: added UI-only tool result summary to all the tools; move summary generation logic to tool's own ts file
This commit is contained in:
@@ -138,6 +138,7 @@ describe('useStreamProcessor', () => {
|
||||
type: 'tool_result',
|
||||
content: '',
|
||||
toolResult: {
|
||||
toolCallId: 'todo-call-1',
|
||||
name: 'update_todo_list',
|
||||
success: true,
|
||||
result: 'todo fallback content',
|
||||
@@ -201,6 +202,7 @@ describe('useStreamProcessor', () => {
|
||||
type: 'tool_result',
|
||||
content: '',
|
||||
toolResult: {
|
||||
toolCallId: 'read-call-1',
|
||||
name: 'read_music',
|
||||
success: true,
|
||||
result: 'music data',
|
||||
@@ -239,7 +241,7 @@ describe('useStreamProcessor', () => {
|
||||
expect(addedMessages.some(message => message.toolName === 'update_todo_list')).toBe(false);
|
||||
});
|
||||
|
||||
it('attaches add_notes summary metadata for chat-only rendering while preserving raw content', async () => {
|
||||
it('uses tool-provided add_notes summary metadata for chat-only rendering while preserving raw content', async () => {
|
||||
const selectedRegion = new KGMidiRegion('region-1', '1', 0, 'Verse Melody');
|
||||
|
||||
vi.spyOn(AgentCore, 'instance').mockReturnValue({
|
||||
@@ -268,6 +270,7 @@ it('attaches add_notes summary metadata for chat-only rendering while preserving
|
||||
type: 'tool_result',
|
||||
content: '',
|
||||
toolResult: {
|
||||
toolCallId: 'add-notes-call-1',
|
||||
name: 'add_notes',
|
||||
success: true,
|
||||
result: 'Successfully created 2 notes: C4 (beat 16, length 4), E4 (beat 20, length 8)',
|
||||
@@ -325,4 +328,64 @@ it('attaches add_notes summary metadata for chat-only rendering while preserving
|
||||
'Successfully created 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7.'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the raw tool result when tool summary generation cannot resolve context', async () => {
|
||||
vi.spyOn(AgentCore, 'instance').mockReturnValue({
|
||||
getAgentState: () => ({
|
||||
getTodos: () => [],
|
||||
}),
|
||||
processUserInput: async function* () {
|
||||
yield {
|
||||
type: 'tool_call',
|
||||
content: '',
|
||||
toolCall: {
|
||||
id: 'read-call-fallback',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_music',
|
||||
arguments: JSON.stringify({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'tool_result',
|
||||
content: '',
|
||||
toolResult: {
|
||||
toolCallId: 'read-call-fallback',
|
||||
name: 'read_music',
|
||||
success: true,
|
||||
result: 'raw music result',
|
||||
},
|
||||
};
|
||||
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 fallback prompt');
|
||||
});
|
||||
|
||||
const toolResultMessage = [...messages.values()].find(message => message.toolName === 'read_music');
|
||||
expect(toolResultMessage?.toolRawResult).toBe('raw music result');
|
||||
expect(toolResultMessage?.toolResultDisplayContent).toBe('raw music result');
|
||||
});
|
||||
});
|
||||
|
||||
+23
-124
@@ -1,129 +1,17 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
import { AVAILABLE_TOOLS } from '../agent/tools';
|
||||
import { createStreamingMessage, createMessage } from '../utils/chatMessageUtils';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
|
||||
const TODO_TOOL_NAME = 'update_todo_list';
|
||||
const ADD_NOTES_TOOL_NAME = 'add_notes';
|
||||
|
||||
interface PendingToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface AddNotesToolArguments {
|
||||
notes: Array<{
|
||||
pitch: string;
|
||||
start: number;
|
||||
length: number;
|
||||
velocity?: number;
|
||||
}>;
|
||||
region_id?: string;
|
||||
}
|
||||
|
||||
const getBarNumberFromStartBeat = (beat: number, beatsPerBar: number): number => (
|
||||
Math.floor(beat / beatsPerBar) + 1
|
||||
);
|
||||
|
||||
const getBarNumberFromEndBeat = (beat: number, beatsPerBar: number): number => (
|
||||
Math.max(1, Math.ceil(beat / beatsPerBar))
|
||||
);
|
||||
|
||||
interface AddNotesSummaryData {
|
||||
noteCount: number;
|
||||
regionName: string;
|
||||
trackName: string;
|
||||
earliestNoteStartBar: number;
|
||||
latestNoteEndBar: number;
|
||||
}
|
||||
|
||||
const resolveTargetMidiRegion = (
|
||||
regionId: string | undefined,
|
||||
storeState: ReturnType<typeof useProjectStore.getState>,
|
||||
): { region: KGMidiRegion; trackName: string } | undefined => {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
const findById = (candidateRegionId: string): { region: KGMidiRegion; trackName: string } | undefined => {
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === candidateRegionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return {
|
||||
region,
|
||||
trackName: track.getName(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (regionId) {
|
||||
return findById(regionId);
|
||||
}
|
||||
|
||||
if (storeState.activeRegionId) {
|
||||
const activeRegion = findById(storeState.activeRegionId);
|
||||
if (activeRegion) {
|
||||
return activeRegion;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRegionId = storeState.selectedRegionIds.at(-1);
|
||||
if (selectedRegionId) {
|
||||
const selectedRegion = findById(selectedRegionId);
|
||||
if (selectedRegion) {
|
||||
return selectedRegion;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedItems = KGCore.instance().getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
const track = tracks.find(candidate => candidate.getId() === item.getTrackId());
|
||||
return {
|
||||
region: item,
|
||||
trackName: track?.getName() ?? `Track ${item.getTrackIndex() + 1}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildAddNotesSummary = (args: Record<string, unknown> | null): AddNotesSummaryData | undefined => {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typedArgs = args as AddNotesToolArguments;
|
||||
if (!Array.isArray(typedArgs.notes) || typedArgs.notes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storeState = useProjectStore.getState();
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator ?? storeState.timeSignature.numerator;
|
||||
const targetRegion = resolveTargetMidiRegion(typedArgs.region_id, storeState);
|
||||
if (!targetRegion) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const earliestNoteStartBeat = Math.min(...typedArgs.notes.map(note => note.start));
|
||||
const latestNoteEndBeat = Math.max(...typedArgs.notes.map(note => note.start + note.length));
|
||||
|
||||
return {
|
||||
noteCount: typedArgs.notes.length,
|
||||
regionName: targetRegion.region.getName(),
|
||||
trackName: targetRegion.trackName,
|
||||
earliestNoteStartBar: getBarNumberFromStartBeat(earliestNoteStartBeat, beatsPerBar),
|
||||
latestNoteEndBar: getBarNumberFromEndBeat(latestNoteEndBeat, beatsPerBar),
|
||||
};
|
||||
};
|
||||
|
||||
const buildToolResultDisplayContent = (
|
||||
toolName: string,
|
||||
success: boolean,
|
||||
@@ -134,14 +22,17 @@ const buildToolResultDisplayContent = (
|
||||
return rawResult;
|
||||
}
|
||||
|
||||
if (toolName === ADD_NOTES_TOOL_NAME) {
|
||||
const summary = buildAddNotesSummary(toolArgs);
|
||||
if (summary) {
|
||||
return `Successfully created ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in region **${summary.regionName}** on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`;
|
||||
}
|
||||
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
|
||||
if (!ToolClass) {
|
||||
return rawResult;
|
||||
}
|
||||
|
||||
return rawResult;
|
||||
try {
|
||||
const toolInstance = new ToolClass();
|
||||
return toolInstance.buildToolResultDisplayContent(toolArgs, { success, result: rawResult }) ?? rawResult;
|
||||
} catch {
|
||||
return rawResult;
|
||||
}
|
||||
};
|
||||
|
||||
interface StreamProcessorOptions {
|
||||
@@ -204,6 +95,10 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
||||
tokenCount
|
||||
}));
|
||||
} else if (chunk.type === 'tool_call' && chunk.toolCall) {
|
||||
console.log('------------ ASSISTANT TOOL CALL ------------');
|
||||
console.log(JSON.stringify(chunk.toolCall, null, 2));
|
||||
console.log('---------------------------------------------');
|
||||
|
||||
// Finalize or remove the current streaming message
|
||||
if (hasTextContent) {
|
||||
onMessageUpdate(currentStreamingId, (msg) => ({
|
||||
@@ -231,10 +126,10 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
||||
try {
|
||||
const args = JSON.parse(chunk.toolCall.function.arguments);
|
||||
argsDisplay = JSON.stringify(args, null, 2);
|
||||
pendingToolCalls.push({ name: toolName, arguments: args });
|
||||
pendingToolCalls.push({ id: chunk.toolCall.id, name: toolName, arguments: args });
|
||||
} catch {
|
||||
argsDisplay = chunk.toolCall.function.arguments;
|
||||
pendingToolCalls.push({ name: toolName, arguments: null });
|
||||
pendingToolCalls.push({ id: chunk.toolCall.id, name: toolName, arguments: null });
|
||||
}
|
||||
const toolCallMsg = {
|
||||
...createMessage('assistant', `🔧 **Calling tool: ${toolName}**\n\n\`\`\`json\n${argsDisplay}\n\`\`\``),
|
||||
@@ -242,9 +137,13 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
||||
};
|
||||
onMessageAdd(toolCallMsg);
|
||||
} else if (chunk.type === 'tool_result' && chunk.toolResult) {
|
||||
console.log('------------ TOOL RESULT ------------');
|
||||
console.log(JSON.stringify(chunk.toolResult, null, 2));
|
||||
console.log('-------------------------------------');
|
||||
|
||||
// Show tool result in UI
|
||||
const { name, success, result } = chunk.toolResult;
|
||||
const pendingToolCallIndex = pendingToolCalls.findIndex(toolCall => toolCall.name === name);
|
||||
const { toolCallId, name, success, result } = chunk.toolResult;
|
||||
const pendingToolCallIndex = pendingToolCalls.findIndex(toolCall => toolCall.id === toolCallId);
|
||||
const pendingToolCall = pendingToolCallIndex >= 0
|
||||
? pendingToolCalls.splice(pendingToolCallIndex, 1)[0]
|
||||
: undefined;
|
||||
|
||||
Reference in New Issue
Block a user