Files
obsidian_ollama/tests/workflow-engine.test.ts

1705 lines
48 KiB
TypeScript

// 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<VaultIndexer>;
mockToolExecutor: jest.Mocked<ToolExecutor>;
mockOllamaClient: jest.Mocked<OllamaClient>;
mockConversationStateManager: jest.Mocked<ConversationStateManager>;
} {
const vault = mockVault ?? createMockVault();
const app = mockApp ?? createMockApp();
const engine = new WorkflowEngine(vault as any, app as any, 'http://localhost:11434', 'llama3');
// Access private properties via jest mocking
const mockVaultIndexer = VaultIndexer as unknown as jest.Mocked<typeof VaultIndexer>;
const mockToolExecutor = ToolExecutor as unknown as jest.Mocked<typeof ToolExecutor>;
const mockOllamaClient = OllamaClient as unknown as jest.Mocked<typeof OllamaClient>;
const mockConversationStateManager = ConversationStateManager as unknown as jest.Mocked<
typeof ConversationStateManager
>;
return {
engine,
mockVaultIndexer: mockVaultIndexer.prototype as unknown as jest.Mocked<VaultIndexer>,
mockToolExecutor: mockToolExecutor.prototype as unknown as jest.Mocked<ToolExecutor>,
mockOllamaClient: mockOllamaClient.prototype as unknown as jest.Mocked<OllamaClient>,
mockConversationStateManager:
mockConversationStateManager.prototype as unknown as jest.Mocked<ConversationStateManager>,
};
}
// ==================== Tests ====================
describe('WorkflowEngine', () => {
let engine: WorkflowEngine;
let mockVaultIndexer: jest.Mocked<VaultIndexer>;
let mockToolExecutor: jest.Mocked<ToolExecutor>;
let mockOllamaClient: jest.Mocked<OllamaClient>;
let mockConversationStateManager: jest.Mocked<ConversationStateManager>;
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 = [
{
file: { path: 'meeting-note.md', basename: 'meeting-note' },
title: 'Team Meeting',
content: 'Meeting notes content',
score: 10,
frontmatter: { tags: 'meeting,team' },
},
{
file: { path: 'project-update.md', basename: 'project-update' },
title: 'Project Update',
content: 'Project progress notes',
score: 8,
frontmatter: { 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 = [
{
file: { path: 'meeting-note.md', basename: 'meeting-note' },
title: 'Team Meeting',
content: 'Meeting notes',
score: 10,
frontmatter: { tags: 'meeting,team' },
},
{
file: { path: 'personal-note.md', basename: 'personal-note' },
title: 'Personal Note',
content: 'Personal thoughts',
score: 8,
frontmatter: { 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([
{
file: { 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([
{
file: { 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([
{
file: { 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([
{
file: { 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();
});
});
});