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
75 changes: 75 additions & 0 deletions packages/mcp/src/bridge/scene-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { loadPlugin, nodeRegistry } from '@pascal-app/core'
import { AGENT_OPERATIONS } from '@pascal-app/core/agent-operations'
import {
BaseNode,
BuildingNode,
DoorNode,
ItemNode,
LevelNode,
nodeType,
objectId,
SiteNode,
WallNode,
ZoneNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import { SceneBridge } from './scene-bridge'

function tick() {
Expand Down Expand Up @@ -202,6 +207,76 @@ describe('SceneBridge', () => {
})

describe('applyPatch', () => {
test('accepts registered plugin nodes in create patches and validation', async () => {
const restoreRegistry = nodeRegistry._snapshot()
const PluginProbe = BaseNode.extend({
id: objectId('pluginprobe'),
type: nodeType('fixture:mcp-probe'),
value: z.number().default(1),
})

try {
await loadPlugin({
id: 'fixture:mcp-probe-pack',
apiVersion: 1,
nodes: [
{
kind: 'fixture:mcp-probe',
schemaVersion: 1,
schema: PluginProbe,
category: 'utility',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
value: 1,
}),
capabilities: {},
} as any,
],
})

const level = bridge.findNodes({ type: 'level' })[0]!
const probe = PluginProbe.parse({ value: 7 })

const result = bridge.applyPatch([{ op: 'create', node: probe as any, parentId: level.id }])

expect(result.createdIds).toContain(probe.id)
expect(bridge.getNode(probe.id as any)).toMatchObject({
type: 'fixture:mcp-probe',
value: 7,
parentId: level.id,
})
expect(bridge.validateScene()).toEqual({ valid: true, errors: [], warnings: [] })

const invalidProbe = {
...probe,
id: 'pluginprobe_invalid',
value: 'not-a-number',
}
expect(() =>
bridge.applyPatch([{ op: 'create', node: invalidProbe as any, parentId: level.id }]),
).toThrow(/value/)

const nodes = bridge.getNodes()
bridge.setScene(
{
...nodes,
[probe.id]: { ...nodes[probe.id]!, value: 'not-a-number' } as any,
},
bridge.getRootNodeIds(),
)
const invalidScene = bridge.validateScene()
expect(invalidScene.valid).toBe(false)
expect(invalidScene.errors).toContainEqual(
expect.objectContaining({ nodeId: probe.id, path: 'value' }),
)
} finally {
restoreRegistry()
}
})

test('applies mixed create/update/delete atomically', async () => {
const level = bridge.findNodes({ type: 'level' })[0]!
const wallA = WallNode.parse({ start: [0, 0], end: [2, 0] })
Expand Down
20 changes: 17 additions & 3 deletions packages/mcp/src/bridge/scene-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
hierarchyCycleErrors,
type NodeDeletionPlan,
type NodeDeletionScene,
nodeRegistry,
pauseSceneHistory,
pauseSpaceDetection,
planNodeDeletion,
Expand All @@ -26,7 +27,6 @@ import {
import type { AnyNode, Collection, CollectionId, SceneMaterialId } from '@pascal-app/core/schema'
import {
type AnyNodeId,
AnyNode as AnyNodeSchema,
type AnyNodeType,
generateId,
parseNode,
Expand All @@ -35,6 +35,20 @@ import {
import type { SceneMeta } from '../storage/types'
import { InMemoryAgentSession } from './agent-session'

type ParsedNode = ReturnType<typeof parseNode>

function parseRegisteredNode(node: unknown): ParsedNode {
const builtin = parseNode(node)
if (builtin.success) return builtin
if (!(node && typeof node === 'object' && !Array.isArray(node))) return builtin

const type = (node as { type?: unknown }).type
if (typeof type !== 'string') return builtin

const registered = nodeRegistry.get(type)?.schema
return registered ? (registered.safeParse(node) as ParsedNode) : builtin
}

export type ValidationError = { nodeId: string; path: string; message: string }
export type ValidationResult = {
valid: boolean
Expand Down Expand Up @@ -488,7 +502,7 @@ export class SceneBridge {
)
materialUpserts.set(parsed.data.id, parsed.data)
} else if (p.op === 'create') {
const res = parseNode(p.node)
const res = parseRegisteredNode(p.node)
if (!res.success) {
throw new Error(
`invalid patch: patches[${i}] create node failed schema: ${res.error.message}`,
Expand Down Expand Up @@ -691,7 +705,7 @@ export class SceneBridge {
if (node.type === 'site' && node.visible === false) {
warnings.push({ nodeId: id, path: 'visible', message: HIDDEN_SITE_NOTE })
}
const res = AnyNodeSchema.safeParse(node)
const res = parseRegisteredNode(node)
if (res.success) continue
for (const issue of res.error.issues) {
errors.push({
Expand Down