Files
moeka-project/services/minecraft/src/cognitive/conscious/js-planner.test.ts
T
Rin 3f059527c3 feat(minecraft): add control action queue to brain for async action management with capacity limits
Add ControlActionQueueEntry/ActionQueueSnapshot types tracking action state (pending/executing/succeeded/failed/cancelled), implement control action queue with MAX_QUEUED_CONTROL_ACTIONS=5 and MAX_PENDING_CONTROL_ACTIONS=4 capacity limits, add enqueueControlAction to queue async control actions (skip chat/skip/stop/readonly tools) instead of blocking turn completion, implement runControlActionWorker processing
2026-02-18 11:14:41 +08:00

253 lines
9.5 KiB
TypeScript

import type { Action } from '../../libs/mineflayer/action'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { JavaScriptPlanner } from './js-planner'
function createAction(name: string, schema: Action['schema']): Action {
return {
name,
description: `${name} tool`,
execution: 'sync',
schema,
perform: () => () => '',
}
}
const actions: Action[] = [
createAction('chat', z.object({ message: z.string() })),
createAction('goToPlayer', z.object({
player_name: z.string(),
closeness: z.number().min(0),
})),
]
describe('javaScriptPlanner', () => {
const globals = {
event: {
type: 'perception',
payload: { type: 'chat_message' },
source: { type: 'minecraft', id: 'test' },
timestamp: Date.now(),
},
snapshot: {
self: { health: 20, food: 20, location: { x: 0, y: 64, z: 0 } },
environment: { nearbyPlayers: [] },
social: {},
threat: {},
attention: {},
},
llmInput: {
systemPrompt: 'system prompt',
userMessage: 'latest user message',
messages: [{ role: 'user', content: 'hello' }],
conversationHistory: [{ role: 'assistant', content: 'previous reply' }],
updatedAt: Date.now(),
attempt: 1,
},
actionQueue: {
executing: null,
pending: [],
recent: [],
capacity: { total: 5, executing: 1, pending: 4 },
counts: { total: 0, executing: 0, pending: 0 },
updatedAt: Date.now(),
},
forgetConversation: () => ({ ok: true, cleared: ['conversationHistory', 'lastLlmInputSnapshot'] }),
} as any
it('maps positional/object args and executes tools in order', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate(`
await chat("hello")
await goToPlayer({ player_name: "Alex", closeness: 2 })
`, actions, globals, executeAction)
expect(executeAction).toHaveBeenCalledTimes(2)
expect(executeAction).toHaveBeenNthCalledWith(1, { tool: 'chat', params: { message: 'hello' } })
expect(executeAction).toHaveBeenNthCalledWith(2, { tool: 'goToPlayer', params: { player_name: 'Alex', closeness: 2 } })
expect(planned.actions.map(a => a.action)).toEqual([
{ tool: 'chat', params: { message: 'hello' } },
{ tool: 'goToPlayer', params: { player_name: 'Alex', closeness: 2 } },
])
})
it('supports dynamic dispatch with use(toolName, params)', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate(`await use("chat", { message: "via-use" })`, actions, globals, executeAction)
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'via-use' } }])
})
it('persists script variables across turns with mem', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
await planner.evaluate('mem.count = 2', actions, globals, executeAction)
const planned = await planner.evaluate('await chat("count=" + mem.count)', actions, globals, executeAction)
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'count=2' } }])
})
it('persists typed previous return via prevRun.returnRaw', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
await planner.evaluate(`
const inv = [{ name: "oak_log", count: 2 }]
return inv
`, actions, globals, executeAction)
const planned = await planner.evaluate(`
const inv = prevRun.returnRaw
await chat(inv.map(item => item.count + " " + item.name).join(", "))
`, actions, globals, executeAction)
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: '2 oak_log' } }])
})
it('provides snapshot globals in script scope', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate('await chat("hp=" + self.health)', actions, globals, executeAction)
expect(planned.actions.map(a => a.action)).toEqual([{ tool: 'chat', params: { message: 'hp=20' } }])
})
it('rejects mixed skip + tool calls', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
await expect(planner.evaluate('await skip(); await chat("oops")', actions, globals, executeAction)).rejects.toThrow(/skip\(\) cannot be mixed/i)
})
it('returns structured validation failures without aborting the script', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate(`
const first = await goToPlayer({ player_name: "Alex", closeness: -1 })
if (!first.ok) {
await chat("fallback")
}
`, actions, globals, executeAction)
expect(planned.actions[0]?.ok).toBe(false)
expect(planned.actions[0]?.error).toMatch(/Invalid tool parameters/i)
expect(executeAction).toHaveBeenCalledTimes(1)
expect(planned.actions[1]?.action.tool).toBe('chat')
})
it('enforces timeout on long-running scripts', async () => {
const planner = new JavaScriptPlanner({ timeoutMs: 20 })
const executeAction = vi.fn(async action => `ok:${action.tool}`)
await expect(planner.evaluate('while (true) {}', actions, globals, executeAction)).rejects.toThrow(/Script execution timed out/i)
})
it('supports expectation guardrails on structured action telemetry', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async () => ({
ok: true,
movedDistance: 1.25,
distanceToTargetAfter: 1.5,
endPos: { x: 8, y: 64, z: 4 },
}))
const planned = await planner.evaluate(`
const nav = await goToPlayer({ player_name: "Alex", closeness: 2 })
expect(nav.ok, "go failed")
expectMoved(1)
expectNear(2)
expectNear({ x: 7, y: 64, z: 4 }, 2)
`, actions, globals, executeAction)
expect(planned.actions).toHaveLength(1)
expect(planned.actions[0]?.ok).toBe(true)
})
it('throws when expectation guardrail fails', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async () => ({
ok: true,
movedDistance: 0.1,
}))
await expect(planner.evaluate(`
await goToPlayer({ player_name: "Alex", closeness: 2 })
expectMoved(1, "did not move enough")
`, actions, globals, executeAction)).rejects.toThrow(/Expectation failed: did not move enough/i)
})
it('describes registered globals for debug REPL', () => {
const planner = new JavaScriptPlanner()
const descriptors = planner.describeGlobals(actions, globals)
const names = descriptors.map(d => d.name)
expect(names).toContain('mem')
expect(names).toContain('chat')
expect(names).toContain('goToPlayer')
expect(names).toContain('llmInput')
expect(names).toContain('llmUserMessage')
expect(names).toContain('query')
expect(names).toContain('bot')
expect(names).toContain('mineflayer')
expect(names).toContain('currentInput')
expect(names).toContain('llmLog')
expect(names).toContain('actionQueue')
expect(names).toContain('forget_conversation')
const mem = descriptors.find(d => d.name === 'mem')
expect(mem?.readonly).toBe(false)
})
it('exposes actionQueue runtime global to scripts', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate('return actionQueue.capacity.total', actions, globals, executeAction)
expect(planned.returnValue).toBe('5')
expect(planned.actions).toHaveLength(0)
})
it('exposes llm input globals to scripts', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate('await chat("llm=" + llmUserMessage)', actions, globals, executeAction)
expect(planned.actions[0]?.action).toEqual({ tool: 'chat', params: { message: 'llm=latest user message' } })
})
it('exposes forget_conversation runtime function', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate('return forget_conversation()', actions, globals, executeAction)
expect(planned.returnValue).toContain('conversationHistory')
expect(planned.actions).toHaveLength(0)
})
it('renders nested return objects without [Object] truncation', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate(`
return {
nearbyCopperOre: [{
name: 'copper_ore',
pos: { x: 10, y: 64, z: -2 },
distance: 1.8,
}],
}
`, actions, globals, executeAction)
expect(planned.returnValue).toContain('pos: { x: 10, y: 64, z: -2 }')
expect(planned.returnValue).not.toContain('[Object]')
})
it('detects expression-friendly REPL inputs', () => {
const planner = new JavaScriptPlanner()
expect(planner.canEvaluateAsExpression('2 + 3')).toBe(true)
expect(planner.canEvaluateAsExpression('const a = 1; a + 1')).toBe(false)
})
})