chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Keep the provider matrix in one place so the Vitest spec and subprocess
|
||||
* runner stay in sync.
|
||||
*/
|
||||
export const PROVIDER_MATRIX = [
|
||||
{
|
||||
provider: 'llamacpp',
|
||||
requiredEnv: 'LEON_LLAMACPP_BASE_URL',
|
||||
llmTarget: 'llamacpp'
|
||||
},
|
||||
{
|
||||
provider: 'openrouter',
|
||||
requiredEnv: 'LEON_OPENROUTER_API_KEY',
|
||||
llmTarget: 'openrouter/z-ai/glm-5-turbo'
|
||||
},
|
||||
{
|
||||
provider: 'openai',
|
||||
requiredEnv: 'LEON_OPENAI_API_KEY',
|
||||
llmTarget: 'openai/gpt-5.4'
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
requiredEnv: 'LEON_ANTHROPIC_API_KEY',
|
||||
llmTarget: 'anthropic/claude-sonnet-4-20250514'
|
||||
},
|
||||
{
|
||||
provider: 'moonshotai',
|
||||
requiredEnv: 'LEON_MOONSHOTAI_API_KEY',
|
||||
llmTarget: 'moonshotai/kimi-k2.5'
|
||||
},
|
||||
{
|
||||
provider: 'zai',
|
||||
requiredEnv: 'LEON_ZAI_API_KEY',
|
||||
llmTarget: 'zai/glm-5-turbo'
|
||||
},
|
||||
{
|
||||
provider: 'minimax',
|
||||
requiredEnv: 'LEON_MINIMAX_API_KEY',
|
||||
llmTarget: 'minimax/MiniMax-M3'
|
||||
}
|
||||
] as const
|
||||
|
||||
export type AgentProvider = (typeof PROVIDER_MATRIX)[number]['provider']
|
||||
|
||||
export const PROVIDER_REQUIRED_ENV = Object.fromEntries(
|
||||
PROVIDER_MATRIX.map(({ provider, requiredEnv }) => [provider, requiredEnv])
|
||||
) as Record<AgentProvider, string>
|
||||
@@ -0,0 +1,357 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import execa from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PROVIDER_MATRIX } from './provider-matrix'
|
||||
|
||||
const CURRENT_DIR = fileURLToPath(new URL('.', import.meta.url))
|
||||
const ROOT_DIR = path.resolve(CURRENT_DIR, '..', '..', '..')
|
||||
const RESULT_PREFIX = '__AGENT_RESULT__'
|
||||
const PROGRESS_PREFIX = '__AGENT_PROGRESS__'
|
||||
|
||||
interface ProviderProgressEvent {
|
||||
provider: string
|
||||
stage:
|
||||
| 'bootstrap'
|
||||
| 'turn_start'
|
||||
| 'tool_call'
|
||||
| 'turn_result'
|
||||
| 'scenario_complete'
|
||||
turn?: number
|
||||
message: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProviderScenarioResult {
|
||||
provider: string
|
||||
skipped: boolean
|
||||
reason?: string
|
||||
assetPath?: string
|
||||
turns?: Array<{
|
||||
input: string
|
||||
output: string
|
||||
finalIntent: string | null
|
||||
executionHistory: Array<{
|
||||
function: string
|
||||
status: string
|
||||
observation: string
|
||||
stepLabel?: string
|
||||
requestedToolInput?: string
|
||||
}>
|
||||
toolCalls: Array<{
|
||||
toolkitId?: string
|
||||
toolId: string
|
||||
functionName?: string
|
||||
toolInput?: string
|
||||
parsedInput?: Record<string, unknown>
|
||||
toolOutput?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
function extractTestNamePattern(argv: string[]): string | null {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
|
||||
if (!arg) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '-t' || arg === '--testNamePattern' || arg === '--test-name-pattern') {
|
||||
return argv[index + 1] || null
|
||||
}
|
||||
|
||||
if (arg.startsWith('-t=')) {
|
||||
return arg.slice(3) || null
|
||||
}
|
||||
|
||||
if (arg.startsWith('--testNamePattern=')) {
|
||||
return arg.slice('--testNamePattern='.length) || null
|
||||
}
|
||||
|
||||
if (arg.startsWith('--test-name-pattern=')) {
|
||||
return arg.slice('--test-name-pattern='.length) || null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveProviderMatrix(
|
||||
pattern: string | null
|
||||
): typeof PROVIDER_MATRIX {
|
||||
if (!pattern) {
|
||||
return PROVIDER_MATRIX
|
||||
}
|
||||
|
||||
const matchesPattern = (provider: string): boolean => {
|
||||
const testName = `runs the 3-turn scenario on ${provider}`
|
||||
|
||||
try {
|
||||
return new RegExp(pattern, 'i').test(testName)
|
||||
} catch {
|
||||
return testName.toLowerCase().includes(pattern.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
const filteredProviders = PROVIDER_MATRIX.filter(({ provider }) =>
|
||||
matchesPattern(provider)
|
||||
)
|
||||
|
||||
return filteredProviders.length > 0 ? filteredProviders : PROVIDER_MATRIX
|
||||
}
|
||||
|
||||
const ACTIVE_PROVIDER_MATRIX = resolveProviderMatrix(
|
||||
process.env['LEON_AGENT_PROVIDER_PATTERN'] ||
|
||||
extractTestNamePattern(process.argv)
|
||||
)
|
||||
|
||||
function collectTurnTrace(
|
||||
turn: NonNullable<ProviderScenarioResult['turns']>[number]
|
||||
): string {
|
||||
return [
|
||||
turn.output,
|
||||
...turn.executionHistory.map((item) => item.observation),
|
||||
...turn.executionHistory.map((item) => item.requestedToolInput || ''),
|
||||
...turn.toolCalls.map((item) => item.toolInput || ''),
|
||||
...turn.toolCalls.map((item) => item.toolOutput || ''),
|
||||
...turn.toolCalls.map((item) =>
|
||||
item.parsedInput ? JSON.stringify(item.parsedInput) : ''
|
||||
)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function summarizeText(value: string | undefined, maxLength = 500): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
function summarizeScenarioResult(result: ProviderScenarioResult): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
provider: result.provider,
|
||||
skipped: result.skipped,
|
||||
reason: result.reason,
|
||||
assetPath: result.assetPath,
|
||||
turns: result.turns?.map((turn, index) => ({
|
||||
turn: index + 1,
|
||||
input: turn.input,
|
||||
output: summarizeText(turn.output),
|
||||
finalIntent: turn.finalIntent,
|
||||
executionHistory: turn.executionHistory.map((item) => ({
|
||||
function: item.function,
|
||||
status: item.status,
|
||||
stepLabel: item.stepLabel,
|
||||
observation: summarizeText(item.observation),
|
||||
requestedToolInput: summarizeText(item.requestedToolInput)
|
||||
})),
|
||||
toolCalls: turn.toolCalls.map((item) => ({
|
||||
toolkitId: item.toolkitId,
|
||||
toolId: item.toolId,
|
||||
functionName: item.functionName,
|
||||
toolInput: summarizeText(item.toolInput),
|
||||
parsedInput: item.parsedInput,
|
||||
toolOutput: summarizeText(item.toolOutput)
|
||||
}))
|
||||
}))
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
function formatProgressEvent(event: ProviderProgressEvent): string {
|
||||
const prefix = `[agent:e2e:${event.provider}]`
|
||||
|
||||
if (event.stage === 'turn_start') {
|
||||
return `${prefix} turn ${event.turn} input=${JSON.stringify(event.data?.['input'] || '')}`
|
||||
}
|
||||
|
||||
if (event.stage === 'tool_call') {
|
||||
return `${prefix} tool=${event.data?.['toolName'] || 'unknown'} input=${JSON.stringify(event.data?.['toolInput'] || '')} output=${JSON.stringify(event.data?.['toolOutput'] || '')}`
|
||||
}
|
||||
|
||||
if (event.stage === 'turn_result') {
|
||||
return `${prefix} turn ${event.turn} intent=${String(event.data?.['finalIntent'] || 'unknown')} toolCalls=${String(event.data?.['toolCalls'] || 0)} output=${JSON.stringify(event.data?.['output'] || '')}`
|
||||
}
|
||||
|
||||
if (event.stage === 'bootstrap') {
|
||||
return `${prefix} bootstrap asset=${JSON.stringify(event.data?.['assetPath'] || '')}`
|
||||
}
|
||||
|
||||
return `${prefix} ${event.message}`
|
||||
}
|
||||
|
||||
async function runProviderScenario(
|
||||
provider: string
|
||||
): Promise<ProviderScenarioResult> {
|
||||
/**
|
||||
* Provider choice is read at module-load time, so each provider run needs a
|
||||
* fresh process with its own env.
|
||||
*/
|
||||
const childProcess = execa(
|
||||
'node',
|
||||
[
|
||||
'--import',
|
||||
'tsx',
|
||||
'test/agent/e2e/run-agent-provider-scenario.ts',
|
||||
provider
|
||||
],
|
||||
{
|
||||
cwd: ROOT_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
LEON_NODE_ENV: 'testing',
|
||||
LEON_LLM:
|
||||
PROVIDER_MATRIX.find((item) => item.provider === provider)?.llmTarget ||
|
||||
provider
|
||||
},
|
||||
all: true,
|
||||
reject: false,
|
||||
timeout: 300_000
|
||||
}
|
||||
)
|
||||
|
||||
let streamBuffer = ''
|
||||
childProcess.all?.setEncoding('utf8')
|
||||
childProcess.all?.on('data', (chunk: string) => {
|
||||
streamBuffer += chunk
|
||||
const lines = streamBuffer.split('\n')
|
||||
streamBuffer = lines.pop() || ''
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (!line.startsWith(PROGRESS_PREFIX)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = line.slice(PROGRESS_PREFIX.length)
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload) as ProviderProgressEvent
|
||||
console.info(formatProgressEvent(event))
|
||||
} catch {
|
||||
console.info(`[agent:e2e:${provider}] ${payload}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { stdout, stderr, exitCode } = await childProcess
|
||||
|
||||
const combinedOutput = `${stdout}\n${stderr}`
|
||||
const resultLine = combinedOutput
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith(RESULT_PREFIX))
|
||||
.at(-1)
|
||||
|
||||
if (!resultLine) {
|
||||
throw new Error(
|
||||
`Missing agent result marker for provider "${provider}". Output:\n${combinedOutput}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = JSON.parse(
|
||||
resultLine.slice(RESULT_PREFIX.length)
|
||||
) as ProviderScenarioResult
|
||||
|
||||
if (exitCode !== 0 && !result.skipped) {
|
||||
throw new Error(
|
||||
`Provider "${provider}" scenario failed with exit code ${exitCode}. Output:\n${combinedOutput}`
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
describe('agent e2e', () => {
|
||||
for (const { provider, requiredEnv } of ACTIVE_PROVIDER_MATRIX) {
|
||||
/**
|
||||
* Missing credentials should skip that provider cleanly rather than fail
|
||||
* the whole matrix.
|
||||
*/
|
||||
it.skipIf(!process.env[requiredEnv])(
|
||||
`runs the 3-turn scenario on ${provider}`,
|
||||
async () => {
|
||||
const result = await runProviderScenario(provider)
|
||||
|
||||
console.info(
|
||||
`[agent:e2e:${provider}] validating turn outputs and tool usage`
|
||||
)
|
||||
|
||||
try {
|
||||
if (result.skipped) {
|
||||
console.info(
|
||||
`[agent:e2e:${provider}] skipped at runtime: ${result.reason || 'provider unavailable'}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
expect(result.turns).toHaveLength(3)
|
||||
|
||||
const [turn1, turn2, turn3] = result.turns!
|
||||
const turn2Trace = collectTurnTrace(turn2!)
|
||||
const turn3Trace = collectTurnTrace(turn3!)
|
||||
|
||||
expect(turn1!.output.trim().length).toBeGreaterThan(0)
|
||||
expect(turn1!.output).toMatch(/ping|pong/i)
|
||||
expect(turn1!.finalIntent).toBe('answer')
|
||||
|
||||
expect(turn2!.output.trim().length).toBeGreaterThan(0)
|
||||
expect(turn2!.finalIntent).toBe('answer')
|
||||
expect(
|
||||
turn2!.executionHistory.some(
|
||||
(item) =>
|
||||
item.function === 'weather.openmeteo.getCurrentConditions'
|
||||
) ||
|
||||
turn2!.toolCalls.some(
|
||||
(item) =>
|
||||
item.toolkitId === 'weather' &&
|
||||
item.toolId === 'openmeteo' &&
|
||||
item.functionName === 'getCurrentConditions'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(turn2Trace).toMatch(/shenzhen/i)
|
||||
expect(turn2Trace).toMatch(
|
||||
/clear|rain|cloud|temperature|feels|humidity|wind|weather|°c|°f/i
|
||||
)
|
||||
|
||||
/**
|
||||
* The third turn is intentionally structural: we care that Leon read
|
||||
* the injected file and listed the project root, not about exact prose.
|
||||
*/
|
||||
expect(turn3!.output.trim().length).toBeGreaterThan(0)
|
||||
expect(turn3!.finalIntent).toBe('answer')
|
||||
expect(
|
||||
turn3!.executionHistory.some(
|
||||
(item) =>
|
||||
item.function ===
|
||||
'operating_system_control.shell.executeCommand'
|
||||
) ||
|
||||
turn3!.toolCalls.some(
|
||||
(item) =>
|
||||
item.toolkitId === 'operating_system_control' &&
|
||||
item.toolId === 'shell'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(turn3Trace).toContain(result.assetPath!)
|
||||
expect(turn3Trace).toMatch(/project root/i)
|
||||
} catch (error) {
|
||||
console.info(
|
||||
`[agent:e2e:${provider}] scenario result on assertion failure:\n${summarizeScenarioResult(result)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
330_000
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,478 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { MessageLog } from '../../../server/src/types'
|
||||
import type { AgentProvider } from './provider-matrix'
|
||||
import { PROVIDER_MATRIX, PROVIDER_REQUIRED_ENV } from './provider-matrix'
|
||||
|
||||
const RESULT_PREFIX = '__AGENT_RESULT__'
|
||||
const PROGRESS_PREFIX = '__AGENT_PROGRESS__'
|
||||
const TEST_HOME_PREFIX = 'leon-agent-e2e'
|
||||
const TEST_PROFILE_PREFIX = 'agent-e2e'
|
||||
const EMPTY_PROFILE_DISABLED_CONFIG = {
|
||||
skills: [],
|
||||
tools: []
|
||||
}
|
||||
const REACT_CONTINUATION_STATE_FILENAME =
|
||||
'.react-execution-continuation-state.json'
|
||||
const REACT_HISTORY_COMPACTION_STATE_FILENAME =
|
||||
'.react-history-compaction-state.json'
|
||||
const PROVIDER_UNAVAILABLE_PATTERNS = [
|
||||
/cannot find llama\.cpp model/i,
|
||||
/credit balance is too low/i,
|
||||
/insufficient[_\s-]?quota/i,
|
||||
/no default installed local llm was found/i,
|
||||
/no llm is configured/i,
|
||||
/rate limit/i,
|
||||
/\b429\b/i
|
||||
]
|
||||
|
||||
interface AgentProgressEvent {
|
||||
provider: AgentProvider
|
||||
stage:
|
||||
| 'bootstrap'
|
||||
| 'turn_start'
|
||||
| 'tool_call'
|
||||
| 'turn_result'
|
||||
| 'scenario_complete'
|
||||
turn?: number
|
||||
message: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface AgentTurnResult {
|
||||
input: string
|
||||
output: string
|
||||
finalIntent: string | null
|
||||
executionHistory: Array<{
|
||||
function: string
|
||||
status: string
|
||||
observation: string
|
||||
stepLabel?: string
|
||||
requestedToolInput?: string
|
||||
}>
|
||||
toolCalls: Array<{
|
||||
toolkitId?: string
|
||||
toolId: string
|
||||
functionName?: string
|
||||
toolInput?: string
|
||||
parsedInput?: Record<string, unknown>
|
||||
toolOutput?: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface AgentRunnerResult {
|
||||
provider: AgentProvider
|
||||
skipped: boolean
|
||||
reason?: string
|
||||
assetPath?: string
|
||||
turns?: AgentTurnResult[]
|
||||
}
|
||||
|
||||
type ConversationLoggerRecord = Omit<MessageLog, 'sentAt'>
|
||||
|
||||
function printResult(result: AgentRunnerResult): void {
|
||||
/**
|
||||
* A fixed marker makes it easy for the parent Vitest process to extract the
|
||||
* structured result from mixed stdout/stderr.
|
||||
*/
|
||||
console.log(`${RESULT_PREFIX}${JSON.stringify(result)}`)
|
||||
}
|
||||
|
||||
function printProgress(event: AgentProgressEvent): void {
|
||||
console.log(`${PROGRESS_PREFIX}${JSON.stringify(event)}`)
|
||||
}
|
||||
|
||||
function serializeToolOutput(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeValue(value: string, maxLength = 220): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
function createConversationLoggerRecord(
|
||||
who: MessageLog['who'],
|
||||
message: string
|
||||
): ConversationLoggerRecord {
|
||||
return {
|
||||
who,
|
||||
message,
|
||||
isAddedToHistory: true
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderUnavailableReason(value: unknown): string | null {
|
||||
const message =
|
||||
value instanceof Error
|
||||
? `${value.name}: ${value.message}\n${value.stack || ''}`
|
||||
: String(value || '')
|
||||
|
||||
if (
|
||||
PROVIDER_UNAVAILABLE_PATTERNS.some((pattern) => pattern.test(message))
|
||||
) {
|
||||
return summarizeValue(message.replace(/\s+/g, ' ').trim(), 500)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function removeTestHomePath(
|
||||
homePath: string,
|
||||
expectedHomePath: string
|
||||
): Promise<void> {
|
||||
const resolvedHomePath = path.resolve(homePath)
|
||||
const resolvedExpectedHomePath = path.resolve(expectedHomePath)
|
||||
|
||||
if (
|
||||
resolvedHomePath !== resolvedExpectedHomePath ||
|
||||
path.dirname(resolvedHomePath) !== os.tmpdir() ||
|
||||
!path.basename(resolvedHomePath).startsWith(`${TEST_HOME_PREFIX}-`)
|
||||
) {
|
||||
throw new Error(`Refusing to remove non-test Leon home path: ${homePath}`)
|
||||
}
|
||||
|
||||
await fs.rm(resolvedHomePath, { force: true, recursive: true })
|
||||
}
|
||||
|
||||
async function prepareTestProfilePath(
|
||||
directories: string[],
|
||||
disabledConfigPath: string
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
directories.map((directory) => fs.mkdir(directory, { recursive: true }))
|
||||
)
|
||||
|
||||
await fs.writeFile(
|
||||
disabledConfigPath,
|
||||
`${JSON.stringify(EMPTY_PROFILE_DISABLED_CONFIG, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const providerArg = process.argv[2] as AgentProvider | undefined
|
||||
if (!providerArg || !(providerArg in PROVIDER_REQUIRED_ENV)) {
|
||||
printResult({
|
||||
provider: (providerArg || 'openai') as AgentProvider,
|
||||
skipped: true,
|
||||
reason: 'invalid_provider'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const provider = providerArg
|
||||
const providerConfig = PROVIDER_MATRIX.find(
|
||||
(item) => item.provider === provider
|
||||
)
|
||||
const requiredEnv = PROVIDER_REQUIRED_ENV[provider]
|
||||
const testRunId = `${provider}-${process.pid}-${Date.now()}`
|
||||
const testProfileName = `${TEST_PROFILE_PREFIX}-${testRunId}`
|
||||
const testHomePath = path.join(
|
||||
os.tmpdir(),
|
||||
`${TEST_HOME_PREFIX}-${testRunId}`
|
||||
)
|
||||
if (!process.env[requiredEnv]) {
|
||||
printResult({
|
||||
provider,
|
||||
skipped: true,
|
||||
reason: `missing_${requiredEnv.toLowerCase()}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
process.env['LEON_NODE_ENV'] = 'testing'
|
||||
process.env['LEON_LLM'] = providerConfig?.llmTarget || provider
|
||||
process.env['LEON_HOME'] = testHomePath
|
||||
process.env['LEON_PROFILE'] = testProfileName
|
||||
|
||||
const tempAssetPath = path.join(
|
||||
os.tmpdir(),
|
||||
`leon-agent-${provider}-${Date.now()}.txt`
|
||||
)
|
||||
|
||||
const {
|
||||
CACHE_PATH,
|
||||
LEON_PROFILE_PATH,
|
||||
LEON_PROFILES_PATH,
|
||||
LEON_HOME_PATH,
|
||||
LEON_TOOLKITS_PATH,
|
||||
MODELS_PATH,
|
||||
PROFILE_AGENT_SKILLS_PATH,
|
||||
PROFILE_CONTEXT_PATH,
|
||||
PROFILE_DISABLED_PATH,
|
||||
PROFILE_LOGS_PATH,
|
||||
PROFILE_MEMORY_PATH,
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
PROFILE_SKILLS_PATH,
|
||||
PROFILE_TOOLS_PATH,
|
||||
TMP_PATH
|
||||
} = await import('../../../server/src/constants')
|
||||
|
||||
await prepareTestProfilePath(
|
||||
[
|
||||
LEON_HOME_PATH,
|
||||
LEON_PROFILES_PATH,
|
||||
LEON_PROFILE_PATH,
|
||||
CACHE_PATH,
|
||||
LEON_TOOLKITS_PATH,
|
||||
MODELS_PATH,
|
||||
TMP_PATH,
|
||||
PROFILE_CONTEXT_PATH,
|
||||
PROFILE_MEMORY_PATH,
|
||||
PROFILE_LOGS_PATH,
|
||||
PROFILE_SKILLS_PATH,
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
PROFILE_AGENT_SKILLS_PATH,
|
||||
PROFILE_TOOLS_PATH
|
||||
],
|
||||
PROFILE_DISABLED_PATH
|
||||
)
|
||||
|
||||
const continuationStatePath = path.join(
|
||||
PROFILE_CONTEXT_PATH,
|
||||
REACT_CONTINUATION_STATE_FILENAME
|
||||
)
|
||||
const historyCompactionStatePath = path.join(
|
||||
PROFILE_CONTEXT_PATH,
|
||||
REACT_HISTORY_COMPACTION_STATE_FILENAME
|
||||
)
|
||||
|
||||
await fs.writeFile(
|
||||
tempAssetPath,
|
||||
`Please list the files in this exact project root directory: ${process.cwd()}.\n`,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const {
|
||||
ReActLLMDuty
|
||||
} = await import('../../../server/src/core/llm-manager/llm-duties/react-llm-duty')
|
||||
const { CONVERSATION_LOGGER, TOOL_EXECUTOR, LLM_PROVIDER } = await import(
|
||||
'../../../server/src/core/index'
|
||||
)
|
||||
|
||||
const turns: string[] = [
|
||||
// Return final answer directly
|
||||
'Hi Leon, just doing a quick check since I switched your LLM provider. What do you reply if I tell you "ping"?',
|
||||
// Create simple plan
|
||||
'What\'s the weather like today in Shenzhen?',
|
||||
// Create plan with dynamic replanning (inject new step)
|
||||
`There is a file waiting for you in ${tempAssetPath}, do what it asks you to do.`
|
||||
]
|
||||
|
||||
const turnResults: AgentTurnResult[] = []
|
||||
const toolCalls: AgentTurnResult['toolCalls'] = []
|
||||
type ExecuteTool = typeof TOOL_EXECUTOR.executeTool
|
||||
type ToolExecutionInput = Parameters<ExecuteTool>[0]
|
||||
type ToolExecutionResult = Awaited<ReturnType<ExecuteTool>>
|
||||
|
||||
const originalExecuteTool = TOOL_EXECUTOR.executeTool.bind(
|
||||
TOOL_EXECUTOR
|
||||
) as ExecuteTool
|
||||
|
||||
/**
|
||||
* Wrap tool execution so the parent spec can assert on real tool usage
|
||||
* without changing the production ReAct path.
|
||||
*/
|
||||
TOOL_EXECUTOR.executeTool = async (
|
||||
input: ToolExecutionInput
|
||||
): Promise<ToolExecutionResult> => {
|
||||
const toolResult = await originalExecuteTool(input)
|
||||
const toolName = `${input.toolkitId}.${input.toolId}.${input.functionName || 'unknown'}`
|
||||
const serializedInput = input.toolInput || ''
|
||||
const serializedOutput = serializeToolOutput(toolResult)
|
||||
const toolCall: AgentTurnResult['toolCalls'][number] = {
|
||||
toolId: input.toolId,
|
||||
toolOutput: serializedOutput
|
||||
}
|
||||
|
||||
if (input.toolkitId !== undefined) {
|
||||
toolCall.toolkitId = input.toolkitId
|
||||
}
|
||||
|
||||
if (input.functionName !== undefined) {
|
||||
toolCall.functionName = input.functionName
|
||||
}
|
||||
|
||||
if (input.toolInput !== undefined) {
|
||||
toolCall.toolInput = input.toolInput
|
||||
}
|
||||
|
||||
if (input.parsedInput && typeof input.parsedInput === 'object') {
|
||||
toolCall.parsedInput = { ...input.parsedInput }
|
||||
}
|
||||
|
||||
printProgress({
|
||||
provider,
|
||||
stage: 'tool_call',
|
||||
message: `Executed ${toolName}`,
|
||||
data: {
|
||||
toolName,
|
||||
toolInput: summarizeValue(serializedInput),
|
||||
toolOutput: summarizeValue(serializedOutput)
|
||||
}
|
||||
})
|
||||
|
||||
toolCalls.push(toolCall)
|
||||
|
||||
return toolResult
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* The e2e subprocess bypasses the normal server bootstrap, so initialize
|
||||
* the selected provider explicitly before the first ReAct turn.
|
||||
*/
|
||||
await LLM_PROVIDER.init()
|
||||
printProgress({
|
||||
provider,
|
||||
stage: 'bootstrap',
|
||||
message: `Initialized provider ${provider}`,
|
||||
data: {
|
||||
assetPath: tempAssetPath
|
||||
}
|
||||
})
|
||||
await CONVERSATION_LOGGER.clear()
|
||||
await fs.rm(continuationStatePath, { force: true })
|
||||
await fs.rm(historyCompactionStatePath, { force: true })
|
||||
|
||||
let recordedToolCalls = 0
|
||||
for (let index = 0; index < turns.length; index += 1) {
|
||||
const input = turns[index]!
|
||||
const turnNumber = index + 1
|
||||
|
||||
printProgress({
|
||||
provider,
|
||||
stage: 'turn_start',
|
||||
turn: turnNumber,
|
||||
message: `Starting turn ${turnNumber}`,
|
||||
data: {
|
||||
input
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Push each owner/Leon turn through the shared conversation logger so the
|
||||
* next ReAct invocation sees real multi-turn history.
|
||||
*/
|
||||
await CONVERSATION_LOGGER.push(
|
||||
createConversationLoggerRecord('owner', input)
|
||||
)
|
||||
|
||||
const duty = new ReActLLMDuty({ input })
|
||||
await duty.init({ force: index === 0 })
|
||||
const result = await duty.execute()
|
||||
|
||||
const output =
|
||||
result && typeof result.output === 'string' ? result.output : ''
|
||||
const finalIntent =
|
||||
result &&
|
||||
result.data &&
|
||||
typeof result.data === 'object' &&
|
||||
'finalIntent' in result.data &&
|
||||
typeof result.data['finalIntent'] === 'string'
|
||||
? result.data['finalIntent']
|
||||
: null
|
||||
|
||||
if (finalIntent === 'error') {
|
||||
const providerUnavailableReason = getProviderUnavailableReason(output)
|
||||
|
||||
if (providerUnavailableReason) {
|
||||
printResult({
|
||||
provider,
|
||||
skipped: true,
|
||||
reason: providerUnavailableReason,
|
||||
assetPath: tempAssetPath,
|
||||
turns: turnResults
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (output) {
|
||||
await CONVERSATION_LOGGER.push(
|
||||
createConversationLoggerRecord('leon', output)
|
||||
)
|
||||
}
|
||||
|
||||
printProgress({
|
||||
provider,
|
||||
stage: 'turn_result',
|
||||
turn: turnNumber,
|
||||
message: `Completed turn ${turnNumber}`,
|
||||
data: {
|
||||
finalIntent,
|
||||
output: summarizeValue(output),
|
||||
toolCalls: toolCalls.length - recordedToolCalls
|
||||
}
|
||||
})
|
||||
|
||||
turnResults.push({
|
||||
input,
|
||||
output,
|
||||
finalIntent,
|
||||
executionHistory:
|
||||
result &&
|
||||
result.data &&
|
||||
typeof result.data === 'object' &&
|
||||
Array.isArray(result.data['executionHistory'])
|
||||
? (result.data['executionHistory'] as AgentTurnResult['executionHistory'])
|
||||
: [],
|
||||
toolCalls: toolCalls.slice(recordedToolCalls)
|
||||
})
|
||||
|
||||
recordedToolCalls = toolCalls.length
|
||||
}
|
||||
|
||||
printResult({
|
||||
provider,
|
||||
skipped: false,
|
||||
assetPath: tempAssetPath,
|
||||
turns: turnResults
|
||||
})
|
||||
printProgress({
|
||||
provider,
|
||||
stage: 'scenario_complete',
|
||||
message: `Completed ${turnResults.length} turns`,
|
||||
data: {
|
||||
turns: turnResults.length
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
TOOL_EXECUTOR.executeTool = originalExecuteTool
|
||||
await CONVERSATION_LOGGER.clear()
|
||||
await fs.rm(tempAssetPath, { force: true })
|
||||
await fs.rm(continuationStatePath, { force: true })
|
||||
await fs.rm(historyCompactionStatePath, { force: true })
|
||||
await removeTestHomePath(LEON_HOME_PATH, testHomePath)
|
||||
}
|
||||
}
|
||||
|
||||
void main()
|
||||
.then(() => {
|
||||
/**
|
||||
* Core singletons keep background handles open, so exit explicitly once the
|
||||
* structured result has been printed and cleanup has finished.
|
||||
*/
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
const provider = (process.argv[2] || 'openai') as AgentProvider
|
||||
const providerUnavailableReason = getProviderUnavailableReason(error)
|
||||
|
||||
printResult({
|
||||
provider,
|
||||
skipped: Boolean(providerUnavailableReason),
|
||||
reason: providerUnavailableReason || String(error)
|
||||
})
|
||||
process.exit(providerUnavailableReason ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ResolvedLLMTarget } from '@/core/llm-manager/llm-routing'
|
||||
import type { CompletionParams } from '@/core/llm-manager/types'
|
||||
import { LLMDuties, LLMProviders } from '@/core/llm-manager/types'
|
||||
import OpenRouterLLMProvider from '@/core/llm-manager/llm-providers/openrouter-llm-provider'
|
||||
|
||||
const openRouterMocks = vi.hoisted(() => {
|
||||
const languageModel = {
|
||||
doGenerate: vi.fn(),
|
||||
doStream: vi.fn()
|
||||
}
|
||||
const chat = vi.fn(() => languageModel)
|
||||
const createOpenRouter = vi.fn(() => ({
|
||||
chat
|
||||
}))
|
||||
|
||||
return {
|
||||
chat,
|
||||
createOpenRouter,
|
||||
languageModel
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@openrouter/ai-sdk-provider', () => ({
|
||||
createOpenRouter: openRouterMocks.createOpenRouter
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
CONFIG_MANAGER: {
|
||||
getProviderAPIKeyEnv: vi.fn(() => null)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
interface ProviderWithPrivateCallOptions {
|
||||
buildCallOptions(
|
||||
prompt: string,
|
||||
completionParams: CompletionParams
|
||||
): Record<string, unknown>
|
||||
}
|
||||
|
||||
function createOpenRouterProvider(): ProviderWithPrivateCallOptions {
|
||||
const target: ResolvedLLMTarget = {
|
||||
provider: LLMProviders.OpenRouter,
|
||||
model: 'qwen/qwen3.7-max',
|
||||
label: 'openrouter/qwen/qwen3.7-max',
|
||||
isLocal: false,
|
||||
isEnabled: true,
|
||||
isResolved: true
|
||||
}
|
||||
|
||||
return new OpenRouterLLMProvider(target) as unknown as ProviderWithPrivateCallOptions
|
||||
}
|
||||
|
||||
function createCompletionParams(
|
||||
data: CompletionParams['data']
|
||||
): CompletionParams {
|
||||
return {
|
||||
dutyType: LLMDuties.ReAct,
|
||||
systemPrompt: 'Plan the next step.',
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
describe('AISDKRemoteLLMProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('LEON_OPENROUTER_API_KEY', 'test-openrouter-key')
|
||||
})
|
||||
|
||||
it('adds a JSON instruction when structured response format is enabled', () => {
|
||||
const provider = createOpenRouterProvider()
|
||||
const options = provider.buildCallOptions('Choose a tool.', createCompletionParams({
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' }
|
||||
},
|
||||
required: ['type'],
|
||||
additionalProperties: false
|
||||
}))
|
||||
|
||||
const messages = options['prompt'] as Array<Record<string, unknown>>
|
||||
const systemMessage = messages[0] as Record<string, unknown>
|
||||
|
||||
expect(systemMessage['role']).toBe('system')
|
||||
expect(systemMessage['content']).toContain('JSON')
|
||||
expect(options['responseFormat']).toEqual({
|
||||
type: 'json',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' }
|
||||
},
|
||||
required: ['type'],
|
||||
additionalProperties: false
|
||||
},
|
||||
name: 'structured_output'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add the JSON instruction for plain text calls', () => {
|
||||
const provider = createOpenRouterProvider()
|
||||
const options = provider.buildCallOptions(
|
||||
'Answer normally.',
|
||||
createCompletionParams(null)
|
||||
)
|
||||
|
||||
const messages = options['prompt'] as Array<Record<string, unknown>>
|
||||
const systemMessage = messages[0] as Record<string, unknown>
|
||||
|
||||
expect(systemMessage['content']).toBe('Plan the next step.')
|
||||
expect(options['responseFormat']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('External HTTP plugin loader', () => {
|
||||
it('loads plugins from conventional roots and lets profile plugins override global plugins', async () => {
|
||||
const { loadExternalHTTPPlugins } = await import(
|
||||
'@/core/http-server/http-plugins/loader'
|
||||
)
|
||||
const tempRoot = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'leon-http-plugins-')
|
||||
)
|
||||
const globalPluginsRoot = path.join(tempRoot, 'global')
|
||||
const profilePluginsRoot = path.join(tempRoot, 'profile')
|
||||
const globalPlugin = path.join(globalPluginsRoot, 'company-agent')
|
||||
const profilePlugin = path.join(profilePluginsRoot, 'company-agent')
|
||||
|
||||
await fs.mkdir(globalPlugin, { recursive: true })
|
||||
await fs.mkdir(profilePlugin, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(globalPlugin, 'plugin.yml'),
|
||||
[
|
||||
'id: company_agent',
|
||||
'name: Company Agent',
|
||||
'version: 1.0.0',
|
||||
'description: Global company integration',
|
||||
'entry: index.mjs'
|
||||
].join('\n')
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(globalPlugin, 'index.mjs'),
|
||||
[
|
||||
'export default {',
|
||||
' id: "company-agent",',
|
||||
' name: "Company Agent",',
|
||||
' version: "1.0.0",',
|
||||
' description: "Global company integration",',
|
||||
' register: async () => undefined',
|
||||
'}'
|
||||
].join('\n')
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(profilePlugin, 'plugin.yml'),
|
||||
[
|
||||
'id: company_agent',
|
||||
'name: Profile Company Agent',
|
||||
'version: 2.0.0',
|
||||
'description: Profile company integration',
|
||||
'entry: index.mjs'
|
||||
].join('\n')
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(profilePlugin, 'index.mjs'),
|
||||
[
|
||||
'export const httpPlugin = {',
|
||||
' id: "company-agent",',
|
||||
' name: "Profile Company Agent",',
|
||||
' version: "2.0.0",',
|
||||
' description: "Profile company integration",',
|
||||
' register: async () => undefined',
|
||||
'}'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const plugins = await loadExternalHTTPPlugins([
|
||||
{
|
||||
scope: 'global',
|
||||
directory: globalPluginsRoot
|
||||
},
|
||||
{
|
||||
scope: 'profile',
|
||||
directory: profilePluginsRoot
|
||||
}
|
||||
])
|
||||
|
||||
expect(plugins).toHaveLength(1)
|
||||
expect(plugins[0]).toMatchObject({
|
||||
source: 'profile',
|
||||
path: profilePlugin,
|
||||
definition: {
|
||||
id: 'company-agent',
|
||||
name: 'Profile Company Agent',
|
||||
version: '2.0.0',
|
||||
description: 'Profile company integration'
|
||||
}
|
||||
})
|
||||
|
||||
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MiniMaxLLMProvider from '@/core/llm-manager/llm-providers/minimax-llm-provider'
|
||||
import type { ResolvedLLMTarget } from '@/core/llm-manager/llm-routing'
|
||||
import type { CompletionParams } from '@/core/llm-manager/types'
|
||||
import { LLMDuties, LLMProviders } from '@/core/llm-manager/types'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
CONFIG_MANAGER: {
|
||||
getProviderAPIKeyEnv: vi.fn(() => null)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
interface ProviderWithPrivateCallOptions {
|
||||
buildCallOptions(
|
||||
prompt: string,
|
||||
completionParams: CompletionParams
|
||||
): Record<string, unknown>
|
||||
}
|
||||
|
||||
type TestProvider = MiniMaxLLMProvider & ProviderWithPrivateCallOptions
|
||||
|
||||
function createProvider(model: string): TestProvider {
|
||||
const target: ResolvedLLMTarget = {
|
||||
provider: LLMProviders.MiniMax,
|
||||
model,
|
||||
label: `minimax/${model}`,
|
||||
isLocal: false,
|
||||
isEnabled: true,
|
||||
isResolved: true
|
||||
}
|
||||
|
||||
return new MiniMaxLLMProvider(target) as TestProvider
|
||||
}
|
||||
|
||||
function createCompletionParams(
|
||||
overrides: Partial<CompletionParams> = {}
|
||||
): CompletionParams {
|
||||
return {
|
||||
dutyType: LLMDuties.ReAct,
|
||||
systemPrompt: 'Plan the next step.',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('MiniMaxLLMProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('LEON_MINIMAX_API_KEY', 'test-minimax-key')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses adaptive thinking and separated reasoning for MiniMax-M3', () => {
|
||||
const provider = createProvider('MiniMax-M3')
|
||||
const options = provider.buildCallOptions(
|
||||
'Answer normally.',
|
||||
createCompletionParams({ reasoningMode: 'on' })
|
||||
)
|
||||
|
||||
expect(options['providerOptions']).toEqual({
|
||||
minimax: {
|
||||
reasoning_split: true,
|
||||
thinking: { type: 'adaptive' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disables MiniMax-M3 thinking when requested', () => {
|
||||
const provider = createProvider('MiniMax-M3')
|
||||
const options = provider.buildCallOptions(
|
||||
'Answer directly.',
|
||||
createCompletionParams({ reasoningMode: 'off' })
|
||||
)
|
||||
|
||||
expect(options['providerOptions']).toEqual({
|
||||
minimax: {
|
||||
reasoning_split: true,
|
||||
thinking: { type: 'disabled' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps MiniMax-M2.7 thinking enabled', () => {
|
||||
const provider = createProvider('MiniMax-M2.7')
|
||||
const options = provider.buildCallOptions(
|
||||
'Answer directly.',
|
||||
createCompletionParams({ reasoningMode: 'off' })
|
||||
)
|
||||
|
||||
expect(options['providerOptions']).toEqual({
|
||||
minimax: {
|
||||
reasoning_split: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the documented request fields to the OpenAI-compatible endpoint', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
void input
|
||||
void init
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl-test',
|
||||
created: 0,
|
||||
model: 'MiniMax-M3',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'Done.'
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const provider = createProvider('MiniMax-M3')
|
||||
|
||||
await provider.runChatCompletion(
|
||||
'Answer directly.',
|
||||
createCompletionParams({ reasoningMode: 'off' })
|
||||
)
|
||||
|
||||
const [requestURL, requestInit] = fetchMock.mock.calls[0]!
|
||||
const requestBody = JSON.parse(String(requestInit?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
expect(String(requestURL)).toBe(
|
||||
'https://api.minimax.io/v1/chat/completions'
|
||||
)
|
||||
expect(requestBody['thinking']).toEqual({ type: 'disabled' })
|
||||
expect(requestBody['reasoning_split']).toBe(true)
|
||||
expect(requestBody['reasoning_effort']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
process.env['LEON_NODE_ENV'] = 'testing'
|
||||
|
||||
const coreMocks = vi.hoisted(() => ({
|
||||
brain: {
|
||||
skillFriendlyName: '',
|
||||
skillProcess: undefined as unknown,
|
||||
skillOutput: '',
|
||||
lang: 'en',
|
||||
isMuted: true,
|
||||
talk: vi.fn(),
|
||||
speakSkillError: vi.fn()
|
||||
},
|
||||
socket: {
|
||||
emit: vi.fn()
|
||||
},
|
||||
conversationLogger: {
|
||||
upsert: vi.fn(async () => undefined)
|
||||
},
|
||||
nlu: {
|
||||
nluProcessResult: {
|
||||
context: {
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/core', () => ({
|
||||
BRAIN: coreMocks.brain,
|
||||
SOCKET_SERVER: {
|
||||
socket: coreMocks.socket,
|
||||
emitToChatClients: coreMocks.socket.emit,
|
||||
emitAnswerToChatClients: coreMocks.socket.emit,
|
||||
clearLiveWidgets: vi.fn()
|
||||
},
|
||||
CONVERSATION_LOGGER: coreMocks.conversationLogger,
|
||||
NLU: coreMocks.nlu
|
||||
}))
|
||||
|
||||
let LogicActionSkillHandler: typeof import('@/core/brain/logic-action-skill-handler').LogicActionSkillHandler
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ LogicActionSkillHandler } = await import(
|
||||
'@/core/brain/logic-action-skill-handler'
|
||||
))
|
||||
})
|
||||
|
||||
describe('controlled skill smoke', () => {
|
||||
it('runs a real controlled action skill and returns its answer', async () => {
|
||||
const skillConfigPath = path.join(
|
||||
process.cwd(),
|
||||
'skills',
|
||||
'native',
|
||||
'date_time_skill',
|
||||
'skill.json'
|
||||
)
|
||||
|
||||
const result = await LogicActionSkillHandler.handle(
|
||||
{
|
||||
contextName: 'date_time',
|
||||
skillName: 'date_time_skill',
|
||||
actionName: 'current_time',
|
||||
skillConfigPath,
|
||||
skillConfig: {
|
||||
name: 'Date/Time',
|
||||
bridge: 'nodejs',
|
||||
version: '1.0.0',
|
||||
workflow: []
|
||||
},
|
||||
localeSkillConfig: {
|
||||
variables: {},
|
||||
widgetContents: {}
|
||||
},
|
||||
actionConfig: {
|
||||
type: 'logic',
|
||||
description: 'Tell the current time.',
|
||||
answers: {
|
||||
current_time: ['It is {{ hours }}:{{ minutes }}:{{ seconds }}.']
|
||||
}
|
||||
},
|
||||
new: {
|
||||
utterance: 'What time is it?',
|
||||
actionArguments: {},
|
||||
entities: [],
|
||||
sentiment: {}
|
||||
},
|
||||
context: {
|
||||
utterances: ['What time is it?'],
|
||||
actionArguments: [],
|
||||
entities: [],
|
||||
sentiments: [],
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
`controlled-skill-smoke-${Date.now()}`
|
||||
)
|
||||
|
||||
const answer = result.lastOutputFromSkill?.answer
|
||||
|
||||
expect(typeof answer).toBe('string')
|
||||
expect(String(answer).startsWith('It is ')).toBe(true)
|
||||
expect(String(answer).includes(':')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
process.env['LEON_NODE_ENV'] = 'testing'
|
||||
process.env['LEON_LLM'] = 'openai/gpt-5.4'
|
||||
|
||||
const coreMocks = vi.hoisted(() => ({
|
||||
llmProvider: {
|
||||
prompt: vi.fn()
|
||||
},
|
||||
llmManager: {
|
||||
coreLLMDuties: {
|
||||
'action-calling': {
|
||||
maxTokens: 384,
|
||||
thoughtTokensBudget: 0,
|
||||
temperature: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const skillHelperMocks = vi.hoisted(() => ({
|
||||
getNewSkillConfig: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/core', () => ({
|
||||
LLM_PROVIDER: coreMocks.llmProvider,
|
||||
LLM_MANAGER: coreMocks.llmManager
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/skill-domain-helper', () => ({
|
||||
SkillDomainHelper: skillHelperMocks
|
||||
}))
|
||||
|
||||
let ActionCallingLLMDuty: typeof import('@/core/llm-manager/llm-duties/action-calling-llm-duty').ActionCallingLLMDuty
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ ActionCallingLLMDuty } = await import(
|
||||
'@/core/llm-manager/llm-duties/action-calling-llm-duty'
|
||||
))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ActionCallingLLMDuty', () => {
|
||||
it('uses tool calls to return a successful action selection', async () => {
|
||||
skillHelperMocks.getNewSkillConfig.mockResolvedValue({
|
||||
actions: {
|
||||
set_timer: {
|
||||
type: 'logic',
|
||||
description: 'Set a timer.',
|
||||
parameters: {
|
||||
duration: {
|
||||
type: 'string',
|
||||
description: 'Timer duration.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
workflow: []
|
||||
})
|
||||
|
||||
coreMocks.llmProvider.prompt.mockResolvedValue({
|
||||
output: '',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_timer',
|
||||
arguments: JSON.stringify({
|
||||
duration: '3 seconds'
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
usedInputTokens: 10,
|
||||
usedOutputTokens: 4
|
||||
})
|
||||
|
||||
const duty = new ActionCallingLLMDuty({
|
||||
input: 'Set a timer for 3 seconds',
|
||||
skillName: 'timer_skill',
|
||||
workflowContext: {
|
||||
recentUtterances: [],
|
||||
recentActionArguments: [],
|
||||
collectedParameters: {},
|
||||
recentEntities: []
|
||||
}
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
const result = await duty.execute()
|
||||
const parsedOutput = JSON.parse(String(result?.output))
|
||||
|
||||
expect(parsedOutput).toEqual([
|
||||
{
|
||||
status: 'success',
|
||||
name: 'set_timer',
|
||||
arguments: {
|
||||
duration: '3 seconds'
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('short-circuits when the skill workflow starts with a parameterless action', async () => {
|
||||
skillHelperMocks.getNewSkillConfig.mockResolvedValue({
|
||||
actions: {
|
||||
set_up: {
|
||||
type: 'dialog',
|
||||
description: 'Set up the game.'
|
||||
},
|
||||
play: {
|
||||
type: 'logic',
|
||||
description: 'Play.'
|
||||
}
|
||||
},
|
||||
workflow: ['set_up', 'play']
|
||||
})
|
||||
|
||||
const duty = new ActionCallingLLMDuty({
|
||||
input: 'Play rock paper scissors',
|
||||
skillName: 'rochambeau_skill'
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
const result = await duty.execute()
|
||||
const parsedOutput = JSON.parse(String(result?.output))
|
||||
|
||||
expect(coreMocks.llmProvider.prompt).not.toHaveBeenCalled()
|
||||
expect(parsedOutput).toEqual([
|
||||
{
|
||||
status: 'success',
|
||||
name: 'set_up',
|
||||
arguments: {}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import YAML from 'yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const syncedEnvKeys = [
|
||||
'LEON_LANG',
|
||||
'LEON_HOST',
|
||||
'LEON_PORT',
|
||||
'LEON_ROUTING_MODE',
|
||||
'LEON_MOOD',
|
||||
'LEON_LLM',
|
||||
'LEON_WORKFLOW_LLM',
|
||||
'LEON_AGENT_LLM',
|
||||
'LEON_WAKE_WORD',
|
||||
'LEON_ASR',
|
||||
'LEON_ASR_PROVIDER',
|
||||
'LEON_TTS',
|
||||
'LEON_TTS_PROVIDER',
|
||||
'LEON_TIME_ZONE',
|
||||
'LEON_AFTER_SPEECH',
|
||||
'LEON_TELEMETRY',
|
||||
'LEON_PY_TCP_SERVER_HOST',
|
||||
'LEON_PY_TCP_SERVER_PORT',
|
||||
'LEON_LLAMACPP_BASE_URL',
|
||||
'LEON_SGLANG_BASE_URL',
|
||||
'LEON_CLIENT_INTERFACE_TOKEN',
|
||||
'LEON_OPENAI_API_KEY'
|
||||
]
|
||||
|
||||
const profilePaths = {
|
||||
configPath: ''
|
||||
}
|
||||
|
||||
async function loadConfigManager(): Promise<
|
||||
typeof import('@/config').CONFIG_MANAGER
|
||||
> {
|
||||
vi.doMock('@/leon-roots', () => ({
|
||||
PROFILE_CONFIG_PATH: profilePaths.configPath
|
||||
}))
|
||||
|
||||
const module = await import('@/config')
|
||||
|
||||
return module.CONFIG_MANAGER
|
||||
}
|
||||
|
||||
describe('ConfigManager', () => {
|
||||
let tmpDir = ''
|
||||
let previousEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leon-config-'))
|
||||
profilePaths.configPath = path.join(tmpDir, 'config.yml')
|
||||
previousEnv = Object.fromEntries(
|
||||
syncedEnvKeys.map((key) => [key, process.env[key]])
|
||||
)
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of syncedEnvKeys) {
|
||||
const value = previousEnv[key]
|
||||
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(tmpDir, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns profile config values and syncs runtime env mappings', async () => {
|
||||
process.env['LEON_CLIENT_INTERFACE_TOKEN'] = 'client-secret'
|
||||
process.env['LEON_OPENAI_API_KEY'] = 'openai-secret'
|
||||
|
||||
const profileConfig = {
|
||||
language: 'fr-FR',
|
||||
server: {
|
||||
host: 'http://127.0.0.1',
|
||||
port: 5_499
|
||||
},
|
||||
client_interface: {
|
||||
allowed_origins: ['http://localhost:1420'],
|
||||
auth: {
|
||||
enabled: true,
|
||||
token: {
|
||||
env: 'LEON_CLIENT_INTERFACE_TOKEN'
|
||||
}
|
||||
}
|
||||
},
|
||||
routing: {
|
||||
mode: 'controlled'
|
||||
},
|
||||
llm: {
|
||||
default: null,
|
||||
workflow: 'openai/gpt-4.1-mini',
|
||||
agent: null,
|
||||
providers: {
|
||||
llamacpp: {
|
||||
base_url: 'http://127.0.0.1:8081/v1',
|
||||
api_key: {
|
||||
env: 'LEON_LLAMACPP_API_KEY'
|
||||
}
|
||||
},
|
||||
sglang: {
|
||||
base_url: 'http://127.0.0.1:30001/v1',
|
||||
api_key: {
|
||||
env: 'LEON_SGLANG_API_KEY'
|
||||
}
|
||||
},
|
||||
openrouter: {
|
||||
api_key: {
|
||||
env: 'LEON_OPENROUTER_API_KEY'
|
||||
}
|
||||
},
|
||||
zai: {
|
||||
api_key: {
|
||||
env: 'LEON_ZAI_API_KEY'
|
||||
}
|
||||
},
|
||||
minimax: {
|
||||
api_key: {
|
||||
env: 'LEON_MINIMAX_API_KEY'
|
||||
}
|
||||
},
|
||||
openai: {
|
||||
api_key: {
|
||||
env: 'LEON_OPENAI_API_KEY'
|
||||
}
|
||||
},
|
||||
anthropic: {
|
||||
api_key: {
|
||||
env: 'LEON_ANTHROPIC_API_KEY'
|
||||
}
|
||||
},
|
||||
moonshotai: {
|
||||
api_key: {
|
||||
env: 'LEON_MOONSHOTAI_API_KEY'
|
||||
}
|
||||
},
|
||||
huggingface: {
|
||||
api_key: {
|
||||
env: 'LEON_HUGGINGFACE_API_KEY'
|
||||
}
|
||||
},
|
||||
cerebras: {
|
||||
api_key: {
|
||||
env: 'LEON_CEREBRAS_API_KEY'
|
||||
}
|
||||
},
|
||||
groq: {
|
||||
api_key: {
|
||||
env: 'LEON_GROQ_API_KEY'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mood: {
|
||||
mode: 'tired'
|
||||
},
|
||||
runtime: {
|
||||
pulse_enabled: false,
|
||||
private_diary_enabled: false
|
||||
},
|
||||
context: {
|
||||
disabled_files: ['*']
|
||||
},
|
||||
availability: {
|
||||
skills: {
|
||||
allowed: ['date_time_skill'],
|
||||
disabled: ['weather_forecast_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex'],
|
||||
disabled: ['coding_development.claude_code']
|
||||
}
|
||||
},
|
||||
python_tcp_server: {
|
||||
host: '127.0.0.2',
|
||||
port: 5_477
|
||||
},
|
||||
voice: {
|
||||
wake_word_enabled: true,
|
||||
asr: {
|
||||
enabled: true,
|
||||
provider: 'local'
|
||||
},
|
||||
tts: {
|
||||
enabled: true,
|
||||
provider: 'local'
|
||||
}
|
||||
},
|
||||
time_zone: 'Europe/Paris',
|
||||
after_speech_enabled: true,
|
||||
telemetry_enabled: false
|
||||
}
|
||||
|
||||
fs.writeFileSync(profilePaths.configPath, YAML.stringify(profileConfig))
|
||||
|
||||
const configManager = await loadConfigManager()
|
||||
|
||||
expect(configManager.getConfig()).toEqual(profileConfig)
|
||||
expect(process.env['LEON_LANG']).toBe('fr-FR')
|
||||
expect(process.env['LEON_HOST']).toBe('http://127.0.0.1')
|
||||
expect(process.env['LEON_PORT']).toBe('5499')
|
||||
expect(process.env['LEON_ROUTING_MODE']).toBe('controlled')
|
||||
expect(process.env['LEON_MOOD']).toBe('tired')
|
||||
expect(process.env['LEON_LLM']).toBe('')
|
||||
expect(process.env['LEON_WORKFLOW_LLM']).toBe('openai/gpt-4.1-mini')
|
||||
expect(process.env['LEON_AGENT_LLM']).toBe('')
|
||||
expect(process.env['LEON_TIME_ZONE']).toBe('Europe/Paris')
|
||||
expect(process.env['LEON_PY_TCP_SERVER_PORT']).toBe('5477')
|
||||
expect(configManager.resolveSecretReference(
|
||||
profileConfig.client_interface.auth.token
|
||||
)).toBe('client-secret')
|
||||
expect(configManager.getProviderAPIKeyEnv('openai')).toBe(
|
||||
'LEON_OPENAI_API_KEY'
|
||||
)
|
||||
expect(configManager.getProviderAPIKey('openai')).toBe('openai-secret')
|
||||
expect(configManager.getProviderBaseURL('llamacpp')).toBe(
|
||||
'http://127.0.0.1:8081/v1'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { JsonRedactionHelper } from '@/helpers/json-redaction-helper'
|
||||
|
||||
describe('JsonRedactionHelper', () => {
|
||||
it('redacts sensitive keys recursively', () => {
|
||||
expect(
|
||||
JsonRedactionHelper.redactSensitiveValues({
|
||||
OPENROUTER_API_KEY: 'sk-test',
|
||||
nested: {
|
||||
access_token: 'token-test',
|
||||
model: 'google/gemini'
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
clientSecret: 'secret-test',
|
||||
name: 'openrouter'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
OPENROUTER_API_KEY: '***',
|
||||
nested: {
|
||||
access_token: '***',
|
||||
model: 'google/gemini'
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
clientSecret: '***',
|
||||
name: 'openrouter'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,617 @@
|
||||
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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
process.env['LEON_NODE_ENV'] = 'testing'
|
||||
process.env['LEON_LLM'] = 'openai/gpt-5.4'
|
||||
|
||||
const coreMocks = vi.hoisted(() => ({
|
||||
eventEmitter: {
|
||||
on: vi.fn()
|
||||
},
|
||||
persona: {
|
||||
getDutySystemPrompt: vi.fn((prompt: string) => `persona:${prompt}`)
|
||||
},
|
||||
llmProvider: {
|
||||
prompt: vi.fn(),
|
||||
consumeLastProviderErrorMessage: vi.fn(() => null)
|
||||
},
|
||||
llmManager: {
|
||||
coreLLMDuties: {
|
||||
paraphrase: {
|
||||
maxTokens: 192,
|
||||
thoughtTokensBudget: 0,
|
||||
temperature: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/core', () => ({
|
||||
EVENT_EMITTER: coreMocks.eventEmitter,
|
||||
PERSONA: coreMocks.persona,
|
||||
LLM_PROVIDER: coreMocks.llmProvider,
|
||||
LLM_MANAGER: coreMocks.llmManager
|
||||
}))
|
||||
|
||||
let ParaphraseLLMDuty: typeof import('@/core/llm-manager/llm-duties/paraphrase-llm-duty').ParaphraseLLMDuty
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ ParaphraseLLMDuty } = await import(
|
||||
'@/core/llm-manager/llm-duties/paraphrase-llm-duty'
|
||||
))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ParaphraseLLMDuty', () => {
|
||||
it('returns the provider paraphrase output', async () => {
|
||||
coreMocks.llmProvider.prompt.mockResolvedValue({
|
||||
output: 'I included your items in the shopping list.',
|
||||
usedInputTokens: 12,
|
||||
usedOutputTokens: 9
|
||||
})
|
||||
|
||||
const duty = new ParaphraseLLMDuty({
|
||||
input: 'I added your items to the shopping list.'
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
const result = await duty.execute()
|
||||
|
||||
expect(result?.output).toBe('I included your items in the shopping list.')
|
||||
expect(coreMocks.persona.getDutySystemPrompt).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import YAML from 'yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const profilePaths = {
|
||||
configPath: '',
|
||||
dotEnvPath: ''
|
||||
}
|
||||
|
||||
async function loadProfileHelper(): Promise<
|
||||
typeof import('@/helpers/profile-helper').ProfileHelper
|
||||
> {
|
||||
vi.doMock('@/leon-roots', () => ({
|
||||
PROFILE_CONFIG_PATH: profilePaths.configPath,
|
||||
PROFILE_DOT_ENV_PATH: profilePaths.dotEnvPath
|
||||
}))
|
||||
|
||||
const module = await import('@/helpers/profile-helper')
|
||||
|
||||
return module.ProfileHelper
|
||||
}
|
||||
|
||||
function writeAvailabilityConfig(availability: unknown): void {
|
||||
fs.writeFileSync(
|
||||
profilePaths.configPath,
|
||||
YAML.stringify({
|
||||
availability
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function readAvailabilityConfig(): unknown {
|
||||
return YAML.parse(fs.readFileSync(profilePaths.configPath, 'utf8')).availability
|
||||
}
|
||||
|
||||
describe('ProfileHelper', () => {
|
||||
let tmpDir = ''
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leon-profile-helper-'))
|
||||
profilePaths.configPath = path.join(tmpDir, 'config.yml')
|
||||
profilePaths.dotEnvPath = path.join(tmpDir, '.env')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
it('allows everything by default when policy files are missing', async () => {
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
expect(ProfileHelper.isSkillDisabled('date_time_skill')).toBe(false)
|
||||
expect(
|
||||
ProfileHelper.isToolDisabled('codex', 'coding_development')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats empty allowed lists as unrestricted', async () => {
|
||||
writeAvailabilityConfig({
|
||||
skills: {
|
||||
allowed: [],
|
||||
disabled: ['date_time_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: [],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
expect(ProfileHelper.isSkillDisabled('weather_forecast_skill')).toBe(false)
|
||||
expect(ProfileHelper.isSkillDisabled('date_time_skill')).toBe(true)
|
||||
expect(
|
||||
ProfileHelper.isToolDisabled('opencode', 'coding_development')
|
||||
).toBe(false)
|
||||
expect(
|
||||
ProfileHelper.isToolDisabled('codex', 'coding_development')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('restricts access when allowed lists contain ids', async () => {
|
||||
writeAvailabilityConfig({
|
||||
skills: {
|
||||
allowed: ['coding_agent_router_skill'],
|
||||
disabled: []
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex'],
|
||||
disabled: []
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
expect(
|
||||
ProfileHelper.isSkillDisabled('coding_agent_router_skill')
|
||||
).toBe(false)
|
||||
expect(ProfileHelper.isSkillDisabled('date_time_skill')).toBe(true)
|
||||
expect(
|
||||
ProfileHelper.isToolDisabled('codex', 'coding_development')
|
||||
).toBe(false)
|
||||
expect(
|
||||
ProfileHelper.isToolDisabled('codex', 'other_toolkit')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores disabled ids while allow-only lists are active', async () => {
|
||||
writeAvailabilityConfig({
|
||||
skills: {
|
||||
allowed: ['coding_agent_router_skill'],
|
||||
disabled: ['coding_agent_router_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex'],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
expect(
|
||||
ProfileHelper.isSkillDisabled('coding_agent_router_skill')
|
||||
).toBe(false)
|
||||
expect(
|
||||
ProfileHelper.isToolDisabled('codex', 'coding_development')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps enable commands scoped to disabled lists', async () => {
|
||||
writeAvailabilityConfig({
|
||||
skills: {
|
||||
allowed: ['existing_skill'],
|
||||
disabled: ['coding_agent_router_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.opencode'],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
await ProfileHelper.enableSkill('coding_agent_router_skill')
|
||||
await ProfileHelper.enableTool('coding_development.codex')
|
||||
|
||||
expect(readAvailabilityConfig()).toEqual({
|
||||
skills: {
|
||||
allowed: ['existing_skill'],
|
||||
disabled: []
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.opencode'],
|
||||
disabled: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps disable commands scoped to disabled lists', async () => {
|
||||
writeAvailabilityConfig({
|
||||
skills: {
|
||||
allowed: ['coding_agent_router_skill'],
|
||||
disabled: []
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex'],
|
||||
disabled: []
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
await ProfileHelper.disableSkill('coding_agent_router_skill')
|
||||
await ProfileHelper.disableTool('coding_development.codex')
|
||||
|
||||
expect(readAvailabilityConfig()).toEqual({
|
||||
skills: {
|
||||
allowed: ['coding_agent_router_skill'],
|
||||
disabled: ['coding_agent_router_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex'],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('adds and removes items from allow-only lists', async () => {
|
||||
writeAvailabilityConfig({
|
||||
skills: {
|
||||
allowed: ['existing_skill'],
|
||||
disabled: ['coding_agent_router_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.opencode'],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
|
||||
const ProfileHelper = await loadProfileHelper()
|
||||
|
||||
await ProfileHelper.allowOnlySkill('coding_agent_router_skill')
|
||||
await ProfileHelper.allowOnlyTool('coding_development.codex')
|
||||
|
||||
expect(readAvailabilityConfig()).toEqual({
|
||||
skills: {
|
||||
allowed: ['coding_agent_router_skill', 'existing_skill'],
|
||||
disabled: ['coding_agent_router_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex', 'coding_development.opencode'],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
|
||||
await ProfileHelper.removeAllowOnlySkill('existing_skill')
|
||||
await ProfileHelper.removeAllowOnlyTool('coding_development.opencode')
|
||||
|
||||
expect(readAvailabilityConfig()).toEqual({
|
||||
skills: {
|
||||
allowed: ['coding_agent_router_skill'],
|
||||
disabled: ['coding_agent_router_skill']
|
||||
},
|
||||
tools: {
|
||||
allowed: ['coding_development.codex'],
|
||||
disabled: ['coding_development.codex']
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
process.env['LEON_NODE_ENV'] = 'testing'
|
||||
process.env['LEON_LLM'] = 'openai/gpt-5.4'
|
||||
|
||||
const coreMocks = vi.hoisted(() => ({
|
||||
llmProvider: {
|
||||
prompt: vi.fn()
|
||||
},
|
||||
llmManager: {
|
||||
skillListContent: 'timer_skill: Set timers',
|
||||
coreLLMDuties: {
|
||||
'skill-router': {
|
||||
maxTokens: 128,
|
||||
thoughtTokensBudget: 0,
|
||||
temperature: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/core', () => ({
|
||||
LLM_PROVIDER: coreMocks.llmProvider,
|
||||
LLM_MANAGER: coreMocks.llmManager
|
||||
}))
|
||||
|
||||
let SkillRouterLLMDuty: typeof import('@/core/llm-manager/llm-duties/skill-router-llm-duty').SkillRouterLLMDuty
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ SkillRouterLLMDuty } = await import(
|
||||
'@/core/llm-manager/llm-duties/skill-router-llm-duty'
|
||||
))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('SkillRouterLLMDuty', () => {
|
||||
it('returns the chosen skill from the provider result', async () => {
|
||||
const history = [
|
||||
{
|
||||
who: 'owner',
|
||||
message: 'Set a timer for me'
|
||||
}
|
||||
]
|
||||
|
||||
coreMocks.llmProvider.prompt.mockResolvedValue({
|
||||
output: 'timer_skill',
|
||||
usedInputTokens: 10,
|
||||
usedOutputTokens: 2
|
||||
})
|
||||
|
||||
const duty = new SkillRouterLLMDuty({
|
||||
input: 'Set a timer for me',
|
||||
history
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
const result = await duty.execute()
|
||||
|
||||
expect(result?.output).toBe('timer_skill')
|
||||
expect(coreMocks.llmProvider.prompt).toHaveBeenCalledWith(
|
||||
'User Query: "Set a timer for me"\nChosen Skill Name: ',
|
||||
expect.objectContaining({
|
||||
dutyType: 'skill-router',
|
||||
history,
|
||||
maxTokens: 128,
|
||||
temperature: 0,
|
||||
disableThinking: true
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
process.env['LEON_NODE_ENV'] = 'testing'
|
||||
process.env['LEON_LLM'] = 'openai/gpt-5.4'
|
||||
|
||||
const coreMocks = vi.hoisted(() => ({
|
||||
llmProvider: {
|
||||
prompt: vi.fn()
|
||||
},
|
||||
llmManager: {
|
||||
coreLLMDuties: {
|
||||
'slot-filling': {
|
||||
maxTokens: 96,
|
||||
thoughtTokensBudget: 0,
|
||||
temperature: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/helpers/log-helper', () => ({
|
||||
LogHelper: {
|
||||
title: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/core', () => ({
|
||||
LLM_PROVIDER: coreMocks.llmProvider,
|
||||
LLM_MANAGER: coreMocks.llmManager
|
||||
}))
|
||||
|
||||
let SlotFillingLLMDuty: typeof import('@/core/llm-manager/llm-duties/slot-filling-llm-duty').SlotFillingLLMDuty
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ SlotFillingLLMDuty } = await import(
|
||||
'@/core/llm-manager/llm-duties/slot-filling-llm-duty'
|
||||
))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('SlotFillingLLMDuty', () => {
|
||||
it('returns a filled slot when the provider extracts one', async () => {
|
||||
coreMocks.llmProvider.prompt.mockResolvedValue({
|
||||
output: JSON.stringify({
|
||||
filled_slots: {
|
||||
duration: '3 secs'
|
||||
}
|
||||
}),
|
||||
usedInputTokens: 20,
|
||||
usedOutputTokens: 8
|
||||
})
|
||||
|
||||
const duty = new SlotFillingLLMDuty({
|
||||
input: {
|
||||
slotName: 'duration',
|
||||
slotDescription: 'Timer duration',
|
||||
slotType: 'string',
|
||||
latestUtterance: '3 secs',
|
||||
recentUtterances: ['Set a timer', '3 secs']
|
||||
},
|
||||
startingUtterance: 'Set a timer'
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
const result = await duty.execute()
|
||||
|
||||
expect(result?.output).toEqual({
|
||||
status: 'success',
|
||||
filled_slots: {
|
||||
duration: '3 secs'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns not_found when no slot is extracted', async () => {
|
||||
coreMocks.llmProvider.prompt.mockResolvedValue({
|
||||
output: JSON.stringify({}),
|
||||
usedInputTokens: 12,
|
||||
usedOutputTokens: 3
|
||||
})
|
||||
|
||||
const duty = new SlotFillingLLMDuty({
|
||||
input: {
|
||||
slotName: 'duration',
|
||||
slotDescription: 'Timer duration',
|
||||
slotType: 'string',
|
||||
latestUtterance: 'I do not know',
|
||||
recentUtterances: ['Set a timer', 'I do not know']
|
||||
},
|
||||
startingUtterance: 'Set a timer'
|
||||
})
|
||||
|
||||
await duty.init()
|
||||
const result = await duty.execute()
|
||||
|
||||
expect(result?.output).toEqual({
|
||||
status: 'not_found'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user