From 23a2f045ce6e13d429f173e9ad29a3a2f8c615d0 Mon Sep 17 00:00:00 2001 From: Srujan Reddy Date: Wed, 29 Jul 2026 19:16:26 +0530 Subject: [PATCH] fixed errors listed --- packages/mcp/src/prompts/from-brief.ts | 17 +- packages/mcp/src/prompts/prompts.test.ts | 236 ++++++++++++++++++++++- packages/mcp/src/tools/live-sync.test.ts | 99 ++++++++++ packages/mcp/src/tools/live-sync.ts | 20 +- test-flow.mjs | 119 ++++++++++++ 5 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 packages/mcp/src/tools/live-sync.test.ts create mode 100644 test-flow.mjs diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts index 4fb6d24d6..0f611da66 100644 --- a/packages/mcp/src/prompts/from-brief.ts +++ b/packages/mcp/src/prompts/from-brief.ts @@ -6,8 +6,13 @@ import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' const PREAMBLE = [ 'You are a Pascal 3D scene designer.', 'You have access to semantic scene tools and the lower-level `apply_patch` tool. Prefer semantic construction/room/opening/furnishing tools for architectural work, and use `apply_patch` for bulk graph edits that need exact control.', - 'If the user asks for a new project, call `create_project` before building. Use `create_house_from_brief` for a fast starter, then refine with semantic tools. Semantic tools update the browser-visible draft; call `save_scene` with `saveMode: "checkpoint"` only for meaningful milestones, then call `verify_scene` and `get_project_status`, and return the final `editorUrl`.', - 'Build incrementally with visible progress. Starting from an empty scene, first create/load a Site and Building, then create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.', + 'CRITICAL FIRST STEP: Before making any scene modifications, you MUST bind an active scene to this MCP session.', + ' - For a new project: call `create_project` first to create and bind a project.', + ' - For an existing project: call `list_scenes` to find it, then call `load_scene` with its id to bind it.', + ' - Alternatively, call `create_house_from_brief` which both creates a project and loads a starter template.', + 'Without a bound scene, all mutations are applied in-memory but NOT persisted or visible in the browser.', + 'Build incrementally with visible progress. After binding a scene, create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.', + 'Semantic tools update the browser-visible draft. Call `save_scene` with `saveMode: "checkpoint"` only for meaningful milestones, then call `verify_scene` and `get_project_status`, and return the final `editorUrl`.', 'Respect these invariants:', ' - Levels live under a Building.', ' - Walls, fences, zones, slabs, ceilings, roofs, stairs live under a Level.', @@ -33,7 +38,11 @@ export function buildFromBriefPrompt(args: { parts.push( '', '## Task', - 'Produce tool calls that realise the brief within the stated constraints. For a new project, call create_project first. Use create_house_from_brief for a fast starter or prefer create_story_shell/create_room/add_door/add_window/create_stair_between_levels/create_roof/furnish_room for architectural layout, use apply_patch for exact bulk graph work, call save_scene with saveMode: "checkpoint" only when the design reaches a meaningful milestone, then call validate_scene, verify_scene, and get_project_status.', + 'Produce tool calls that realise the brief within the stated constraints.', + '1. FIRST: Bind a scene — call `create_project` (new), `load_scene` (existing), or `create_house_from_brief` (starter template).', + '2. THEN: Build the design — prefer `create_story_shell` for exterior shells, `create_room`/`add_door`/`add_window`/`furnish_room` for interior layout, `create_roof` for roofing, and `apply_patch` for precise bulk edits.', + '3. FINALLY: Call `validate_scene`, `verify_scene`, and `get_project_status`, then return the `editorUrl`.', + 'Call `save_scene` with `saveMode: "checkpoint"` only when the design reaches a meaningful milestone.', ) return parts.join('\n') } @@ -44,7 +53,7 @@ export function registerFromBrief(server: McpServer, _bridge: SceneOperations): { title: 'Generate a Pascal scene from a brief', description: - 'Produces a plan of apply_patch calls to create a scene from a natural-language brief.', + 'Generate a Pascal 3D scene from a natural-language brief. Binds an active scene and produces semantic tool calls to realise the design.', argsSchema: { brief: z.string(), constraints: z.string().optional(), diff --git a/packages/mcp/src/prompts/prompts.test.ts b/packages/mcp/src/prompts/prompts.test.ts index f35aa1d86..e16708820 100644 --- a/packages/mcp/src/prompts/prompts.test.ts +++ b/packages/mcp/src/prompts/prompts.test.ts @@ -5,8 +5,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WallNode } from '@pascal-app/core/schema' import useScene from '@pascal-app/core/store' import { SceneBridge } from '../bridge/scene-bridge' +import { createSceneOperations } from '../operations' +import { publishLiveSceneSnapshot } from '../tools/live-sync' +import { InMemorySceneStore } from '../tools/scene-lifecycle/test-utils' import { buildFromBriefPrompt, registerFromBrief } from './from-brief' import { buildIterateOnFeedbackPrompt, registerIterateOnFeedback } from './iterate-on-feedback' import { buildRenovationMessages, registerRenovationFromPhotos } from './renovation-from-photos' @@ -61,7 +65,6 @@ describe('from_brief', () => { expect(m.content.type).toBe('text') if (m.content.type === 'text') { expect(m.content.text).toContain('60 sqm studio') - expect(m.content.text).toContain('apply_patch') expect(m.content.text).toContain('create_story_shell') expect(m.content.text).toContain('pascal://agent/guide') expect(m.content.text).toContain('dedicated roof level') @@ -71,6 +74,28 @@ describe('from_brief', () => { } }) + test('instructs the LLM to bind an active scene before mutations', async () => { + const pair = await spinUp(registerFromBrief) + try { + const res = await pair.client.getPrompt({ + name: 'from_brief', + arguments: { brief: 'Add a bedroom' }, + }) + const m = res.messages[0] + expect(m).toBeDefined() + if (!m) return + if (m.content.type === 'text') { + expect(m.content.text).toContain('CRITICAL FIRST STEP') + expect(m.content.text).toContain('load_scene') + expect(m.content.text).toContain('create_project') + expect(m.content.text).toContain('create_house_from_brief') + expect(m.content.text).toContain('no bound scene') + } + } finally { + await pair.close() + } + }) + test('appends constraints section when provided', async () => { const pair = await spinUp(registerFromBrief) try { @@ -221,3 +246,212 @@ describe('renovation_from_photos', () => { } }) }) + +describe('full prompt→tool→save→event regression', () => { + beforeEach(() => { + useScene.getState().unloadScene() + useScene.temporal.getState().clear() + }) + + test('bedroom prompt scenario: create_room + add_door + add_window + furnish_room persists to store and emits events', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) + + const graph = { nodes: bridge.getNodes(), rootNodeIds: bridge.getRootNodeIds() } + const meta = await store.save({ + name: 'Empty Project', + graph, + saveMode: 'draft', + publish: false, + operation: 'init', + }) + operations.setActiveScene(meta) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + + const { + ZoneNode, + SlabNode, + CeilingNode, + WallNode: WallSchema, + DoorNode, + WindowNode, + ItemNode, + } = await import('@pascal-app/core/schema') + + const polygon: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + + const zone = ZoneNode.parse({ name: 'Bedroom', polygon, color: '#60a5fa' }) + const slab = SlabNode.parse({ polygon }) + const ceiling = CeilingNode.parse({ polygon }) + const walls = polygon.map((start, i) => + WallSchema.parse({ + name: `Bedroom wall ${i + 1}`, + start, + end: polygon[(i + 1) % polygon.length], + }), + ) + + const patchResult = await operations.applyPatch([ + { op: 'create', node: zone, parentId: level.id }, + { op: 'create', node: slab, parentId: level.id }, + { op: 'create', node: ceiling, parentId: level.id }, + ...walls.map((w) => ({ op: 'create' as const, node: w, parentId: level.id })), + ]) + expect(patchResult.appliedOps).toBe(7) + await publishLiveSceneSnapshot(operations, 'create_room') + + const doorWall = walls[0]! + const door = DoorNode.parse({ + wallId: doorWall.id, + parentId: doorWall.id, + position: [1.5, 1.05, 0], + width: 0.9, + height: 2.1, + }) + await operations.applyPatch([{ op: 'create', node: door, parentId: doorWall.id }]) + await publishLiveSceneSnapshot(operations, 'add_door') + + const windowWall = walls[2]! + const win = WindowNode.parse({ + wallId: windowWall.id, + parentId: windowWall.id, + position: [2, 1.65, 0], + width: 1.5, + height: 1.5, + }) + await operations.applyPatch([{ op: 'create', node: win, parentId: windowWall.id }]) + await publishLiveSceneSnapshot(operations, 'add_window') + + const bed = ItemNode.parse({ + name: 'Double Bed', + position: [2, 0, 0.5], + asset: { + id: 'double-bed', + name: 'Double Bed', + category: 'furniture', + thumbnail: '', + src: '', + dimensions: [1.8, 0.5, 2], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + }) + const nightstand1 = ItemNode.parse({ + name: 'Nightstand 1', + position: [0.4, 0, 0.5], + asset: { + id: 'bedside-table', + name: 'Nightstand', + category: 'furniture', + thumbnail: '', + src: '', + dimensions: [0.4, 0.5, 0.4], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + }) + const nightstand2 = ItemNode.parse({ + name: 'Nightstand 2', + position: [3.6, 0, 0.5], + asset: { + id: 'bedside-table', + name: 'Nightstand', + category: 'furniture', + thumbnail: '', + src: '', + dimensions: [0.4, 0.5, 0.4], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + }) + const wardrobe = ItemNode.parse({ + name: 'Wardrobe', + position: [0.3, 0, 1.5], + rotation: [0, Math.PI / 2, 0], + asset: { + id: 'closet', + name: 'Wardrobe', + category: 'furniture', + thumbnail: '', + src: '', + dimensions: [1.2, 0.6, 2], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + }) + await operations.applyPatch([ + { op: 'create', node: bed, parentId: level.id }, + { op: 'create', node: nightstand1, parentId: level.id }, + { op: 'create', node: nightstand2, parentId: level.id }, + { op: 'create', node: wardrobe, parentId: level.id }, + ]) + await publishLiveSceneSnapshot(operations, 'furnish_room') + + const events = await store.listSceneEvents(meta.id) + expect(events.length).toBe(4) + expect(events.map((e) => e.kind)).toEqual([ + 'create_room', + 'add_door', + 'add_window', + 'furnish_room', + ]) + + const savedScene = await store.load(meta.id) + expect(savedScene).not.toBeNull() + const nodes = savedScene!.graph.nodes + const nodeTypes = new Set(Object.values(nodes).map((n) => n.type)) + expect(nodeTypes).toContain('zone') + expect(nodeTypes).toContain('slab') + expect(nodeTypes).toContain('ceiling') + expect(nodeTypes).toContain('wall') + expect(nodeTypes).toContain('door') + expect(nodeTypes).toContain('window') + expect(nodeTypes).toContain('item') + + const itemCount = Object.values(nodes).filter((n) => n.type === 'item').length + expect(itemCount).toBe(4) + const doorCount = Object.values(nodes).filter((n) => n.type === 'door').length + expect(doorCount).toBe(1) + const windowCount = Object.values(nodes).filter((n) => n.type === 'window').length + expect(windowCount).toBe(1) + }) + + test('mutation without active scene and with store throws a descriptive error', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall, level.id) + + await expect( + publishLiveSceneSnapshot(operations, 'create_wall'), + ).rejects.toThrow('no_active_scene') + }) + + test('mutation without active scene and without store silently returns', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const operations = createSceneOperations({ bridge }) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall, level.id) + + await publishLiveSceneSnapshot(operations, 'create_wall') + }) +}) diff --git a/packages/mcp/src/tools/live-sync.test.ts b/packages/mcp/src/tools/live-sync.test.ts new file mode 100644 index 000000000..e00a0ebd7 --- /dev/null +++ b/packages/mcp/src/tools/live-sync.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { WallNode } from '@pascal-app/core/schema' +import useScene from '@pascal-app/core/store' +import { SceneBridge } from '../bridge/scene-bridge' +import { createSceneOperations } from '../operations' +import { InMemorySceneStore } from './scene-lifecycle/test-utils' +import { publishLiveSceneSnapshot } from './live-sync' + +function resetScene(): void { + useScene.getState().unloadScene() + useScene.temporal.getState().clear() +} + +describe('publishLiveSceneSnapshot', () => { + beforeEach(() => resetScene()) + + test('throws when a SceneStore is attached but no active scene is bound', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) + + await expect( + publishLiveSceneSnapshot(operations, 'test_mutation'), + ).rejects.toThrow('no_active_scene') + }) + + test('silently returns when no SceneStore is attached (headless mode)', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const operations = createSceneOperations({ bridge }) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall, level.id) + + await publishLiveSceneSnapshot(operations, 'create_wall') + }) + + test('persists and emits events when active scene is set', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) + + const graph = { nodes: bridge.getNodes(), rootNodeIds: bridge.getRootNodeIds() } + const meta = await store.save({ + name: 'Test Scene', + graph, + saveMode: 'draft', + publish: false, + operation: 'init', + }) + operations.setActiveScene(meta) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const wall = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall, level.id) + + await publishLiveSceneSnapshot(operations, 'create_wall') + + const events = await store.listSceneEvents(meta.id) + expect(events.length).toBe(1) + expect(events[0]!.kind).toBe('create_wall') + expect(events[0]!.version).toBe(2) + }) + + test('increments version on each snapshot', async () => { + const bridge = new SceneBridge() + bridge.loadDefault() + const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) + + const graph = { nodes: bridge.getNodes(), rootNodeIds: bridge.getRootNodeIds() } + const meta = await store.save({ + name: 'Test Scene', + graph, + saveMode: 'draft', + publish: false, + operation: 'init', + }) + operations.setActiveScene(meta) + + const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + + const wall1 = WallNode.parse({ start: [0, 0], end: [5, 0] }) + bridge.createNode(wall1, level.id) + await publishLiveSceneSnapshot(operations, 'create_wall') + + const wall2 = WallNode.parse({ start: [5, 0], end: [5, 4] }) + bridge.createNode(wall2, level.id) + await publishLiveSceneSnapshot(operations, 'create_wall') + + const events = await store.listSceneEvents(meta.id) + expect(events.length).toBe(2) + expect(events[0]!.version).toBe(2) + expect(events[1]!.version).toBe(3) + }) +}) diff --git a/packages/mcp/src/tools/live-sync.ts b/packages/mcp/src/tools/live-sync.ts index 658c0fb55..adf7caacd 100644 --- a/packages/mcp/src/tools/live-sync.ts +++ b/packages/mcp/src/tools/live-sync.ts @@ -19,8 +19,14 @@ export function syncDerivedStairOpenings(operations: SceneOperations): number { /** * Persist the bridge's current graph to the active scene and append a live - * event for browser subscribers. No-ops when the MCP session is not currently - * bound to a saved scene. + * event for browser subscribers. + * + * Throws an error when the MCP session has a SceneStore but is not bound to + * an active scene, so the caller (and the LLM) gets clear feedback that a + * scene must be loaded or created first via `load_scene` / `create_project` / + * `create_house_from_brief`. + * + * Silently returns when no SceneStore is attached (headless / test mode). */ export async function publishLiveSceneSnapshot( operations: SceneOperations, @@ -29,7 +35,15 @@ export async function publishLiveSceneSnapshot( syncDerivedStairOpenings(operations) const active = operations.getActiveScene() - if (!(active && operations.canAppendSceneEvents)) return + + if (!active && operations.hasStore) { + throwMcpError( + ErrorCode.InvalidRequest, + 'no_active_scene: call load_scene, create_project, or create_house_from_brief before using mutation tools', + ) + } + + if (!active || !operations.canAppendSceneEvents) return const graph = operations.exportSceneGraph() diff --git a/test-flow.mjs b/test-flow.mjs new file mode 100644 index 000000000..a22f6b228 --- /dev/null +++ b/test-flow.mjs @@ -0,0 +1,119 @@ +// Side-effect import MUST come first: installs RAF polyfill before core loads. +import './packages/mcp/src/bridge/node-shims.js' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import useScene from '@pascal-app/core/store' +import { SceneBridge } from './packages/mcp/src/bridge/scene-bridge.js' +import { registerRoomTools } from './packages/mcp/src/tools/room-tools.js' +import { registerApplyPatch } from './packages/mcp/src/tools/apply-patch.js' + +async function spinUp() { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerRoomTools(server, bridge) + registerApplyPatch(server, bridge) + const client = new Client({ name: 'test-client', version: '0.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + return { client, server, bridge } +} + +function resetScene() { + useScene.getState().unloadScene() + useScene.temporal.getState().clear() +} + +async function testFullFlow() { + resetScene() + const { client, server, bridge } = await spinUp() + + try { + // 1. Create a room + const nodes = bridge.getNodes() + const level = Object.values(nodes).find((n) => n.type === 'level') + if (!level) { + console.log('ERROR: No level found') + return + } + console.log('Level found:', level.id) + + const roomResult = await client.callTool({ + name: 'create_room', + arguments: { + levelId: level.id, + name: 'Bedroom', + polygon: [[0, 0], [4, 0], [4, 3], [0, 3]], + }, + }) + console.log('create_room result:', roomResult.isError ? 'ERROR' : 'OK') + if (roomResult.isError) { + console.log('Error:', roomResult.content) + return + } + const room = JSON.parse(roomResult.content[0].text) + console.log('Room created:', room) + + // 2. Add a door + const wallId = room.wallIds[0] + console.log('Adding door to wall:', wallId) + const doorResult = await client.callTool({ + name: 'add_door', + arguments: { wallId, t: 0.5, width: 0.9, height: 2.1 }, + }) + console.log('add_door result:', doorResult.isError ? 'ERROR' : 'OK') + if (doorResult.isError) { + console.log('Error:', doorResult.content) + return + } + const door = JSON.parse(doorResult.content[0].text) + console.log('Door created:', door) + + // 3. Add a window + const windowResult = await client.callTool({ + name: 'add_window', + arguments: { wallId: room.wallIds[2], t: 0.5, width: 1.5, height: 1.5, sillHeight: 0.9 }, + }) + console.log('add_window result:', windowResult.isError ? 'ERROR' : 'OK') + if (windowResult.isError) { + console.log('Error:', windowResult.content) + return + } + const window = JSON.parse(windowResult.content[0].text) + console.log('Window created:', window) + + // 4. Furnish the room + const furnishResult = await client.callTool({ + name: 'furnish_room', + arguments: { + zoneId: room.zoneId, + roomType: 'bedroom', + doorWallIndex: 0, + }, + }) + console.log('furnish_room result:', furnishResult.isError ? 'ERROR' : 'OK') + if (furnishResult.isError) { + console.log('Error:', furnishResult.content) + return + } + const furnish = JSON.parse(furnishResult.content[0].text) + console.log('Furnished:', furnish) + + // 5. Validate scene + const validation = bridge.validateScene() + console.log('Scene valid:', validation.valid) + if (!validation.valid) { + console.log('Validation errors:', validation.errors) + } + + console.log('\n✅ Full flow test PASSED!') + } finally { + await client.close() + await server.close() + } +} + +testFullFlow().catch(console.error)