feat: added delete_track tool

This commit is contained in:
Xiaohan-Tian
2026-06-05 12:58:30 -07:00
parent 7d53a9cd82
commit 2e3f3623ea
4 changed files with 294 additions and 0 deletions
+5
View File
@@ -39,6 +39,9 @@ Create a new MIDI track using a `track_name` and an `instrument`. The `instrumen
## update_track ## 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`. 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_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. 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. 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. 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. 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. - **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. - **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`. - **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. - **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. - **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. - **Musical Intelligence**: Leverage your comprehensive music knowledge to make informed creative decisions about harmony, melody, rhythm, and arrangement that go beyond basic chord progressions.
+170
View File
@@ -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();
});
});
+116
View File
@@ -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<string, ToolParameter> = {
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<string, unknown> | 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<string, unknown> | 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<string, unknown>): Promise<ToolResult> {
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}`);
}
}
}
+3
View File
@@ -14,6 +14,7 @@ import { ListAllTracksTool } from './ListAllTracksTool';
import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool'; import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool';
import { CreateNewTrackTool } from './CreateNewTrackTool'; import { CreateNewTrackTool } from './CreateNewTrackTool';
import { UpdateTrackTool } from './UpdateTrackTool'; import { UpdateTrackTool } from './UpdateTrackTool';
import { DeleteTrackTool } from './DeleteTrackTool';
export { export {
AddNotesTool, AddNotesTool,
@@ -26,6 +27,7 @@ export {
ListAllAvailableInstrumentsTool, ListAllAvailableInstrumentsTool,
CreateNewTrackTool, CreateNewTrackTool,
UpdateTrackTool, UpdateTrackTool,
DeleteTrackTool,
}; };
// Tool registry for easy access // Tool registry for easy access
@@ -40,6 +42,7 @@ export const AVAILABLE_TOOLS = {
list_all_available_instruments: ListAllAvailableInstrumentsTool, list_all_available_instruments: ListAllAvailableInstrumentsTool,
create_new_track: CreateNewTrackTool, create_new_track: CreateNewTrackTool,
update_track: UpdateTrackTool, update_track: UpdateTrackTool,
delete_track: DeleteTrackTool,
} as const; } as const;
export type ToolName = keyof typeof AVAILABLE_TOOLS; export type ToolName = keyof typeof AVAILABLE_TOOLS;