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 } 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 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[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 { /** * 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 ) } })