diff --git a/public/prompts/system.md b/public/prompts/system.md index a22f582..d992cf8 100644 --- a/public/prompts/system.md +++ b/public/prompts/system.md @@ -39,6 +39,9 @@ Create a new MIDI track using a `track_name` and an `instrument`. The `instrumen ## update_track Update an existing MIDI track by `track_id` or `track_name`. Prefer `track_id` because `track_name` may be duplicated. You can rename the track with `new_track_name` and/or change the instrument with `instrument`. The `instrument` parameter must be the exact English instrument name returned by `list_all_available_instruments`. +## delete_track +Delete an existing MIDI track by `track_id` or `track_name`. Prefer `track_id` because `track_name` may be duplicated. If multiple MIDI tracks share the same `track_name`, do not guess which one to delete; use `track_id`. + ## read_chord_progression Read the user-defined chord progression from the global chord track. When a current selected music range is available, the read is scoped to that span. Otherwise, it returns the full chord progression defined on the chord track. @@ -71,6 +74,7 @@ To create a melodic line, use sequential `start` values for each note. To create 11. If you are editing notes for the currently selected track, you do not need to pass `track_id` or `track_name`; the editing tools can use the selected track context directly. 12. If a required parameter cannot be determined from context, ask the user instead of guessing. 13. Proceed step-by-step. Each action should build on confirmed results from previous steps. +14. Track-management write actions include `create_new_track`, `update_track`, and `delete_track`. Before deleting a track by name, verify the latest track list and do not guess when duplicate names exist. ==== @@ -137,6 +141,7 @@ CAPABILITIES - **Selection Awareness**: Use `get_user_selected_music_range_and_track` to confirm the current selected music range and selected regular track whenever selection context is important to the task. - **Track Awareness**: Use `list_all_tracks` to inspect all available MIDI tracks and their instruments before choosing a target track. - **Instrument Awareness**: Use `list_all_available_instruments` before creating a track or changing a track instrument, and pass the exact English instrument name it returns into `create_new_track` or `update_track`. +- **Track Management**: You can create, update, and delete MIDI tracks. Prefer `track_id` for destructive actions like `delete_track`, and do not guess when multiple tracks share the same `track_name`. - **Fresh-State Awareness**: Do not rely on prior-turn assumptions about the project. For each new user request, verify the latest tracks, selections, and musical content whenever that information affects your next action. - **Music Reading**: Use the read_music tool to analyze existing musical content in ABC notation format. Multiple tracks will be presented separately, and track names (e.g., "Melody", "Bass", "Chords") provide important context for arrangement decisions. - **Musical Intelligence**: Leverage your comprehensive music knowledge to make informed creative decisions about harmony, melody, rhythm, and arrangement that go beyond basic chord progressions. diff --git a/src/agent/tools/DeleteTrackTool.test.ts b/src/agent/tools/DeleteTrackTool.test.ts new file mode 100644 index 0000000..046655f --- /dev/null +++ b/src/agent/tools/DeleteTrackTool.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DeleteTrackTool } from './DeleteTrackTool'; +import { KGCore } from '../../core/KGCore'; +import { KGProject } from '../../core/KGProject'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: { + getState: () => ({ + activeRegionId: null, + selectedRegionIds: [], + selectedTrackId: null, + }), + }, +})); + +function mockCore(project: KGProject) { + vi.spyOn(KGCore, 'instance').mockReturnValue({ + getCurrentProject: () => project, + executeCommand: (command: { execute(): void }) => command.execute(), + } as unknown as KGCore); +} + +describe('DeleteTrackTool', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({ + removeTrackSynth: vi.fn(), + removeTrackAudioPlayerBus: vi.fn(), + } as unknown as KGAudioInterface); + }); + + it('deletes a MIDI track by track_id', async () => { + const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet'); + const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass'); + const project = new KGProject('delete-by-id-project'); + project.setTracks([leadTrack, bassTrack]); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({ track_id: '1' }); + + expect(result).toEqual({ + success: true, + result: 'Track deleted:\ntrack_id: 1\ntrack_name: Lead', + }); + expect(project.getTracks().map(track => track.getName())).toEqual(['Bass']); + expect(tool.buildToolResultDisplayContent({ track_id: '1' }, result)).toBe( + 'Track deleted:\n- track_id: 1\n- track_name: Lead', + ); + }); + + it('deletes a MIDI track by track_name', async () => { + const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet'); + const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass'); + const project = new KGProject('delete-by-name-project'); + project.setTracks([leadTrack, bassTrack]); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({ track_name: 'Bass' }); + + expect(result).toEqual({ + success: true, + result: 'Track deleted:\ntrack_id: 2\ntrack_name: Bass', + }); + expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']); + }); + + it('uses track_id when both track_id and track_name are provided', async () => { + const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet'); + const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass'); + const project = new KGProject('delete-track-id-precedence-project'); + project.setTracks([leadTrack, bassTrack]); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({ + track_id: '2', + track_name: 'Lead', + }); + + expect(result.success).toBe(true); + expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']); + }); + + it('rejects duplicate track names when track_id is omitted', async () => { + const firstLead = new KGMidiTrack('Lead', 1, 'trumpet'); + const secondLead = new KGMidiTrack('Lead', 2, 'flute'); + const project = new KGProject('delete-duplicate-name-project'); + project.setTracks([firstLead, secondLead]); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({ track_name: 'Lead' }); + + expect(result).toEqual({ + success: false, + result: 'Multiple MIDI tracks share the name "Lead". Provide track_id instead.', + }); + }); + + it('errors when neither identifier is provided', async () => { + const project = new KGProject('delete-missing-id-project'); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({}); + + expect(result).toEqual({ + success: false, + result: 'Either track_id or track_name must be provided.', + }); + }); + + it('errors when the target track does not exist', async () => { + const project = new KGProject('delete-missing-track-project'); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({ track_id: '99' }); + + expect(result).toEqual({ + success: false, + result: 'Track with ID "99" not found or is not a MIDI track.', + }); + }); + + it('errors when the target is not a MIDI track', async () => { + const audioTrack = new KGAudioTrack('Vocal', 1); + const project = new KGProject('delete-audio-track-project'); + project.setTracks([audioTrack]); + mockCore(project); + + const tool = new DeleteTrackTool(); + const result = await tool.execute({ track_id: '1' }); + + expect(result).toEqual({ + success: false, + result: 'Track with ID "1" not found or is not a MIDI track.', + }); + expect(tool.isReadOnlyTool()).toBe(false); + expect(tool.isAvailableInEfficientMode()).toBe(false); + }); + + it('generates confirmation content only for valid target input', () => { + const tool = new DeleteTrackTool(); + + expect(tool.buildConfirmationContent({ track_id: '1' })).toBe('Allow deleting track ID **1**?'); + expect(tool.buildConfirmationContent({ track_name: 'Lead' })).toBe('Allow deleting track **Lead**?'); + expect(tool.buildConfirmationContent({})).toBeUndefined(); + expect(tool.buildConfirmationContent(null)).toBeUndefined(); + }); + + it('does not generate display content for failed or malformed results', () => { + const tool = new DeleteTrackTool(); + + expect(tool.buildToolResultDisplayContent( + { track_id: '1' }, + { success: false, result: 'Track with ID "1" not found or is not a MIDI track.' }, + )).toBeUndefined(); + + expect(tool.buildToolResultDisplayContent( + { track_id: '1' }, + { success: true, result: 'Track deleted.' }, + )).toBeUndefined(); + }); +}); diff --git a/src/agent/tools/DeleteTrackTool.ts b/src/agent/tools/DeleteTrackTool.ts new file mode 100644 index 0000000..f4bfe8c --- /dev/null +++ b/src/agent/tools/DeleteTrackTool.ts @@ -0,0 +1,116 @@ +import { RemoveTrackCommand } from '../../core/commands/track/RemoveTrackCommand'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { BaseTool } from './BaseTool'; +import type { ToolParameter, ToolResult } from './BaseTool'; +import { resolveMidiTrackByExactName, resolveMidiTrackByIdOrName } from './toolTargeting'; + +export class DeleteTrackTool extends BaseTool { + readonly name = 'delete_track'; + readonly description = + 'Delete an existing MIDI track by track_id or track_name. Prefer track_id because track_name may be duplicated.'; + + readonly parameters: Record = { + track_id: { + type: 'string', + description: 'Target MIDI track ID. Preferred when available.', + required: false, + }, + track_name: { + type: 'string', + description: 'Target MIDI track name. Used only when track_id is omitted.', + required: false, + }, + }; + + override isReadOnlyTool(): boolean { + return false; + } + + override isAvailableInEfficientMode(): boolean { + return false; + } + + override buildConfirmationContent(args: Record | null): string | undefined { + if (!args) { + return undefined; + } + + if (typeof args.track_id === 'string') { + return `Allow deleting track ID **${args.track_id}**?`; + } + + if (typeof args.track_name === 'string') { + return `Allow deleting track **${args.track_name}**?`; + } + + return undefined; + } + + override buildToolResultDisplayContent(args: Record | null, toolResult: ToolResult): string | undefined { + if (!args || !toolResult.success) { + return undefined; + } + + const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/); + const trackNameMatch = toolResult.result.match(/track_name:\s*(.+)/); + if (!trackIdMatch || !trackNameMatch) { + return undefined; + } + + return [ + 'Track deleted:', + `- track_id: ${trackIdMatch[1]}`, + `- track_name: ${trackNameMatch[1]}`, + ].join('\n'); + } + + async execute(params: Record): Promise { + try { + this.validateParameters(params); + + const trackId = params.track_id as string | undefined; + const trackName = params.track_name as string | undefined; + + if (!trackId && !trackName) { + return this.createErrorResult('Either track_id or track_name must be provided.'); + } + + if (!trackId && trackName) { + const matchingTracks = resolveMidiTrackByExactName(trackName); + if (matchingTracks.length > 1) { + return this.createErrorResult(`Multiple MIDI tracks share the name "${trackName}". Provide track_id instead.`); + } + } + + const resolvedTrack = resolveMidiTrackByIdOrName(trackId, trackName); + if (!resolvedTrack) { + return this.createErrorResult( + trackId + ? `Track with ID "${trackId}" not found or is not a MIDI track.` + : `Track with name "${trackName}" not found or is not a MIDI track.`, + ); + } + + if (!(resolvedTrack instanceof KGMidiTrack)) { + return this.createErrorResult( + trackId + ? `Track with ID "${trackId}" not found or is not a MIDI track.` + : `Track with name "${trackName}" not found or is not a MIDI track.`, + ); + } + + const deletedTrackId = resolvedTrack.getId().toString(); + const deletedTrackName = resolvedTrack.getName(); + const command = new RemoveTrackCommand(resolvedTrack.getId()); + await this.executeCommand(command); + + return this.createSuccessResult([ + 'Track deleted:', + `track_id: ${deletedTrackId}`, + `track_name: ${deletedTrackName}`, + ].join('\n')); + } catch (error) { + return this.createErrorResult(`Failed to delete track: ${error}`); + } + } +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index b294e8e..1bf37a6 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -14,6 +14,7 @@ import { ListAllTracksTool } from './ListAllTracksTool'; import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool'; import { CreateNewTrackTool } from './CreateNewTrackTool'; import { UpdateTrackTool } from './UpdateTrackTool'; +import { DeleteTrackTool } from './DeleteTrackTool'; export { AddNotesTool, @@ -26,6 +27,7 @@ export { ListAllAvailableInstrumentsTool, CreateNewTrackTool, UpdateTrackTool, + DeleteTrackTool, }; // Tool registry for easy access @@ -40,6 +42,7 @@ export const AVAILABLE_TOOLS = { list_all_available_instruments: ListAllAvailableInstrumentsTool, create_new_track: CreateNewTrackTool, update_track: UpdateTrackTool, + delete_track: DeleteTrackTool, } as const; export type ToolName = keyof typeof AVAILABLE_TOOLS;