Stage 2 of #429. Until now an agent could only look at the editor. Now it reads the project and changes it through the same commands the user's own edits run, so every change shows up live and one Ctrl+Z takes back a whole agent call. - Read tools: list_assets, get_asset, get_graph and catalog. The catalog lists the commands with their arguments, the nodes of a graph from the same palette the user sees, asset types and variable kinds. - run_commands runs its steps as one undo step named "Agent: ...". A failing step rolls back the steps before it. The answer names the step and the reason the editor gave. - A step can name what it made ("as") and later steps use "$name". Each step is built after the one before has run, so one call can make a behavior and fill it. - 23 commands cover assets, variables, scene objects and graphs. Textures, fonts, sounds and videos need a file and are left out. - open, select, play and undo/redo drive the editor. - Deleting asks the user with the same text as their own delete. The tab sends "wait", and the relay then gives the call 2 minutes instead of 30 seconds. - tools.json is the only source of the argument schemas. argCheck checks every call against them before anything runs. - An open menu now names its first entries in get_view, instead of an empty title. - The new host functions live in editorToolHost.ts, so Editor.ts does not grow further. Found while testing: e2e 71 hung for 30 s in Page.navigate after a failure. The beforeunload guard of an unsaved project held the page and hid the real error. The module now reports the error first and waits for the save before it navigates. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 317 files, 4903 tests. - agent-edit 14/14, agent-view 10/10, relay 24/24. - e2e 71 16/16 on atx and against the live site. One call builds a sprite with an arrow-key behavior, play moves it from x 0 to -125, and one undo restores the assets exactly. - Counter-checks turned red: no rollback, a partial undo, no delete question, a relay that ignores "wait". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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<string, unknown>) =>
|
||
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');
|
||
});
|
||
});
|