After stage 5a the agent could build behaviors and scenes, but not animate an object, cut a texture into frames or paint a tilemap. Stage 5b adds three areas with 30 commands, 115 in all. - animation (12): set the root, add, retarget and make rows relative, set, move, copy and delete keys, easing, insert or remove frames, and curve points. - textures (8): add, change and delete frames, slice a grid, nine slice, sorting line and collision outline. - tiles (10): the images of a tile, painting tilemaps, layers (add, copy, move, change) and the border of a placed tilemap. - get_asset names the targets of an animation with their animatable properties. A tilemap up to 400 cells shows its cells, a bigger one only their count. - After such a call the editor opens the tab of the animation, curve, texture, tile or tilemap. A frame edit selects the frame there. - One paint step takes rectangles and single cells. Rectangles go first, so a cell can cut a hole into a rectangle. Cells outside the grid, ids that are no tile and more than 20 000 cells are refused. The grid is the tilemap's largest, a grown placement included. Left out on purpose: Generate collision, because it reads the pixels of the texture, and pasting keys, which goes through the clipboard. Found on the way: - Nine slice without fill now keeps the fill the image has. The model's default is repeat, not stretch as first described. - Painting ran the other way round, rectangles overwrote cells. Tests: - agent-art (new, 11) and the other agent tests green, 64/64. 5 bends of the code, all red. - e2e 71 on atx: 23/23, the new check opens the agent's animation in its tab and reads its targets. - Gate on atx: 321 files, 4969 tests. - Live on a test project: a Spin animation of 360 degrees in 30 frames matched 12 degrees per frame to 0.01 frames in the game. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
23 KiB
JavaScript
521 lines
23 KiB
JavaScript
import { authHeaders, SESSION_COOKIE, sessionToken } from '../lib/auth.mjs';
|
||
|
||
/** The tokens this step makes; a run that died halfway leaves them for the next to revoke. */
|
||
const TOKEN_NAME = 'e2e 71';
|
||
const TOOLS = [
|
||
'get_view',
|
||
'screenshot',
|
||
'list_assets',
|
||
'get_asset',
|
||
'get_graph',
|
||
'lint_graph',
|
||
'catalog',
|
||
'run_commands',
|
||
'open',
|
||
'select',
|
||
'play',
|
||
'play_input',
|
||
'get_runtime',
|
||
'undo',
|
||
'redo',
|
||
];
|
||
|
||
/**
|
||
* An agent in the editor over the MCP relay (#429): a token from the account menu's dialog, the
|
||
* agent switched on there, and an MCP client that reads what the user sees – the tab, the
|
||
* selection, a screenshot – and hears when it changes. It builds a walking object in one call,
|
||
* plays it, and one Ctrl+Z of the user takes it all back; deleting waits for the user's answer.
|
||
* Switched off at the badge, the agent gets no further.
|
||
*
|
||
* In CI the relay stands in front of the PHP server on a port of its own (`FEUERWERK_MCP_BASE_URL`,
|
||
* see tools/ci/serve.sh); on stein2 nginx serves it beside the editor.
|
||
*/
|
||
export async function mcp(t) {
|
||
const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t;
|
||
const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity } = t;
|
||
const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl;
|
||
const endpoint = `${site}mcp`;
|
||
|
||
const api = async (method, route, body) =>
|
||
fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||
method,
|
||
headers: { ...(await authHeaders(baseUrl)), 'Content-Type': 'application/json' },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: AbortSignal.timeout(20_000),
|
||
});
|
||
const sweep = async () => {
|
||
for (const token of await (await api('GET', 'agent-tokens')).json()) {
|
||
if (token.name.startsWith(TOKEN_NAME)) await api('DELETE', `agent-tokens/${token.id}`);
|
||
}
|
||
};
|
||
|
||
let token = '';
|
||
let session = '';
|
||
const rpc = async (method, params = {}, as = token) => {
|
||
const res = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Accept: 'application/json, text/event-stream',
|
||
Authorization: `Bearer ${as}`,
|
||
...(session ? { 'Mcp-Session-Id': session } : {}),
|
||
},
|
||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||
signal: AbortSignal.timeout(40_000),
|
||
});
|
||
const text = await res.text();
|
||
return {
|
||
status: res.status,
|
||
session: res.headers.get('mcp-session-id'),
|
||
body: text ? JSON.parse(text) : null,
|
||
};
|
||
};
|
||
const callTool = async (name, args = {}) =>
|
||
(await rpc('tools/call', { name, arguments: args })).body?.result;
|
||
const viewNow = async () => (await callTool('get_view'))?.structuredContent;
|
||
const openAccountMenu = async () => {
|
||
await clickText('Account');
|
||
await sleep(200);
|
||
};
|
||
|
||
await sweep();
|
||
let stream = null;
|
||
try {
|
||
if (site !== baseUrl) {
|
||
await cdp.send('Network.setCookie', {
|
||
name: SESSION_COOKIE,
|
||
value: await sessionToken(baseUrl),
|
||
url: site,
|
||
});
|
||
await cdp.send('Page.navigate', { url: `${site}?project=${projectId}` });
|
||
await waitUntil('Boolean(window.__gm?.state?.project?.id)', {
|
||
timeout: 20000,
|
||
label: 'the editor behind the relay',
|
||
});
|
||
await sleep(1000);
|
||
}
|
||
|
||
// 71a. The account menu has the switch and the tokens; the dialog makes a token and shows it once,
|
||
// with the line for Claude Code.
|
||
await openAccountMenu();
|
||
const menu = await menuTicked();
|
||
await clickText('Agent tokens…');
|
||
await waitUntil('__gm.agentTokens() !== null', { label: 'the token dialog is open' });
|
||
await t.typeText(TOKEN_NAME);
|
||
await press('Enter');
|
||
await waitUntil('__gm.agentTokens()?.made != null', { label: 'the dialog shows the new token' });
|
||
const dialog = await json('__gm.agentTokens()');
|
||
token = dialog.made;
|
||
await shot('71-agent-tokens');
|
||
await press('Escape');
|
||
await settle();
|
||
const listed = await (await api('GET', 'agent-tokens')).json();
|
||
check(
|
||
'the account menu switches the agent and opens the tokens; a token made there is shown once with the Claude Code line',
|
||
menu.some((row) => row?.label === 'Agent' && row.ticked === false) &&
|
||
menu.some((row) => row?.label === 'Agent tokens…') &&
|
||
/^fwa_[0-9a-f]{64}$/.test(token) &&
|
||
dialog.tokens.includes(TOKEN_NAME) &&
|
||
dialog.command ===
|
||
`claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"` &&
|
||
listed.some((row) => row.name === TOKEN_NAME && row.token === undefined),
|
||
JSON.stringify({
|
||
menu,
|
||
dialog: { ...dialog, made: Boolean(dialog.made), command: dialog.command?.slice(0, 60) },
|
||
}),
|
||
);
|
||
|
||
// 71b. Before the switch, the agent hears there is no editor.
|
||
const init = await rpc('initialize', {
|
||
protocolVersion: '2025-06-18',
|
||
capabilities: {},
|
||
clientInfo: { name: 'e2e' },
|
||
});
|
||
session = init.session ?? '';
|
||
const tools = (await rpc('tools/list')).body?.result?.tools.map((tool) => tool.name);
|
||
const lonely = await callTool('get_view');
|
||
check(
|
||
'an agent with the token starts a session, lists its tools, and hears no editor is on',
|
||
init.status === 200 &&
|
||
init.body?.result?.serverInfo?.name === 'feuerwerk' &&
|
||
JSON.stringify(tools) === JSON.stringify(TOOLS) &&
|
||
lonely?.isError === true &&
|
||
/No Feuerwerk editor is connected/.test(lonely.content[0].text),
|
||
JSON.stringify({ status: init.status, tools, lonely }),
|
||
);
|
||
|
||
// 71c. Switched on in the menu, the tab gets onto the relay; the badge counts the agent.
|
||
await openAccountMenu();
|
||
await clickText('Agent');
|
||
await waitUntil('__gm.agent().status === "on"', { timeout: 15000, label: 'the agent link is on' });
|
||
await waitUntil('__gm.agent().badge === "Agent · 1"', {
|
||
timeout: 5000,
|
||
label: 'the badge counts the agent',
|
||
});
|
||
await openAccountMenu();
|
||
const ticked = (await menuTicked()).find((row) => row?.label === 'Agent')?.ticked;
|
||
await press('Escape');
|
||
const agent = await json('__gm.agent()');
|
||
check(
|
||
'switched on in the account menu, the tab is on the relay and the badge says one agent is connected',
|
||
agent.status === 'on' && agent.badge === 'Agent · 1' && ticked === true,
|
||
JSON.stringify({ agent: { ...agent, view: undefined }, ticked }),
|
||
);
|
||
|
||
// 71d. get_view is what the tab shows.
|
||
await t.evaluate('__gm.showTab("scene")');
|
||
await settle();
|
||
const scene = await viewNow();
|
||
check(
|
||
'get_view names the project and the tab the user sees',
|
||
scene?.project?.id === projectId && scene?.tab?.id === 'scene' && scene.play === 'editing',
|
||
JSON.stringify(scene),
|
||
);
|
||
|
||
// 71e. Subscribed, the agent hears when the user switches the tab, and get_view follows it.
|
||
await rpc('resources/subscribe', { uri: 'feuerwerk://view' });
|
||
const events = await fetch(endpoint, {
|
||
headers: { Authorization: `Bearer ${token}`, 'Mcp-Session-Id': session, Accept: 'text/event-stream' },
|
||
});
|
||
stream = events.body.getReader();
|
||
const heard = (async () => {
|
||
let text = '';
|
||
const until = Date.now() + 10_000;
|
||
while (!text.includes('notifications/resources/updated') && Date.now() < until) {
|
||
const { value, done } = await stream.read();
|
||
if (done) break;
|
||
text += new TextDecoder().decode(value);
|
||
}
|
||
return text;
|
||
})();
|
||
await clickText('Game');
|
||
await settle();
|
||
const note = await heard;
|
||
const game = await viewNow();
|
||
check(
|
||
'a tab switched by the user reaches the subscribed agent, and get_view says the Game tab',
|
||
note.includes('"uri":"feuerwerk://view"') &&
|
||
game?.tab?.id === 'game' &&
|
||
(await json('__gm.tab()')) === 'game',
|
||
JSON.stringify({ note: note.slice(-160), tab: game?.tab }),
|
||
);
|
||
|
||
// 71f. The screenshot is the editor as drawn, scaled to the width asked for; an agent that has
|
||
// not looked since the user moved on is told so with it.
|
||
await t.evaluate('__gm.showTab("scene")');
|
||
await settle();
|
||
await sleep(800);
|
||
const shownTab = await json('__gm.tab()');
|
||
const picture = await callTool('screenshot', { maxWidth: 640 });
|
||
const png = Buffer.from(picture?.content?.[0]?.data ?? '', 'base64');
|
||
const width = png.length > 24 ? png.readUInt32BE(16) : 0;
|
||
const height = png.length > 24 ? png.readUInt32BE(20) : 0;
|
||
const canvas = await json('({ w: __gm.app.canvas.width, h: __gm.app.canvas.height })');
|
||
const told =
|
||
picture?.content?.find((item) => item.type === 'text' && item.text.includes('has changed'))?.text ?? '';
|
||
check(
|
||
'screenshot answers a PNG of the editor at the width asked for, and tells the agent the view changed',
|
||
png.subarray(1, 4).toString() === 'PNG' &&
|
||
width === 640 &&
|
||
Math.abs(height - Math.round((canvas.h * 640) / canvas.w)) <= 1 &&
|
||
told.includes('tab Scene'),
|
||
JSON.stringify({ shownTab, width, height, canvas, bytes: png.length, told }),
|
||
);
|
||
|
||
// 71g. One call builds an object that walks left while the left arrow is held: a sprite, a behavior
|
||
// placed under it, three nodes and three edges, as one undo step.
|
||
const sceneId = await json('__gm.state.sceneToPlay()?.id');
|
||
const assetsBefore = await json('JSON.stringify(__gm.state.project.assets)');
|
||
const built = await callTool('run_commands', {
|
||
label: 'walk left',
|
||
steps: [
|
||
{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} hero` }, as: 'hero' },
|
||
{ command: 'create_asset', args: { type: 'behavior', name: `${TOKEN_NAME} walk` }, as: 'walk' },
|
||
{ command: 'place_asset', args: { in: sceneId, asset: '$hero', x: 0, y: 0 }, as: 'player' },
|
||
{ command: 'place_asset', args: { in: sceneId, asset: '$walk', x: 0, y: 0, 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: -300 } }, as: 'move' },
|
||
{ command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' },
|
||
...[
|
||
['$key', 'exec', 'exec'],
|
||
['$key', 'dt', 'dt'],
|
||
['$owner', 'object', 'object'],
|
||
].map(([node, from, to]) => ({
|
||
command: 'connect',
|
||
args: { asset: '$walk', from: { node, port: from }, to: { node: '$move', port: to } },
|
||
})),
|
||
],
|
||
});
|
||
const made = built?.structuredContent?.made ?? [];
|
||
const walkId = made.find((entry) => entry.step === 2)?.id;
|
||
const playerId = made.find((entry) => entry.step === 3)?.id;
|
||
const graph = walkId ? (await callTool('get_graph', { asset: walkId }))?.structuredContent : null;
|
||
check(
|
||
'run_commands builds a sprite with a walking behavior in one call, one undo step named by the agent',
|
||
built?.isError === false &&
|
||
(await json('__gm.state.commands.undoLabel')) === 'Agent: walk left' &&
|
||
graph?.nodes?.map((node) => node.type).join() === 'OnKeyHeld,Move,Owner' &&
|
||
graph.edges.length === 3,
|
||
JSON.stringify({ built: built?.content?.[0]?.text?.slice(0, 300), graph: graph?.edges?.length }),
|
||
);
|
||
|
||
// 71g1. The editor follows the agent: the user sees the graph it built, its nodes selected.
|
||
await settle();
|
||
const followed = await viewNow();
|
||
check(
|
||
'the editor follows the agent: the behavior it built is the open tab, with its three nodes selected',
|
||
followed?.document?.asset?.id === walkId &&
|
||
followed.selection?.kind === 'node' &&
|
||
followed.selection.in?.id === walkId &&
|
||
followed.selection.nodes?.length === 3,
|
||
JSON.stringify({ document: followed?.document, selection: followed?.selection }),
|
||
);
|
||
|
||
// 71g2. The nodes the agent gave no place come out arranged, as the drawn nodes measure.
|
||
const lint = walkId ? (await callTool('lint_graph', { asset: walkId }))?.structuredContent : null;
|
||
check(
|
||
'the agent’s nodes are arranged: no overlaps, no link over a node, none backwards',
|
||
lint?.nodes === 3 && lint.overlaps === 0 && lint.throughNodes === 0 && lint.backwards === 0,
|
||
JSON.stringify(lint),
|
||
);
|
||
|
||
// 71g3. Arrange in the graph's header puts a tangle right again, as one step of the user's own.
|
||
await callTool('open', { asset: walkId });
|
||
const [keyNode, moveNode] = graph.nodes;
|
||
await callTool('run_commands', {
|
||
label: 'tangle',
|
||
steps: [
|
||
{
|
||
command: 'move_node',
|
||
args: { asset: walkId, node: moveNode.id, x: keyNode.x - 400, y: keyNode.y + 300 },
|
||
},
|
||
],
|
||
});
|
||
await settle();
|
||
const tangled = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
|
||
await clickText('Arrange');
|
||
await settle();
|
||
const tidied = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
|
||
const arrangedLabel = await json('__gm.state.commands.undoLabel');
|
||
await press('z', { ctrl: true });
|
||
await settle();
|
||
const backTo = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
|
||
await callTool('undo');
|
||
check(
|
||
'Arrange in the header turns the links forward again in one undo step, and Ctrl+Z brings the tangle back',
|
||
tangled?.backwards > 0 &&
|
||
tidied?.backwards === 0 &&
|
||
tidied.overlaps === 0 &&
|
||
arrangedLabel === 'Arrange nodes' &&
|
||
backTo?.backwards === tangled.backwards &&
|
||
(await json('__gm.state.commands.undoLabel')) === 'Agent: walk left',
|
||
JSON.stringify({ tangled, tidied, arrangedLabel, backTo }),
|
||
);
|
||
|
||
// 71g4. In a graph tab that is open already, the camera goes after a node added far to one side,
|
||
// keeping its zoom: the user sees it rather than a selection out of sight.
|
||
const zoomBefore = await json('__gm.graph.zoom()');
|
||
const far = await callTool('run_commands', {
|
||
label: 'far node',
|
||
steps: [
|
||
{ command: 'add_node', args: { asset: walkId, node: 'Owner', x: keyNode.x + 3000, y: keyNode.y } },
|
||
],
|
||
});
|
||
const farId = far?.structuredContent?.made?.[0]?.id;
|
||
const inSight = `(() => {
|
||
const node = __gm.graph.nodeRect(${JSON.stringify(farId)});
|
||
const view = __gm.graph.canvasRect();
|
||
return Boolean(node) && node.x >= view.x && node.x + node.w <= view.x + view.w &&
|
||
node.y >= view.y && node.y + node.h <= view.y + view.h;
|
||
})()`;
|
||
const revealed = await waitUntil(inSight, { timeout: 3000, label: 'the far node in sight' }).then(
|
||
() => true,
|
||
() => false,
|
||
);
|
||
const farView = await json(
|
||
`({ node: __gm.graph.nodeRect(${JSON.stringify(farId)}), view: __gm.graph.canvasRect(), zoom: __gm.graph.zoom() })`,
|
||
);
|
||
await callTool('undo');
|
||
check(
|
||
'the open graph tab moves its camera to a node the agent added far away, at the same zoom',
|
||
revealed && farView.zoom === zoomBefore,
|
||
JSON.stringify({ zoomBefore, ...farView }),
|
||
);
|
||
|
||
// 71g5. Past nodes and edges (#429 stage 5a): a Variables node reads a variable once add_port names it,
|
||
// and a group made in the same call frames every node as drawn, the one the call arranged among them.
|
||
const gathered = await callTool('run_commands', {
|
||
label: 'speed in a group',
|
||
steps: [
|
||
{ command: 'add_variable', args: { asset: walkId, key: 'speed', kind: 'number' } },
|
||
{ command: 'add_node', args: { asset: walkId, node: 'Variables' }, as: 'vars' },
|
||
{ command: 'add_port', args: { asset: walkId, node: '$vars', key: 'speed' } },
|
||
{
|
||
command: 'add_group',
|
||
args: { asset: walkId, nodes: [...graph.nodes.map((node) => node.id), '$vars'], title: 'Walk' },
|
||
},
|
||
],
|
||
});
|
||
await settle();
|
||
const groupId = gathered?.structuredContent?.made?.find((entry) => entry.step === 4)?.id;
|
||
const varsId = gathered?.structuredContent?.made?.find((entry) => entry.step === 2)?.id;
|
||
const framing = await json(`(() => {
|
||
const box = __gm.graph.annotationRect(${JSON.stringify(groupId)});
|
||
const ids = ${JSON.stringify([...graph.nodes.map((node) => node.id), varsId])};
|
||
const nodes = ids.map((id) => __gm.graph.nodeRect(id));
|
||
const inside = Boolean(box) && nodes.every((n) => n && n.x > box.x && n.y > box.y &&
|
||
n.x + n.w < box.x + box.w && n.y + n.h < box.y + box.h);
|
||
return { box, nodes, inside };
|
||
})()`);
|
||
const readVars = (await callTool('get_graph', { asset: walkId }))?.structuredContent?.nodes?.find(
|
||
(node) => node.id === varsId,
|
||
);
|
||
const gatheredLint = (await callTool('lint_graph', { asset: walkId }))?.structuredContent;
|
||
await callTool('undo');
|
||
check(
|
||
'add_port names a variable on a Variables node, and add_group frames every drawn node of the call',
|
||
gathered?.isError === false &&
|
||
framing.inside &&
|
||
readVars?.outputs?.some((port) => port.key === 'speed' && port.type === 'number') &&
|
||
gatheredLint?.overlaps === 0,
|
||
JSON.stringify({ text: gathered?.content?.[0]?.text?.slice(0, 300), framing, readVars, gatheredLint }),
|
||
);
|
||
|
||
// 71g6. Past graphs (#429 stage 5b): an animation keyed by the agent comes up in its own tab.
|
||
const heroId = made.find((entry) => entry.step === 1)?.id;
|
||
const animated = await callTool('run_commands', {
|
||
label: 'slide',
|
||
steps: [
|
||
{ command: 'create_asset', args: { type: 'animation', name: `${TOKEN_NAME} slide` }, as: 'anim' },
|
||
{ command: 'set_animation_root', args: { asset: '$anim', root: heroId } },
|
||
{ command: 'add_animation_track', args: { asset: '$anim', target: '', property: 'x' }, as: 'row' },
|
||
{ command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 0, value: 0 } },
|
||
{ command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 30, value: 120 } },
|
||
],
|
||
});
|
||
await settle();
|
||
const animId = animated?.structuredContent?.made?.find((entry) => entry.step === 1)?.id;
|
||
const animView = await viewNow();
|
||
const animRead = animId ? (await callTool('get_asset', { asset: animId }))?.structuredContent : null;
|
||
await callTool('undo');
|
||
await callTool('open', { asset: walkId });
|
||
await settle();
|
||
check(
|
||
'an animation the agent keys comes up in its tab, and get_asset names the root among its targets',
|
||
animated?.isError === false &&
|
||
animView?.document?.asset?.id === animId &&
|
||
animRead?.tracks?.[0]?.keys?.length === 2 &&
|
||
animRead?.targets?.[0]?.properties?.some((property) => property.key === 'x'),
|
||
JSON.stringify({
|
||
text: animated?.content?.[0]?.text?.slice(0, 300),
|
||
document: animView?.document,
|
||
targets: animRead?.targets?.length,
|
||
}),
|
||
);
|
||
|
||
// 71h. Played by the agent, the object walks left while the user holds the arrow.
|
||
const started = await callTool('play', { action: 'play' });
|
||
await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' });
|
||
await sleep(300);
|
||
const x0 = (await entity(playerId))?.x;
|
||
await holdKey('ArrowLeft', 400);
|
||
const x1 = (await entity(playerId))?.x;
|
||
|
||
// 71h2. The agent plays the game itself: it holds the arrow, clicks the object and reads where it went.
|
||
const played = await callTool('play_input', {
|
||
steps: [
|
||
{ key: 'ArrowLeft', hold: 0.5 },
|
||
{ mouse: 'click', at: { object: playerId } },
|
||
],
|
||
});
|
||
const runtime = (await callTool('get_runtime', { objects: [playerId] }))?.structuredContent;
|
||
const x2 = runtime?.objects?.[0]?.x;
|
||
check(
|
||
'the agent holds a key and clicks in the running game, and get_runtime reads the object walking',
|
||
played?.isError === false &&
|
||
played.structuredContent?.seconds >= 0.5 &&
|
||
played.structuredContent.held?.length === 0 &&
|
||
played.structuredContent.hits?.[0]?.hit === true &&
|
||
x2 < x1 - 100 &&
|
||
x2 > x1 - 200,
|
||
JSON.stringify({
|
||
played: played?.structuredContent ?? played?.content,
|
||
x1,
|
||
x2,
|
||
camera: runtime?.camera,
|
||
}),
|
||
);
|
||
const stopped = await callTool('play', { action: 'stop' });
|
||
await waitUntil('__gm.state.mode === "edit"', { label: 'the game stopped' });
|
||
check(
|
||
'play started by the agent runs its behavior: the object walks left while ArrowLeft is held',
|
||
started?.isError === false && stopped?.isError === false && x0 === 0 && x1 < -40,
|
||
JSON.stringify({ started: started?.content, x0, x1 }),
|
||
);
|
||
|
||
// 71i. One Ctrl+Z of the user takes the agent's whole step back.
|
||
await press('z', { ctrl: true });
|
||
await settle();
|
||
check(
|
||
'one Ctrl+Z takes back everything the call made',
|
||
(await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore,
|
||
JSON.stringify({ undo: await json('__gm.state.commands.undoLabel') }),
|
||
);
|
||
|
||
// 71j. Deleting asks the user: the call waits for the answer, and a no leaves the asset.
|
||
const temp = await callTool('run_commands', {
|
||
steps: [{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} temp` } }],
|
||
});
|
||
const tempId = temp?.structuredContent?.made?.[0]?.id;
|
||
const deleting = callTool('run_commands', {
|
||
steps: [{ command: 'delete_asset', args: { asset: tempId } }],
|
||
});
|
||
const asks = `The agent asks: Delete "${TOKEN_NAME} temp"?`;
|
||
await waitUntil(`Boolean(__gm.locate(${JSON.stringify(asks)}, false))`, {
|
||
label: 'the question is open',
|
||
});
|
||
await shot('71-agent-delete');
|
||
await clickText('Cancel');
|
||
const refused = await deleting;
|
||
const kept = await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(tempId)})`);
|
||
await callTool('undo');
|
||
check(
|
||
'deleting asks the user first; the call waits, and on Cancel the asset stays',
|
||
refused?.isError === true &&
|
||
/said no/.test(refused.content[0].text) &&
|
||
kept === true &&
|
||
(await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore,
|
||
JSON.stringify({ refused: refused?.content, kept }),
|
||
);
|
||
|
||
// 71k. The badge is the off switch: the agent gets no further, and a revoked token not even in.
|
||
const badge = await locate('Agent · 1');
|
||
await click(badge.x, badge.y);
|
||
await waitUntil('__gm.agent().status === "off"', { label: 'the agent is off' });
|
||
const after = await callTool('get_view');
|
||
await api('DELETE', `agent-tokens/${listed.find((row) => row.name === TOKEN_NAME).id}`);
|
||
const revoked = await rpc('tools/list');
|
||
check(
|
||
'a click on the badge switches the agent off; the agent hears no editor, and after revoking it is refused',
|
||
(await json('__gm.agent().badge')) === null && after?.isError === true && revoked.status === 401,
|
||
JSON.stringify({ after, revoked: revoked.status }),
|
||
);
|
||
check('the agent session threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||
} catch (error) {
|
||
// The clean-up below can fail on a page that hangs, and its error would hide this one.
|
||
console.error('71 stopped:', error);
|
||
throw error;
|
||
} finally {
|
||
await stream?.cancel().catch(() => {});
|
||
await t.evaluate('__gm.switchAgent?.(false)').catch(() => {});
|
||
await sweep();
|
||
// An unsaved project holds the page with "Leave site?", which no one here answers.
|
||
await waitUntil('__gm.state.saveStatus === "saved"', { timeout: 10000, label: 'saved' }).catch(() => {});
|
||
if (site !== baseUrl) await navigate();
|
||
}
|
||
}
|