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>
184 lines
6.8 KiB
TypeScript
184 lines
6.8 KiB
TypeScript
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, type ToolHost } from '../../src/editor/agent/agentTools';
|
||
import { claudeCommand, tokenLine } from '../../src/editor/agent/AgentTokensDialog';
|
||
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. */
|
||
|
||
function facts(fixture: ReturnType<typeof makeSpriteScene>, extra: Partial<ViewFacts> = {}): ViewFacts {
|
||
return {
|
||
project: fixture.project,
|
||
mode: 'edit',
|
||
paused: false,
|
||
saveStatus: 'saved',
|
||
tab: { id: 'scene', label: 'Scene' },
|
||
document: null,
|
||
windows: [],
|
||
selection: null,
|
||
popups: [],
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
describe('get_view', () => {
|
||
it('names the selected objects, with the asset’s name where an object has none', () => {
|
||
const fixture = makeSpriteScene('Crate');
|
||
const a = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||
const b = place(fixture, fixture.spriteId, { x: 10, y: 0 });
|
||
fixture.object(b).name = 'Lid';
|
||
const view = agentView(
|
||
facts(fixture, {
|
||
selection: { kind: 'object', holderId: fixture.sceneId, objectId: b, objectIds: [a, b] },
|
||
}),
|
||
);
|
||
expect(view.selection).toEqual({
|
||
kind: 'object',
|
||
in: { id: fixture.sceneId, name: 'Level', type: 'scene' },
|
||
primary: b,
|
||
objects: [
|
||
{ id: a, name: 'Crate', asset: fixture.spriteId },
|
||
{ id: b, name: 'Lid', asset: fixture.spriteId },
|
||
],
|
||
});
|
||
expect(viewSummary(view)).toBe(
|
||
'project "P"; tab Scene; selection: 2 objects ("Crate", "Lid") in "Level"; editing',
|
||
);
|
||
});
|
||
|
||
it('names the selected nodes by type, and the document a tab shows', () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture, 'Jump');
|
||
const node = graphEditor(fixture, behavior).addNode('OnStart');
|
||
const view = agentView(
|
||
facts(fixture, {
|
||
tab: { id: 'graph', label: 'Behavior' },
|
||
document: `asset:${behavior.id}`,
|
||
selection: {
|
||
kind: 'node',
|
||
assetId: behavior.id,
|
||
nodeId: node.id,
|
||
nodeIds: [node.id],
|
||
annotationIds: [],
|
||
edgeIds: [],
|
||
},
|
||
mode: 'play',
|
||
paused: true,
|
||
}),
|
||
);
|
||
expect(view.document).toEqual({
|
||
id: `asset:${behavior.id}`,
|
||
asset: { id: behavior.id, name: 'Jump', type: 'behavior' },
|
||
});
|
||
expect(view.selection).toMatchObject({
|
||
kind: 'node',
|
||
nodes: [{ id: node.id, type: 'OnStart' }],
|
||
edges: [],
|
||
});
|
||
expect(view.play).toBe('paused');
|
||
expect(viewSummary(view)).toBe(
|
||
'project "P"; tab Behavior showing behavior "Jump"; selection: 1 node (OnStart) in "Jump"; paused',
|
||
);
|
||
});
|
||
|
||
it('keeps the ids of the other kinds, says what an asset is, and the dialog on top', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = new CreateAssetCommand('texture', 'Tiles');
|
||
fixture.stack.execute(texture);
|
||
const frame = agentView(
|
||
facts(fixture, { selection: { kind: 'frame', assetId: texture.asset.id, frameId: 'f1' } }),
|
||
);
|
||
expect(frame.selection).toEqual({
|
||
kind: 'frame',
|
||
in: { id: texture.asset.id, name: 'Tiles', type: 'texture' },
|
||
frameId: 'f1',
|
||
});
|
||
const asset = agentView(
|
||
facts(fixture, {
|
||
selection: { kind: 'asset', assetId: fixture.spriteId },
|
||
popups: [
|
||
{ kind: 'menu', title: 'New project' },
|
||
{ kind: 'dialog', title: 'Agent tokens' },
|
||
],
|
||
}),
|
||
);
|
||
expect(viewSummary(asset)).toBe(
|
||
'project "P"; tab Scene; selection: sprite "Box"; editing; dialog "Agent tokens" is open',
|
||
);
|
||
});
|
||
|
||
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));
|
||
expect(viewSummary({ ...view, camera: { x: 1, y: 2, zoom: 3 } })).toBe(viewSummary(view));
|
||
});
|
||
});
|
||
|
||
describe('the tools', () => {
|
||
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('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' });
|
||
expect(result.content[0]).toEqual({ type: 'text', text: JSON.stringify(result.structuredContent) });
|
||
});
|
||
|
||
it('screenshot answers a PNG at 1280 pixels unless told otherwise, and refuses a width out of range', async () => {
|
||
const shot = await runTool('screenshot', {}, host);
|
||
expect(shot.content[0]).toEqual({ type: 'image', data: 'iVBOR', mimeType: 'image/png' });
|
||
expect(shot.content[1]).toEqual({
|
||
type: 'text',
|
||
text: 'The editor as the user sees it, 1280x900 pixels.',
|
||
});
|
||
expect((await runTool('screenshot', { maxWidth: 100 }, host)).isError).toBe(true);
|
||
expect((await runTool('screenshot', { maxWidth: 'big' }, host)).isError).toBe(true);
|
||
});
|
||
|
||
it('an unknown tool says to reload', async () => {
|
||
const answer = (await runTool('teleport', {}, host)).content[0];
|
||
expect(answer?.type === 'text' && answer.text).toMatch(/reload/);
|
||
});
|
||
});
|
||
|
||
describe('agent tokens', () => {
|
||
it('are listed by day made, day of expiry and last use', () => {
|
||
const day = Date.UTC(2026, 9, 17) / 1000;
|
||
expect(tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 90 * 86400, usedAt: null })).toBe(
|
||
'made 2026-10-17 · expires 2027-01-15 · never used',
|
||
);
|
||
expect(
|
||
tokenLine({ id: 'a', name: 'x', createdAt: day, expiresAt: day + 86400, usedAt: day + 3600 }),
|
||
).toBe('made 2026-10-17 · expires 2026-10-18 · last used 2026-10-17');
|
||
});
|
||
|
||
it('are set up in Claude Code with one line', () => {
|
||
expect(claudeCommand('https://feuerwerk.nockal.com/mcp', 'fwa_00')).toBe(
|
||
'claude mcp add --transport http feuerwerk https://feuerwerk.nockal.com/mcp --header "Authorization: Bearer fwa_00"',
|
||
);
|
||
});
|
||
});
|