// tests/workflow-engine.test.ts import { WorkflowEngine } from '../src/workflow-engine/workflow-engine'; import { WorkflowDefinition, WorkflowStep, WorkflowStepResult, WorkflowExecutionContext, WorkflowExecutionResult, OllamaMessage, OllamaTool, } from '../src/types'; import { VaultIndexer } from '../src/vault-indexer'; import { ToolExecutor } from '../src/tool-executor'; import { OllamaClient } from '../src/ollama-client'; import { ConversationStateManager } from '../src/conversation-state'; import { Vault, App, Workspace, WorkspaceLeaf, TFile } from '../__mocks__/obsidian'; // ==================== Mock Setup ==================== jest.mock('../src/vault-indexer'); jest.mock('../src/tool-executor'); jest.mock('../src/ollama-client'); jest.mock('../src/conversation-state'); // ==================== Helpers ==================== function createMockVault(): Vault { const mockVault = new Vault(); mockVault.getMarkdownFiles = jest.fn(() => []); return mockVault; } function createMockApp(): App { const mockApp = new App(); mockApp.vault = createMockVault(); return mockApp; } function createMockWorkflowEngine( mockVault?: Vault, mockApp?: App ): { engine: WorkflowEngine; mockVaultIndexer: jest.Mocked; mockToolExecutor: jest.Mocked; mockOllamaClient: jest.Mocked; mockConversationStateManager: jest.Mocked; } { const vault = mockVault ?? createMockVault(); const app = mockApp ?? createMockApp(); const engine = new WorkflowEngine(vault as any, app as any, 'http://localhost:11434', 'llama3'); // Access the actual mock instances created inside WorkflowEngine constructor const mockVaultIndexer = (engine as any).vaultIndexer as jest.Mocked; const mockToolExecutor = (engine as any).toolExecutor as jest.Mocked; const mockOllamaClient = (engine as any).ollamaClient as jest.Mocked; const mockConversationStateManager = (engine as any).conversationStateManager as jest.Mocked; return { engine, mockVaultIndexer, mockToolExecutor, mockOllamaClient, mockConversationStateManager, }; } // ==================== Tests ==================== describe('WorkflowEngine', () => { let engine: WorkflowEngine; let mockVaultIndexer: jest.Mocked; let mockToolExecutor: jest.Mocked; let mockOllamaClient: jest.Mocked; let mockConversationStateManager: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); const mocks = createMockWorkflowEngine(); engine = mocks.engine; mockVaultIndexer = mocks.mockVaultIndexer; mockToolExecutor = mocks.mockToolExecutor; mockOllamaClient = mocks.mockOllamaClient; mockConversationStateManager = mocks.mockConversationStateManager; // Setup default mock behaviors mockVaultIndexer.searchVault = jest.fn().mockResolvedValue([]); mockToolExecutor.executeTool = jest.fn().mockResolvedValue({ success: true, message: 'Tool executed successfully', data: null, }); mockOllamaClient.chat = jest.fn().mockResolvedValue({ role: 'assistant', content: 'Mock LLM response', tool_calls: [], }); mockConversationStateManager.updateShortTermContext = jest.fn(); mockConversationStateManager.clear = jest.fn(); }); // ==================== Validation Tests ==================== describe('validateWorkflow (via executeWorkflow)', () => { it('should reject workflow with missing id', async () => { const definition: WorkflowDefinition = { id: '', name: 'Test Workflow', description: 'Test', steps: [], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe('Workflow must have an id'); expect(result.workflowId).toBe(''); }); it('should reject workflow with missing name', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: '', description: 'Test', steps: [], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe('Workflow must have a name'); }); it('should reject workflow with missing steps array', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [], }; // Test with empty steps const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe('Workflow must have at least one step'); }); it('should reject workflow with step missing id', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: '', type: 'llm', name: 'Test Step', config: { type: 'llm', userPrompt: 'Hello', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe('Each step must have an id'); }); it('should reject workflow with step missing type', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: '' as any, name: 'Test Step', config: {} as any, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Step 'step_1' must have a type"); }); it('should reject workflow with step missing name', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'llm', name: '', config: { type: 'llm', userPrompt: 'Hello', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Step 'step_1' must have a name"); }); it('should reject workflow with step missing config', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'llm', name: 'Test Step', config: null as unknown as never, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Step 'step_1' must have a config"); }); it('should reject workflow with duplicate step ids', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'llm', name: 'Step 1', config: { type: 'llm', userPrompt: 'Hello', }, }, { id: 'step_1', type: 'format', name: 'Step 2', config: { type: 'format', template: 'Test', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Duplicate step id: 'step_1'"); }); it('should reject workflow with invalid step type', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'invalid_type' as any, name: 'Test Step', config: {} as any, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Step 'step_1' has invalid type: 'invalid_type'"); }); it('should reject workflow with dependsOn referencing unknown step', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'llm', name: 'Step 1', config: { type: 'llm', userPrompt: 'Hello', }, }, { id: 'step_2', type: 'format', name: 'Step 2', config: { type: 'format', template: 'Test', }, dependsOn: 'nonexistent_step', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Step 'step_2' depends on unknown step: 'nonexistent_step'"); }); it('should reject LLM step without userPrompt', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'llm', name: 'LLM Step', config: { type: 'llm', userPrompt: '', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("LLM step 'step_1' requires a userPrompt"); }); it('should reject vault_search step without query', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search Step', config: { type: 'vault_search', query: '', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Vault search step 'step_1' requires a query"); }); it('should reject tool step without toolName', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'tool', name: 'Tool Step', config: { type: 'tool', toolName: '', args: {}, }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Tool step 'step_1' requires a toolName"); }); it('should reject format step without template', async () => { const definition: WorkflowDefinition = { id: 'test-workflow', name: 'Test Workflow', description: 'Test', steps: [ { id: 'step_1', type: 'format', name: 'Format Step', config: { type: 'format', template: '', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toBe("Format step 'step_1' requires a template"); }); }); // ==================== Step Execution Tests ==================== describe('LLM Step Execution', () => { it('should execute a simple LLM step successfully', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'This is the LLM response', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'llm-workflow', name: 'LLM Workflow', description: 'Test LLM step', steps: [ { id: 'step_1', type: 'llm', name: 'LLM Step', config: { type: 'llm', userPrompt: 'Hello, world!', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults).toHaveLength(1); expect(result.stepResults[0].stepId).toBe('step_1'); expect(result.stepResults[0].success).toBe(true); expect(result.stepResults[0].data).toBe('This is the LLM response'); expect(result.finalOutput).toBe('This is the LLM response'); }); it('should include system prompt in LLM step', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'Response with system prompt', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'llm-workflow', name: 'LLM Workflow', description: 'Test LLM step with system prompt', steps: [ { id: 'step_1', type: 'llm', name: 'LLM Step', config: { type: 'llm', systemPrompt: 'You are a helpful assistant.', userPrompt: 'Hello!', }, }, ], }; await engine.executeWorkflow(definition); expect(mockOllamaClient.chat).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ role: 'system', content: 'You are a helpful assistant.', }), ]), [] ); }); it('should handle LLM step failure gracefully', async () => { mockOllamaClient.chat.mockRejectedValueOnce(new Error('LLM API error')); const definition: WorkflowDefinition = { id: 'llm-workflow', name: 'LLM Workflow', description: 'Test LLM step failure', steps: [ { id: 'step_1', type: 'llm', name: 'LLM Step', config: { type: 'llm', userPrompt: 'Hello!', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.stepResults[0].success).toBe(false); expect(result.stepResults[0].error).toBe('LLM API error'); }); }); describe('Vault Search Step Execution', () => { it('should execute vault search step successfully', async () => { const mockEntries = [ { path: 'meeting-note.md', title: 'Team Meeting', content: 'Meeting notes content', score: 10, tags: 'meeting,team', }, { path: 'project-update.md', title: 'Project Update', content: 'Project progress notes', score: 8, tags: 'meeting,project', }, ]; mockVaultIndexer.searchVault.mockResolvedValueOnce(mockEntries as any); const definition: WorkflowDefinition = { id: 'search-workflow', name: 'Search Workflow', description: 'Test vault search', steps: [ { id: 'step_1', type: 'vault_search', name: 'Find Meeting Notes', config: { type: 'vault_search', query: 'meeting', limit: 5, }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].success).toBe(true); expect(Array.isArray(result.stepResults[0].data)).toBe(true); expect(result.stepResults[0].data as any[]).toHaveLength(2); expect((result.stepResults[0].data as any[])[0].path).toBe('meeting-note.md'); }); it('should apply tag filter in vault search', async () => { const mockEntries = [ { path: 'meeting-note.md', title: 'Team Meeting', content: 'Meeting notes', score: 10, tags: 'meeting,team', }, { path: 'personal-note.md', title: 'Personal Note', content: 'Personal thoughts', score: 8, tags: 'personal,daily', }, ]; mockVaultIndexer.searchVault.mockResolvedValueOnce(mockEntries as any); const definition: WorkflowDefinition = { id: 'search-workflow', name: 'Search Workflow', description: 'Test vault search with tag filter', steps: [ { id: 'step_1', type: 'vault_search', name: 'Find Meeting Notes', config: { type: 'vault_search', query: 'meeting', limit: 10, tagFilter: 'meeting', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); const data = result.stepResults[0].data as any[]; expect(data).toHaveLength(1); expect(data[0].path).toBe('meeting-note.md'); }); it('should use default limit of 5 in vault search', async () => { mockVaultIndexer.searchVault.mockResolvedValueOnce([]); const definition: WorkflowDefinition = { id: 'search-workflow', name: 'Search Workflow', description: 'Test vault search default limit', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search', config: { type: 'vault_search', query: 'test', }, }, ], }; await engine.executeWorkflow(definition); expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('test', 5); }); }); describe('Tool Step Execution', () => { it('should execute tool step successfully', async () => { mockToolExecutor.executeTool.mockResolvedValueOnce({ success: true, message: 'File created', data: { path: 'new-file.md', size: 100 }, }); const definition: WorkflowDefinition = { id: 'tool-workflow', name: 'Tool Workflow', description: 'Test tool execution', steps: [ { id: 'step_1', type: 'tool', name: 'Create File', config: { type: 'tool', toolName: 'create_file', args: { path: 'new-file.md', content: 'Hello, world!', }, }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].success).toBe(true); expect(result.stepResults[0].data).toEqual({ success: true, message: 'File created', data: { path: 'new-file.md', size: 100 }, }); }); it('should handle tool execution failure', async () => { mockToolExecutor.executeTool.mockRejectedValueOnce(new Error('Tool execution failed')); const definition: WorkflowDefinition = { id: 'tool-workflow', name: 'Tool Workflow', description: 'Test tool failure', steps: [ { id: 'step_1', type: 'tool', name: 'Create File', config: { type: 'tool', toolName: 'create_file', args: { path: 'new-file.md', content: 'Hello!', }, }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.stepResults[0].success).toBe(false); expect(result.stepResults[0].error).toBe('Tool execution failed'); }); }); describe('Format Step Execution', () => { it('should execute format step with variable interpolation', async () => { mockVaultIndexer.searchVault.mockResolvedValueOnce([ { path: 'note.md', title: 'Test Note', content: 'Note content', score: 10, frontmatter: {}, }, ] as any); const definition: WorkflowDefinition = { id: 'format-workflow', name: 'Format Workflow', description: 'Test format step', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search', config: { type: 'vault_search', query: 'test', }, }, { id: 'step_2', type: 'format', name: 'Format Results', config: { type: 'format', template: 'Search results: {{step_1.output}}', }, dependsOn: 'step_1', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[1].success).toBe(true); expect(typeof result.stepResults[1].data).toBe('string'); expect(result.stepResults[1].data as string).toContain('Search results:'); }); it('should format output as JSON when outputFormat is json', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'LLM output', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'format-workflow', name: 'Format Workflow', description: 'Test JSON format', steps: [ { id: 'step_1', type: 'llm', name: 'LLM Step', config: { type: 'llm', userPrompt: 'Hello!', }, }, { id: 'step_2', type: 'format', name: 'Format as JSON', config: { type: 'format', template: '{"result": "{{step_1.output}}"}', outputFormat: 'json', }, dependsOn: 'step_1', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); const formattedOutput = result.stepResults[1].data as string; expect(formattedOutput).toContain('"result"'); expect(formattedOutput).toContain('LLM output'); }); it('should handle non-JSON template when outputFormat is json', async () => { const definition: WorkflowDefinition = { id: 'format-workflow', name: 'Format Workflow', description: 'Test JSON format with invalid JSON', steps: [ { id: 'step_1', type: 'format', name: 'Format Step', config: { type: 'format', template: 'Not valid JSON {{{', outputFormat: 'json', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].data).toBe('Not valid JSON {{{'); }); }); // ==================== Multi-Step Workflow Tests ==================== describe('Multi-Step Workflow Execution', () => { it('should execute multiple steps in sequence', async () => { mockVaultIndexer.searchVault.mockResolvedValueOnce([ { path: 'note.md', title: 'Test Note', content: 'Content', score: 10, frontmatter: {}, }, ] as any); mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'Extracted: Key decision from meeting', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'multi-step-workflow', name: 'Multi-Step Workflow', description: 'Test multi-step execution', steps: [ { id: 'step_1', type: 'vault_search', name: 'Find Notes', config: { type: 'vault_search', query: 'meeting', limit: 5, }, }, { id: 'step_2', type: 'llm', name: 'Extract Decisions', config: { type: 'llm', systemPrompt: 'Extract decisions from meeting notes.', userPrompt: '{{step_1.output}}', }, dependsOn: 'step_1', }, { id: 'step_3', type: 'format', name: 'Format Output', config: { type: 'format', template: '# Summary\n\n{{step_2.output}}', outputFormat: 'markdown', }, dependsOn: 'step_2', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults).toHaveLength(3); expect(result.stepResults[0].success).toBe(true); expect(result.stepResults[1].success).toBe(true); expect(result.stepResults[2].success).toBe(true); expect(typeof result.finalOutput).toBe('string'); expect(result.finalOutput as string).toContain('# Summary'); }); it('should stop execution when a dependency fails', async () => { mockVaultIndexer.searchVault.mockRejectedValueOnce(new Error('Search failed')); const definition: WorkflowDefinition = { id: 'dependency-workflow', name: 'Dependency Workflow', description: 'Test dependency failure', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search', config: { type: 'vault_search', query: 'test', }, }, { id: 'step_2', type: 'llm', name: 'Process', config: { type: 'llm', userPrompt: '{{step_1.output}}', }, dependsOn: 'step_1', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.stepResults).toHaveLength(2); expect(result.stepResults[0].success).toBe(false); expect(result.stepResults[0].error).toBe('Search failed'); // Step 2 should be skipped due to failed dependency expect(result.stepResults[1].success).toBe(false); expect(result.stepResults[1].error).toContain("Dependency 'step_1' failed"); }); it('should pass data between steps via variable interpolation', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'Processed data', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'interpolation-workflow', name: 'Interpolation Workflow', description: 'Test variable interpolation between steps', steps: [ { id: 'step_1', type: 'llm', name: 'Generate Data', config: { type: 'llm', userPrompt: 'Generate some data', }, }, { id: 'step_2', type: 'format', name: 'Use Data', config: { type: 'format', template: 'Previous result: {{step_1.output}}', }, dependsOn: 'step_1', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[1].data as string).toContain('Previous result: Processed data'); }); it('should handle initial variables correctly', async () => { mockVaultIndexer.searchVault.mockResolvedValueOnce([ { path: 'note.md', title: 'Meeting', content: 'Content', score: 10, frontmatter: {}, }, ] as any); const definition: WorkflowDefinition = { id: 'initial-vars-workflow', name: 'Initial Variables Workflow', description: 'Test initial variables', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search', config: { type: 'vault_search', query: '{{original_query}}', }, }, ], }; await engine.executeWorkflow(definition, { original_query: 'my search term' }); expect(mockVaultIndexer.searchVault).toHaveBeenCalledWith('my search term', 5); }); it('should mark workflow as partial success when some steps fail', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'First response', tool_calls: [], }); mockOllamaClient.chat.mockRejectedValueOnce(new Error('Second LLM call failed')); const definition: WorkflowDefinition = { id: 'partial-workflow', name: 'Partial Workflow', description: 'Test partial success', steps: [ { id: 'step_1', type: 'llm', name: 'First LLM Call', config: { type: 'llm', userPrompt: 'First prompt', }, }, { id: 'step_2', type: 'llm', name: 'Second LLM Call', config: { type: 'llm', userPrompt: 'Second prompt', }, }, { id: 'step_3', type: 'format', name: 'Format', config: { type: 'format', template: 'Done', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.stepResults).toHaveLength(3); expect(result.stepResults[0].success).toBe(true); expect(result.stepResults[1].success).toBe(false); expect(result.stepResults[2].success).toBe(true); }); }); // ==================== Variable Interpolation Tests ==================== describe('Variable Interpolation', () => { it('should interpolate simple variables', async () => { mockVaultIndexer.searchVault.mockResolvedValueOnce([ { path: 'note.md', title: 'Note', content: 'Content', score: 10, frontmatter: {}, }, ] as any); const definition: WorkflowDefinition = { id: 'interpolation-workflow', name: 'Interpolation Test', description: 'Test variable interpolation', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search', config: { type: 'vault_search', query: 'test', }, }, { id: 'step_2', type: 'llm', name: 'Process', config: { type: 'llm', userPrompt: 'Here are the results: {{step_1.output}}', }, dependsOn: 'step_1', }, ], }; await engine.executeWorkflow(definition); // Verify the LLM was called with interpolated content const chatCall = mockOllamaClient.chat.mock.calls[0]; const messages = chatCall[0] as OllamaMessage[]; const userMessage = messages.find((m) => m.role === 'user'); expect(userMessage?.content).toContain('Here are the results:'); }); it('should handle nested property access in interpolation', async () => { mockToolExecutor.executeTool.mockResolvedValueOnce({ success: true, message: 'Success', data: { path: 'file.md', content: 'File content', metadata: { author: 'John' }, }, }); const definition: WorkflowDefinition = { id: 'nested-workflow', name: 'Nested Interpolation', description: 'Test nested property access', steps: [ { id: 'step_1', type: 'tool', name: 'Read File', config: { type: 'tool', toolName: 'read_vault_file', args: { path: 'file.md' }, }, }, { id: 'step_2', type: 'format', name: 'Extract Author', config: { type: 'format', template: 'Author: {{step_1.output.data.metadata.author}}', }, dependsOn: 'step_1', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); const output = result.stepResults[1].data as string; expect(output).toContain('Author:'); }); it('should keep placeholder when variable not found', async () => { const definition: WorkflowDefinition = { id: 'missing-var-workflow', name: 'Missing Variable', description: 'Test missing variable handling', steps: [ { id: 'step_1', type: 'format', name: 'Format', config: { type: 'format', template: 'Hello {{nonexistent.output}}!', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].data).toBe('Hello {{nonexistent.output}}!'); }); it('should interpolate variables in tool args', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'Some data', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'tool-args-workflow', name: 'Tool Args Interpolation', description: 'Test variable interpolation in tool args', steps: [ { id: 'step_1', type: 'llm', name: 'Generate Content', config: { type: 'llm', userPrompt: 'Generate content', }, }, { id: 'step_2', type: 'tool', name: 'Create File', config: { type: 'tool', toolName: 'create_file', args: { path: 'output.md', content: '{{step_1.output}}', }, }, dependsOn: 'step_1', }, ], }; await engine.executeWorkflow(definition); // Verify tool was called with interpolated args expect(mockToolExecutor.executeTool).toHaveBeenCalledWith( 'create_file', expect.objectContaining({ content: 'Some data', }) ); }); }); // ==================== Topological Sort Tests ==================== describe('Step Ordering (Topological Sort)', () => { it('should execute steps in correct order based on dependencies', async () => { const executionOrder: string[] = []; mockOllamaClient.chat = jest.fn().mockImplementation(() => { return Promise.resolve({ role: 'assistant', content: 'Response', tool_calls: [], }); }); const definition: WorkflowDefinition = { id: 'order-workflow', name: 'Order Test', description: 'Test step ordering', steps: [ { id: 'step_c', type: 'format', name: 'Final Format', config: { type: 'format', template: '{{step_a.output}} + {{step_b.output}}', }, dependsOn: 'step_b', }, { id: 'step_a', type: 'llm', name: 'First LLM', config: { type: 'llm', userPrompt: 'First', }, }, { id: 'step_b', type: 'llm', name: 'Second LLM', config: { type: 'llm', userPrompt: 'Second', }, dependsOn: 'step_a', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); // Verify step_a was executed before step_b const stepAIndex = result.stepResults.findIndex((r) => r.stepId === 'step_a'); const stepBIndex = result.stepResults.findIndex((r) => r.stepId === 'step_b'); const stepCIndex = result.stepResults.findIndex((r) => r.stepId === 'step_c'); expect(stepAIndex).toBeLessThan(stepBIndex); expect(stepBIndex).toBeLessThan(stepCIndex); }); it('should detect circular dependencies', async () => { const definition: WorkflowDefinition = { id: 'circular-workflow', name: 'Circular Test', description: 'Test circular dependency detection', steps: [ { id: 'step_a', type: 'llm', name: 'Step A', config: { type: 'llm', userPrompt: 'A', }, dependsOn: 'step_b', }, { id: 'step_b', type: 'llm', name: 'Step B', config: { type: 'llm', userPrompt: 'B', }, dependsOn: 'step_a', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toContain('Circular dependency'); }); }); // ==================== Built-in Workflows Tests ==================== describe('Built-in Workflows', () => { it('should provide meeting summary workflow', () => { const workflows = WorkflowEngine.getBuiltInWorkflows(); const meetingWorkflow = workflows.find((w) => w.id === 'meeting-summary'); expect(meetingWorkflow).toBeDefined(); expect(meetingWorkflow?.name).toBe('Meeting Notes Summary'); expect(meetingWorkflow?.steps).toHaveLength(3); }); it('should provide note analyzer workflow', () => { const workflows = WorkflowEngine.getBuiltInWorkflows(); const analyzerWorkflow = workflows.find((w) => w.id === 'note-analyzer'); expect(analyzerWorkflow).toBeDefined(); expect(analyzerWorkflow?.name).toBe('Note Analyzer'); expect(analyzerWorkflow?.steps).toHaveLength(3); }); it('should return multiple built-in workflows', () => { const workflows = WorkflowEngine.getBuiltInWorkflows(); expect(workflows.length).toBeGreaterThanOrEqual(2); }); }); // ==================== Timeout and Limits Tests ==================== describe('Timeout and Limits', () => { it('should respect maxSteps limit', async () => { // Create engine with very low max steps const limitedEngine = new WorkflowEngine( createMockVault() as any, createMockApp() as any, 'http://localhost:11434', 'llama3', { maxSteps: 2 } ); mockOllamaClient.chat = jest.fn().mockResolvedValue({ role: 'assistant', content: 'Response', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'many-steps-workflow', name: 'Many Steps', description: 'Test max steps limit', steps: [ { id: 'step_1', type: 'llm', name: 'Step 1', config: { type: 'llm', userPrompt: '1' }, }, { id: 'step_2', type: 'llm', name: 'Step 2', config: { type: 'llm', userPrompt: '2' }, }, { id: 'step_3', type: 'llm', name: 'Step 3', config: { type: 'llm', userPrompt: '3' }, }, ], }; const result = await limitedEngine.executeWorkflow(definition); expect(result.success).toBe(false); expect(result.error).toContain('exceeded maximum step count'); }); }); // ==================== Edge Cases ==================== describe('Edge Cases', () => { it('should handle empty workflow result data', async () => { mockVaultIndexer.searchVault.mockResolvedValueOnce([]); const definition: WorkflowDefinition = { id: 'empty-result-workflow', name: 'Empty Results', description: 'Test empty search results', steps: [ { id: 'step_1', type: 'vault_search', name: 'Search', config: { type: 'vault_search', query: 'nonexistent', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].data).toEqual([]); }); it('should handle LLM returning empty content', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: '', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'empty-llm-workflow', name: 'Empty LLM', description: 'Test empty LLM response', steps: [ { id: 'step_1', type: 'llm', name: 'LLM', config: { type: 'llm', userPrompt: 'Hello', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].data).toBe(''); }); it('should handle tool returning null data', async () => { mockToolExecutor.executeTool.mockResolvedValueOnce({ success: true, message: 'Done', data: null, }); const definition: WorkflowDefinition = { id: 'null-data-workflow', name: 'Null Data', description: 'Test null tool data', steps: [ { id: 'step_1', type: 'tool', name: 'Tool', config: { type: 'tool', toolName: 'some_tool', args: {}, }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults[0].data).toEqual({ success: true, message: 'Done', data: null, }); }); it('should include workflow metadata in result', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'Response', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'metadata-workflow', name: 'Metadata Test', description: 'Test workflow metadata', steps: [ { id: 'step_1', type: 'llm', name: 'LLM', config: { type: 'llm', userPrompt: 'Hello', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.workflowId).toBe('metadata-workflow'); expect(result.workflowName).toBe('Metadata Test'); expect(result.stepResults[0].timestamp).toBeGreaterThan(0); }); it('should handle steps without dependsOn executing in definition order', async () => { mockOllamaClient.chat = jest.fn().mockResolvedValue({ role: 'assistant', content: 'Response', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'no-deps-workflow', name: 'No Dependencies', description: 'Test execution without dependencies', steps: [ { id: 'step_1', type: 'llm', name: 'Step 1', config: { type: 'llm', userPrompt: '1' }, }, { id: 'step_2', type: 'llm', name: 'Step 2', config: { type: 'llm', userPrompt: '2' }, }, { id: 'step_3', type: 'llm', name: 'Step 3', config: { type: 'llm', userPrompt: '3' }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.success).toBe(true); expect(result.stepResults).toHaveLength(3); expect(mockOllamaClient.chat).toHaveBeenCalledTimes(3); }); it('should handle array interpolation in tool args', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'First LLM output', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'array-args-workflow', name: 'Array Args', description: 'Test array interpolation in tool args', steps: [ { id: 'step_1', type: 'llm', name: 'Generate', config: { type: 'llm', userPrompt: 'Generate content', }, }, { id: 'step_2', type: 'tool', name: 'Create', config: { type: 'tool', toolName: 'create_file', args: { path: 'output.md', content: '{{step_1.output}}', }, }, dependsOn: 'step_1', }, ], }; await engine.executeWorkflow(definition); expect(mockToolExecutor.executeTool).toHaveBeenCalled(); }); }); // ==================== Workflow Execution Result Tests ==================== describe('WorkflowExecutionResult', () => { it('should return finalOutput from last successful step', async () => { mockOllamaClient.chat.mockResolvedValueOnce({ role: 'assistant', content: 'Intermediate response', tool_calls: [], }); const definition: WorkflowDefinition = { id: 'final-output-workflow', name: 'Final Output Test', description: 'Test final output', steps: [ { id: 'step_1', type: 'llm', name: 'LLM', config: { type: 'llm', userPrompt: 'Hello', }, }, { id: 'step_2', type: 'format', name: 'Format', config: { type: 'format', template: '# Final\n\n{{step_1.output}}', }, dependsOn: 'step_1', }, ], }; const result = await engine.executeWorkflow(definition); expect(result.finalOutput).toBe('# Final\n\nIntermediate response'); }); it('should return null finalOutput when all steps fail', async () => { mockOllamaClient.chat.mockRejectedValueOnce(new Error('LLM failed')); const definition: WorkflowDefinition = { id: 'failed-workflow', name: 'Failed Workflow', description: 'Test all steps failing', steps: [ { id: 'step_1', type: 'llm', name: 'LLM', config: { type: 'llm', userPrompt: 'Hello', }, }, ], }; const result = await engine.executeWorkflow(definition); expect(result.finalOutput).toBeNull(); }); }); });