first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
|
||||
import type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
|
||||
|
||||
export class DirectExecutionStrategy implements ExecutionStrategy {
|
||||
async executeTool(
|
||||
tool: Tool,
|
||||
args: Record<string, unknown>,
|
||||
_context: ExecutionContext,
|
||||
): Promise<unknown> {
|
||||
return await tool.invoke(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
|
||||
import { DirectExecutionStrategy } from './DirectExecutionStrategy';
|
||||
import type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
|
||||
import { QueuedExecutionStrategy } from './QueuedExecutionStrategy';
|
||||
|
||||
export class ExecutionCoordinator {
|
||||
private strategy: ExecutionStrategy;
|
||||
|
||||
constructor(strategy?: ExecutionStrategy) {
|
||||
this.strategy = strategy ?? new DirectExecutionStrategy();
|
||||
}
|
||||
|
||||
async executeTool(
|
||||
tool: Tool,
|
||||
args: Record<string, unknown>,
|
||||
context: ExecutionContext,
|
||||
): Promise<unknown> {
|
||||
return await this.strategy.executeTool(tool, args, context);
|
||||
}
|
||||
|
||||
setStrategy(strategy: ExecutionStrategy): void {
|
||||
this.strategy = strategy;
|
||||
}
|
||||
|
||||
getStrategy(): ExecutionStrategy {
|
||||
return this.strategy;
|
||||
}
|
||||
|
||||
isQueueMode(): boolean {
|
||||
return this.strategy instanceof QueuedExecutionStrategy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
|
||||
export interface ExecutionContext {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionStrategy {
|
||||
executeTool(
|
||||
tool: Tool,
|
||||
args: Record<string, unknown>,
|
||||
context: ExecutionContext,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface PendingCall {
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export class PendingCallsManager {
|
||||
private pendingCalls: Record<string, PendingCall> = {};
|
||||
|
||||
async waitForResult(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
): Promise<unknown> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pendingCalls[callId]) {
|
||||
this.reject(callId, new Error('Worker tool execution timeout'));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingCalls[callId] = {
|
||||
toolName,
|
||||
arguments: args,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
resolve(callId: string, result: unknown): boolean {
|
||||
const pending = this.pendingCalls[callId];
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(result);
|
||||
delete this.pendingCalls[callId];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
reject(callId: string, error: Error): boolean {
|
||||
const pending = this.pendingCalls[callId];
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
delete this.pendingCalls[callId];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get(callId: string): PendingCall | undefined {
|
||||
return this.pendingCalls[callId];
|
||||
}
|
||||
|
||||
has(callId: string): boolean {
|
||||
return callId in this.pendingCalls;
|
||||
}
|
||||
|
||||
remove(callId: string): void {
|
||||
delete this.pendingCalls[callId];
|
||||
}
|
||||
|
||||
cleanupBySessionId(sessionId: string): void {
|
||||
for (const callId of Object.keys(this.pendingCalls)) {
|
||||
if (callId.startsWith(`${sessionId}_`)) {
|
||||
const pending = this.pendingCalls[callId];
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(undefined);
|
||||
}
|
||||
delete this.pendingCalls[callId];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
|
||||
import type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
|
||||
import type { PendingCallsManager } from './PendingCallsManager';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
|
||||
export class QueuedExecutionStrategy implements ExecutionStrategy {
|
||||
constructor(
|
||||
private pendingCalls: PendingCallsManager,
|
||||
private timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
async executeTool(
|
||||
tool: Tool,
|
||||
args: Record<string, unknown>,
|
||||
context: ExecutionContext,
|
||||
): Promise<unknown> {
|
||||
const callId = `${context.sessionId}_${context.messageId ?? 'default'}`;
|
||||
|
||||
return await this.pendingCalls.waitForResult(callId, tool.name, args, this.timeoutMs);
|
||||
}
|
||||
|
||||
resolveToolCall(callId: string, result: unknown): boolean {
|
||||
return this.pendingCalls.resolve(callId, result);
|
||||
}
|
||||
|
||||
rejectToolCall(callId: string, error: Error): boolean {
|
||||
return this.pendingCalls.reject(callId, error);
|
||||
}
|
||||
|
||||
getPendingCallsManager(): PendingCallsManager {
|
||||
return this.pendingCalls;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { createMockTool } from '../../__tests__/helpers';
|
||||
import { DirectExecutionStrategy } from '../DirectExecutionStrategy';
|
||||
|
||||
describe('DirectExecutionStrategy', () => {
|
||||
let strategy: DirectExecutionStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = new DirectExecutionStrategy();
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('should invoke tool with provided arguments', async () => {
|
||||
const tool = createMockTool('test-tool', { invokeReturn: { result: 'success' } });
|
||||
|
||||
const result = await strategy.executeTool(
|
||||
tool,
|
||||
{ input: 'test' },
|
||||
{ sessionId: 'session-1' },
|
||||
);
|
||||
|
||||
expect(tool.invoke).toHaveBeenCalledWith({ input: 'test' });
|
||||
expect(result).toEqual({ result: 'success' });
|
||||
});
|
||||
|
||||
it('should propagate tool errors', async () => {
|
||||
const tool = createMockTool('failing-tool', {
|
||||
invokeError: new Error('Tool failed'),
|
||||
});
|
||||
|
||||
await expect(strategy.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
|
||||
'Tool failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass empty arguments correctly', async () => {
|
||||
const tool = createMockTool('no-args-tool', { invokeReturn: 'result' });
|
||||
|
||||
await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
|
||||
|
||||
expect(tool.invoke).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('should handle complex arguments', async () => {
|
||||
const tool = createMockTool('complex-tool', { invokeReturn: 'result' });
|
||||
const complexArgs = {
|
||||
nested: { deep: { value: 123 } },
|
||||
array: [1, 2, 3],
|
||||
text: 'hello',
|
||||
};
|
||||
|
||||
await strategy.executeTool(tool, complexArgs, { sessionId: 'session-1' });
|
||||
|
||||
expect(tool.invoke).toHaveBeenCalledWith(complexArgs);
|
||||
});
|
||||
|
||||
it('should handle various return types', async () => {
|
||||
const stringTool = createMockTool('string-tool', { invokeReturn: 'string result' });
|
||||
const numberTool = createMockTool('number-tool', { invokeReturn: 42 });
|
||||
const arrayTool = createMockTool('array-tool', { invokeReturn: [1, 2, 3] });
|
||||
const nullTool = createMockTool('null-tool', { invokeReturn: null });
|
||||
|
||||
const context = { sessionId: 'session-1' };
|
||||
|
||||
expect(await strategy.executeTool(stringTool, {}, context)).toBe('string result');
|
||||
expect(await strategy.executeTool(numberTool, {}, context)).toBe(42);
|
||||
expect(await strategy.executeTool(arrayTool, {}, context)).toEqual([1, 2, 3]);
|
||||
expect(await strategy.executeTool(nullTool, {}, context)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle execution context with messageId', async () => {
|
||||
const tool = createMockTool('test-tool', { invokeReturn: 'result' });
|
||||
|
||||
const result = await strategy.executeTool(
|
||||
tool,
|
||||
{ arg: 'value' },
|
||||
{ sessionId: 'session-1', messageId: 'msg-123' },
|
||||
);
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(tool.invoke).toHaveBeenCalledWith({ arg: 'value' });
|
||||
});
|
||||
|
||||
it('should propagate TypeError from tool', async () => {
|
||||
const tool = createMockTool('type-error-tool', {
|
||||
invokeError: new TypeError('Invalid type'),
|
||||
});
|
||||
|
||||
await expect(strategy.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
|
||||
TypeError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { createMockTool } from '../../__tests__/helpers';
|
||||
import { DirectExecutionStrategy } from '../DirectExecutionStrategy';
|
||||
import { ExecutionCoordinator } from '../ExecutionCoordinator';
|
||||
import { PendingCallsManager } from '../PendingCallsManager';
|
||||
import { QueuedExecutionStrategy } from '../QueuedExecutionStrategy';
|
||||
|
||||
describe('ExecutionCoordinator', () => {
|
||||
describe('default behavior', () => {
|
||||
it('should use DirectExecutionStrategy by default', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
expect(coordinator.getStrategy()).toBeInstanceOf(DirectExecutionStrategy);
|
||||
});
|
||||
|
||||
it('should not be in queue mode by default', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
expect(coordinator.isQueueMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor with custom strategy', () => {
|
||||
it('should accept custom strategy in constructor', () => {
|
||||
const customStrategy = new DirectExecutionStrategy();
|
||||
const coordinator = new ExecutionCoordinator(customStrategy);
|
||||
expect(coordinator.getStrategy()).toBe(customStrategy);
|
||||
});
|
||||
|
||||
it('should accept QueuedExecutionStrategy in constructor', () => {
|
||||
const queuedStrategy = new QueuedExecutionStrategy(new PendingCallsManager());
|
||||
const coordinator = new ExecutionCoordinator(queuedStrategy);
|
||||
expect(coordinator.getStrategy()).toBe(queuedStrategy);
|
||||
expect(coordinator.isQueueMode()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy management', () => {
|
||||
it('should allow setting custom strategy', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
const queuedStrategy = new QueuedExecutionStrategy(new PendingCallsManager());
|
||||
|
||||
coordinator.setStrategy(queuedStrategy);
|
||||
|
||||
expect(coordinator.getStrategy()).toBe(queuedStrategy);
|
||||
});
|
||||
|
||||
it('should report queue mode when using QueuedExecutionStrategy', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
coordinator.setStrategy(new QueuedExecutionStrategy(new PendingCallsManager()));
|
||||
|
||||
expect(coordinator.isQueueMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should report non-queue mode when switching back to DirectExecutionStrategy', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
coordinator.setStrategy(new QueuedExecutionStrategy(new PendingCallsManager()));
|
||||
coordinator.setStrategy(new DirectExecutionStrategy());
|
||||
|
||||
expect(coordinator.isQueueMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('should delegate to current strategy', async () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
const tool = createMockTool('test', { invokeReturn: 'result' });
|
||||
|
||||
const result = await coordinator.executeTool(tool, { input: 'test' }, { sessionId: 's1' });
|
||||
|
||||
expect(tool.invoke).toHaveBeenCalledWith({ input: 'test' });
|
||||
expect(result).toBe('result');
|
||||
});
|
||||
|
||||
it('should use DirectExecutionStrategy by default', async () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
const tool = createMockTool('test', { invokeReturn: { data: 'from-tool' } });
|
||||
|
||||
const result = await coordinator.executeTool(tool, {}, { sessionId: 'session-1' });
|
||||
|
||||
expect(result).toEqual({ data: 'from-tool' });
|
||||
expect(tool.invoke).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should propagate errors from strategy', async () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
const tool = createMockTool('failing-tool', {
|
||||
invokeError: new Error('Strategy error'),
|
||||
});
|
||||
|
||||
await expect(coordinator.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
|
||||
'Strategy error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass context to strategy', async () => {
|
||||
const mockStrategy = {
|
||||
executeTool: jest.fn().mockResolvedValue('result'),
|
||||
};
|
||||
const coordinator = new ExecutionCoordinator(mockStrategy);
|
||||
const tool = createMockTool('test', { invokeReturn: 'result' });
|
||||
const context = { sessionId: 'session-1', messageId: 'msg-123' };
|
||||
|
||||
await coordinator.executeTool(tool, { arg: 'value' }, context);
|
||||
|
||||
expect(mockStrategy.executeTool).toHaveBeenCalledWith(tool, { arg: 'value' }, context);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQueueMode detection', () => {
|
||||
it('should detect QueuedExecutionStrategy by constructor name', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
const queuedStrategy = new QueuedExecutionStrategy(new PendingCallsManager());
|
||||
|
||||
coordinator.setStrategy(queuedStrategy);
|
||||
|
||||
expect(coordinator.isQueueMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for DirectExecutionStrategy', () => {
|
||||
const coordinator = new ExecutionCoordinator(new DirectExecutionStrategy());
|
||||
|
||||
expect(coordinator.isQueueMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for anonymous strategy implementations', () => {
|
||||
const coordinator = new ExecutionCoordinator();
|
||||
const anonymousStrategy = {
|
||||
executeTool: jest.fn().mockResolvedValue('result'),
|
||||
};
|
||||
|
||||
coordinator.setStrategy(anonymousStrategy);
|
||||
|
||||
expect(coordinator.isQueueMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import { PendingCallsManager } from '../PendingCallsManager';
|
||||
|
||||
describe('PendingCallsManager', () => {
|
||||
let manager: PendingCallsManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new PendingCallsManager();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('waitForResult', () => {
|
||||
it('should resolve when result is provided via resolve()', async () => {
|
||||
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
|
||||
manager.resolve('call-1', { success: true });
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should reject on timeout with meaningful error', async () => {
|
||||
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 1000);
|
||||
|
||||
jest.advanceTimersByTime(1001);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow('Worker tool execution timeout');
|
||||
});
|
||||
|
||||
it('should track pending call while waiting', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
expect(manager.has('call-1')).toBe(true);
|
||||
// Clean up to avoid unhandled rejection
|
||||
manager.resolve('call-1', undefined);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should remove call from pending after resolution', async () => {
|
||||
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
manager.resolve('call-1', 'result');
|
||||
await resultPromise;
|
||||
|
||||
expect(manager.has('call-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove call from pending after timeout', async () => {
|
||||
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 1000);
|
||||
|
||||
jest.advanceTimersByTime(1001);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow();
|
||||
expect(manager.has('call-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should store tool name and arguments', async () => {
|
||||
const args = { city: 'London' };
|
||||
const promise = manager.waitForResult('call-1', 'get_weather', args, 5000);
|
||||
|
||||
const pendingCall = manager.get('call-1');
|
||||
expect(pendingCall).toBeDefined();
|
||||
expect(pendingCall?.toolName).toBe('get_weather');
|
||||
expect(pendingCall?.arguments).toEqual(args);
|
||||
// Clean up
|
||||
manager.resolve('call-1', undefined);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should handle multiple concurrent calls', async () => {
|
||||
const promise1 = manager.waitForResult('call-1', 'tool-1', {}, 5000);
|
||||
const promise2 = manager.waitForResult('call-2', 'tool-2', {}, 5000);
|
||||
const promise3 = manager.waitForResult('call-3', 'tool-3', {}, 5000);
|
||||
|
||||
expect(manager.has('call-1')).toBe(true);
|
||||
expect(manager.has('call-2')).toBe(true);
|
||||
expect(manager.has('call-3')).toBe(true);
|
||||
|
||||
manager.resolve('call-1', 'result-1');
|
||||
manager.resolve('call-2', 'result-2');
|
||||
manager.resolve('call-3', 'result-3');
|
||||
|
||||
await expect(promise1).resolves.toBe('result-1');
|
||||
await expect(promise2).resolves.toBe('result-2');
|
||||
await expect(promise3).resolves.toBe('result-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should return true when call exists', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
expect(manager.resolve('call-1', 'result')).toBe(true);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should return false when call does not exist', () => {
|
||||
expect(manager.resolve('non-existent', 'result')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle resolving with various result types', async () => {
|
||||
const promise1 = manager.waitForResult('call-1', 'tool', {}, 5000);
|
||||
const promise2 = manager.waitForResult('call-2', 'tool', {}, 5000);
|
||||
const promise3 = manager.waitForResult('call-3', 'tool', {}, 5000);
|
||||
const promise4 = manager.waitForResult('call-4', 'tool', {}, 5000);
|
||||
|
||||
manager.resolve('call-1', undefined);
|
||||
manager.resolve('call-2', null);
|
||||
manager.resolve('call-3', { complex: { nested: 'data' } });
|
||||
manager.resolve('call-4', [1, 2, 3]);
|
||||
|
||||
await expect(promise1).resolves.toBeUndefined();
|
||||
await expect(promise2).resolves.toBeNull();
|
||||
await expect(promise3).resolves.toEqual({ complex: { nested: 'data' } });
|
||||
await expect(promise4).resolves.toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should only resolve once (subsequent resolves return false)', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
|
||||
expect(manager.resolve('call-1', 'first')).toBe(true);
|
||||
expect(manager.resolve('call-1', 'second')).toBe(false);
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('should reject pending call with error', async () => {
|
||||
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
const error = new Error('Tool execution failed');
|
||||
|
||||
manager.reject('call-1', error);
|
||||
|
||||
await expect(resultPromise).rejects.toThrow('Tool execution failed');
|
||||
});
|
||||
|
||||
it('should return true when call exists', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
expect(manager.reject('call-1', new Error('test'))).toBe(true);
|
||||
// Must await/catch the rejection to avoid unhandled rejection
|
||||
await expect(promise).rejects.toThrow('test');
|
||||
});
|
||||
|
||||
it('should return false when call does not exist', () => {
|
||||
expect(manager.reject('non-existent', new Error('test'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove call from pending after rejection', async () => {
|
||||
const resultPromise = manager.waitForResult('call-1', 'test-tool', {}, 5000);
|
||||
manager.reject('call-1', new Error('test'));
|
||||
|
||||
await expect(resultPromise).rejects.toThrow();
|
||||
expect(manager.has('call-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupBySessionId', () => {
|
||||
it('should resolve all calls matching session prefix with underscore', async () => {
|
||||
const promise1 = manager.waitForResult('session-1_msg-1', 'tool', {}, 5000);
|
||||
const promise2 = manager.waitForResult('session-1_msg-2', 'tool', {}, 5000);
|
||||
const promise3 = manager.waitForResult('session-2_msg-1', 'tool', {}, 5000);
|
||||
|
||||
manager.cleanupBySessionId('session-1');
|
||||
|
||||
await expect(promise1).resolves.toBeUndefined();
|
||||
await expect(promise2).resolves.toBeUndefined();
|
||||
|
||||
expect(manager.has('session-1_msg-1')).toBe(false);
|
||||
expect(manager.has('session-1_msg-2')).toBe(false);
|
||||
expect(manager.has('session-2_msg-1')).toBe(true);
|
||||
|
||||
manager.resolve('session-2_msg-1', 'result');
|
||||
await promise3;
|
||||
});
|
||||
|
||||
it('should not cleanup calls without underscore separator', async () => {
|
||||
const promise = manager.waitForResult('session-1', 'tool', {}, 5000);
|
||||
|
||||
manager.cleanupBySessionId('session-1');
|
||||
|
||||
expect(manager.has('session-1')).toBe(true);
|
||||
|
||||
manager.resolve('session-1', 'result');
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should handle cleanup when no matching sessions', async () => {
|
||||
const promise = manager.waitForResult('other-session_msg-1', 'tool', {}, 5000);
|
||||
|
||||
expect(() => manager.cleanupBySessionId('session-1')).not.toThrow();
|
||||
|
||||
expect(manager.has('other-session_msg-1')).toBe(true);
|
||||
|
||||
manager.resolve('other-session_msg-1', 'result');
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should handle cleanup when no pending calls', () => {
|
||||
expect(() => manager.cleanupBySessionId('session-1')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get and has', () => {
|
||||
it('should return call info for existing call', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'test-tool', { arg: 'value' }, 5000);
|
||||
const info = manager.get('call-1');
|
||||
|
||||
expect(info).toBeDefined();
|
||||
expect(info).toHaveProperty('resolve');
|
||||
expect(info).toHaveProperty('reject');
|
||||
expect(info).toHaveProperty('toolName', 'test-tool');
|
||||
expect(info).toHaveProperty('arguments', { arg: 'value' });
|
||||
|
||||
manager.resolve('call-1', 'result');
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent call', () => {
|
||||
expect(manager.get('non-existent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for existing call', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'tool', {}, 5000);
|
||||
expect(manager.has('call-1')).toBe(true);
|
||||
manager.resolve('call-1', 'result');
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should return false for non-existent call', () => {
|
||||
expect(manager.has('non-existent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should remove pending call without resolving or rejecting', async () => {
|
||||
const promise = manager.waitForResult('call-1', 'tool', {}, 5000);
|
||||
|
||||
manager.remove('call-1');
|
||||
|
||||
expect(manager.has('call-1')).toBe(false);
|
||||
// Note: The promise will never resolve/reject when removed this way
|
||||
// This is expected behavior - remove() is for cleanup when we don't care about the result
|
||||
// We need to avoid the unhandled rejection by advancing time to trigger timeout
|
||||
// but the promise was removed so it won't reject. We handle this by catching any potential rejection
|
||||
await Promise.race([promise.catch(() => {}), Promise.resolve()]);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent call', () => {
|
||||
expect(() => manager.remove('non-existent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { createMockTool } from '../../__tests__/helpers';
|
||||
import type { PendingCallsManager } from '../PendingCallsManager';
|
||||
import { QueuedExecutionStrategy } from '../QueuedExecutionStrategy';
|
||||
|
||||
describe('QueuedExecutionStrategy', () => {
|
||||
let strategy: QueuedExecutionStrategy;
|
||||
let mockPendingCalls: jest.Mocked<PendingCallsManager>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPendingCalls = {
|
||||
waitForResult: jest.fn(),
|
||||
resolve: jest.fn(),
|
||||
reject: jest.fn(),
|
||||
get: jest.fn(),
|
||||
has: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
cleanupBySessionId: jest.fn(),
|
||||
} as unknown as jest.Mocked<PendingCallsManager>;
|
||||
|
||||
strategy = new QueuedExecutionStrategy(mockPendingCalls);
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('should create callId from sessionId and messageId', async () => {
|
||||
mockPendingCalls.waitForResult.mockResolvedValue('result');
|
||||
const tool = createMockTool('test-tool');
|
||||
|
||||
await strategy.executeTool(
|
||||
tool,
|
||||
{ arg: 'value' },
|
||||
{ sessionId: 'session-1', messageId: 'msg-1' },
|
||||
);
|
||||
|
||||
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
|
||||
'session-1_msg-1',
|
||||
'test-tool',
|
||||
{ arg: 'value' },
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create callId from sessionId with default suffix when no messageId', async () => {
|
||||
mockPendingCalls.waitForResult.mockResolvedValue('result');
|
||||
const tool = createMockTool('test-tool');
|
||||
|
||||
await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
|
||||
|
||||
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
|
||||
'session-1_default',
|
||||
'test-tool',
|
||||
{},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return result from pending calls manager', async () => {
|
||||
mockPendingCalls.waitForResult.mockResolvedValue({ data: 'from-worker' });
|
||||
const tool = createMockTool('test-tool');
|
||||
|
||||
const result = await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
|
||||
|
||||
expect(result).toEqual({ data: 'from-worker' });
|
||||
});
|
||||
|
||||
it('should pass tool name and arguments to waitForResult', async () => {
|
||||
mockPendingCalls.waitForResult.mockResolvedValue('result');
|
||||
const tool = createMockTool('get_weather');
|
||||
const args = { city: 'London', units: 'metric' };
|
||||
|
||||
await strategy.executeTool(tool, args, { sessionId: 'session-1' });
|
||||
|
||||
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'get_weather',
|
||||
args,
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout', async () => {
|
||||
mockPendingCalls.waitForResult.mockResolvedValue('result');
|
||||
const tool = createMockTool('test-tool');
|
||||
|
||||
await strategy.executeTool(tool, {}, { sessionId: 'session-1' });
|
||||
|
||||
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
120000, // DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate errors from pending calls manager', async () => {
|
||||
mockPendingCalls.waitForResult.mockRejectedValue(new Error('Timeout'));
|
||||
const tool = createMockTool('test-tool');
|
||||
|
||||
await expect(strategy.executeTool(tool, {}, { sessionId: 'session-1' })).rejects.toThrow(
|
||||
'Timeout',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with custom timeout', () => {
|
||||
it('should use custom timeout when provided', async () => {
|
||||
const customTimeout = 60000;
|
||||
const customStrategy = new QueuedExecutionStrategy(mockPendingCalls, customTimeout);
|
||||
mockPendingCalls.waitForResult.mockResolvedValue('result');
|
||||
const tool = createMockTool('test-tool');
|
||||
|
||||
await customStrategy.executeTool(tool, {}, { sessionId: 'session-1' });
|
||||
|
||||
expect(mockPendingCalls.waitForResult).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
customTimeout,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToolCall', () => {
|
||||
it('should delegate to pendingCalls.resolve', () => {
|
||||
mockPendingCalls.resolve.mockReturnValue(true);
|
||||
|
||||
const result = strategy.resolveToolCall('call-1', { success: true });
|
||||
|
||||
expect(mockPendingCalls.resolve).toHaveBeenCalledWith('call-1', { success: true });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when call does not exist', () => {
|
||||
mockPendingCalls.resolve.mockReturnValue(false);
|
||||
|
||||
const result = strategy.resolveToolCall('non-existent', 'result');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectToolCall', () => {
|
||||
it('should delegate to pendingCalls.reject', () => {
|
||||
mockPendingCalls.reject.mockReturnValue(true);
|
||||
const error = new Error('test');
|
||||
|
||||
const result = strategy.rejectToolCall('call-1', error);
|
||||
|
||||
expect(mockPendingCalls.reject).toHaveBeenCalledWith('call-1', error);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when call does not exist', () => {
|
||||
mockPendingCalls.reject.mockReturnValue(false);
|
||||
|
||||
const result = strategy.rejectToolCall('non-existent', new Error('test'));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingCallsManager', () => {
|
||||
it('should return the pending calls manager', () => {
|
||||
expect(strategy.getPendingCallsManager()).toBe(mockPendingCalls);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ExecutionContext, ExecutionStrategy } from './ExecutionStrategy';
|
||||
export * from './DirectExecutionStrategy';
|
||||
export * from './QueuedExecutionStrategy';
|
||||
export * from './PendingCallsManager';
|
||||
export * from './ExecutionCoordinator';
|
||||
Reference in New Issue
Block a user