The agent knew 23 of the editor's commands. It could not point a Variables node at a variable, group nodes, edit ports, transitions, containers, colliders or the project settings. Stage 5a brings the catalog to 85 commands in five areas and keeps every answer short. - The catalog lists names and descriptions only. With a query (an area or command names) it adds the arguments. All schemas at once would pass the 60 000 character limit of one answer. - add_port points a Variables node at a variable. The port takes the variable's kind, and an unknown name is refused with the list of the ones that exist. - add_group and add_comment take nodes instead of coordinates. They follow the nodes after the arrange step, in the same undo step. - The rule for which list a port picks from moved into portChoices.ts. Inspector and agent now share it. - The frame of a group around nodes moved into annotationDrag.ts, so the button and the agent get the same frame. - list_assets returns the project settings. get_asset shows the content of placed containers with the ids overrides need. get_graph shows declared ports and frame colors. - A new node is placed at the height of its port, in the free spot whose links cross the fewest others. A source goes left of the node it feeds. Before, new nodes always went right of the rest. - Arrange breaks a tie in crossings by putting the flow on top, and exec links pull harder when a column is sorted. - A comment over a group keeps clear of the group's title bar. - The agent's instructions and lint_graph ask to look at crossings too and to call arrange_graph when there are more than needed. Left out on purpose: file assets (no upload), paste from the clipboard, internal wrapper commands and switching plugins on. Found on the way: - Variable commands on a Struct asset did not run through WithStructTypes. New fields did not reach variables of that type. - A query "variables" also matched set_variables_scope. A word that names an area now means only that area. Tests: - agent-catalog (new, 10), agent-edit, graph-arrange (3 new) and relay green. 11 bends of the code, all red. - e2e 71 on atx: 22/22, the new check sees port speed and a group around all 4 drawn nodes. - Live on a test project: a speed variable of 300 moved the player to x 150.03 in 0.5 s. A re-placed Variables node landed below Axis input with 4 crossings instead of 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
499 lines
22 KiB
TypeScript
499 lines
22 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { findGraphAsset } from '../../src/core/model/Project';
|
||
import { argProblem } from '../../src/editor/agent/argCheck';
|
||
import { runTool } from '../../src/editor/agent/agentTools';
|
||
import { COMMANDS } from '../../src/editor/agent/commandCatalog';
|
||
import { NODE_STEP } from '../../src/editor/agent/editPlan';
|
||
import { COLUMN_GAP, type GraphLint } from '../../src/editor/graph/arrange';
|
||
import { NODE_WIDTH } from '../../src/editor/graph/nodeBox';
|
||
import TOOLS from '../../src/editor/agent/tools.json';
|
||
import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers';
|
||
import { hostFor, text } from './agentTestHost';
|
||
|
||
/** What an agent changes and reads through the tools (#429, stage 2). */
|
||
|
||
/** 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('arranges the nodes it gave no place right of those the graph has, which stay put', 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' }, as: 'owner' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
|
||
{
|
||
command: 'connect',
|
||
args: {
|
||
asset: behavior.id,
|
||
from: { node: '$owner', port: 'object' },
|
||
to: { node: '$move', port: 'object' },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
host,
|
||
);
|
||
const [start, owner, move] = behavior.graph.nodes;
|
||
expect([start!.x, start!.y]).toEqual([40, 80]);
|
||
// The new part keeps the place it waited at, right of the start, and reads left to right.
|
||
expect(owner!.x).toBe(40 + NODE_STEP);
|
||
expect(move!.x).toBe(owner!.x + NODE_WIDTH + COLUMN_GAP);
|
||
expect(fixture.stack.undoLabel).toBe('Agent: add_node, add_node, connect');
|
||
const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(lint).toMatchObject({
|
||
nodes: 3,
|
||
edges: 1,
|
||
overlaps: 0,
|
||
crossings: 0,
|
||
throughNodes: 0,
|
||
backwards: 0,
|
||
});
|
||
});
|
||
|
||
it('leaves a node given a place where it was put, and arranges the rest around it', 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: 0, y: 0 }, as: 'start' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
|
||
{
|
||
command: 'connect',
|
||
args: {
|
||
asset: behavior.id,
|
||
from: { node: '$start', port: 'exec' },
|
||
to: { node: '$move', port: 'exec' },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
host,
|
||
);
|
||
const [start, move] = behavior.graph.nodes;
|
||
expect([start!.x, start!.y]).toEqual([0, 0]);
|
||
expect(move!.x).toBeGreaterThanOrEqual(NODE_WIDTH);
|
||
});
|
||
|
||
it('arrange_graph arranges what it is given as one step of the edit, and undo puts it back', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture, 'Jump');
|
||
const { host } = hostFor(fixture);
|
||
const steps = [
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 500, y: 0 }, as: 'start' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move', x: 0, y: 0 }, as: 'move' },
|
||
{
|
||
command: 'connect',
|
||
args: {
|
||
asset: behavior.id,
|
||
from: { node: '$start', port: 'exec' },
|
||
to: { node: '$move', port: 'exec' },
|
||
},
|
||
},
|
||
];
|
||
await runTool('run_commands', { steps }, host);
|
||
const tangled = JSON.stringify(behavior.graph.nodes);
|
||
const before = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(before.backwards).toBe(1);
|
||
|
||
const result = await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'arrange_graph', args: { asset: behavior.id } }], label: 'tidy' },
|
||
host,
|
||
);
|
||
expect(result.isError).toBeFalsy();
|
||
const [start, move] = behavior.graph.nodes;
|
||
expect(move!.x).toBe(start!.x + NODE_WIDTH + COLUMN_GAP);
|
||
const after = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(after.backwards).toBe(0);
|
||
await runTool('undo', {}, host);
|
||
expect(JSON.stringify(behavior.graph.nodes)).toBe(tangled);
|
||
|
||
const unknown = await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'arrange_graph', args: { asset: behavior.id, nodes: ['nope'] } }] },
|
||
host,
|
||
);
|
||
expect(text(unknown)).toContain('Step 1 (arrange_graph): Jump has no node nope');
|
||
});
|
||
|
||
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('the editor following the agent', () => {
|
||
it('shows the graph a call built, with every node it touched selected and the removed ones left out', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host, shown } = hostFor(fixture, null, true);
|
||
await runTool('run_commands', { steps: walkLeft(fixture) }, host);
|
||
const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||
const nodes = findGraphAsset(fixture.project, walk.id)!.graph.nodes.map((node) => node.id);
|
||
expect(shown).toEqual([{ kind: 'nodes', asset: walk.id, ids: nodes }]);
|
||
|
||
const steps = [
|
||
{ command: 'add_node', args: { asset: walk.id, node: 'OnStart' }, as: 'start' },
|
||
{ command: 'set_node_param', args: { asset: walk.id, node: nodes[1], key: 'dx', value: -100 } },
|
||
{ command: 'remove_node', args: { asset: walk.id, node: '$start' } },
|
||
];
|
||
await runTool('run_commands', { steps }, host);
|
||
expect(shown[1]).toEqual({ kind: 'nodes', asset: walk.id, ids: [nodes[1]] });
|
||
});
|
||
|
||
it('switches to the scene of the objects a call ends on, inside the same undo step', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host, shown } = hostFor(fixture, null, true);
|
||
const made = await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'create_asset', args: { type: 'scene', name: 'Cave' } }] },
|
||
host,
|
||
);
|
||
const cave = (made.structuredContent as { made: { id: string }[] }).made[0]!.id;
|
||
expect(shown).toEqual([{ kind: 'asset', asset: cave }]);
|
||
expect(fixture.project.activeSceneId).toBe(fixture.sceneId);
|
||
|
||
const place = {
|
||
command: 'place_asset',
|
||
args: { in: cave, asset: fixture.spriteId, x: 0, y: 0 },
|
||
as: 'bat',
|
||
};
|
||
const placed = await runTool('run_commands', { steps: [place], label: 'bat' }, host);
|
||
const bat = (placed.structuredContent as { made: { id: string }[] }).made[0]!.id;
|
||
expect(shown[1]).toEqual({ kind: 'objects', in: cave, ids: [bat] });
|
||
expect(fixture.project.activeSceneId).toBe(cave);
|
||
|
||
expect(text(await runTool('undo', {}, host))).toBe('Undone: Agent: bat.');
|
||
expect(fixture.project.activeSceneId).toBe(fixture.sceneId);
|
||
});
|
||
|
||
it('leaves the view and the scene alone when the user switched following off', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host, shown } = hostFor(fixture);
|
||
const steps = [
|
||
{ command: 'create_asset', args: { type: 'scene', name: 'Cave' }, as: 'cave' },
|
||
{ command: 'place_asset', args: { in: '$cave', asset: fixture.spriteId, x: 0, y: 0 } },
|
||
];
|
||
expect((await runTool('run_commands', { steps }, host)).isError).toBeFalsy();
|
||
expect(shown).toEqual([]);
|
||
expect(fixture.project.activeSceneId).toBe(fixture.sceneId);
|
||
});
|
||
});
|
||
|
||
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 { host } = hostFor(makeSpriteScene());
|
||
const listed = (await runTool('catalog', { topic: 'commands' }, host)).structuredContent as {
|
||
commands: { areas: { area: string; commands: { name: string; args?: unknown }[] }[] };
|
||
};
|
||
const areas = listed.commands.areas;
|
||
expect(areas.flatMap((area) => area.commands.map((command) => command.name))).toEqual(names);
|
||
expect(areas.flatMap((area) => area.commands).some((command) => 'args' in command)).toBe(false);
|
||
// Each area in full fits one answer, which is cut beyond 60 000 characters.
|
||
for (const { area, commands } of areas) {
|
||
const full = await runTool('catalog', { topic: 'commands', query: area }, host);
|
||
expect(full.isError).toBeUndefined();
|
||
expect(text(full).length).toBeLessThan(60_000);
|
||
const rows = (full.structuredContent as { commands: { name: string; args: unknown }[] }).commands;
|
||
expect(rows.map((row) => row.name)).toEqual(commands.map((command) => command.name));
|
||
expect(rows.every((row) => row.args !== undefined)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('a query of command names answers just those in full, and an unknown one says so', async () => {
|
||
const { host } = hostFor(makeSpriteScene());
|
||
const found = await runTool('catalog', { topic: 'commands', query: 'add_port, add_group' }, host);
|
||
const rows = (found.structuredContent as { commands: { area: string; name: string }[] }).commands;
|
||
expect(rows.map((row) => `${row.area}/${row.name}`)).toEqual(['graphs/add_group', 'graphs/add_port']);
|
||
const none = await runTool('catalog', { topic: 'commands', query: 'teleport' }, host);
|
||
expect(none.isError).toBe(true);
|
||
expect(text(none)).toContain('No area or command matches "teleport"');
|
||
});
|
||
|
||
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',
|
||
'lint_graph',
|
||
'catalog',
|
||
'run_commands',
|
||
'open',
|
||
'select',
|
||
'play',
|
||
'play_input',
|
||
'get_runtime',
|
||
'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');
|
||
});
|
||
});
|