Files
leon-ai--leon/test/controlled/unit/nlu-controlled.spec.ts
T
2026-07-13 12:44:08 +08:00

618 lines
16 KiB
TypeScript

import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
process.env['LEON_NODE_ENV'] = 'testing'
process.env['LEON_LLM'] = 'openai/gpt-5.4'
const defaultProcessResult = {
contextName: '',
skillName: '',
actionName: '',
skillConfigPath: '',
skillConfig: {
name: '',
bridge: 'python',
version: '',
workflow: []
},
localeSkillConfig: {
variables: {},
widgetContents: {}
},
actionConfig: null,
new: {
utterance: '',
actionArguments: {},
entities: [],
sentiment: {}
},
context: {
utterances: [],
actionArguments: [],
entities: [],
sentiments: [],
data: {}
}
}
const testState = vi.hoisted(() => ({
currentNlu: null as unknown,
skillConfigs: {} as Record<string, Record<string, unknown>>,
localeConfigs: {} as Record<string, Record<string, unknown>>
}))
const coreMocks = vi.hoisted(() => ({
brain: {
runSkillAction: vi.fn(),
talk: vi.fn(async () => undefined),
suggest: vi.fn(),
isMuted: false,
lang: 'en',
wernicke: vi.fn(() => '')
},
conversationLogger: {
push: vi.fn(async () => undefined),
load: vi.fn(async () => []),
loadAll: vi.fn(async () => [])
},
socket: {
emit: vi.fn()
},
memoryManager: {
observeTurn: vi.fn(async () => undefined),
savePersistentMemoryCandidatesFromTurn: vi.fn(async () => undefined)
},
selfModelManager: {
observeTurn: vi.fn(async () => undefined)
},
llmProvider: {
consumeLastProviderErrorMessage: vi.fn(() => null)
},
persona: {
refreshContextInfo: vi.fn()
},
toolCallLogger: {
runOwnerQuery: vi.fn(async (_utterance: string, runner: () => Promise<unknown>) => {
return runner()
})
},
pulseManager: {
observeOwnerUtterance: vi.fn(async () => undefined)
},
postTurnMaintenanceQueue: {
enqueue: vi.fn()
}
}))
const dutyMocks = vi.hoisted(() => ({
skillRouterExecute: vi.fn(),
actionCallingExecute: vi.fn(),
slotFillingExecute: vi.fn()
}))
const skillHelperMocks = vi.hoisted(() => ({
listSkillDescriptorsSync: vi.fn(() => {
return Object.keys(testState.skillConfigs).map((skillName) => ({
id: skillName,
format: 'leon-native'
}))
}),
getSkillDescriptorSync: vi.fn((skillName: string) => {
return testState.skillConfigs[skillName]
? {
id: skillName,
format: 'leon-native'
}
: null
}),
getNewSkillConfig: vi.fn(async (skillName: string) => {
return testState.skillConfigs[skillName] || null
}),
getNewSkillConfigPath: vi.fn((skillName: string) => {
return `/tmp/${skillName}/skill.json`
}),
getSkillLocaleConfig: vi.fn(async (_lang: string, skillName: string) => {
return (
testState.localeConfigs[skillName] || {
variables: {},
widget_contents: {},
actions: {}
}
)
})
}))
function cloneDefaultProcessResult(): typeof defaultProcessResult {
return JSON.parse(JSON.stringify(defaultProcessResult)) as typeof defaultProcessResult
}
async function applyProcessResultUpdate(
newResult: Record<string, unknown>
): Promise<void> {
const currentNlu = testState.currentNlu as {
nluProcessResult: typeof defaultProcessResult
} | null
if (!currentNlu) {
return
}
const current = currentNlu.nluProcessResult
if (newResult['new'] && typeof newResult['new'] === 'object') {
const newData = newResult['new'] as Record<string, unknown>
if (typeof newData['utterance'] === 'string' && newData['utterance'] !== '') {
currentNlu.nluProcessResult = {
...current,
new: {
utterance: newData['utterance'],
actionArguments: {},
entities: [],
sentiment: {}
},
context: {
...current.context,
utterances: [...current.context.utterances, newData['utterance']],
entities: [],
sentiments: []
}
}
return
}
if (newData['actionArguments']) {
currentNlu.nluProcessResult = {
...current,
new: {
...current.new,
actionArguments: newData['actionArguments'] as Record<string, unknown>
},
context: {
...current.context,
actionArguments: [
...current.context.actionArguments,
{ ...(newData['actionArguments'] as Record<string, unknown>) }
]
}
}
return
}
}
if (typeof newResult['skillName'] === 'string' && newResult['skillName'] !== '') {
const skillName = newResult['skillName'] as string
const skillConfig = testState.skillConfigs[skillName] as Record<string, unknown>
const localeConfig =
(testState.localeConfigs[skillName] as Record<string, unknown>) || {}
currentNlu.nluProcessResult = {
...cloneDefaultProcessResult(),
contextName: skillName.replace(/_skill$/, ''),
skillName,
skillConfigPath: `/tmp/${skillName}/skill.json`,
skillConfig: {
name: String(skillConfig['name'] || ''),
bridge: String(skillConfig['bridge'] || 'nodejs'),
version: String(skillConfig['version'] || '1.0.0'),
workflow: Array.isArray(skillConfig['workflow']) ? skillConfig['workflow'] : []
},
localeSkillConfig: {
variables:
(localeConfig['variables'] as Record<string, unknown>) || {},
widgetContents:
(localeConfig['widget_contents'] as Record<string, unknown>) || {}
},
new: current.new,
context: {
utterances: [current.new.utterance],
actionArguments: [],
entities: [],
sentiments: [],
data: current.context.data
}
}
return
}
if (typeof newResult['actionName'] === 'string' && newResult['actionName'] !== '') {
const actionName = newResult['actionName'] as string
const skillConfig = testState.skillConfigs[current.skillName] as Record<
string,
unknown
>
const actions = (skillConfig['actions'] as Record<string, unknown>) || {}
const localeConfig =
(testState.localeConfigs[current.skillName] as Record<string, unknown>) || {}
const localeActions =
(localeConfig['actions'] as Record<string, Record<string, unknown>>) || {}
currentNlu.nluProcessResult = {
...current,
actionName,
actionConfig: {
...(actions[actionName] as Record<string, unknown>),
...(localeActions[actionName] || {})
}
}
return
}
currentNlu.nluProcessResult = {
...current,
...(newResult as Partial<typeof defaultProcessResult>)
}
}
vi.mock('@/helpers/log-helper', () => ({
LogHelper: {
title: vi.fn(),
success: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
error: vi.fn()
}
}))
vi.mock('@/constants', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/constants')>()
return {
...actual,
LEON_ROUTING_MODE: 'controlled'
}
})
vi.mock('@/core', () => ({
BRAIN: coreMocks.brain,
CONVERSATION_LOGGER: coreMocks.conversationLogger,
SOCKET_SERVER: {
socket: coreMocks.socket,
emitToChatClients: coreMocks.socket.emit,
emitAnswerToChatClients: coreMocks.socket.emit,
clearLiveWidgets: vi.fn()
},
MEMORY_MANAGER: coreMocks.memoryManager,
PERSONA: coreMocks.persona,
LLM_PROVIDER: coreMocks.llmProvider,
TOOL_CALL_LOGGER: coreMocks.toolCallLogger,
SELF_MODEL_MANAGER: coreMocks.selfModelManager,
PULSE_MANAGER: coreMocks.pulseManager,
POST_TURN_MAINTENANCE_QUEUE: coreMocks.postTurnMaintenanceQueue
}))
vi.mock('@/core/config-states/config-state', () => ({
CONFIG_STATE: {
getRoutingModeState: (): { getRoutingMode: () => string } => ({
getRoutingMode: (): string => 'controlled'
}),
getModelState: (): {
getWorkflowTarget: () => { isEnabled: boolean }
getAgentTarget: () => { isEnabled: boolean }
} => ({
getWorkflowTarget: (): { isEnabled: boolean } => ({
isEnabled: true
}),
getAgentTarget: (): { isEnabled: boolean } => ({
isEnabled: true
})
})
}
}))
vi.mock('@/helpers/skill-domain-helper', () => ({
SkillDomainHelper: skillHelperMocks
}))
vi.mock('@/core/nlp/nlu/workflow-progress-widget', () => ({
WorkflowProgressWidget: class {
public startTurn(): void {}
public showChoosingSkill(): void {}
public showPickingAction(): void {}
public showResolvingParameters(): void {}
public startAction(): void {}
public completeRoutingOnly(): void {}
public completeSelectionNotFound(): void {}
public completeAll(): void {}
public reset(): void {}
}
}))
vi.mock('@/core/nlp/nlu/nlu-process-result-updater', () => ({
DEFAULT_NLU_PROCESS_RESULT: cloneDefaultProcessResult(),
NLUProcessResultUpdater: {
update: vi.fn(applyProcessResultUpdate)
}
}))
vi.mock('@/core/llm-manager/llm-duties/skill-router-llm-duty', () => ({
SkillRouterLLMDuty: class {
public async init(): Promise<void> {}
public async execute(): Promise<unknown> {
return dutyMocks.skillRouterExecute()
}
}
}))
vi.mock('@/core/llm-manager/llm-duties/action-calling-llm-duty', () => ({
ActionCallingLLMDuty: class {
public async init(): Promise<void> {}
public async execute(): Promise<unknown> {
return dutyMocks.actionCallingExecute()
}
}
}))
vi.mock('@/core/llm-manager/llm-duties/slot-filling-llm-duty', () => ({
SlotFillingLLMDuty: class {
public async init(): Promise<void> {}
public async execute(): Promise<unknown> {
return dutyMocks.slotFillingExecute()
}
}
}))
vi.mock('@/core/llm-manager/llm-duties/react-llm-duty', () => ({
ReActLLMDuty: class {
public async init(): Promise<void> {}
public async execute(): Promise<unknown> {
return {
output: 'fallback'
}
}
}
}))
let NLUClass: typeof import('@/core/nlp/nlu/nlu').default
beforeAll(async () => {
;({ default: NLUClass } = await import('@/core/nlp/nlu/nlu'))
})
beforeEach(() => {
vi.clearAllMocks()
testState.skillConfigs = {}
testState.localeConfigs = {}
const nlu = new NLUClass()
nlu.nluProcessResult = cloneDefaultProcessResult()
testState.currentNlu = nlu
})
describe('Controlled NLU', () => {
it('selects the only enabled native skill without calling the skill router', async () => {
const nlu = testState.currentNlu as InstanceType<typeof NLUClass>
testState.skillConfigs['demo_only_skill'] = {
name: 'Demo Only',
bridge: 'nodejs',
version: '1.0.0',
workflow: [],
actions: {
run: {
type: 'logic',
description: 'Run the only skill.'
}
}
}
const chosenSkill = await (nlu as unknown as {
chooseSkill: (utterance: string) => Promise<string | null>
}).chooseSkill('Run this')
expect(chosenSkill).toBe('demo_only_skill')
expect(dutyMocks.skillRouterExecute).not.toHaveBeenCalled()
})
it('executes workflow actions sequentially', async () => {
const nlu = testState.currentNlu as InstanceType<typeof NLUClass>
testState.skillConfigs['demo_flow_skill'] = {
name: 'Demo Workflow',
bridge: 'nodejs',
version: '1.0.0',
workflow: ['step_one', 'step_two'],
actions: {
step_one: {
type: 'logic',
description: 'First step.'
},
step_two: {
type: 'logic',
description: 'Second step.'
}
}
}
testState.localeConfigs['demo_flow_skill'] = {
variables: {},
widget_contents: {},
actions: {
step_one: {},
step_two: {}
}
}
nlu.nluProcessResult = {
...cloneDefaultProcessResult(),
contextName: 'demo_flow',
skillName: 'demo_flow_skill',
actionName: 'step_one',
skillConfigPath: '/tmp/demo_flow_skill/skill.json',
skillConfig: {
name: 'Demo Workflow',
bridge: 'nodejs',
version: '1.0.0',
workflow: ['step_one', 'step_two']
},
localeSkillConfig: {
variables: {},
widgetContents: {}
},
actionConfig: {
type: 'logic',
description: 'First step.'
},
new: {
utterance: 'Run the demo workflow',
actionArguments: {},
entities: [],
sentiment: {}
},
context: {
utterances: ['Run the demo workflow'],
actionArguments: [],
entities: [],
sentiments: [],
data: {}
}
}
coreMocks.brain.runSkillAction
.mockResolvedValueOnce({ core: {} })
.mockResolvedValueOnce({ core: {} })
await (nlu as unknown as { handleActionSuccess: (output: Record<string, unknown>) => Promise<void> }).handleActionSuccess({
status: 'success',
name: 'step_one',
arguments: {}
})
expect(coreMocks.brain.runSkillAction).toHaveBeenCalledTimes(2)
expect(coreMocks.brain.runSkillAction.mock.calls[0]?.[0].actionName).toBe(
'step_one'
)
expect(coreMocks.brain.runSkillAction.mock.calls[1]?.[0].actionName).toBe(
'step_two'
)
})
it('fills a missing parameter before executing the pending workflow action', async () => {
const nlu = testState.currentNlu as InstanceType<typeof NLUClass>
testState.skillConfigs['timer_skill'] = {
name: 'Timer',
bridge: 'nodejs',
version: '1.0.0',
workflow: [],
actions: {
set_timer: {
type: 'logic',
description: 'Set a timer.',
parameters: {
duration: {
type: 'string',
description: 'Timer duration.'
}
}
}
}
}
testState.localeConfigs['timer_skill'] = {
variables: {},
widget_contents: {},
actions: {
set_timer: {}
}
}
nlu.nluProcessResult = {
...cloneDefaultProcessResult(),
contextName: 'timer',
skillName: 'timer_skill',
actionName: 'set_timer',
skillConfigPath: '/tmp/timer_skill/skill.json',
skillConfig: {
name: 'Timer',
bridge: 'nodejs',
version: '1.0.0',
workflow: []
},
localeSkillConfig: {
variables: {},
widgetContents: {}
},
actionConfig: {
type: 'logic',
description: 'Set a timer.',
parameters: {
duration: {
type: 'string',
description: 'Timer duration.'
}
}
},
new: {
utterance: '3 secs',
actionArguments: {},
entities: [],
sentiment: {}
},
context: {
utterances: ['Set a timer', '3 secs'],
actionArguments: [],
entities: [],
sentiments: [],
data: {}
}
}
nlu.conversation.setActiveState({
startingUtterance: 'Set a timer',
pendingAction: 'timer_skill:set_timer',
missingParameters: ['duration'],
collectedParameters: {}
})
dutyMocks.slotFillingExecute.mockResolvedValue({
output: {
status: 'success',
filled_slots: {
duration: '3 secs'
}
}
})
coreMocks.brain.runSkillAction.mockResolvedValue({
core: {}
})
const shouldContinue = await (nlu as unknown as {
preProcessRoute: () => Promise<boolean>
}).preProcessRoute()
expect(shouldContinue).toBe(false)
expect(coreMocks.brain.runSkillAction).toHaveBeenCalledTimes(1)
expect(
coreMocks.brain.runSkillAction.mock.calls[0]?.[0].new.actionArguments
).toEqual({
duration: '3 secs'
})
})
it('offers fallback, code generation, or cancel when controlled mode cannot find a skill', async () => {
const nlu = testState.currentNlu as InstanceType<typeof NLUClass>
nlu.nluProcessResult = {
...cloneDefaultProcessResult(),
new: {
utterance: 'Do something unknown',
actionArguments: {},
entities: [],
sentiment: {}
}
}
await (nlu as unknown as {
handleSkillOrActionNotFound: () => Promise<void>
}).handleSkillOrActionNotFound()
expect(coreMocks.brain.talk).toHaveBeenCalledWith(
expect.stringContaining('fall back to agent mode'),
true
)
expect(coreMocks.brain.suggest).toHaveBeenCalledWith([
'Fallback to agent mode',
'Write the skill code',
'Cancel'
])
})
})