The agent got stuck where the editor has it easy: it could not make a
Sample play without a sound asset, it built instruments node by node
although the Preset menu has them ready, and it could not tell what
names an asset before reworking or deleting it.
- list_files is a new tool. It lists the pictures, fonts, sounds or
videos the server holds, with size, video state and the asset that
already wears a file. Bakes and stream copies of songs are left out.
- add_file_asset makes an asset of such a file, as the picker does.
It reuses the create rules of the import pipeline, so the four
import kinds are exported now. It refuses a file already in the
project (naming that asset), a picture without a size, a video not
yet converted and a file the server does not hold.
- run_commands fetches the file lists its steps need before it runs,
so the commands themselves stay synchronous.
- apply_preset sets one of the 16 instrument presets or 7 sfxr
presets, like the Preset menu. Presets take every note, so the
C4 to B4 range of a new Notes node is no trap there.
- get_asset gains usedBy: every asset naming the asset or one of its
frames or clips, with the object, node, item or key that does, plus
start scene, master FX and master routing of the project.
- usesOf in src/core/model/usages.ts walks the project data instead of
a list of fields, so no new field can be missed. The node every
asset has in the master routing does not count as a use.
- The relay tells the agent about all three ways.
Found on the way: suite 740 for 2e4c0a0 (#430) was red in e2e 68h
only, a sound clip 61 ms early against 50 allowed. #430 did not touch
sound timing, and e2e 68 on the same tree was green on atx (-4 ms).
Taken as a timing wobble under load until the next suite says more.
Tests:
- agent-files (new, 4 tests) with the other agent and relay tests:
130/130.
- 8 counter checks, all compiling, all red.
- e2e 71 on atx: 26/26, new 71g9 lists an uploaded sound, makes an
asset of it and fills an instrument from a preset.
- Gate on atx: CHECK EXIT 0, 326 files, 5021 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e4c0a09ea
commit
24cd9fdeaa
20 files changed
+644
-17
No files matched your search
@@ -506,6 +506,7 @@ describe('the catalog', () => {
|
||||
'get_view',
|
||||
'screenshot',
|
||||
'list_assets',
|
||||
'list_files',
|
||||
'get_asset',
|
||||
'get_graph',
|
||||
'lint_graph',
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { InstrumentAsset } from '../../src/core/model/Asset';
|
||||
import { chipPresetGraph } from '../../src/core/instrument/chipPresets';
|
||||
import { bakeUploadName } from '../../src/core/music/bake';
|
||||
import { settleRouting } from '../../src/core/music/projectRouting';
|
||||
import type { StoredFile } from '../../src/core/store/FileStore';
|
||||
import { runTool } from '../../src/editor/agent/agentTools';
|
||||
import type { FileAssetType } from '../../src/editor/agent/storedFiles';
|
||||
import { makeSpriteScene, type SceneFixture } from '../helpers';
|
||||
import { hostFor, text } from './agentTestHost';
|
||||
|
||||
/** Files the server holds (#431), presets of instruments (#432) and what uses an asset (#433). */
|
||||
|
||||
const stored = (file: string, name: string, extra: object = {}): StoredFile => ({
|
||||
file,
|
||||
name,
|
||||
bytes: 1000,
|
||||
updatedAt: '',
|
||||
owner: 'me',
|
||||
deletable: true,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const PICTURE = stored(`${'b2'.repeat(32)}.png`, 'hero-idle.png', { width: 32, height: 16, demo: true });
|
||||
const UNREAD = stored(`${'b3'.repeat(32)}.png`, 'broken.png', { width: 0, height: 0 });
|
||||
const BLIP = stored(`${'c4'.repeat(32)}.wav`, 'blip.wav');
|
||||
const BAKED = stored(`${'c5'.repeat(32)}.ogg`, bakeUploadName('Theme'));
|
||||
const CLIP = stored(`${'d6'.repeat(32)}.mp4`, 'intro.mp4', {
|
||||
status: 'ready',
|
||||
width: 640,
|
||||
height: 360,
|
||||
duration: 4,
|
||||
fps: 30,
|
||||
audio: true,
|
||||
});
|
||||
const QUEUED = stored(`${'d7'.repeat(32)}.mp4`, 'later.mp4', { status: 'queued' });
|
||||
|
||||
const SERVER: Record<FileAssetType, StoredFile[]> = {
|
||||
texture: [PICTURE, UNREAD],
|
||||
font: [],
|
||||
sound: [BLIP, BAKED],
|
||||
video: [CLIP, QUEUED],
|
||||
};
|
||||
|
||||
function filesHost(fixture: SceneFixture) {
|
||||
const made = hostFor(fixture);
|
||||
const asked: FileAssetType[] = [];
|
||||
made.host.files = (type) => {
|
||||
asked.push(type);
|
||||
return Promise.resolve(SERVER[type]);
|
||||
};
|
||||
return { ...made, asked };
|
||||
}
|
||||
|
||||
const run = async (fixture: SceneFixture, steps: object[]) => {
|
||||
const { host, asked } = filesHost(fixture);
|
||||
const result = await runTool('run_commands', { steps }, host);
|
||||
return { result, asked, text: text(result) };
|
||||
};
|
||||
|
||||
const list = async (fixture: SceneFixture, type: FileAssetType, query?: string) =>
|
||||
(
|
||||
(await runTool('list_files', { type, ...(query ? { query } : {}) }, filesHost(fixture).host))
|
||||
.structuredContent as {
|
||||
files: Record<string, unknown>[];
|
||||
}
|
||||
).files;
|
||||
|
||||
const read = async (fixture: SceneFixture, asset: string) =>
|
||||
(await runTool('get_asset', { asset }, hostFor(fixture).host)).structuredContent as Record<string, unknown>;
|
||||
|
||||
const named = (fixture: SceneFixture, name: string) => fixture.project.assets.find((a) => a.name === name)!;
|
||||
|
||||
describe('stored files', () => {
|
||||
it('lists the files of a kind as the picker offers them, with the asset wearing one', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
expect(await list(fixture, 'texture')).toEqual([
|
||||
{ file: PICTURE.file, name: 'hero-idle.png', bytes: 1000, width: 32, height: 16, demo: true },
|
||||
{ file: UNREAD.file, name: 'broken.png', bytes: 1000, width: 0, height: 0 },
|
||||
]);
|
||||
expect((await list(fixture, 'texture', 'HERO')).map((f) => f.file)).toEqual([PICTURE.file]);
|
||||
// A bake is the editor's own copy of a song, not one of the user's sounds.
|
||||
expect((await list(fixture, 'sound')).map((f) => f.file)).toEqual([BLIP.file]);
|
||||
expect(await list(fixture, 'video')).toEqual([
|
||||
{
|
||||
file: CLIP.file,
|
||||
name: 'intro.mp4',
|
||||
bytes: 1000,
|
||||
status: 'ready',
|
||||
width: 640,
|
||||
height: 360,
|
||||
seconds: 4,
|
||||
fps: 30,
|
||||
audio: true,
|
||||
},
|
||||
{ file: QUEUED.file, name: 'later.mp4', bytes: 1000, status: 'queued' },
|
||||
]);
|
||||
|
||||
const done = await run(fixture, [
|
||||
{ command: 'add_file_asset', args: { type: 'texture', file: PICTURE.file } },
|
||||
]);
|
||||
expect(done.result.isError).toBeUndefined();
|
||||
const asset = named(fixture, 'hero-idle');
|
||||
expect((await list(fixture, 'texture'))[0]!.asset).toEqual({ id: asset.id, name: 'hero-idle' });
|
||||
|
||||
const without = hostFor(fixture).host;
|
||||
expect(text(await runTool('list_files', { type: 'sound' }, without))).toBe('This editor lists no files');
|
||||
});
|
||||
|
||||
it('makes an asset of a stored file as the picker does, one undo step, and refuses what cannot be one', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const done = await run(fixture, [
|
||||
{ command: 'add_file_asset', args: { type: 'texture', file: PICTURE.file }, as: 'hero' },
|
||||
{ command: 'add_file_asset', args: { type: 'sound', file: BLIP.file, name: 'Jump' } },
|
||||
{ command: 'add_file_asset', args: { type: 'video', file: CLIP.file } },
|
||||
{ command: 'set_asset_property', args: { asset: fixture.spriteId, key: 'texture', value: '$hero' } },
|
||||
]);
|
||||
expect(done.result.isError).toBeUndefined();
|
||||
// Only the lists the steps need are fetched, once each.
|
||||
expect(done.asked).toEqual(['texture', 'sound', 'video']);
|
||||
const picture = named(fixture, 'hero-idle');
|
||||
expect(picture).toMatchObject({ type: 'texture', file: PICTURE.file, width: 32, height: 16 });
|
||||
expect(named(fixture, 'Jump')).toMatchObject({ type: 'sound', file: BLIP.file });
|
||||
expect(named(fixture, 'intro')).toMatchObject({ type: 'video', file: CLIP.file, fps: 30, audio: true });
|
||||
expect(done.text).toContain(`"id":"${picture.id}"`);
|
||||
|
||||
const refused = async (args: object) => {
|
||||
const before = fixture.project.assets.length;
|
||||
const outcome = await run(fixture, [{ command: 'add_file_asset', args }]);
|
||||
expect(outcome.result.isError).toBe(true);
|
||||
expect(fixture.project.assets).toHaveLength(before);
|
||||
return outcome.text;
|
||||
};
|
||||
expect(await refused({ type: 'texture', file: PICTURE.file })).toContain(
|
||||
`is in the project already as "hero-idle" (${picture.id}): use that one`,
|
||||
);
|
||||
expect(await refused({ type: 'texture', file: UNREAD.file })).toContain('could not read the size');
|
||||
expect(await refused({ type: 'video', file: QUEUED.file })).toContain('is queued, not converted yet');
|
||||
expect(await refused({ type: 'sound', file: BAKED.file })).toContain('The server holds no sound file');
|
||||
expect(await refused({ type: 'sound', file: PICTURE.file })).toContain('The server holds no sound file');
|
||||
|
||||
fixture.stack.undo();
|
||||
expect(fixture.project.assets.some((a) => a.type === 'texture' || a.type === 'sound')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instrument presets', () => {
|
||||
it('replaces the graph of an instrument with a preset, so its notes are heard, and refuses anything else', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
await run(fixture, [
|
||||
{ command: 'create_asset', args: { type: 'instrument', name: 'Bass' }, as: 'bass' },
|
||||
{ command: 'create_asset', args: { type: 'pattern', name: 'Line' }, as: 'line' },
|
||||
{ command: 'set_asset_property', args: { asset: '$line', key: 'instrument', value: '$bass' } },
|
||||
{ command: 'add_notes', args: { asset: '$line', notes: [{ tick: 0, length: 480, note: 'C2' }] } },
|
||||
]);
|
||||
const bass = named(fixture, 'Bass') as InstrumentAsset;
|
||||
const line = named(fixture, 'Line');
|
||||
expect((await read(fixture, line.id)).silent).toBeDefined();
|
||||
|
||||
const done = await run(fixture, [
|
||||
{ command: 'apply_preset', args: { asset: bass.id, preset: 'acidBass' } },
|
||||
]);
|
||||
expect(done.result.isError).toBeUndefined();
|
||||
const types = (graph: { nodes: { type: string }[] }) => graph.nodes.map((n) => n.type).sort();
|
||||
expect(types(bass.graph)).toEqual(types(chipPresetGraph('acidBass')));
|
||||
expect(fixture.stack.undoLabel).toContain('apply_preset');
|
||||
expect((await read(fixture, line.id)).silent).toBeUndefined();
|
||||
|
||||
fixture.stack.undo();
|
||||
expect(types(bass.graph)).not.toEqual(types(chipPresetGraph('acidBass')));
|
||||
|
||||
const wrong = await run(fixture, [
|
||||
{ command: 'apply_preset', args: { asset: line.id, preset: 'organ' } },
|
||||
]);
|
||||
expect(wrong.text).toContain('Line is no instrument');
|
||||
const unknown = await run(fixture, [
|
||||
{ command: 'apply_preset', args: { asset: bass.id, preset: 'kazoo' } },
|
||||
]);
|
||||
expect(unknown.result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usedBy', () => {
|
||||
it('names every asset naming it or a part of it, with the objects, nodes and items in there', async () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const done = await run(fixture, [
|
||||
{ command: 'add_file_asset', args: { type: 'texture', file: PICTURE.file }, as: 'sheet' },
|
||||
{ command: 'add_frame', args: { asset: '$sheet', x: 0, y: 0, w: 16, h: 16 }, as: 'frame' },
|
||||
{ command: 'set_asset_property', args: { asset: fixture.spriteId, key: 'texture', value: '$frame' } },
|
||||
{
|
||||
command: 'place_asset',
|
||||
args: { in: fixture.sceneId, asset: fixture.spriteId, x: 0, y: 0 },
|
||||
as: 'box',
|
||||
},
|
||||
{ command: 'set_start_scene', args: { scene: fixture.sceneId } },
|
||||
{ command: 'add_file_asset', args: { type: 'sound', file: BLIP.file }, as: 'blip' },
|
||||
{ command: 'create_asset', args: { type: 'instrument', name: 'Keys' }, as: 'keys' },
|
||||
{ command: 'create_asset', args: { type: 'pattern', name: 'Riff' }, as: 'riff' },
|
||||
{ command: 'create_asset', args: { type: 'song', name: 'Theme' }, as: 'theme' },
|
||||
{ command: 'create_asset', args: { type: 'sprite', name: 'Unused' }, as: 'unused' },
|
||||
]);
|
||||
expect(done.result.isError).toBeUndefined();
|
||||
const id = (name: string) => named(fixture, name).id;
|
||||
const keys = named(fixture, 'Keys') as InstrumentAsset;
|
||||
const sample = keys.graph.nodes.find((n) => n.type === 'InstrumentSample')!;
|
||||
const theme = fixture.project.assets.find((a) => a.id === id('Theme')) as { tracks: { id: string }[] };
|
||||
const placed = await run(fixture, [
|
||||
{
|
||||
command: 'set_node_param',
|
||||
args: { asset: keys.id, node: sample.id, key: 'sound', value: id('blip') },
|
||||
},
|
||||
{ command: 'set_asset_property', args: { asset: id('Riff'), key: 'instrument', value: keys.id } },
|
||||
{
|
||||
command: 'add_song_item',
|
||||
args: { asset: id('Theme'), track: theme.tracks[0]!.id, play: id('Riff'), tick: 0 },
|
||||
as: 'item',
|
||||
},
|
||||
]);
|
||||
expect(placed.result.isError).toBeUndefined();
|
||||
const item = (fixture.project.assets.find((a) => a.id === id('Theme')) as { items: { id: string }[] })
|
||||
.items[0]!.id;
|
||||
const box = fixture.sceneOf().objects.find((o) => o.assetId === fixture.spriteId)!.id;
|
||||
|
||||
// The sprite wears a frame of the sheet: a use of the sheet, on the sprite itself.
|
||||
expect((await read(fixture, id('hero-idle'))).usedBy).toEqual([
|
||||
{ id: fixture.spriteId, name: 'Box', type: 'sprite' },
|
||||
]);
|
||||
expect((await read(fixture, fixture.spriteId)).usedBy).toEqual([
|
||||
{ id: fixture.sceneId, name: 'Level', type: 'scene', at: [{ id: box }] },
|
||||
]);
|
||||
expect((await read(fixture, fixture.sceneId)).usedBy).toEqual([{ project: 'startScene' }]);
|
||||
expect((await read(fixture, id('blip'))).usedBy).toEqual([
|
||||
expect.objectContaining({
|
||||
id: keys.id,
|
||||
type: 'instrument',
|
||||
at: [{ id: sample.id, type: 'InstrumentSample' }],
|
||||
}),
|
||||
]);
|
||||
expect((await read(fixture, id('Riff'))).usedBy).toEqual([
|
||||
{
|
||||
id: id('Theme'),
|
||||
name: 'Theme',
|
||||
type: 'song',
|
||||
at: [expect.objectContaining({ id: item, kind: 'pattern' })],
|
||||
},
|
||||
]);
|
||||
expect((await read(fixture, keys.id)).usedBy).toEqual([
|
||||
{ id: id('Riff'), name: 'Riff', type: 'pattern' },
|
||||
]);
|
||||
// The Master routing has a node for every song, joined to the output; that alone is no use of it.
|
||||
settleRouting(fixture.project);
|
||||
expect(fixture.project.routing.edges.some((edge) => edge.from.node === id('Theme'))).toBe(true);
|
||||
expect((await read(fixture, id('Theme'))).usedBy).toEqual([]);
|
||||
expect((await read(fixture, id('Unused'))).usedBy).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user