import { describe, expect, it } from 'vitest'; import { findGraphAsset } from '../../src/core/model/Project'; import { argProblem } from '../../src/editor/agent/argCheck'; import { runTool } from '../../src/editor/agent/agentTools'; import { COMMANDS } from '../../src/editor/agent/commandCatalog'; import { NODE_STEP } from '../../src/editor/agent/editPlan'; import { COLUMN_GAP, type GraphLint } from '../../src/editor/graph/arrange'; import { NODE_WIDTH } from '../../src/editor/graph/nodeBox'; import TOOLS from '../../src/editor/agent/tools.json'; import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers'; import { hostFor, text } from './agentTestHost'; /** What an agent changes and reads through the tools (#429, stage 2). */ /** An object that walks left while the left arrow is held: behavior, placement, three nodes, three edges. */ function walkLeft(fixture: SceneFixture) { return [ { command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' }, { command: 'place_asset', args: { in: fixture.sceneId, asset: fixture.spriteId, x: 100, y: 50 }, as: 'player', }, { command: 'place_asset', args: { in: fixture.sceneId, asset: '$walk', x: 100, y: 50, parent: '$player' }, }, { command: 'add_node', args: { asset: '$walk', node: 'OnKeyHeld', params: { key: 'ArrowLeft' } }, as: 'key', }, { command: 'add_node', args: { asset: '$walk', node: 'Move', params: { dx: -200 } }, as: 'move' }, { command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' }, { command: 'connect', args: { asset: '$walk', from: { node: '$key', port: 'exec' }, to: { node: '$move', port: 'exec' } }, }, { command: 'connect', args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'dt' } }, }, { command: 'connect', args: { asset: '$walk', from: { node: '$owner', port: 'object' }, to: { node: '$move', port: 'object' }, }, }, ]; } describe('run_commands', () => { it('builds an object with a behavior in one call, as one undo step that takes all of it back', async () => { const fixture = makeSpriteScene('Hero'); const { host } = hostFor(fixture); const before = JSON.stringify(fixture.project); const result = await runTool('run_commands', { steps: walkLeft(fixture), label: 'walk left' }, host); expect(result.isError).toBeFalsy(); expect(fixture.stack.undoLabel).toBe('Agent: walk left'); const behavior = fixture.project.assets.find((asset) => asset.name === 'Walk')!; const graph = findGraphAsset(fixture.project, behavior.id)!.graph; expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Owner']); expect(graph.nodes[0]!.params.key).toBe('ArrowLeft'); expect(graph.nodes[1]!.params.dx).toBe(-200); expect(graph.edges.map((edge) => `${edge.from.port}>${edge.to.port}`)).toEqual([ 'exec>exec', 'dt>dt', 'object>object', ]); const scene = fixture.sceneOf(); const [player, walk] = scene.objects; expect(walk).toMatchObject({ assetId: behavior.id, parentId: player!.id }); expect(text(result)).toContain(`Step 1 made {"id":"${behavior.id}"}`); const after = JSON.stringify(fixture.project); expect(text(await runTool('undo', {}, host))).toBe('Undone: Agent: walk left.'); expect(JSON.stringify(fixture.project)).toBe(before); await runTool('redo', {}, host); expect(JSON.stringify(fixture.project)).toBe(after); }); it('arranges the nodes it gave no place right of those the graph has, which stay put', async () => { const fixture = makeSpriteScene(); const behavior = makeBehavior(fixture, 'Jump'); const { host } = hostFor(fixture); await runTool( 'run_commands', { steps: [{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 40, y: 80 } }] }, host, ); await runTool( 'run_commands', { steps: [ { command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' }, { command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' }, { command: 'connect', args: { asset: behavior.id, from: { node: '$owner', port: 'object' }, to: { node: '$move', port: 'object' }, }, }, ], }, host, ); const [start, owner, move] = behavior.graph.nodes; expect([start!.x, start!.y]).toEqual([40, 80]); // The new part keeps the place it waited at, right of the start, and reads left to right. expect(owner!.x).toBe(40 + NODE_STEP); expect(move!.x).toBe(owner!.x + NODE_WIDTH + COLUMN_GAP); expect(fixture.stack.undoLabel).toBe('Agent: add_node, add_node, connect'); const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint; expect(lint).toMatchObject({ nodes: 3, edges: 1, overlaps: 0, crossings: 0, throughNodes: 0, backwards: 0, }); }); it('leaves a node given a place where it was put, and arranges the rest around it', async () => { const fixture = makeSpriteScene(); const behavior = makeBehavior(fixture, 'Jump'); const { host } = hostFor(fixture); await runTool( 'run_commands', { steps: [ { command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 0, y: 0 }, as: 'start' }, { command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' }, { command: 'connect', args: { asset: behavior.id, from: { node: '$start', port: 'exec' }, to: { node: '$move', port: 'exec' }, }, }, ], }, host, ); const [start, move] = behavior.graph.nodes; expect([start!.x, start!.y]).toEqual([0, 0]); expect(move!.x).toBeGreaterThanOrEqual(NODE_WIDTH); }); it('arrange_graph arranges what it is given as one step of the edit, and undo puts it back', async () => { const fixture = makeSpriteScene(); const behavior = makeBehavior(fixture, 'Jump'); const { host } = hostFor(fixture); const steps = [ { command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 500, y: 0 }, as: 'start' }, { command: 'add_node', args: { asset: behavior.id, node: 'Move', x: 0, y: 0 }, as: 'move' }, { command: 'connect', args: { asset: behavior.id, from: { node: '$start', port: 'exec' }, to: { node: '$move', port: 'exec' }, }, }, ]; await runTool('run_commands', { steps }, host); const tangled = JSON.stringify(behavior.graph.nodes); const before = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint; expect(before.backwards).toBe(1); const result = await runTool( 'run_commands', { steps: [{ command: 'arrange_graph', args: { asset: behavior.id } }], label: 'tidy' }, host, ); expect(result.isError).toBeFalsy(); const [start, move] = behavior.graph.nodes; expect(move!.x).toBe(start!.x + NODE_WIDTH + COLUMN_GAP); const after = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint; expect(after.backwards).toBe(0); await runTool('undo', {}, host); expect(JSON.stringify(behavior.graph.nodes)).toBe(tangled); const unknown = await runTool( 'run_commands', { steps: [{ command: 'arrange_graph', args: { asset: behavior.id, nodes: ['nope'] } }] }, host, ); 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('gives a group made round new nodes room, what else would stand in it going below', async () => { const fixture = makeSpriteScene('Hero'); const { host } = hostFor(fixture); const steps = [ ...walkLeft(fixture), { command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } }, { command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } }, ]; const result = await runTool('run_commands', { steps, label: 'walk' }, host); expect(result.isError, text(result)).toBeFalsy(); const behavior = fixture.project.assets.find((asset) => asset.name === 'Walk')!; const { graph } = findGraphAsset(fixture.project, behavior.id)!; const [walk, who] = graph.groups!; const centred = (box: { x: number; y: number; w: number; h: number }) => graph.nodes .filter((node) => { const cx = node.x + NODE_WIDTH / 2; const cy = node.y + 20; return cx >= box.x && cx <= box.x + box.w && cy >= box.y && cy <= box.y + box.h; }) .map((node) => node.type); // Move is taller than the key: before, the Owner beside the key came to stand in the Walk group. expect(centred(walk!)).toEqual(['OnKeyHeld', 'Move']); expect(centred(who!)).toEqual(['Owner']); const apart = who!.x + who!.w <= walk!.x || walk!.x + walk!.w <= who!.x || who!.y + who!.h <= walk!.y || walk!.y + walk!.h <= who!.y; expect(apart).toBe(true); const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint; expect(lint).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0, coveredNotes: 0 }); }); it('changes nothing when a step fails, and names the step and why', async () => { const fixture = makeSpriteScene(); const { host } = hostFor(fixture); const before = JSON.stringify(fixture.project); const steps = walkLeft(fixture); steps[7] = { command: 'connect', args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'exec' } }, }; const result = await runTool('run_commands', { steps }, host); expect(result.isError).toBe(true); expect(text(result)).toBe('Nothing was changed. Step 8 (connect): Cannot connect number to exec'); expect(JSON.stringify(fixture.project)).toBe(before); expect(fixture.stack.canUndo).toBe(true); // the fixture's own two assets, nothing of the agent's expect(fixture.stack.undoLabel).not.toMatch(/^Agent/); }); it('checks every step’s arguments against its command, and refuses an unknown command or node', async () => { const fixture = makeSpriteScene(); const behavior = makeBehavior(fixture); const { host } = hostFor(fixture); const one = async (step: object) => text(await runTool('run_commands', { steps: [step] }, host)); expect(await one({ command: 'rename_asset', args: { asset: fixture.spriteId } })).toBe( 'Nothing was changed. Step 1 (rename_asset): args.name is missing', ); expect(await one({ command: 'create_asset', args: { type: 'texture', name: 'T' } })).toMatch( /Step 1 \(create_asset\): args\.type has to be one of scene, /, ); expect(await one({ command: 'teleport', args: {} })).toMatch(/Step 1 \(teleport\): no such command/); expect(await one({ command: 'add_node', args: { asset: behavior.id, node: 'Fly' } })).toMatch( /Step 1 \(add_node\): A behavior graph has no node "Fly" – look it up with catalog/, ); expect( await one({ command: 'rename_asset', args: { asset: fixture.spriteId, name: 'X', colour: 1 } }), ).toMatch(/args has no colour/); expect(text(await runTool('run_commands', { steps: [] }, host))).toBe( 'arguments.steps needs at least 1 item(s)', ); }); it('finds a node by its label too, and keeps a typed-in input value as given', async () => { const fixture = makeSpriteScene(); const behavior = makeBehavior(fixture); const { host } = hostFor(fixture); await runTool( 'run_commands', { steps: [{ command: 'add_node', args: { asset: behavior.id, node: 'on key held' } }] }, host, ); expect(behavior.graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld']); }); it('asks the user before deleting an asset that was there, and does nothing on a no', async () => { const fixture = makeSpriteScene(); const no = hostFor(fixture, 'The user said no; nothing was changed.'); const deleting = { steps: [{ command: 'delete_asset', args: { asset: fixture.spriteId } }] }; const refused = await runTool('run_commands', deleting, no.host); expect(no.asked).toEqual([[fixture.spriteId]]); expect(text(refused)).toBe('The user said no; nothing was changed.'); expect(fixture.project.assets.some((asset) => asset.id === fixture.spriteId)).toBe(true); const yes = hostFor(fixture); await runTool('run_commands', deleting, yes.host); expect(fixture.project.assets.some((asset) => asset.id === fixture.spriteId)).toBe(false); // What the agent made in the same call is its own to take away again. const own = hostFor(fixture); await runTool( 'run_commands', { steps: [ { command: 'create_asset', args: { type: 'sprite', name: 'Tmp' }, as: 'tmp' }, { command: 'delete_asset', args: { asset: '$tmp' } }, ], }, own.host, ); expect(own.asked).toEqual([]); }); it('refuses a name for a step that makes nothing, or a part a step did not make', async () => { const fixture = makeSpriteScene(); const { host } = hostFor(fixture); const rename = { command: 'rename_asset', args: { asset: fixture.spriteId, name: 'A' }, as: 'r' }; expect(text(await runTool('run_commands', { steps: [rename] }, host))).toMatch( /Step 1 \(rename_asset\): makes nothing a later step could call "r"/, ); const steps = [ { command: 'create_asset', args: { type: 'sprite', name: 'S' }, as: 's' }, { command: 'rename_asset', args: { asset: '$s.closing', name: 'T' } }, ]; expect(text(await runTool('run_commands', { steps }, host))).toMatch( /Step 2 \(rename_asset\): "\$s.closing": that step made no closing/, ); }); }); describe('the editor following the agent', () => { it('shows the graph a call built, with every node it touched selected and the removed ones left out', async () => { const fixture = makeSpriteScene(); const { host, shown } = hostFor(fixture, null, true); await runTool('run_commands', { steps: walkLeft(fixture) }, host); const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!; const nodes = findGraphAsset(fixture.project, walk.id)!.graph.nodes.map((node) => node.id); expect(shown).toEqual([{ kind: 'nodes', asset: walk.id, ids: nodes }]); const steps = [ { command: 'add_node', args: { asset: walk.id, node: 'OnStart' }, as: 'start' }, { command: 'set_node_param', args: { asset: walk.id, node: nodes[1], key: 'dx', value: -100 } }, { command: 'remove_node', args: { asset: walk.id, node: '$start' } }, ]; await runTool('run_commands', { steps }, host); expect(shown[1]).toEqual({ kind: 'nodes', asset: walk.id, ids: [nodes[1]] }); }); it('switches to the scene of the objects a call ends on, inside the same undo step', async () => { const fixture = makeSpriteScene(); const { host, shown } = hostFor(fixture, null, true); const made = await runTool( 'run_commands', { steps: [{ command: 'create_asset', args: { type: 'scene', name: 'Cave' } }] }, host, ); const cave = (made.structuredContent as { made: { id: string }[] }).made[0]!.id; expect(shown).toEqual([{ kind: 'asset', asset: cave }]); expect(fixture.project.activeSceneId).toBe(fixture.sceneId); const place = { command: 'place_asset', args: { in: cave, asset: fixture.spriteId, x: 0, y: 0 }, as: 'bat', }; const placed = await runTool('run_commands', { steps: [place], label: 'bat' }, host); const bat = (placed.structuredContent as { made: { id: string }[] }).made[0]!.id; expect(shown[1]).toEqual({ kind: 'objects', in: cave, ids: [bat] }); expect(fixture.project.activeSceneId).toBe(cave); expect(text(await runTool('undo', {}, host))).toBe('Undone: Agent: bat.'); expect(fixture.project.activeSceneId).toBe(fixture.sceneId); }); it('leaves the view and the scene alone when the user switched following off', async () => { const fixture = makeSpriteScene(); const { host, shown } = hostFor(fixture); const steps = [ { command: 'create_asset', args: { type: 'scene', name: 'Cave' }, as: 'cave' }, { command: 'place_asset', args: { in: '$cave', asset: fixture.spriteId, x: 0, y: 0 } }, ]; expect((await runTool('run_commands', { steps }, host)).isError).toBeFalsy(); expect(shown).toEqual([]); expect(fixture.project.activeSceneId).toBe(fixture.sceneId); }); }); describe('reading', () => { it('get_graph lists each node’s ports as they are, and the edges', async () => { const fixture = makeSpriteScene(); const { host } = hostFor(fixture); await runTool('run_commands', { steps: walkLeft(fixture) }, host); const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!; const graph = (await runTool('get_graph', { asset: walk.id }, host)).structuredContent as { nodes: { type: string; inputs: { key: string; type: string }[] }[]; edges: unknown[]; }; expect(graph.edges).toHaveLength(3); expect(graph.nodes.find((node) => node.type === 'Move')!.inputs.map((port) => port.key)).toEqual([ 'exec', 'object', 'dx', 'dy', 'scale', 'dt', ]); }); it('get_asset counts a graph instead of spelling it out, and names a scene’s objects', async () => { const fixture = makeSpriteScene('Hero'); const { host } = hostFor(fixture); await runTool('run_commands', { steps: walkLeft(fixture) }, host); const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!; const asset = (await runTool('get_asset', { asset: walk.id }, host)).structuredContent as Record< string, unknown >; expect(asset.graph).toEqual({ nodes: 3, edges: 3, read: 'get_graph' }); const scene = (await runTool('get_asset', { asset: fixture.sceneId }, host)).structuredContent as { objects: { name: string }[]; }; expect(scene.objects.map((object) => object.name)).toEqual(['Hero', 'Walk']); expect(text(await runTool('get_asset', { asset: 'nope' }, host))).toMatch(/There is no asset nope/); }); it('list_assets filters by type and name', async () => { const fixture = makeSpriteScene('Hero'); makeBehavior(fixture, 'Jump'); const { host } = hostFor(fixture); const listed = (query: Record) => runTool('list_assets', query, host).then( (r) => (r.structuredContent as { assets: { name: string }[] }).assets, ); expect((await listed({})).map((asset) => asset.name)).toEqual(['Level', 'Hero', 'Jump']); expect((await listed({ type: 'behavior' })).map((asset) => asset.name)).toEqual(['Jump']); expect((await listed({ query: 'he' })).map((asset) => asset.name)).toEqual(['Hero']); }); it('the node catalog of a graph answers a search in full, and lists every node without one', async () => { const fixture = makeSpriteScene(); const behavior = makeBehavior(fixture); const { host } = hostFor(fixture); const nodes = async (query?: string) => ( (await runTool('catalog', { topic: 'nodes', asset: behavior.id, ...(query ? { query } : {}) }, host)) .structuredContent as { nodes: { id: string; inputs?: unknown[]; settings?: { key: string }[] }[] } ).nodes; const found = await nodes('key held'); expect(found[0]).toMatchObject({ id: 'OnKeyHeld', inputs: [] }); expect(found[0]!.settings!.map((setting) => setting.key)).toContain('key'); const all = await nodes(); expect(all.some((entry) => entry.id === 'Move')).toBe(true); expect(all[0]!.inputs).toBeUndefined(); expect(text(await runTool('catalog', { topic: 'nodes' }, host))).toMatch(/Name the graph asset/); }); }); describe('the catalog', () => { it('has every command once, each with arguments the check understands', async () => { const names = COMMANDS.map((command) => command.name); expect(new Set(names).size).toBe(names.length); for (const command of COMMANDS) { expect(command.args).toMatchObject({ type: 'object', additionalProperties: false }); expect(argProblem(command.args, {})).toMatch(/is missing|^null$/); } const { host } = hostFor(makeSpriteScene()); const listed = (await runTool('catalog', { topic: 'commands' }, host)).structuredContent as { commands: { areas: { area: string; commands: { name: string; args?: unknown }[] }[] }; }; const areas = listed.commands.areas; expect(areas.flatMap((area) => area.commands.map((command) => command.name))).toEqual(names); expect(areas.flatMap((area) => area.commands).some((command) => 'args' in command)).toBe(false); // Each area in full fits one answer, which is cut beyond 60 000 characters. for (const { area, commands } of areas) { const full = await runTool('catalog', { topic: 'commands', query: area }, host); expect(full.isError).toBeUndefined(); expect(text(full).length).toBeLessThan(60_000); const rows = (full.structuredContent as { commands: { name: string; args: unknown }[] }).commands; expect(rows.map((row) => row.name)).toEqual(commands.map((command) => command.name)); expect(rows.every((row) => row.args !== undefined)).toBe(true); } }); it('a query of command names answers just those in full, and an unknown one says so', async () => { const { host } = hostFor(makeSpriteScene()); const found = await runTool('catalog', { topic: 'commands', query: 'add_port, add_group' }, host); const rows = (found.structuredContent as { commands: { area: string; name: string }[] }).commands; expect(rows.map((row) => `${row.area}/${row.name}`)).toEqual(['graphs/add_group', 'graphs/add_port']); const none = await runTool('catalog', { topic: 'commands', query: 'teleport' }, host); expect(none.isError).toBe(true); expect(text(none)).toContain('No area or command matches "teleport"'); }); it('the tools are the ones the relay lists, each with a schema', () => { expect(TOOLS.map((tool) => tool.name)).toEqual([ 'get_view', 'screenshot', 'list_assets', 'list_files', 'get_asset', 'get_graph', 'lint_graph', 'catalog', 'run_commands', 'open', 'select', 'play', 'play_input', 'get_runtime', 'undo', 'redo', ]); for (const tool of TOOLS) expect(tool.inputSchema).toMatchObject({ type: 'object', additionalProperties: false }); }); }); describe('argProblem', () => { it('names the place of the first problem', () => { const schema = { type: 'object' as const, properties: { at: { type: 'array' as const, items: { type: 'integer' as const, minimum: 0 } }, kind: { type: 'string' as const, enum: ['a', 'b'] }, }, required: ['kind'], additionalProperties: false as const, }; expect(argProblem(schema, { kind: 'a', at: [1, 2] })).toBeNull(); expect(argProblem(schema, { kind: 'c' })).toBe('arguments.kind has to be one of a, b'); expect(argProblem(schema, { kind: 'a', at: [1, -1] })).toBe('arguments.at[1] has to be 0 or more'); expect(argProblem(schema, { kind: 'a', at: [1.5] })).toBe('arguments.at[0] has to be a whole number'); expect(argProblem(schema, [])).toBe('arguments has to be an object'); }); });