Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions packages/mcp/src/prompts/from-brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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')
}
Expand All @@ -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(),
Expand Down
236 changes: 235 additions & 1 deletion packages/mcp/src/prompts/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand All @@ -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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prompt test substring mismatch

High Severity

The new binding-scene test expects the prompt to contain "no bound scene", but the actual prompt text uses "Without a bound scene". This string mismatch causes the test assertion to fail.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 23a2f04. Configure here.

}
} finally {
await pair.close()
}
})

test('appends constraints section when provided', async () => {
const pair = await spinUp(registerFromBrief)
try {
Expand Down Expand Up @@ -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')
})
})
Loading