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 { 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. */ function facts(fixture: ReturnType, extra: Partial = {}): 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('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 host = { view: () => agentView(facts(makeSpriteScene())), screenshot: (maxWidth: number) => Promise.resolve({ data: 'iVBOR', width: Math.min(maxWidth, 1600), height: 900 }), }; 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' }); 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('run_command', {}, 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"', ); }); });