import { describe, expect, it } from 'vitest'; import { type Command, CommandError } from '../../src/core/commands/Command'; import { findGraphAsset } from '../../src/core/model/Project'; import { argProblem } from '../../src/editor/agent/argCheck'; import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/agentTools'; import type { AgentView } from '../../src/editor/agent/agentView'; import { COMMANDS } from '../../src/editor/agent/commandCatalog'; import { NODE_STEP } from '../../src/editor/agent/editPlan'; import TOOLS from '../../src/editor/agent/tools.json'; import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers'; /** What an agent changes and reads through the tools (#429, stage 2). */ function hostFor(fixture: SceneFixture, deleteAnswer: string | null = null) { const asked: string[][] = []; const host: ToolHost = { view: () => ({ play: 'editing' }) as AgentView, screenshot: () => Promise.resolve({ data: '', width: 0, height: 0 }), project: () => fixture.project, execute: (command: Command) => { try { fixture.stack.execute(command); return null; } catch (error) { if (error instanceof CommandError) return error.message; throw error; } }, confirmDeletes: (ids) => { asked.push([...ids]); return Promise.resolve(deleteAnswer); }, open: () => null, select: () => null, play: () => null, undo: () => { const label = fixture.stack.undoLabel; if (label !== null) fixture.stack.undo(); return label; }, redo: () => { const label = fixture.stack.redoLabel; if (label !== null) fixture.stack.redo(); return label; }, }; return { host, asked }; } const text = (result: ToolResult) => result.content.map((item) => (item.type === 'text' ? item.text : '')).join('\n'); /** 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('places nodes given no place side by side, right of those the graph has', 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' } }, { command: 'add_node', args: { asset: behavior.id, node: 'Move' } }, ], }, host, ); expect(behavior.graph.nodes.map(({ x, y }) => [x, y])).toEqual([ [40, 80], [40 + NODE_STEP, 80], [40 + 2 * NODE_STEP, 80], ]); }); 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('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 fixture = makeSpriteScene(); const listed = (await runTool('catalog', { topic: 'commands' }, hostFor(fixture).host)) .structuredContent as { commands: { name: string }[]; }; expect(listed.commands.map((command) => command.name)).toEqual(names); }); 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', 'get_asset', 'get_graph', 'catalog', 'run_commands', 'open', 'select', 'play', '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'); }); });