Keep groups, comments and silent music in view for the agent (#429)
feuerwerk / gate (push) Successful in 4m47s
feuerwerk / suite (push) Successful in 19m5s

A live test of the music areas built a pattern, a song and a
composition that stayed silent: no instrument at first, and later
notes outside the C4 to B4 range of a new Notes node. The agent got
no hint of either. The same test showed that new nodes left groups
behind, that a comment could end up under other nodes, and that an
end node like Output stayed left of the devices put in front of it.

- get_asset lists under silent what of a pattern, song or
  composition is not heard. That is a missing instrument, an
  instrument that sounds no note, or notes outside what its Notes
  nodes take in, in a song after the transpose.
- soundingNotes moves from the pattern tab to
  src/core/instrument/compile.ts. The tab and the agent use the
  same rule.
- When new nodes go between two that have no room, everything from
  the fed node on moves right together. This holds for Arrange with
  a selection too, so no link runs backwards.
- A new node linked to a node in a group joins that group, and the
  group grows in the same undo step. A group that would take in a
  foreign node stays as it is.
- Arrange takes groups and comments along: a group whose nodes all
  moved the same keeps its size, otherwise it is fitted anew. The
  rules live in the new src/editor/graph/annotationFollow.ts.
- add_comment and fit_group put a comment below its nodes when a
  node or a group title is in the way above. lint_graph counts
  covered comments as coveredNotes.
- add_notes, get_asset, lint_graph and the relay instructions name
  the default range and how to mend a covered comment.

Tests:
- graph-follow (new, 12), agent-edit, agent-music (11) and the
  other agent, graph, pattern and relay tests green
- 10 deliberate breakages, each one red in its own test
- e2e 71: 25/25
- Gate: CHECK EXIT 0, 324 files, 5002 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 18:27:57 +02:00
1 parent 10f5e3ba8f
commit 19902a9f02
16 files changed
+923 -119

No files matched your search

+46
View File
@@ -195,6 +195,52 @@ describe('run_commands', () => {
expect(text(unknown)).toContain('Step 1 (arrange_graph): Jump has no node nope');
});
it('puts a new node linked into a group into it, the group growing in the same undo step', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Jump');
const { host } = hostFor(fixture);
const link = (from: string, fromPort: string, to: string, toPort: string) => ({
command: 'connect',
args: { asset: behavior.id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } },
});
await runTool(
'run_commands',
{
steps: [
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart' }, as: 'start' },
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
link('$start', 'exec', '$move', 'exec'),
{ command: 'add_group', args: { asset: behavior.id, nodes: ['$start', '$move'], title: 'Jump' } },
],
},
host,
);
const [start, move] = behavior.graph.nodes;
const before = { ...behavior.graph.groups![0]! };
const result = await runTool(
'run_commands',
{
steps: [
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' },
link('$owner', 'object', move!.id, 'object'),
],
},
host,
);
expect(result.isError).toBeFalsy();
const owner = behavior.graph.nodes[2]!;
const group = behavior.graph.groups![0]!;
expect(owner.x + NODE_WIDTH + COLUMN_GAP).toBe(move!.x);
expect(owner.x + NODE_WIDTH / 2).toBeGreaterThan(group.x);
expect(owner.y).toBeGreaterThan(group.y);
expect(owner.y).toBeLessThan(group.y + group.h);
// Under the start, where the group had room already.
expect(group.h).toBeGreaterThanOrEqual(before.h);
expect([start!.x, start!.y]).toEqual([behavior.graph.nodes[0]!.x, behavior.graph.nodes[0]!.y]);
await runTool('undo', {}, host);
expect(behavior.graph.groups![0]).toEqual(before);
});
it('changes nothing when a step fails, and names the step and why', async () => {
const fixture = makeSpriteScene();
const { host } = hostFor(fixture);
+99
View File
@@ -423,3 +423,102 @@ describe('compositions', () => {
expect(last.text).toContain('keeps at least one track');
});
});
describe('silent music', () => {
it('get_asset says what plays nothing for want of an instrument, until it has one', async () => {
const fixture = makeSpriteScene();
const { Riff, Theme, Cut, Lead } = await made(fixture, {
Riff: 'pattern',
Theme: 'song',
Cut: 'composition',
Lead: 'instrument',
});
const track = byId<SongAsset>(fixture, Theme).tracks[0]!.id;
const lane = byId<CompositionAsset>(fixture, Cut).tracks[0]!.id;
const built = await run(fixture, [
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0 }, as: 'item' },
{ command: 'add_composition_item', args: { asset: Cut, track: lane, place: Riff, start: 0 } },
]);
expect(built.result.isError).toBeUndefined();
const item = byId<SongAsset>(fixture, Theme).items[0]!.id;
expect((await read(fixture, Riff)).silent).toEqual([
'It has no instrument and plays nothing: set the property instrument of the pattern to an Instrument asset.',
]);
expect((await read(fixture, Theme)).silent).toEqual([expect.stringContaining(`Item ${item} plays Riff`)]);
expect((await read(fixture, Cut)).silent).toEqual([expect.stringContaining('which is not all heard')]);
// The item's own instrument is enough for the song; the pattern's for all of them.
await run(fixture, [{ command: 'change_song_item', args: { asset: Theme, item, instrument: Lead } }]);
expect((await read(fixture, Theme)).silent).toBeUndefined();
expect((await read(fixture, Riff)).silent).toHaveLength(1);
await run(fixture, [
{ command: 'set_asset_property', args: { asset: Riff, key: 'instrument', value: Lead } },
]);
for (const asset of [Riff, Theme, Cut]) expect((await read(fixture, asset)).silent).toBeUndefined();
});
it('names the notes outside what the instrument sounds, after the transpose, until its range takes them in', async () => {
const fixture = makeSpriteScene();
const { Riff, Theme, Cut, Lead } = await made(fixture, {
Riff: 'pattern',
Theme: 'song',
Cut: 'composition',
Lead: 'instrument',
});
const node = (type: string) =>
(
fixture.project.assets.find((a) => a.id === Lead) as {
graph: { nodes: { id: string; type: string }[] };
}
).graph.nodes.find((n) => n.type === type)!.id;
const track = byId<SongAsset>(fixture, Theme).tracks[0]!.id;
const lane = byId<CompositionAsset>(fixture, Cut).tracks[0]!.id;
const built = await run(fixture, [
{ command: 'set_asset_property', args: { asset: Riff, key: 'instrument', value: Lead } },
{
command: 'add_notes',
args: {
asset: Riff,
notes: [
{ tick: 0, length: 480, note: 'C4' },
{ tick: 480, length: 480, note: 'C5' },
],
},
},
{ command: 'add_song_item', args: { asset: Theme, track, play: Riff, tick: 0, transpose: -12 } },
{ command: 'add_composition_item', args: { asset: Cut, track: lane, place: Riff, start: 0 } },
]);
expect(built.result.isError).toBeUndefined();
const item = byId<SongAsset>(fixture, Theme).items[0]!.id;
// A new instrument's Sample waits for its sound: no note is heard, and get_graph says why.
expect((await read(fixture, Riff)).silent).toEqual([
'C4, C5 are not heard: Lead sounds no note at all. To hear them, get_graph on it shows what it lacks, like the sound of a Sample, or move the notes.',
]);
const sound = addSound(fixture);
await run(fixture, [
{
command: 'set_node_param',
args: { asset: Lead, node: node('InstrumentSample'), key: 'sound', value: sound },
},
]);
expect((await read(fixture, Riff)).silent).toEqual([
'C5 is not heard: Lead sounds only C4 to B4. To hear them, widen "from" and "to" of a Notes node in it, or move the notes.',
]);
// An octave down, C5 comes in and C4 drops out: the song goes by the notes as they sound.
expect((await read(fixture, Theme)).silent).toEqual([
`Item ${item} plays Riff, transposed by -12. C3 is not heard: Lead sounds only C4 to B4. To hear them, widen "from" and "to" of a Notes node in it, or change the transpose.`,
]);
expect((await read(fixture, Cut)).silent).toEqual([
expect.stringContaining('Riff, which is not all heard'),
]);
const notes = node('InstrumentNotes');
await run(fixture, [
{ command: 'set_node_param', args: { asset: Lead, node: notes, key: 'from', value: '48' } },
{ command: 'set_node_param', args: { asset: Lead, node: notes, key: 'to', value: '72' } },
]);
for (const asset of [Riff, Theme, Cut]) expect((await read(fixture, asset)).silent).toBeUndefined();
});
});
+2 -2
View File
@@ -141,7 +141,7 @@ describe('arrange', () => {
node('new-b', ['exec'], [], 30, 20),
],
edges: [edge('kept', 'exec', 'new-a', 'exec'), edge('new-a', 'exec', 'new-b', 'exec')],
boxes: [{ id: 'group', x: 200, y: -40, w: 300, h: 200 }],
boxes: [{ id: 'group', kind: 'group', x: 200, y: -40, w: 300, h: 200 }],
};
const result = placed(graph, new Set(['new-a', 'new-b']));
const kept = result.nodes.find((n) => n.id === 'kept')!;
@@ -226,7 +226,7 @@ describe('arrange', () => {
const graph: LayoutGraph = {
nodes: [node('a', [], ['exec'], 220, 0), node('b', ['exec'], [], 30, 20)],
edges: [edge('a', 'exec', 'b', 'exec')],
boxes: [{ id: 'group', x: 0, y: -40, w: 700, h: 300 }],
boxes: [{ id: 'group', kind: 'group', x: 0, y: -40, w: 700, h: 300 }],
};
const result = placed(graph);
expect(Math.min(...result.nodes.map((n) => n.x))).toBe(30);
+250
View File
@@ -0,0 +1,250 @@
/**
* Groups and comments going along when nodes are arranged (#429), new nodes joining the group of
* what they link to, room made for nodes put between, and comments kept clear of other nodes.
*/
import { describe, expect, it } from 'vitest';
import { freeNoteBox, groupBoxAround, noteBoxAbove } from '../../src/editor/graph/annotationDrag';
import { adoptingGroups, followedBoxes } from '../../src/editor/graph/annotationFollow';
import {
arrange,
COLUMN_GAP,
type LayoutBox,
type LayoutEdge,
type LayoutGraph,
type LayoutNode,
lintLayout,
} from '../../src/editor/graph/arrange';
function node(id: string, inputs: string[], outputs: string[], x = 0, y = 0): LayoutNode {
const rows = (keys: string[]) => Object.fromEntries(keys.map((key, i) => [key, 30 + i * 20 + 10]));
return {
id,
x,
y,
w: 176,
h: 36 + Math.max(inputs.length, outputs.length) * 20,
inputs: rows(inputs),
outputs: rows(outputs),
};
}
let edgeCount = 0;
function edge(from: string, fromPort: string, to: string, toPort: string): LayoutEdge {
edgeCount += 1;
return {
id: `e${edgeCount}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
exec: fromPort === 'exec',
};
}
/** Arranges as the editor does: the nodes, then the groups and comments after them. */
function placed(graph: LayoutGraph, movable?: ReadonlySet<string>, fresh = false): LayoutGraph {
const joining = fresh && movable ? adoptingGroups(graph, movable) : [];
const spots = arrange(graph, movable, { open: new Set(joining), fresh });
const boxes = followedBoxes(graph, spots, { groups: joining, nodes: [...(movable ?? [])] });
return {
...graph,
nodes: graph.nodes.map((n) => ({ ...n, ...(spots.get(n.id) ?? {}) })),
boxes: graph.boxes.map((b) => ({ ...b, ...boxes.get(b.id)! })),
};
}
const at = (graph: LayoutGraph, id: string) => graph.nodes.find((n) => n.id === id)!;
const box = (graph: LayoutGraph, id: string) => graph.boxes.find((b) => b.id === id)!;
const centredIn = (b: LayoutBox, n: LayoutNode) =>
n.x + n.w / 2 >= b.x && n.x + n.w / 2 <= b.x + b.w && n.y + n.h / 2 >= b.y && n.y + n.h / 2 <= b.y + b.h;
/** The walking graph of #429 in its group, with the comment above it, as the user has it. */
function walking(): LayoutGraph {
const nodes = [
node('update', [], ['exec', 'dt'], 0, 0),
node('owner', [], ['object'], 0, 100),
node('axis', [], ['x', 'y', 'active'], 0, 180),
node('move', ['exec', 'object', 'dx', 'dy', 'scale', 'dt'], ['exec'], 248, 0),
];
const group = { id: 'group', kind: 'group' as const, ...groupBoxAround(nodes)! };
const note = { id: 'note', kind: 'note' as const, ...noteBoxAbove(nodes)! };
return {
nodes,
edges: [
edge('update', 'exec', 'move', 'exec'),
edge('update', 'dt', 'move', 'dt'),
edge('owner', 'object', 'move', 'object'),
edge('axis', 'x', 'move', 'dx'),
edge('axis', 'y', 'move', 'dy'),
],
boxes: [group, note],
};
}
describe('new nodes and groups', () => {
it('puts a new node feeding one in a group into that group, which grows round it', () => {
const graph = walking();
graph.nodes.push(node('vars', [], ['speed'], 508, 0));
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
expect(adoptingGroups(graph, new Set(['vars']))).toEqual(['group']);
const result = placed(graph, new Set(['vars']), true);
const group = box(result, 'group');
for (const n of result.nodes) expect(centredIn(group, n)).toBe(true);
// It grew down for the new node only; the nodes there stay, and the comment stays above it.
expect(group.y).toBe(box(graph, 'group').y);
expect(group.h).toBeGreaterThan(box(graph, 'group').h);
expect(box(result, 'note')).toEqual(box(graph, 'note'));
expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0, coveredNotes: 0, crossings: 4 });
});
it('leaves a group as it is when growing round the new node would take in a node not its own', () => {
const graph = walking();
graph.nodes.push(node('vars', [], ['speed'], 508, 0));
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
// A stranger right under the group, where the new node would go.
const group = box(graph, 'group');
graph.nodes.push(node('stranger', [], [], 0, group.y + group.h + 8));
const result = placed(graph, new Set(['vars']), true);
expect(box(result, 'group')).toEqual(group);
});
it('puts a new node taking from one in a group next to it, not where it waited far off', () => {
const graph = walking();
graph.nodes.push(node('far', [], [], 2000, 400));
// Where the agent's new node waits: right of everything.
graph.nodes.push(node('after', ['exec'], [], 2260, 0));
graph.edges.push(edge('move', 'exec', 'after', 'exec'));
const result = placed(graph, new Set(['after']), true);
const move = at(result, 'move');
expect(at(result, 'after').x).toBe(move.x + move.w + COLUMN_GAP);
expect(centredIn(box(result, 'group'), at(result, 'after'))).toBe(true);
expect(centredIn(box(result, 'group'), at(result, 'far'))).toBe(false);
});
it('takes nodes in only while none of the new ones stands in a group already', () => {
const graph = walking();
graph.nodes.push(node('vars', [], ['speed'], 40, 60));
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
expect(adoptingGroups(graph, new Set(['vars']))).toEqual([]);
});
});
describe('arranging with groups and comments', () => {
it('fits a group round its nodes again after Arrange, with its comment going along', () => {
const graph = walking();
// In one row, as the agent leaves new nodes, and grouped like that.
graph.nodes.forEach((n, i) => Object.assign(n, { x: i * 260, y: 0 }));
graph.boxes[0] = { id: 'group', kind: 'group', ...groupBoxAround(graph.nodes)! };
graph.boxes[1] = { id: 'note', kind: 'note', ...noteBoxAbove(graph.nodes)! };
const result = placed(graph);
const group = box(result, 'group');
expect(group).toMatchObject(groupBoxAround(result.nodes)!);
expect(group.w).toBeLessThan(box(graph, 'group').w / 2);
const note = box(result, 'note');
// Just above the group, as far from it as before.
expect(group.y - (note.y + note.h)).toBe(
box(graph, 'group').y - (box(graph, 'note').y + box(graph, 'note').h),
);
expect(lintLayout(result).coveredNotes).toBe(0);
});
it('moves a group whose nodes all moved alike as it is, keeping its size', () => {
const graph: LayoutGraph = {
nodes: [node('a', [], ['exec'], 0, 0), node('b', ['exec'], [], 248, 0)],
edges: [edge('a', 'exec', 'b', 'exec')],
boxes: [{ id: 'roomy', kind: 'group', x: -100, y: -100, w: 700, h: 400 }],
};
const spots = new Map([
['a', { x: 50, y: 30 }],
['b', { x: 298, y: 30 }],
]);
expect(followedBoxes(graph, spots).get('roomy')).toEqual({ x: -50, y: -70, w: 700, h: 400 });
});
});
describe('room for nodes put between', () => {
/** A fresh instrument: Notes and Output close together, and the chain the agent puts between. */
function instrument(): LayoutGraph {
return {
nodes: [
node('notes', [], ['note'], 0, 0),
node('output', ['audio'], [], 248, 0),
node('beside', [], [], 400, 200),
node('chip', ['note'], ['audio'], 600, 0),
node('env', ['audio'], ['audio'], 860, 0),
node('delay', ['audio'], ['audio'], 1120, 0),
],
edges: [
edge('notes', 'note', 'chip', 'note'),
edge('chip', 'audio', 'env', 'audio'),
edge('env', 'audio', 'delay', 'audio'),
edge('delay', 'audio', 'output', 'audio'),
],
boxes: [{ id: 'about', kind: 'note', x: 248, y: -120, w: 220, h: 96 }],
};
}
it('pushes what they feed, and everything right of it, far enough right', () => {
const graph = instrument();
const result = placed(graph, new Set(['chip', 'env', 'delay']), true);
expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0, crossings: 0 });
const notes = at(result, 'notes');
const delay = at(result, 'delay');
expect(at(result, 'chip').x).toBe(notes.x + notes.w + COLUMN_GAP);
expect(at(result, 'output').x).toBe(delay.x + delay.w + COLUMN_GAP);
expect([notes.x, notes.y]).toEqual([0, 0]);
// What stood right of the output moved by as much, and so did the comment on it.
const by = at(result, 'output').x - 248;
expect(at(result, 'beside')).toMatchObject({ x: 400 + by, y: 200 });
expect(box(result, 'about')).toMatchObject({ x: 248 + by, y: -120 });
});
it('pushes nothing where there is room between already', () => {
const graph = instrument();
at(graph, 'output').x = 1500;
const result = placed(graph, new Set(['chip', 'env', 'delay']), true);
expect(at(result, 'output').x).toBe(1500);
expect(at(result, 'beside').x).toBe(400);
expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0 });
});
});
describe('freeNoteBox', () => {
const targets = [{ id: 'a', x: 0, y: 200, w: 176, h: 76 }];
const above = noteBoxAbove(targets)!;
const empty = { nodes: [], groups: [], notes: [] };
it('puts a comment above its nodes while that is free', () => {
expect(freeNoteBox(targets, empty)).toEqual(above);
});
it('puts it below them where a node stands above, and above again when below is taken too', () => {
const over = { x: 0, y: above.y + 10, w: 176, h: 40 };
const below = freeNoteBox(targets, { ...empty, nodes: [over] })!;
expect(below.y).toBe(200 + 76 + 40);
const under = { x: 0, y: below.y + 10, w: 176, h: 40 };
expect(freeNoteBox(targets, { ...empty, nodes: [over, under] })).toEqual(above);
});
it('may lie inside a group, but not over its title bar or across its edge', () => {
const roomy = { x: -50, y: above.y - 50, w: 400, h: 500 };
expect(freeNoteBox(targets, { ...empty, groups: [roomy] })).toEqual(above);
const titled = { ...roomy, y: above.y - 10 };
expect(freeNoteBox(targets, { ...empty, groups: [titled] })!.y).toBe(200 + 76 + 40);
});
});
describe('lintLayout', () => {
it('counts a comment a node lies on', () => {
const graph: LayoutGraph = {
nodes: [node('a', [], [], 0, 0), node('b', [], [], 0, 300)],
edges: [],
boxes: [
{ id: 'hidden', kind: 'note', x: 50, y: 20, w: 220, h: 96 },
{ id: 'clear', kind: 'note', x: 0, y: 150, w: 220, h: 96 },
{ id: 'group', kind: 'group', x: -20, y: -44, w: 216, h: 120 },
],
};
const lint = lintLayout(graph);
expect(lint.coveredNotes).toBe(1);
expect(lint.examples).toContainEqual({ kind: 'covered', ids: ['hidden', 'a'] });
});
});