An agent that built a graph through MCP left every new node in one row. In the first real test that put nine nodes on a line 2080 px long, with the Owner's links running straight through all the others. Placing nodes by hand cost nine move_node calls and guessed heights. The agent now gives no coordinates, and the editor lays the nodes out. - arrange.ts is a pure function. It puts the flow into columns from left to right and sets an Owner or Get right before the first node it feeds. It orders each column for the fewest crossings and lines nodes up by their linked ports without overlaps. - Node sizes come from the drawn NodeView in the browser and from an estimate in unit tests. Both share the constants in nodeBox.ts. - run_commands arranges the nodes that came without x and y when the call ends, inside the same undo step. Nodes that were there stay. - New command arrange_graph and new tool lint_graph. The lint counts overlaps, crossings, links through foreign nodes, backward links and the longest links, with example ids. - The relay instructions tell the agent not to give coordinates and to fetch far values with a second Get or Owner node. - An Arrange button next to the snap magnet in all eight graph tabs. It arranges the selected nodes, or all of them, as one undo step. Found while testing on a real project: - lint_graph missed a crossing where two links swap rows exactly mirrored. Both curves then share a sample point in the middle, and only a crossing between two sample points was counted. Links that meet on a point count now (segmentsMeet). - The Arrow-key graph with one Owner for four Moves cannot get below 8 crossings. The Owner feeds object in row 2 of Move, between exec and dt of the key, so it always crosses one of them. Tests: - graph-arrange, agent-edit, snap-toggle: 33/33. The swapped-rows test was red before the fix (0 instead of 1). - Four counter-checks that bend arrange or the edit plan, all red. - e2e 71 on melektaus-atx: 18/18. Lint after the build shows 0 overlaps, 0 links through nodes, 0 backward. Arrange takes a tangled graph from 3 backward links and 2 crossings to 0, and one Ctrl+Z restores it. - Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4917 tests, measured before the lint fix. - Live through MCP on the user's project: lint_graph reports 8 crossings after Arrange, matching the screenshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
381 lines
17 KiB
JavaScript
381 lines
17 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',
|
||
'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 }),
|
||
);
|
||
|
||
// 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 }),
|
||
);
|
||
|
||
// 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;
|
||
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();
|
||
}
|
||
}
|