feat: add instrument listing, track creation and update tools to AI agent
This commit is contained in:
@@ -253,6 +253,21 @@ describe('AgentCore todo integration', () => {
|
||||
expect(provider.systemPrompts[0]).toBe('system prompt:prompts/system.md');
|
||||
});
|
||||
|
||||
it('exposes the new track management tools in regular mode', async () => {
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
const provider = new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Inspect available tools.');
|
||||
|
||||
const toolNames = provider.tools[0].map(tool => tool.function.name);
|
||||
expect(toolNames).toContain('list_all_available_instruments');
|
||||
expect(toolNames).toContain('create_new_track');
|
||||
expect(toolNames).toContain('update_track');
|
||||
});
|
||||
|
||||
it('uses the compact system prompt in efficient mode', async () => {
|
||||
configState.set('general.agent_mode', 'efficient');
|
||||
const provider = new ScriptedProvider([
|
||||
@@ -290,7 +305,11 @@ describe('AgentCore todo integration', () => {
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
expect(provider.tools[0].map(tool => tool.function.name)).not.toContain('read_music');
|
||||
const toolNames = provider.tools[0].map(tool => tool.function.name);
|
||||
expect(toolNames).not.toContain('read_music');
|
||||
expect(toolNames).not.toContain('list_all_available_instruments');
|
||||
expect(toolNames).not.toContain('create_new_track');
|
||||
expect(toolNames).not.toContain('update_track');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CreateNewTrackTool } from './CreateNewTrackTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
|
||||
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('CreateNewTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({
|
||||
createTrackSynth: vi.fn(),
|
||||
} as unknown as KGAudioInterface);
|
||||
});
|
||||
|
||||
it('creates a new track and returns the exact output shape', async () => {
|
||||
const project = new KGProject('create-track-project');
|
||||
project.setTracks([new KGMidiTrack('Lead', 1, 'trumpet')]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new CreateNewTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Bass',
|
||||
instrument: 'Electric Bass (finger)',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'New track created:\ntrack_id: 2\ntrack_name: Bass\ninstrument: Electric Bass (finger)',
|
||||
});
|
||||
expect(project.getTracks()).toHaveLength(2);
|
||||
expect(project.getTracks()[1]).toBeInstanceOf(KGMidiTrack);
|
||||
expect((project.getTracks()[1] as KGMidiTrack).getInstrument()).toBe('electric_bass_finger');
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(tool.buildToolResultDisplayContent({
|
||||
track_name: 'Bass',
|
||||
instrument: 'Electric Bass (finger)',
|
||||
}, result)).toBe(
|
||||
'New track created:\n- track_id: 2\n- track_name: Bass\n- instrument: Electric Bass (finger)',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid instrument name', async () => {
|
||||
const project = new KGProject('invalid-instrument-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new CreateNewTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Bass',
|
||||
instrument: 'electric_bass_finger',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Invalid instrument "electric_bass_finger". Use the exact English name from list_all_available_instruments.',
|
||||
});
|
||||
expect(project.getTracks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AddTrackCommand } from '../../core/commands/track/AddTrackCommand';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
getEnglishInstrumentName,
|
||||
resolveInstrumentKeyByEnglishName,
|
||||
} from './toolTargeting';
|
||||
|
||||
export class CreateNewTrackTool extends BaseTool {
|
||||
readonly name = 'create_new_track';
|
||||
readonly description =
|
||||
'Create a new MIDI track with a given track name and exact English instrument name. Use list_all_available_instruments first to discover valid instrument names.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the new MIDI track to create.',
|
||||
required: true,
|
||||
},
|
||||
instrument: {
|
||||
type: 'string',
|
||||
description: 'Exact English instrument name from list_all_available_instruments.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackName = typeof args.track_name === 'string' ? args.track_name : null;
|
||||
const instrument = typeof args.instrument === 'string' ? args.instrument : null;
|
||||
if (!trackName || !instrument) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow creating track **${trackName}** with instrument **${instrument}**?`;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackName = typeof args.track_name === 'string' ? args.track_name : null;
|
||||
const instrument = typeof args.instrument === 'string' ? args.instrument : null;
|
||||
if (!trackName || !instrument) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/);
|
||||
if (!trackIdMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
'New track created:',
|
||||
`- track_id: ${trackIdMatch[1]}`,
|
||||
`- track_name: ${trackName}`,
|
||||
`- instrument: ${instrument}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const trackName = params.track_name as string;
|
||||
const instrumentName = params.instrument as string;
|
||||
const instrumentKey = resolveInstrumentKeyByEnglishName(instrumentName);
|
||||
if (!instrumentKey) {
|
||||
return this.createErrorResult(`Invalid instrument "${instrumentName}". Use the exact English name from list_all_available_instruments.`);
|
||||
}
|
||||
|
||||
const command = new AddTrackCommand(undefined, trackName, instrumentKey);
|
||||
await this.executeCommand(command);
|
||||
|
||||
return this.createSuccessResult([
|
||||
'New track created:',
|
||||
`track_id: ${command.getTrackId().toString()}`,
|
||||
`track_name: ${trackName}`,
|
||||
`instrument: ${getEnglishInstrumentName(instrumentKey)}`,
|
||||
].join('\n'));
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to create track: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool';
|
||||
import { listAvailableInstrumentsByGroup } from './toolTargeting';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
activeRegionId: null,
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ListAllAvailableInstrumentsTool', () => {
|
||||
it('lists instruments grouped by English group name with blank lines between groups', async () => {
|
||||
const tool = new ListAllAvailableInstrumentsTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.startsWith(
|
||||
'Group: Piano and Keyboards\n- Acoustic Grand Piano\n- Bright Acoustic Piano',
|
||||
)).toBe(true);
|
||||
expect(result.result).toContain('\n\nGroup: Guitar\n- Acoustic Guitar (nylon)');
|
||||
expect(result.result).toContain('\n\nGroup: Bass\n- Acoustic Bass');
|
||||
expect(result.result).toContain('\n\nGroup: Percussion Kit\n- Standard Drum Kit');
|
||||
expect(result.result).toContain('\n\nGroup: Synthesizer\n- Lead 1 (square)');
|
||||
});
|
||||
|
||||
it('is unavailable in efficient mode', () => {
|
||||
const tool = new ListAllAvailableInstrumentsTool();
|
||||
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns a simplified UI display message', async () => {
|
||||
const tool = new ListAllAvailableInstrumentsTool();
|
||||
const result = await tool.execute({});
|
||||
const totalInstruments = listAvailableInstrumentsByGroup()
|
||||
.reduce((count, group) => count + group.instruments.length, 0);
|
||||
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe(`Listed ${totalInstruments} available instruments.`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { listAvailableInstrumentsByGroup } from './toolTargeting';
|
||||
|
||||
export class ListAllAvailableInstrumentsTool extends BaseTool {
|
||||
readonly name = 'list_all_available_instruments';
|
||||
readonly description =
|
||||
'List all available instruments grouped by English instrument family names. Use this before creating a new MIDI track or changing a track instrument, because write tools require the exact English instrument name from this list.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const totalInstruments = listAvailableInstrumentsByGroup()
|
||||
.reduce((count, group) => count + group.instruments.length, 0);
|
||||
return `Listed ${totalInstruments} available instruments.`;
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const result = listAvailableInstrumentsByGroup()
|
||||
.map(({ groupName, instruments }) => [
|
||||
`Group: ${groupName}`,
|
||||
...instruments.map(instrument => `- ${instrument}`),
|
||||
].join('\n'))
|
||||
.join('\n\n');
|
||||
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to list available instruments: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UpdateTrackTool } from './UpdateTrackTool';
|
||||
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('UpdateTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({
|
||||
setTrackInstrument: vi.fn(),
|
||||
} as unknown as KGAudioInterface);
|
||||
});
|
||||
|
||||
it('renames a track by track_id', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('rename-track-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead 2\ninstrument: Trumpet',
|
||||
});
|
||||
expect(track.getName()).toBe('Lead 2');
|
||||
expect(tool.buildToolResultDisplayContent({
|
||||
track_id: '1',
|
||||
new_track_name: 'Lead 2',
|
||||
}, result)).toBe(
|
||||
'Track updated:\n- track_id: 1\n- track_name: Lead 2\n- instrument: Trumpet',
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a track instrument by track_name', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('instrument-track-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
instrument: 'Flute',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead\ninstrument: Flute',
|
||||
});
|
||||
expect(track.getInstrument()).toBe('flute');
|
||||
});
|
||||
|
||||
it('updates both track name and instrument', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('rename-and-instrument-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Flute Lead',
|
||||
instrument: 'Flute',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Flute Lead\ninstrument: Flute',
|
||||
});
|
||||
expect(track.getName()).toBe('Flute Lead');
|
||||
expect(track.getInstrument()).toBe('flute');
|
||||
});
|
||||
|
||||
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('track-id-precedence-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '2',
|
||||
track_name: 'Lead',
|
||||
new_track_name: 'Bass 2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(leadTrack.getName()).toBe('Lead');
|
||||
expect(bassTrack.getName()).toBe('Bass 2');
|
||||
});
|
||||
|
||||
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('duplicate-name-project');
|
||||
project.setTracks([firstLead, secondLead]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Multiple MIDI tracks share the name "Lead". Provide track_id instead.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when neither identifier is provided', async () => {
|
||||
const project = new KGProject('missing-id-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Either track_id or track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when no update fields are provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('missing-fields-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'At least one of instrument or new_track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty string optional fields as not provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('empty-string-fields-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'At least one of instrument or new_track_name must be provided.',
|
||||
});
|
||||
expect(tool.buildConfirmationContent({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: '',
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats null optional fields as not provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('null-fields-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: null,
|
||||
instrument: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'At least one of instrument or new_track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a valid change when the other optional field is empty', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('mixed-empty-field-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: 'Flute',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead\ninstrument: Flute',
|
||||
});
|
||||
expect(track.getInstrument()).toBe('flute');
|
||||
expect(tool.buildConfirmationContent({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: 'Flute',
|
||||
})).toBe('Allow updating track ID **1** to set instrument to **Flute**?');
|
||||
});
|
||||
|
||||
it('returns an error when provided values do not change the track', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('unchanged-values-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Lead',
|
||||
instrument: 'Trumpet',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'No changes to apply to the target track.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error for an invalid instrument', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('invalid-instrument-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
instrument: 'trumpet',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Invalid instrument "trumpet". Use the exact English name from list_all_available_instruments.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when the track does not exist', async () => {
|
||||
const project = new KGProject('missing-track-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '99',
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "99" not found or is not a MIDI track.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when the target is not a MIDI track', async () => {
|
||||
const audioTrack = new KGAudioTrack('Vocal', 1);
|
||||
const project = new KGProject('audio-track-project');
|
||||
project.setTracks([audioTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Vocal 2',
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { UpdateTrackCommand } from '../../core/commands/track/UpdateTrackCommand';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
getEnglishInstrumentName,
|
||||
getTrackDisplayName,
|
||||
resolveInstrumentKeyByEnglishName,
|
||||
resolveMidiTrackByExactName,
|
||||
resolveMidiTrackByIdOrName,
|
||||
} from './toolTargeting';
|
||||
|
||||
export class UpdateTrackTool extends BaseTool {
|
||||
readonly name = 'update_track';
|
||||
readonly description =
|
||||
'Update an existing MIDI track by track_id or track_name. Supports renaming the track and/or changing its instrument to an exact English instrument name from list_all_available_instruments.';
|
||||
|
||||
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,
|
||||
},
|
||||
instrument: {
|
||||
type: 'string',
|
||||
description: 'Optional exact English instrument name from list_all_available_instruments.',
|
||||
required: false,
|
||||
},
|
||||
new_track_name: {
|
||||
type: 'string',
|
||||
description: 'Optional new name for the track.',
|
||||
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;
|
||||
}
|
||||
|
||||
const normalizedInstrumentName = this.normalizeOptionalString(args.instrument);
|
||||
const normalizedNewTrackName = this.normalizeOptionalString(args.new_track_name);
|
||||
const targetLabel = typeof args.track_id === 'string'
|
||||
? `track ID **${args.track_id}**`
|
||||
: typeof args.track_name === 'string'
|
||||
? `track **${args.track_name}**`
|
||||
: null;
|
||||
if (!targetLabel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
if (normalizedNewTrackName !== undefined) {
|
||||
changes.push(`rename to **${normalizedNewTrackName}**`);
|
||||
}
|
||||
if (normalizedInstrumentName !== undefined) {
|
||||
changes.push(`set instrument to **${normalizedInstrumentName}**`);
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow updating ${targetLabel} to ${changes.join(' and ')}?`;
|
||||
}
|
||||
|
||||
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*(.+)/);
|
||||
const instrumentMatch = toolResult.result.match(/instrument:\s*(.+)/);
|
||||
if (!trackIdMatch || !trackNameMatch || !instrumentMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
'Track updated:',
|
||||
`- track_id: ${trackIdMatch[1]}`,
|
||||
`- track_name: ${trackNameMatch[1]}`,
|
||||
`- instrument: ${instrumentMatch[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;
|
||||
const instrumentName = this.normalizeOptionalString(params.instrument);
|
||||
const newTrackName = this.normalizeOptionalString(params.new_track_name);
|
||||
|
||||
if (!trackId && !trackName) {
|
||||
return this.createErrorResult('Either track_id or track_name must be provided.');
|
||||
}
|
||||
|
||||
if (instrumentName === undefined && newTrackName === undefined) {
|
||||
return this.createErrorResult('At least one of instrument or new_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(
|
||||
`Track "${getTrackDisplayName(resolvedTrack)}" is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
let instrumentKey: InstrumentType | undefined;
|
||||
if (instrumentName !== undefined) {
|
||||
instrumentKey = resolveInstrumentKeyByEnglishName(instrumentName);
|
||||
if (!instrumentKey) {
|
||||
return this.createErrorResult(`Invalid instrument "${instrumentName}". Use the exact English name from list_all_available_instruments.`);
|
||||
}
|
||||
}
|
||||
|
||||
const trackNameChanged = newTrackName !== undefined && newTrackName !== resolvedTrack.getName();
|
||||
const instrumentChanged = instrumentKey !== undefined && instrumentKey !== resolvedTrack.getInstrument();
|
||||
|
||||
if (!trackNameChanged && !instrumentChanged) {
|
||||
return this.createErrorResult('No changes to apply to the target track.');
|
||||
}
|
||||
|
||||
const command = new UpdateTrackCommand(resolvedTrack.getId(), {
|
||||
...(trackNameChanged ? { name: newTrackName } : {}),
|
||||
...(instrumentChanged && instrumentKey !== undefined ? { instrument: instrumentKey } : {}),
|
||||
});
|
||||
await this.executeCommand(command);
|
||||
|
||||
return this.createSuccessResult([
|
||||
'Track updated:',
|
||||
`track_id: ${resolvedTrack.getId().toString()}`,
|
||||
`track_name: ${resolvedTrack.getName()}`,
|
||||
`instrument: ${getEnglishInstrumentName(resolvedTrack.getInstrument())}`,
|
||||
].join('\n'));
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to update track: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { ReadChordProgressionTool } from './ReadChordProgressionTool';
|
||||
import { UpdateTodoListTool } from './UpdateTodoListTool';
|
||||
import { GetUserSelectedMusicRangeAndTrackTool } from './GetUserSelectedMusicRangeAndTrackTool';
|
||||
import { ListAllTracksTool } from './ListAllTracksTool';
|
||||
import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool';
|
||||
import { CreateNewTrackTool } from './CreateNewTrackTool';
|
||||
import { UpdateTrackTool } from './UpdateTrackTool';
|
||||
|
||||
export {
|
||||
AddNotesTool,
|
||||
@@ -20,6 +23,9 @@ export {
|
||||
UpdateTodoListTool,
|
||||
GetUserSelectedMusicRangeAndTrackTool,
|
||||
ListAllTracksTool,
|
||||
ListAllAvailableInstrumentsTool,
|
||||
CreateNewTrackTool,
|
||||
UpdateTrackTool,
|
||||
};
|
||||
|
||||
// Tool registry for easy access
|
||||
@@ -31,6 +37,9 @@ export const AVAILABLE_TOOLS = {
|
||||
read_chord_progression: ReadChordProgressionTool,
|
||||
get_user_selected_music_range_and_track: GetUserSelectedMusicRangeAndTrackTool,
|
||||
list_all_tracks: ListAllTracksTool,
|
||||
list_all_available_instruments: ListAllAvailableInstrumentsTool,
|
||||
create_new_track: CreateNewTrackTool,
|
||||
update_track: UpdateTrackTool,
|
||||
} as const;
|
||||
|
||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGGlobalRegion } from '../../core/region/KGGlobalRegion';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import type { InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { FLUIDR3_INSTRUMENT_MAP, INSTRUMENT_GROUPS } from '../../constants/generalMidiConstants';
|
||||
|
||||
export const NO_MIDI_TARGET_RAW_MESSAGE =
|
||||
'No MIDI target could be resolved. Select the MIDI region you want me to edit and retry, or tell me which MIDI track to operate on by providing its track_id.';
|
||||
@@ -57,6 +59,48 @@ export function resolveMidiTrackByIdOrName(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveMidiTrackByExactName(trackName: string): KGMidiTrack[] {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
return project.getTracks().filter((track): track is KGMidiTrack => (
|
||||
track instanceof KGMidiTrack && track.getName() === trackName
|
||||
));
|
||||
}
|
||||
|
||||
export function resolveInstrumentKeyByEnglishName(instrumentName: string): InstrumentType | null {
|
||||
for (const [instrumentKey, instrumentInfo] of Object.entries(FLUIDR3_INSTRUMENT_MAP)) {
|
||||
if (instrumentInfo.displayName === instrumentName) {
|
||||
return instrumentKey as InstrumentType;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getEnglishInstrumentName(instrumentKey: InstrumentType): string {
|
||||
return FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName ?? String(instrumentKey);
|
||||
}
|
||||
|
||||
export function listAvailableInstrumentsByGroup(): Array<{ groupName: string; instruments: string[] }> {
|
||||
const groupedInstruments = new Map<string, string[]>();
|
||||
|
||||
for (const groupName of Object.values(INSTRUMENT_GROUPS)) {
|
||||
groupedInstruments.set(groupName, []);
|
||||
}
|
||||
|
||||
for (const instrumentInfo of Object.values(FLUIDR3_INSTRUMENT_MAP)) {
|
||||
const groupName = INSTRUMENT_GROUPS[instrumentInfo.group as keyof typeof INSTRUMENT_GROUPS];
|
||||
if (!groupedInstruments.has(groupName)) {
|
||||
groupedInstruments.set(groupName, []);
|
||||
}
|
||||
groupedInstruments.get(groupName)!.push(instrumentInfo.displayName);
|
||||
}
|
||||
|
||||
return Array.from(groupedInstruments.entries()).map(([groupName, instruments]) => ({
|
||||
groupName,
|
||||
instruments,
|
||||
}));
|
||||
}
|
||||
|
||||
export function findRegionById(regionId: string): KGRegion | null {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user