239 lines
8.6 KiB
TypeScript
239 lines
8.6 KiB
TypeScript
|
|
import { describe, it, expect, vi } from 'vitest';
|
||
|
|
import { executePipeline, type ExecuteOptions } from '../src/proxymodel/executor.js';
|
||
|
|
import type { ProxyModelDefinition } from '../src/proxymodel/schema.js';
|
||
|
|
import type { LLMProvider, CacheProvider, StageLogger } from '../src/proxymodel/types.js';
|
||
|
|
|
||
|
|
function mockLlm(available = false): LLMProvider {
|
||
|
|
return {
|
||
|
|
async complete(prompt) { return `Summary: ${prompt.slice(0, 30)}...`; },
|
||
|
|
available: () => available,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function mockCache(): CacheProvider {
|
||
|
|
const store = new Map<string, string>();
|
||
|
|
return {
|
||
|
|
async getOrCompute(key, compute) {
|
||
|
|
if (store.has(key)) return store.get(key)!;
|
||
|
|
const val = await compute();
|
||
|
|
store.set(key, val);
|
||
|
|
return val;
|
||
|
|
},
|
||
|
|
hash(content) { return content.slice(0, 8); },
|
||
|
|
async get(key) { return store.get(key) ?? null; },
|
||
|
|
async set(key, value) { store.set(key, value); },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function mockLog(): StageLogger {
|
||
|
|
return {
|
||
|
|
debug: vi.fn(),
|
||
|
|
info: vi.fn(),
|
||
|
|
warn: vi.fn(),
|
||
|
|
error: vi.fn(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeModel(stages: ProxyModelDefinition['spec']['stages'], appliesTo = ['toolResult'] as const): ProxyModelDefinition {
|
||
|
|
return {
|
||
|
|
kind: 'ProxyModel',
|
||
|
|
metadata: { name: 'test' },
|
||
|
|
spec: {
|
||
|
|
controller: 'gate',
|
||
|
|
stages,
|
||
|
|
appliesTo: [...appliesTo],
|
||
|
|
cacheable: false,
|
||
|
|
},
|
||
|
|
source: 'built-in',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeOpts(content: string, model: ProxyModelDefinition, overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
|
||
|
|
return {
|
||
|
|
content,
|
||
|
|
contentType: 'toolResult',
|
||
|
|
sourceName: 'test/tool',
|
||
|
|
projectName: 'test',
|
||
|
|
sessionId: 'sess-1',
|
||
|
|
proxyModel: model,
|
||
|
|
llm: mockLlm(),
|
||
|
|
cache: mockCache(),
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('executePipeline', () => {
|
||
|
|
it('passes content through passthrough stage unchanged', async () => {
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
const result = await executePipeline(makeOpts('hello world', model));
|
||
|
|
expect(result.content).toBe('hello world');
|
||
|
|
expect(result.sections).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('chains multiple stages', async () => {
|
||
|
|
// passthrough → passthrough should still return same content
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'passthrough' },
|
||
|
|
{ type: 'passthrough' },
|
||
|
|
]);
|
||
|
|
const result = await executePipeline(makeOpts('data', model));
|
||
|
|
expect(result.content).toBe('data');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('paginate splits large content', async () => {
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'paginate', config: { pageSize: 50 } },
|
||
|
|
]);
|
||
|
|
const content = 'line\n'.repeat(100);
|
||
|
|
const result = await executePipeline(makeOpts(content, model));
|
||
|
|
expect(result.sections).toBeDefined();
|
||
|
|
expect(result.sections!.length).toBeGreaterThan(1);
|
||
|
|
expect(result.content).toContain('pages');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('skips missing stages with warning', async () => {
|
||
|
|
const log = mockLog();
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'nonexistent-stage' },
|
||
|
|
{ type: 'passthrough' },
|
||
|
|
]);
|
||
|
|
const result = await executePipeline(makeOpts('data', model, { log }));
|
||
|
|
expect(result.content).toBe('data');
|
||
|
|
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('nonexistent-stage'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('continues pipeline on stage error', async () => {
|
||
|
|
// We'll test this by verifying the pipeline doesn't throw even if something goes wrong internally
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'passthrough' },
|
||
|
|
]);
|
||
|
|
const result = await executePipeline(makeOpts('data', model));
|
||
|
|
expect(result.content).toBe('data');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves originalContent across all stages', async () => {
|
||
|
|
// section-split + summarize-tree pipeline — originalContent should always be the initial input
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'section-split', config: { minSectionSize: 5 } },
|
||
|
|
]);
|
||
|
|
const jsonContent = JSON.stringify([
|
||
|
|
{ id: 'a', label: 'First', data: 'x'.repeat(100) },
|
||
|
|
{ id: 'b', label: 'Second', data: 'y'.repeat(100) },
|
||
|
|
]);
|
||
|
|
const result = await executePipeline(makeOpts(jsonContent, model));
|
||
|
|
expect(result.sections).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('respects appliesTo filter', async () => {
|
||
|
|
const model = makeModel(
|
||
|
|
[{ type: 'passthrough' }],
|
||
|
|
['resource'],
|
||
|
|
);
|
||
|
|
// contentType is toolResult but model only applies to resource
|
||
|
|
const result = await executePipeline(makeOpts('data', model));
|
||
|
|
expect(result.content).toBe('data');
|
||
|
|
expect(result.sections).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns empty metadata when no stages set it', async () => {
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
const result = await executePipeline(makeOpts('data', model));
|
||
|
|
expect(result.metadata).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles section-split + summarize-tree (subindex) pipeline', async () => {
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'section-split', config: { minSectionSize: 5 } },
|
||
|
|
{ type: 'summarize-tree' },
|
||
|
|
]);
|
||
|
|
const items = Array.from({ length: 10 }, (_, i) => ({
|
||
|
|
id: `item-${i}`,
|
||
|
|
name: `Item ${i}`,
|
||
|
|
data: 'x'.repeat(300),
|
||
|
|
}));
|
||
|
|
const json = JSON.stringify(items);
|
||
|
|
const result = await executePipeline(makeOpts(json, model));
|
||
|
|
// Should produce sections
|
||
|
|
expect(result.sections).toBeDefined();
|
||
|
|
expect(result.content).toContain('sections');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('works with empty content', async () => {
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
const result = await executePipeline(makeOpts('', model));
|
||
|
|
expect(result.content).toBe('');
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('audit event emission', () => {
|
||
|
|
const mockCollector = { emit: vi.fn(), flush: vi.fn(), dispose: vi.fn() };
|
||
|
|
|
||
|
|
it('emits stage_execution for each stage + pipeline_execution summary', async () => {
|
||
|
|
mockCollector.emit.mockClear();
|
||
|
|
const model = makeModel([
|
||
|
|
{ type: 'passthrough' },
|
||
|
|
{ type: 'paginate', config: { pageSize: 50 } },
|
||
|
|
]);
|
||
|
|
const content = 'line\n'.repeat(100);
|
||
|
|
await executePipeline(makeOpts(content, model, { auditCollector: mockCollector as never }));
|
||
|
|
|
||
|
|
// 2 stages + 1 pipeline summary = 3 events
|
||
|
|
expect(mockCollector.emit).toHaveBeenCalledTimes(3);
|
||
|
|
|
||
|
|
const calls = mockCollector.emit.mock.calls.map((c: unknown[]) => c[0] as { eventKind: string; payload: Record<string, unknown> });
|
||
|
|
expect(calls[0]!.eventKind).toBe('stage_execution');
|
||
|
|
expect(calls[0]!.payload['stage']).toBe('passthrough');
|
||
|
|
expect(calls[0]!.payload['durationMs']).toBeGreaterThanOrEqual(0);
|
||
|
|
expect(calls[1]!.eventKind).toBe('stage_execution');
|
||
|
|
expect(calls[1]!.payload['stage']).toBe('paginate');
|
||
|
|
expect(calls[2]!.eventKind).toBe('pipeline_execution');
|
||
|
|
expect(calls[2]!.payload['totalDurationMs']).toBeGreaterThanOrEqual(0);
|
||
|
|
expect(calls[2]!.payload['stageCount']).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('includes serverName and correlationId when provided', async () => {
|
||
|
|
mockCollector.emit.mockClear();
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
await executePipeline(makeOpts('hello', model, {
|
||
|
|
auditCollector: mockCollector as never,
|
||
|
|
serverName: 'ha',
|
||
|
|
correlationId: 'req-1',
|
||
|
|
}));
|
||
|
|
|
||
|
|
const calls = mockCollector.emit.mock.calls.map((c: unknown[]) => c[0] as { serverName?: string; correlationId?: string });
|
||
|
|
for (const call of calls) {
|
||
|
|
expect(call.serverName).toBe('ha');
|
||
|
|
expect(call.correlationId).toBe('req-1');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not emit when auditCollector is undefined', async () => {
|
||
|
|
mockCollector.emit.mockClear();
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
// No auditCollector — should not throw
|
||
|
|
await executePipeline(makeOpts('hello', model));
|
||
|
|
expect(mockCollector.emit).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves correct inputSize/outputSize per stage', async () => {
|
||
|
|
mockCollector.emit.mockClear();
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
await executePipeline(makeOpts('hello', model, { auditCollector: mockCollector as never }));
|
||
|
|
|
||
|
|
const stageEvent = mockCollector.emit.mock.calls[0]![0] as { payload: Record<string, unknown> };
|
||
|
|
expect(stageEvent.payload['inputSize']).toBe(5);
|
||
|
|
expect(stageEvent.payload['outputSize']).toBe(5);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('emits pipeline_execution with input/output sizes', async () => {
|
||
|
|
mockCollector.emit.mockClear();
|
||
|
|
const model = makeModel([{ type: 'passthrough' }]);
|
||
|
|
await executePipeline(makeOpts('hello', model, { auditCollector: mockCollector as never }));
|
||
|
|
|
||
|
|
const pipelineEvent = mockCollector.emit.mock.calls[1]![0] as { payload: Record<string, unknown> };
|
||
|
|
expect(pipelineEvent.payload['inputSize']).toBe(5);
|
||
|
|
expect(pipelineEvent.payload['outputSize']).toBe(5);
|
||
|
|
expect(pipelineEvent.payload['stageCount']).toBe(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|