Let an AI agent read and edit the open project through MCP (#429)
feuerwerk / gate (push) Successful in 4m40s
feuerwerk / suite (push) Successful in 18m43s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 11:08:39 +02:00
1 parent 48ec722660
commit 25af064249
19 files changed
+1913 -60

No files matched your search

+369
View File
@@ -0,0 +1,369 @@
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');
});
});
+22 -9
View File
@@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { agentView, type ViewFacts, viewSummary } from '../../src/editor/agent/agentView';
import { runTool } from '../../src/editor/agent/agentTools';
import { runTool, type ToolHost } from '../../src/editor/agent/agentTools';
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
import TOOLS from '../../src/editor/agent/tools.json';
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
/** What the agent learns of the editor (#429): `get_view`, its one line, and the tools around it. */
@@ -109,6 +108,14 @@ describe('get_view', () => {
);
});
it('says which menu is open by its first entries when it has no title', () => {
const fixture = makeSpriteScene();
const menu = (entries: string[]) =>
viewSummary(agentView(facts(fixture, { popups: [{ kind: 'menu', title: '', entries }] })));
expect(menu(['Change password…', 'Sign out'])).toMatch(/; menu \(Change password…, Sign out\) is open$/);
expect(menu(['a', 'b', 'c', 'd', 'e'])).toMatch(/; menu \(a, b, c, d, …\) is open$/);
});
it('has a line that does not move with the camera', () => {
const fixture = makeSpriteScene();
const view = agentView(facts(fixture));
@@ -117,17 +124,23 @@ describe('get_view', () => {
});
describe('the tools', () => {
const host = {
const unused = () => {
throw new Error('not in this test');
};
const host: ToolHost = {
view: () => agentView(facts(makeSpriteScene())),
screenshot: (maxWidth: number) =>
Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }),
project: unused,
execute: unused,
confirmDeletes: unused,
open: unused,
select: unused,
play: unused,
undo: unused,
redo: unused,
};
it('are the ones the relay lists, each with a schema', () => {
expect(TOOLS.map((tool) => tool.name)).toEqual(['get_view', 'screenshot']);
for (const tool of TOOLS) expect(tool.inputSchema.type).toBe('object');
});
it('get_view answers the view as text and as structured content', async () => {
const result = await runTool('get_view', {}, host);
expect(result.structuredContent).toMatchObject({ tab: { id: 'scene' }, play: 'editing' });
@@ -146,7 +159,7 @@ describe('the tools', () => {
});
it('an unknown tool says to reload', async () => {
const answer = (await runTool('run_command', {}, host)).content[0];
const answer = (await runTool('teleport', {}, host)).content[0];
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
});
});
+25
View File
@@ -371,6 +371,31 @@ describe('tabs', () => {
});
});
it('that ask the user something get the longer time, once', async () => {
await start({ callTimeoutMs: 100, waitTimeoutMs: 400 });
const tab = await onTab('alice');
tab.answer = (message) => {
tab.socket.send(JSON.stringify({ type: 'wait', id: message.id }));
if (message.tool === 'get_view') {
setTimeout(() => {
tab.socket.send(
JSON.stringify({ type: 'result', id: message.id, content: [{ type: 'text', text: 'yes' }] }),
);
}, 250);
} else {
tab.socket.send(JSON.stringify({ type: 'wait', id: message.id }));
}
return null;
};
const session = await initialize('fwa_alice');
expect(textOf((await call('fwa_alice', session, 'get_view')).body)).toBe('yes');
const started = Date.now();
const unanswered = await call('fwa_alice', session, 'screenshot');
const took = Date.now() - started;
expect(toolResult(unanswered.body).isError).toBe(true);
expect([took >= 350, took < 1500]).toEqual([true, true]);
});
it('pass a picture of several hundred kilobytes through, and only content the protocol knows', async () => {
const tab = await onTab('alice');
const data = Buffer.alloc(300_000, 7).toString('base64');