1705 lines
48 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|