feat: eager vLLM warmup and smart page titles in paginate stage
- Add warmup() to LlmProvider interface for eager subprocess startup - ManagedVllmProvider.warmup() starts vLLM in background on project load - ProviderRegistry.warmupAll() triggers all managed providers - NamedProvider proxies warmup() to inner provider - paginate stage generates LLM-powered descriptive page titles when available, cached by content hash, falls back to generic "Page N" - project-mcp-endpoint calls warmupAll() on router creation so vLLM is loading while the session initializes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
238
src/mcplocal/tests/proxymodel-executor.test.ts
Normal file
238
src/mcplocal/tests/proxymodel-executor.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user