Files
leon-ai--leon/test/agent/unit/react-llm-duty.spec.ts
T
2026-07-13 12:44:08 +08:00

1329 lines
41 KiB
TypeScript

import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
process.env['LEON_NODE_ENV'] = 'testing'
process.env['LEON_LLM'] = 'openai/gpt-5.4'
/**
* Hoisted mocks let the imported ReAct module capture the fake phase functions
* and shared runtime singletons during module initialization.
*/
const phaseMocks = vi.hoisted(() => ({
buildCatalog: vi.fn(() => ({
text: 'mock catalog',
mode: 'function' as const
})),
runPlanningPhase: vi.fn(),
runRecoveryPlanningPhase: vi.fn(),
runExecutionSelfObservationPhase: vi.fn(),
runExecutionStep: vi.fn(),
runFinalAnswerPhase: vi.fn()
}))
const coreMocks = vi.hoisted(() => ({
persona: {
getCompactDutySystemPrompt: vi.fn((prompt: string) => prompt)
},
toolkitRegistry: {
isLoaded: true,
load: vi.fn(),
getFlattenedTools: vi.fn(() => [
{
toolkitId: 'operating_system_control',
toolkitName: 'Operating System Control',
toolkitDescription: 'Control the operating system.',
toolkitIconName: 'terminal',
toolId: 'shell',
toolName: 'Shell',
toolDescription: 'Execute shell commands.',
toolIconName: 'terminal'
}
]),
getToolFunctions: vi.fn((toolkitId: string, toolId: string) => {
if (toolkitId !== 'operating_system_control' || toolId !== 'shell') {
return null
}
return {
executeCommand: {
description:
'Execute a command in the active platform shell and return the result.',
parameters: {
type: 'object',
properties: {
command: { type: 'string' }
},
required: ['command']
}
}
}
}),
resolveToolById: vi.fn((toolId: string, toolkitId?: string) => {
if (
toolId !== 'shell' ||
(toolkitId && toolkitId !== 'operating_system_control')
) {
return null
}
return {
toolkitId: 'operating_system_control',
toolkitName: 'Operating System Control',
toolkitIconName: 'terminal',
toolId: 'shell',
toolName: 'Shell',
toolDescription: 'Execute shell commands.',
toolIconName: 'terminal'
}
}),
getToolkitContextFiles: vi.fn(() => [])
},
contextManager: {
isLoaded: true,
load: vi.fn(),
getContextFileContent: vi.fn(() => null),
getManifest: vi.fn(() => '')
},
selfModelManager: {
getSnapshot: vi.fn(() => '')
},
conversationLogger: {
loadAll: vi.fn(async () => [])
},
toolCallLogger: {
getRecentArtifactManifest: vi.fn(() => '')
},
postTurnMaintenanceQueue: {
enqueue: vi.fn()
},
llmProvider: {
consumeLastProviderErrorMessage: vi.fn(() => null),
prompt: vi.fn(),
promptText: vi.fn(),
promptWithTools: vi.fn()
},
brain: {
talk: vi.fn(async () => undefined),
wernicke: vi.fn(() => ''),
isMuted: true
},
socket: {
emit: vi.fn()
}
}))
const widgetMocks = vi.hoisted(() => ({
emitPlanWidget: vi.fn(),
widgetId: vi.fn(() => 'plan_test_widget')
}))
/**
* The unit suite stays on the remote-provider path, so a lightweight session
* stub is enough to satisfy the local-provider import surface.
*/
vi.mock('node-llama-cpp', () => ({
LlamaChatSession: class MockLlamaChatSession {
setChatHistory(): void {}
dispose(): void {}
}
}))
vi.mock('@/helpers/log-helper', () => ({
LogHelper: {
title: vi.fn(),
success: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
warning: vi.fn(),
error: vi.fn()
}
}))
vi.mock('@/core', () => ({
LLM_MANAGER: {
model: {
createContext: vi.fn()
}
},
LLM_PROVIDER: coreMocks.llmProvider,
PERSONA: coreMocks.persona,
TOOLKIT_REGISTRY: coreMocks.toolkitRegistry,
TOOL_EXECUTOR: {
execute: vi.fn()
},
CONTEXT_MANAGER: coreMocks.contextManager,
SELF_MODEL_MANAGER: coreMocks.selfModelManager,
CONVERSATION_LOGGER: coreMocks.conversationLogger,
TOOL_CALL_LOGGER: coreMocks.toolCallLogger,
BRAIN: coreMocks.brain,
POST_TURN_MAINTENANCE_QUEUE: coreMocks.postTurnMaintenanceQueue,
SOCKET_SERVER: {
socket: coreMocks.socket,
emitToChatClients: coreMocks.socket.emit,
emitAnswerToChatClients: coreMocks.socket.emit,
clearLiveWidgets: vi.fn()
}
}))
vi.mock('@/core/llm-manager/llm-duties/react-llm-duty/phases', () => ({
buildCatalog: phaseMocks.buildCatalog,
runPlanningPhase: phaseMocks.runPlanningPhase,
runRecoveryPlanningPhase: phaseMocks.runRecoveryPlanningPhase,
runExecutionSelfObservationPhase: phaseMocks.runExecutionSelfObservationPhase,
runExecutionStep: phaseMocks.runExecutionStep,
runFinalAnswerPhase: phaseMocks.runFinalAnswerPhase
}))
vi.mock('@/core/llm-manager/llm-duties/react-llm-duty/plan-widget', () => ({
emitPlanWidget: widgetMocks.emitPlanWidget,
widgetId: widgetMocks.widgetId
}))
let ReActLLMDuty: typeof import('@/core/llm-manager/llm-duties/react-llm-duty').ReActLLMDuty
let runFinalAnswerPhase: typeof import('@/core/llm-manager/llm-duties/react-llm-duty/final-answer').runFinalAnswerPhase
let runPlanningPhaseDirect: typeof import('@/core/llm-manager/llm-duties/react-llm-duty/planning').runPlanningPhase
let runExecutionStepDirect: typeof import('@/core/llm-manager/llm-duties/react-llm-duty/execution').runExecutionStep
let runExecutionSelfObservationPhaseDirect: typeof import('@/core/llm-manager/llm-duties/react-llm-duty/execution').runExecutionSelfObservationPhase
function createMessageLog(
who: 'owner' | 'leon',
message: string,
sentAt: number
): {
who: 'owner' | 'leon'
message: string
sentAt: number
isAddedToHistory: true
} {
return {
who,
message,
sentAt,
isAddedToHistory: true
}
}
function createCompactionState(
overrides: Partial<{
summary: string | null
summarySentAt: number | null
tail: ReturnType<typeof createMessageLog>[]
newMessagesSinceCompaction: number
}> = {}
): {
summary: string | null
summarySentAt: number | null
tail: ReturnType<typeof createMessageLog>[]
newMessagesSinceCompaction: number
} {
return {
summary: '- Previous topic',
summarySentAt: 1,
tail: [],
newMessagesSinceCompaction: 0,
...overrides
}
}
function logUnitProgress(message: string, data?: Record<string, unknown>): void {
const serializedData = data ? ` ${JSON.stringify(data)}` : ''
console.info(`[agent:unit] ${message}${serializedData}`)
}
async function createDuty(input: string): Promise<InstanceType<typeof ReActLLMDuty>> {
const duty = new ReActLLMDuty({ input })
/**
* The suite focuses on loop orchestration, so history loading and post-answer
* compaction are stubbed out to keep each case deterministic.
*/
vi.spyOn(duty as never, 'loadPreparedHistory' as never).mockResolvedValue({
messageLogs: [],
localChatHistory: undefined
})
vi.spyOn(
duty as never,
'maybeCompactHistoryAfterAnswer' as never
).mockResolvedValue(undefined)
await duty.init({ force: true })
return duty
}
beforeAll(async () => {
;({ ReActLLMDuty } = await import('@/core/llm-manager/llm-duties/react-llm-duty'))
;({ runFinalAnswerPhase } = await import(
'@/core/llm-manager/llm-duties/react-llm-duty/final-answer'
))
;({ runPlanningPhase: runPlanningPhaseDirect } = await import(
'@/core/llm-manager/llm-duties/react-llm-duty/planning'
))
;({
runExecutionStep: runExecutionStepDirect,
runExecutionSelfObservationPhase: runExecutionSelfObservationPhaseDirect
} = await import('@/core/llm-manager/llm-duties/react-llm-duty/execution'))
})
beforeEach(() => {
vi.clearAllMocks()
phaseMocks.buildCatalog.mockReturnValue({
text: 'mock catalog',
mode: 'function'
})
phaseMocks.runRecoveryPlanningPhase.mockResolvedValue(null)
phaseMocks.runExecutionSelfObservationPhase.mockResolvedValue(null)
coreMocks.persona.getCompactDutySystemPrompt.mockImplementation(
(prompt: string) => prompt
)
coreMocks.contextManager.getContextFileContent.mockReturnValue(null)
coreMocks.contextManager.getManifest.mockReturnValue('')
coreMocks.selfModelManager.getSnapshot.mockReturnValue('')
coreMocks.toolCallLogger.getRecentArtifactManifest.mockReturnValue('')
coreMocks.llmProvider.consumeLastProviderErrorMessage.mockReturnValue(null)
})
describe('ReActLLMDuty agent loop', () => {
it('passes conversation history into execution tool argument selection', async () => {
const history = [
createMessageLog(
'leon',
'Install a local Obsidian smooth cursor plugin named leon-smooth-cursor across the detected vaults.',
1
),
createMessageLog('owner', 'Then do it', 2)
]
const callLLMWithTools = vi.fn(async () => ({
textContent: JSON.stringify({
type: 'handoff',
intent: 'answer',
draft: 'Execution context retained.'
})
}))
const caller = {
callLLM: vi.fn(),
callLLMText: vi.fn(),
callLLMWithTools,
supportsNativeTools: true,
isLocalProvider: false,
input: 'Then do it',
history,
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runExecutionStepDirect(
caller,
{
function: 'functions.executeCommand',
label: 'Install smooth cursor plugin'
},
[],
{
text: 'mock catalog',
mode: 'function'
}
)
expect(result).toEqual({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'Execution context retained.',
source: 'execution'
}
})
expect(callLLMWithTools).toHaveBeenCalledOnce()
expect(callLLMWithTools.mock.calls[0]?.[4]).toBe(history)
})
it('passes conversation history into execution self-observation', async () => {
const history = [
createMessageLog('leon', 'The next step is to write plugin files.', 1),
createMessageLog('owner', 'Then do it', 2)
]
const callLLM = vi.fn(async () => ({
output: {
type: 'handoff',
intent: 'answer',
draft: 'No more steps needed.',
functions: null,
steps: null,
reason: null
}
}))
const caller = {
callLLM,
callLLMText: vi.fn(),
callLLMWithTools: vi.fn(),
supportsNativeTools: true,
isLocalProvider: false,
input: 'Then do it',
history,
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runExecutionSelfObservationPhaseDirect(caller, [])
expect(result).toEqual({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'No more steps needed.',
source: 'self_observation'
}
})
expect(callLLM).toHaveBeenCalledOnce()
expect(callLLM.mock.calls[0]?.[3]).toBe(history)
expect(callLLM.mock.calls[0]?.[5]).toMatchObject({
reasoningMode: 'off',
streamToProvider: false
})
})
it('lets final answer synthesis continue when self-observation fails', async () => {
const consumeProviderErrorMessage = vi.fn(
() => 'Provider timed out during self-observation.'
)
const caller = {
callLLM: vi.fn(async () => null),
callLLMText: vi.fn(),
callLLMWithTools: vi.fn(),
supportsNativeTools: true,
isLocalProvider: false,
input: 'Summarize the transcript',
history: [],
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage
}
const result = await runExecutionSelfObservationPhaseDirect(caller, [
{
function: 'operating_system_control.file.read',
status: 'success',
observation: 'Transcript content was read.',
stepLabel: 'Read transcript'
}
])
expect(result).toBeNull()
expect(consumeProviderErrorMessage).toHaveBeenCalledOnce()
})
it('skips Agent Skill selection when no name or metadata match is present', async () => {
const callLLMWithTools = vi.fn(async () => ({
toolCall: {
functionName: 'create_plan',
arguments: JSON.stringify({
type: 'final',
answer: 'Acknowledge the thanks.',
intent: 'answer'
})
}
}))
const caller = {
callLLM: vi.fn(),
callLLMText: vi.fn(),
callLLMWithTools,
supportsNativeTools: true,
isLocalProvider: false,
input: 'Awesome, thanks',
history: [],
agentSkillCatalog:
'1. tiny-web-crawler: Crawl from starting web pages, fetch readable content, search within pages, and follow relevant links.',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runPlanningPhaseDirect(
caller,
{
text: 'mock catalog',
mode: 'function'
},
[]
)
expect(callLLMWithTools).toHaveBeenCalledOnce()
expect(callLLMWithTools.mock.calls[0]?.[2]?.[0]?.function.name).toBe(
'create_plan'
)
expect(result).toEqual({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'Acknowledge the thanks.',
source: 'planning'
}
})
})
it('routes local plain-text planning output to final answer handoff', async () => {
const callLLM = vi.fn(async () => ({
output: 'Good morning. I am here and ready.'
}))
const caller = {
callLLM,
callLLMText: vi.fn(),
callLLMWithTools: vi.fn(),
supportsNativeTools: false,
isLocalProvider: true,
input: 'Good morning Leon',
history: [],
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runPlanningPhaseDirect(
caller,
{
text: 'mock catalog',
mode: 'function'
},
[]
)
expect(callLLM).toHaveBeenCalledOnce()
expect(result).toEqual({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'Good morning. I am here and ready.',
source: 'planning'
}
})
})
it('routes remote no-tool planning text to final answer handoff', async () => {
const callLLMWithTools = vi.fn(async () => ({
textContent: 'Hey. I am here. What do you need?'
}))
const caller = {
callLLM: vi.fn(),
callLLMText: vi.fn(),
callLLMWithTools,
supportsNativeTools: true,
isLocalProvider: false,
input: 'Hi there',
history: [],
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runPlanningPhaseDirect(
caller,
{
text: 'mock catalog',
mode: 'function'
},
[]
)
expect(caller.callLLM).not.toHaveBeenCalled()
expect(result).toEqual({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'Hey. I am here. What do you need?',
source: 'planning'
}
})
})
it('recovers a local plan step that uses function_name', async () => {
const callLLM = vi.fn(async () => ({
output: {
type: 'plan',
steps: [
{
function_name: 'operating_system_control.shell.executeCommand',
label: 'Run shell command'
}
],
summary: 'Running the shell command...',
answer: null,
intent: null
}
}))
const caller = {
callLLM,
callLLMText: vi.fn(),
callLLMWithTools: vi.fn(),
supportsNativeTools: false,
isLocalProvider: true,
input: 'Run a command',
history: [],
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runPlanningPhaseDirect(
caller,
{
text: '- operating_system_control.shell.executeCommand (command): Execute a command.',
mode: 'function'
},
[]
)
expect(result).toEqual({
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Run shell command'
}
],
summary: 'Running the shell command...'
})
})
it('recovers exact catalog function names from local planning text', async () => {
const callLLM = vi.fn(async () => ({
output:
'I will use search_web.grok.search first, then operating_system_control.shell.executeCommand.'
}))
const caller = {
callLLM,
callLLMText: vi.fn(),
callLLMWithTools: vi.fn(),
supportsNativeTools: false,
isLocalProvider: true,
input: 'Search and run a command',
history: [],
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runPlanningPhaseDirect(
caller,
{
text: [
'- search_web.grok.search (query): Search both web and X.',
'- operating_system_control.shell.executeCommand (command): Execute a command.'
].join('\n'),
mode: 'function'
},
[]
)
expect(result).toEqual({
type: 'plan',
steps: [
{
function: 'search_web.grok.search',
label: 'Run search'
},
{
function: 'operating_system_control.shell.executeCommand',
label: 'Run executeCommand'
}
],
summary: 'Running the recovered tool plan...'
})
})
it('preserves an Agent Skill id selected in a plan step', async () => {
const agentSkillContext = {
id: 'tiny-web-crawler',
name: 'tiny-web-crawler',
description: 'Crawl from starting web pages.',
rootPath: '/tmp/tiny-web-crawler',
skillPath: '/tmp/tiny-web-crawler/SKILL.md',
instructions: '# Tiny Web Crawler'
}
const callLLMWithTools = vi.fn().mockResolvedValueOnce({
toolCall: {
functionName: 'create_plan',
arguments: JSON.stringify({
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Fetch target page',
agent_skill_id: 'tiny-web-crawler'
}
],
summary: 'Fetching the target page...',
answer: null,
intent: null
})
}
})
const caller = {
callLLM: vi.fn(),
callLLMText: vi.fn(),
callLLMWithTools,
supportsNativeTools: true,
isLocalProvider: false,
input:
'Please crawl from https://example.com, fetch readable content, and follow relevant links to find pricing.',
history: [],
agentSkillCatalog:
'1. tiny-web-crawler: Crawl from starting web pages, fetch readable content, search within pages, and follow relevant links.',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(async () => agentSkillContext),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runPlanningPhaseDirect(
caller,
{
text: 'mock catalog',
mode: 'function'
},
[]
)
expect(callLLMWithTools).toHaveBeenCalledOnce()
expect(callLLMWithTools.mock.calls[0]?.[2]?.[0]?.function.name).toBe(
'create_plan'
)
expect(caller.setAgentSkillContext).not.toHaveBeenCalled()
expect(result).toEqual({
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Fetch target page',
agentSkillId: 'tiny-web-crawler'
}
],
summary: 'Fetching the target page...'
})
})
it('uses the handoff draft when final answer synthesis fails', async () => {
const callLLMText = vi.fn(async () => null)
const callLLMWithTools = vi.fn()
const caller = {
callLLM: vi.fn(),
callLLMText,
callLLMWithTools,
supportsNativeTools: true,
isLocalProvider: false,
input: 'Summarize the completed research.',
history: [],
agentSkillCatalog: '',
setAgentSkillContext: vi.fn(),
getAgentSkillContext: vi.fn(),
getContextFileContent: vi.fn(() => null),
getContextManifest: vi.fn(() => ''),
getSelfModelSnapshot: vi.fn(() => ''),
consumeProviderErrorMessage: vi.fn(() => null)
}
const result = await runFinalAnswerPhase(
caller,
[],
{
intent: 'answer',
draft: 'Research complete. The useful summary is already here.',
source: 'execution'
}
)
expect(result).toBe(
'Research complete. The useful summary is already here.'
)
expect(callLLMText).toHaveBeenCalledOnce()
expect(callLLMWithTools).not.toHaveBeenCalled()
})
it('finalizes directly when planning returns a handoff', async () => {
logUnitProgress('planning handoff scenario', {
input: 'Hi there, what do you reply if I tell you "ping"?',
expectedIntent: 'answer'
})
phaseMocks.runPlanningPhase.mockResolvedValue({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'Reply with pong.',
source: 'planning'
}
})
phaseMocks.runFinalAnswerPhase.mockResolvedValue('Pong.')
const duty = await createDuty(
'Hi there, what do you reply if I tell you "ping"?'
)
const result = await duty.execute()
logUnitProgress('planning handoff result', {
output: result?.output,
finalIntent: result?.data.finalIntent
})
expect(phaseMocks.runPlanningPhase).toHaveBeenCalledOnce()
expect(phaseMocks.runExecutionStep).not.toHaveBeenCalled()
expect(phaseMocks.runFinalAnswerPhase).toHaveBeenCalledOnce()
expect(result?.output).toBe('Pong.')
expect(result?.data.finalIntent).toBe('answer')
expect(result?.data.executionHistory).toEqual([])
})
it('executes a planned step and synthesizes the final answer', async () => {
logUnitProgress('planned execution scenario', {
input: 'What\'s the weather like today in Shenzhen?',
stepFunction: 'weather.openmeteo.getCurrentConditions'
})
phaseMocks.runPlanningPhase.mockResolvedValue({
type: 'plan',
steps: [
{
function: 'weather.openmeteo.getCurrentConditions',
label: 'Check weather'
}
],
summary: 'Checking the weather...'
})
phaseMocks.runExecutionStep.mockResolvedValue({
type: 'executed',
execution: {
function: 'weather.openmeteo.getCurrentConditions',
status: 'success',
observation: 'Current weather is 24C and sunny.',
stepLabel: 'Check weather',
requestedToolInput: '{"location":"Shenzhen"}'
}
})
phaseMocks.runExecutionSelfObservationPhase.mockResolvedValue(null)
phaseMocks.runFinalAnswerPhase.mockResolvedValue('It is 24C and sunny in Shenzhen.')
const duty = await createDuty('What\'s the weather like today in Shenzhen?')
const result = await duty.execute()
logUnitProgress('planned execution result', {
output: result?.output,
executionHistory: result?.data.executionHistory
})
expect(coreMocks.socket.emit).toHaveBeenCalledWith(
expect.objectContaining({
answer: 'Checking the weather...',
fallbackText: 'Checking the weather...',
historyMode: 'system_widget'
})
)
expect(phaseMocks.runExecutionStep).toHaveBeenCalledOnce()
expect(phaseMocks.runExecutionSelfObservationPhase).toHaveBeenCalledOnce()
expect(result?.output).toBe('It is 24C and sunny in Shenzhen.')
expect(result?.data.finalIntent).toBe('answer')
expect(result?.data.executionHistory).toEqual([
{
function: 'weather.openmeteo.getCurrentConditions',
status: 'success',
observation: 'Current weather is 24C and sunny.',
stepLabel: 'Check weather',
requestedToolInput: '{"location":"Shenzhen"}'
}
])
})
it('finalizes an observed tool result when self-observation says it satisfies the request', async () => {
phaseMocks.runPlanningPhase.mockResolvedValue({
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Run scanner'
}
],
summary: 'Running the scanner...'
})
phaseMocks.runExecutionStep.mockResolvedValue({
type: 'executed',
execution: {
function: 'operating_system_control.shell.executeCommand',
status: 'observed',
observation: JSON.stringify({
status: 'observed',
data: {
output: {
result: {
success: false,
stdout: 'Scan completed with useful findings.',
returncode: 5
}
}
}
}),
stepLabel: 'Run scanner',
requestedToolInput: '{"command":"scanner --target example.test"}'
}
})
phaseMocks.runExecutionSelfObservationPhase.mockResolvedValue({
type: 'handoff',
signal: {
intent: 'answer',
draft: 'Report the scan findings.',
source: 'self_observation'
}
})
phaseMocks.runFinalAnswerPhase.mockResolvedValue(
'The scan completed and returned useful findings.'
)
const duty = await createDuty('Run the scanner and tell me what it finds.')
const result = await duty.execute()
expect(phaseMocks.runExecutionSelfObservationPhase).toHaveBeenCalledOnce()
expect(phaseMocks.runRecoveryPlanningPhase).not.toHaveBeenCalled()
expect(result?.output).toBe(
'The scan completed and returned useful findings.'
)
expect(result?.data.executionHistory).toEqual([
expect.objectContaining({
function: 'operating_system_control.shell.executeCommand',
status: 'observed',
stepLabel: 'Run scanner'
})
])
})
it('replans from an observed tool result when self-observation says more work is needed', async () => {
phaseMocks.runPlanningPhase.mockResolvedValue({
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Run invalid command'
}
],
summary: 'Running the command...'
})
phaseMocks.runExecutionStep
.mockResolvedValueOnce({
type: 'executed',
execution: {
function: 'operating_system_control.shell.executeCommand',
status: 'observed',
observation: JSON.stringify({
status: 'observed',
observed_tool_failure: {
success: false,
error: 'Invalid option'
}
}),
stepLabel: 'Run invalid command',
requestedToolInput: '{"command":"scanner --bad-option"}'
}
})
.mockResolvedValueOnce({
type: 'executed',
execution: {
function: 'operating_system_control.shell.executeCommand',
status: 'success',
observation: 'Command completed with corrected options.',
stepLabel: 'Run corrected command',
requestedToolInput: '{"command":"scanner --good-option"}'
}
})
phaseMocks.runExecutionSelfObservationPhase
.mockResolvedValueOnce({
type: 'replan',
reason: 'Correcting the command options...',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Run corrected command'
}
]
})
.mockResolvedValueOnce(null)
phaseMocks.runFinalAnswerPhase.mockResolvedValue(
'The corrected command completed.'
)
const duty = await createDuty('Run the scanner.')
const result = await duty.execute()
expect(phaseMocks.runExecutionStep).toHaveBeenCalledTimes(2)
expect(phaseMocks.runExecutionSelfObservationPhase).toHaveBeenCalledTimes(2)
expect(result?.output).toBe('The corrected command completed.')
expect(result?.data.executionHistory).toEqual([
expect.objectContaining({
status: 'error',
stepLabel: 'Run invalid command'
}),
expect.objectContaining({
status: 'success',
stepLabel: 'Run corrected command'
})
])
})
it('keeps a selected Agent Skill active during execution', async () => {
const agentSkillContext = {
id: 'tiny-web-crawler',
name: 'tiny-web-crawler',
description: 'Fetch and crawl web pages.',
rootPath: '/tmp/tiny-web-crawler',
skillPath: '/tmp/tiny-web-crawler/SKILL.md',
instructions: '# Tiny Web Crawler'
}
phaseMocks.runPlanningPhase.mockImplementation(async (caller) => {
caller.setAgentSkillContext(agentSkillContext)
return {
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'Fetch target page'
}
],
summary: 'Fetching the target page...'
}
})
phaseMocks.runExecutionStep.mockImplementation(async (caller) => {
expect(caller.agentSkillContext).toEqual(agentSkillContext)
return {
type: 'executed',
execution: {
function: 'operating_system_control.shell.executeCommand',
status: 'success',
observation: 'Fetched with skill script.',
stepLabel: 'Fetch target page'
}
}
})
phaseMocks.runFinalAnswerPhase.mockResolvedValue('Fetched with skill script.')
const duty = await createDuty(
'Use the tiny-web-crawler skill to inspect a page.'
)
const result = await duty.execute()
expect(phaseMocks.runExecutionStep).toHaveBeenCalledOnce()
expect(result?.output).toBe('Fetched with skill script.')
})
it('short-circuits to final synthesis when a tool returns a handoff signal', async () => {
// This covers the path where a tool result already contains the semantic
// handoff Leon should forward into the final-answer phase.
logUnitProgress('tool handoff scenario', {
input: 'There is a file waiting for you. Do what it asks you to do.',
stepFunction: 'operating_system_control.shell.executeCommand'
})
phaseMocks.runPlanningPhase.mockResolvedValue({
type: 'plan',
steps: [
{
function: 'operating_system_control.shell.executeCommand',
label: 'List project root'
}
],
summary: 'Listing the project root...'
})
phaseMocks.runExecutionStep.mockResolvedValue({
type: 'executed',
execution: {
function: 'operating_system_control.shell.executeCommand',
status: 'success',
observation: 'package.json\nserver\nbridges',
stepLabel: 'List project root',
requestedToolInput: '{"command":"ls -1"}'
},
handoffSignal: {
intent: 'answer',
draft: 'Report the listed project root files.',
source: 'tool'
}
})
phaseMocks.runFinalAnswerPhase.mockResolvedValue(
'The project root contains package.json, server, and bridges.'
)
const duty = await createDuty(
'There is a file waiting for you. Do what it asks you to do.'
)
const result = await duty.execute()
logUnitProgress('tool handoff result', {
output: result?.output,
finalIntent: result?.data.finalIntent
})
expect(coreMocks.socket.emit).toHaveBeenCalledWith(
expect.objectContaining({
answer: 'Listing the project root...',
fallbackText: 'Listing the project root...',
historyMode: 'system_widget'
})
)
expect(phaseMocks.runExecutionStep).toHaveBeenCalledOnce()
expect(phaseMocks.runExecutionSelfObservationPhase).not.toHaveBeenCalled()
expect(result?.output).toBe(
'The project root contains package.json, server, and bridges.'
)
expect(result?.data.executionHistory).toEqual([
{
function: 'operating_system_control.shell.executeCommand',
status: 'success',
observation: 'package.json\nserver\nbridges',
stepLabel: 'List project root',
requestedToolInput: '{"command":"ls -1"}'
}
])
})
it('pauses for clarification during execution and resumes pending steps', async () => {
interface SavedContinuationState {
originalInput: string
clarificationQuestion: string
pendingSteps: Array<{ function: string, label: string }>
}
let savedContinuation: SavedContinuationState | null = null
const fileCreationStep = {
function: 'operating_system_control.shell.executeCommand',
label: 'Create file'
}
phaseMocks.runPlanningPhase.mockResolvedValue({
type: 'plan',
steps: [fileCreationStep],
summary: 'Preparing the file creation...'
})
phaseMocks.runExecutionStep
.mockResolvedValueOnce({
type: 'handoff',
signal: {
intent: 'clarification',
draft: 'What filename should I use?',
source: 'execution'
}
})
.mockImplementationOnce(async (caller, currentStep) => {
expect(caller.input).toContain(
'Previous clarification request: "What filename should I use?"'
)
expect(caller.input).toContain('Clarification reply: "test.txt"')
expect(currentStep).toEqual(fileCreationStep)
return {
type: 'executed',
execution: {
function: 'operating_system_control.shell.executeCommand',
status: 'success',
observation: 'Created /home/louis/Downloads/test.txt',
stepLabel: 'Create file',
requestedToolInput: '{"command":"touch /home/louis/Downloads/test.txt"}'
}
}
})
phaseMocks.runFinalAnswerPhase
.mockResolvedValueOnce('What filename should I use?')
.mockResolvedValueOnce(
'Done. I created [FILE_PATH]/home/louis/Downloads/test.txt[/FILE_PATH].'
)
const initialDuty = await createDuty(
'Create a text file in Downloads, but ask me for the filename first.'
)
vi.spyOn(
initialDuty as never,
'saveExecutionContinuation' as never
).mockImplementation((state: SavedContinuationState) => {
savedContinuation = state
})
const clarificationResult = await initialDuty.execute()
expect(clarificationResult?.output).toBe('What filename should I use?')
expect(clarificationResult?.data.finalIntent).toBe('clarification')
expect(savedContinuation?.pendingSteps).toEqual([fileCreationStep])
const resumeDuty = await createDuty('test.txt')
vi.spyOn(
resumeDuty as never,
'loadExecutionContinuation' as never
).mockReturnValue(savedContinuation)
vi.spyOn(
resumeDuty as never,
'clearExecutionContinuation' as never
).mockImplementation(() => undefined)
const resumedResult = await resumeDuty.execute()
expect(phaseMocks.runPlanningPhase).toHaveBeenCalledOnce()
expect(phaseMocks.runExecutionStep).toHaveBeenCalledTimes(2)
expect(resumedResult?.output).toBe(
'Done. I created [FILE_PATH]/home/louis/Downloads/test.txt[/FILE_PATH].'
)
expect(resumedResult?.data.finalIntent).toBe('answer')
})
describe('history compaction', () => {
it('tracks only newly appended history entries when the saved tail still matches', () => {
const duty = new ReActLLMDuty({ input: 'Hi there' })
const currentState = createCompactionState({
tail: [
createMessageLog('owner', 'Earlier owner turn', 10),
createMessageLog('leon', 'Earlier Leon turn', 11)
]
})
const synchronized = (
duty as never
).synchronizeHistoryCompactionState(
[
...currentState.tail,
createMessageLog('owner', 'New owner turn', 12),
createMessageLog('leon', 'New Leon turn', 13)
],
currentState
)
expect(synchronized.shouldPersist).toBe(true)
expect(synchronized.state.summary).toBe('- Previous topic')
expect(synchronized.state.tail).toHaveLength(4)
expect(synchronized.state.newMessagesSinceCompaction).toBe(2)
})
it('rebuilds the tail from the saved compaction boundary when the stored tail no longer matches', () => {
const duty = new ReActLLMDuty({ input: 'Hi there' })
const conversationLogs = [
createMessageLog('owner', 'Before summary', 10),
createMessageLog('leon', 'Last compacted reply', 20),
createMessageLog('owner', 'Fresh owner turn', 30),
createMessageLog('leon', 'Fresh Leon turn', 31)
]
const currentState = createCompactionState({
summarySentAt: 20,
tail: [createMessageLog('owner', 'Stale tail entry', 21)]
})
const synchronized = (
duty as never
).synchronizeHistoryCompactionState(conversationLogs, currentState)
expect(synchronized.shouldPersist).toBe(true)
expect(synchronized.state.summary).toBe('- Previous topic')
expect(synchronized.state.summarySentAt).toBe(20)
expect(synchronized.state.tail).toEqual(conversationLogs.slice(2))
expect(synchronized.state.newMessagesSinceCompaction).toBe(2)
})
it('rebuilds the tail from the saved compaction boundary when the stored tail is empty', () => {
const duty = new ReActLLMDuty({ input: 'Hi there' })
const conversationLogs = [
createMessageLog('owner', 'Before summary', 10),
createMessageLog('leon', 'Last compacted reply', 20),
createMessageLog('owner', 'Fresh owner turn', 30)
]
const currentState = createCompactionState({
summarySentAt: 20,
tail: []
})
const synchronized = (
duty as never
).synchronizeHistoryCompactionState(conversationLogs, currentState)
expect(synchronized.shouldPersist).toBe(true)
expect(synchronized.state.summary).toBe('- Previous topic')
expect(synchronized.state.tail).toEqual([conversationLogs[2]])
expect(synchronized.state.newMessagesSinceCompaction).toBe(1)
})
it('keeps the compacted summary in prompt history while clipping the raw tail window', () => {
const duty = new ReActLLMDuty({ input: 'Hi there' })
const state = createCompactionState({
summarySentAt: 50,
tail: Array.from({ length: 60 }, (_, index) =>
createMessageLog(
index % 2 === 0 ? 'owner' : 'leon',
`Message ${index + 1}`,
100 + index
)
),
newMessagesSinceCompaction: 24
})
const history = (duty as never).buildHistoryForCurrentTurn([], state, {
historyLimit: 48,
compactionBatchSize: 36
})
expect(history).toHaveLength(48)
expect(history[0]?.message).toBe(
'Earlier conversation summary:\n- Previous topic'
)
expect(history[1]?.message).toBe('Message 14')
expect(history[47]?.message).toBe('Message 60')
})
it('does not compact again when the fresh-entry counter is still below the threshold', async () => {
const duty = new ReActLLMDuty({ input: 'Set a timer for 3 seconds' })
const state = createCompactionState({
summary: '- Timer discussion already compacted',
summarySentAt: 100,
tail: Array.from({ length: 38 }, (_, index) =>
createMessageLog(
index % 2 === 0 ? 'owner' : 'leon',
`Tail message ${index + 1}`,
200 + index
)
),
newMessagesSinceCompaction: 2
})
const loadStateSpy = vi
.spyOn(duty as never, 'loadHistoryCompactionProviderState' as never)
.mockReturnValue(state)
const rollSpy = vi
.spyOn(duty as never, 'rollHistoryCompactionState' as never)
.mockResolvedValue(state)
coreMocks.conversationLogger.loadAll.mockResolvedValue(state.tail)
await (duty as never).maybeCompactHistoryAfterAnswer('plan_test_widget', [])
expect(loadStateSpy).toHaveBeenCalledOnce()
expect(rollSpy).not.toHaveBeenCalled()
expect(widgetMocks.emitPlanWidget).not.toHaveBeenCalled()
})
})
})