feat: per-project LLM models, ACP session pool, smart pagination tests
- ACP session pool with per-model subprocesses and 8h idle eviction - Per-project LLM config: local override → mcpd recommendation → global default - Model override support in ResponsePaginator - /llm/models endpoint + available models in mcpctl status - Remove --llm-provider/--llm-model from create project (use edit/apply) - 8 new smart pagination integration tests (e2e flow) - 260 mcplocal tests, 330 CLI tests passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,14 @@
|
||||
* (node:http) and a mock LLM provider. No Docker or external services needed.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
|
||||
import { McpRouter } from '../../src/router.js';
|
||||
import { McpdUpstream } from '../../src/upstream/mcpd.js';
|
||||
import { McpdClient } from '../../src/http/mcpd-client.js';
|
||||
import { LlmProcessor, DEFAULT_PROCESSOR_CONFIG } from '../../src/llm/processor.js';
|
||||
import { ResponsePaginator } from '../../src/llm/pagination.js';
|
||||
import { ProviderRegistry } from '../../src/providers/registry.js';
|
||||
import { TieredHealthMonitor } from '../../src/health/tiered.js';
|
||||
import { refreshUpstreams } from '../../src/discovery.js';
|
||||
@@ -1096,4 +1097,429 @@ describe('End-to-end integration: 3-tier architecture', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 8. Smart pagination through the full pipeline
|
||||
// -----------------------------------------------------------------------
|
||||
describe('Smart pagination', () => {
|
||||
// Helper: generate a large JSON response (~100KB)
|
||||
function makeLargeToolResult(): { flows: Array<{ id: string; type: string; label: string; wires: string[] }> } {
|
||||
return {
|
||||
flows: Array.from({ length: 200 }, (_, i) => ({
|
||||
id: `flow-${String(i).padStart(4, '0')}`,
|
||||
type: i % 3 === 0 ? 'function' : i % 3 === 1 ? 'http request' : 'inject',
|
||||
label: `Node ${String(i)}: ${i % 3 === 0 ? 'Data transform' : i % 3 === 1 ? 'API call' : 'Timer trigger'}`,
|
||||
wires: [`flow-${String(i + 1).padStart(4, '0')}`],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it('paginates large tool response with smart AI summaries through router', async () => {
|
||||
const largeResult = makeLargeToolResult();
|
||||
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-nodered:tools/list', {
|
||||
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||
}],
|
||||
['srv-nodered:tools/call', {
|
||||
result: largeResult,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
// Set up paginator with LLM provider for smart summaries
|
||||
const registry = new ProviderRegistry();
|
||||
const completeFn = vi.fn().mockImplementation(() => ({
|
||||
content: JSON.stringify([
|
||||
{ page: 1, summary: 'Function nodes and data transforms (flow-0000 through flow-0050)' },
|
||||
{ page: 2, summary: 'HTTP request nodes and API integrations (flow-0051 through flow-0100)' },
|
||||
{ page: 3, summary: 'Inject/timer nodes and triggers (flow-0101 through flow-0150)' },
|
||||
{ page: 4, summary: 'Remaining nodes and wire connections (flow-0151 through flow-0199)' },
|
||||
]),
|
||||
}));
|
||||
const mockProvider: LlmProvider = {
|
||||
name: 'test-paginator',
|
||||
isAvailable: () => true,
|
||||
complete: completeFn,
|
||||
};
|
||||
registry.register(mockProvider);
|
||||
|
||||
// Low threshold so our response triggers pagination
|
||||
const paginator = new ResponsePaginator(registry, {
|
||||
sizeThreshold: 1000,
|
||||
pageSize: 8000,
|
||||
});
|
||||
router.setPaginator(paginator);
|
||||
|
||||
// Call the tool — should get pagination index, not raw data
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'paginate-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'node-red/get_flows', arguments: {} },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
const result = response.result as { content: Array<{ type: string; text: string }> };
|
||||
expect(result.content).toHaveLength(1);
|
||||
const indexText = result.content[0]!.text;
|
||||
|
||||
// Verify smart index with AI summaries
|
||||
expect(indexText).toContain('AI-generated summaries');
|
||||
expect(indexText).toContain('Function nodes and data transforms');
|
||||
expect(indexText).toContain('HTTP request nodes');
|
||||
expect(indexText).toContain('_resultId');
|
||||
expect(indexText).toContain('_page');
|
||||
|
||||
// LLM was called to generate summaries
|
||||
expect(completeFn).toHaveBeenCalledOnce();
|
||||
const llmCall = completeFn.mock.calls[0]![0]!;
|
||||
expect(llmCall.messages[0].role).toBe('system');
|
||||
expect(llmCall.messages[1].content).toContain('node-red/get_flows');
|
||||
});
|
||||
|
||||
it('retrieves specific pages after pagination via _resultId/_page', async () => {
|
||||
const largeResult = makeLargeToolResult();
|
||||
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-nodered:tools/list', {
|
||||
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||
}],
|
||||
['srv-nodered:tools/call', {
|
||||
result: largeResult,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
// Simple paginator (no LLM) for predictable behavior
|
||||
const paginator = new ResponsePaginator(null, {
|
||||
sizeThreshold: 1000,
|
||||
pageSize: 8000,
|
||||
});
|
||||
router.setPaginator(paginator);
|
||||
|
||||
// First call — get the pagination index
|
||||
const indexResponse = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'idx-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'node-red/get_flows', arguments: {} },
|
||||
});
|
||||
|
||||
expect(indexResponse.error).toBeUndefined();
|
||||
const indexResult = indexResponse.result as { content: Array<{ text: string }> };
|
||||
const indexText = indexResult.content[0]!.text;
|
||||
const resultIdMatch = /"_resultId": "([^"]+)"/.exec(indexText);
|
||||
expect(resultIdMatch).not.toBeNull();
|
||||
const resultId = resultIdMatch![1]!;
|
||||
|
||||
// Second call — retrieve page 1 via _resultId/_page
|
||||
const page1Response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'page-1',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'node-red/get_flows',
|
||||
arguments: { _resultId: resultId, _page: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(page1Response.error).toBeUndefined();
|
||||
const page1Result = page1Response.result as { content: Array<{ text: string }> };
|
||||
expect(page1Result.content[0]!.text).toContain('Page 1/');
|
||||
// Page content should contain flow data
|
||||
expect(page1Result.content[0]!.text).toContain('flow-');
|
||||
|
||||
// Third call — retrieve page 2
|
||||
const page2Response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'page-2',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'node-red/get_flows',
|
||||
arguments: { _resultId: resultId, _page: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(page2Response.error).toBeUndefined();
|
||||
const page2Result = page2Response.result as { content: Array<{ text: string }> };
|
||||
expect(page2Result.content[0]!.text).toContain('Page 2/');
|
||||
});
|
||||
|
||||
it('retrieves full content with _page=all', async () => {
|
||||
const largeResult = makeLargeToolResult();
|
||||
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-nodered:tools/list', {
|
||||
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||
}],
|
||||
['srv-nodered:tools/call', {
|
||||
result: largeResult,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
const paginator = new ResponsePaginator(null, {
|
||||
sizeThreshold: 1000,
|
||||
pageSize: 8000,
|
||||
});
|
||||
router.setPaginator(paginator);
|
||||
|
||||
// Get index
|
||||
const indexResponse = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'all-idx',
|
||||
method: 'tools/call',
|
||||
params: { name: 'node-red/get_flows', arguments: {} },
|
||||
});
|
||||
const indexText = (indexResponse.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
const resultId = /"_resultId": "([^"]+)"/.exec(indexText)![1]!;
|
||||
|
||||
// Request all pages
|
||||
const allResponse = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'all-1',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'node-red/get_flows',
|
||||
arguments: { _resultId: resultId, _page: 'all' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(allResponse.error).toBeUndefined();
|
||||
const allResult = allResponse.result as { content: Array<{ text: string }> };
|
||||
// Full response should contain the original JSON
|
||||
const fullText = allResult.content[0]!.text;
|
||||
expect(fullText).toContain('flow-0000');
|
||||
expect(fullText).toContain('flow-0199');
|
||||
// Should be the full serialized result
|
||||
expect(JSON.parse(fullText)).toEqual(largeResult);
|
||||
});
|
||||
|
||||
it('falls back to simple index when LLM fails', async () => {
|
||||
const largeResult = makeLargeToolResult();
|
||||
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-nodered:tools/list', {
|
||||
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||
}],
|
||||
['srv-nodered:tools/call', {
|
||||
result: largeResult,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
// Set up paginator with a failing LLM
|
||||
const registry = new ProviderRegistry();
|
||||
registry.register(createFailingLlmProvider('broken-llm'));
|
||||
const paginator = new ResponsePaginator(registry, {
|
||||
sizeThreshold: 1000,
|
||||
pageSize: 8000,
|
||||
});
|
||||
router.setPaginator(paginator);
|
||||
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'fallback-idx',
|
||||
method: 'tools/call',
|
||||
params: { name: 'node-red/get_flows', arguments: {} },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
const text = (response.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Should still paginate, just without AI summaries
|
||||
expect(text).toContain('_resultId');
|
||||
expect(text).not.toContain('AI-generated summaries');
|
||||
expect(text).toContain('Page 1:');
|
||||
});
|
||||
|
||||
it('returns expired cache message for stale _resultId', async () => {
|
||||
router = new McpRouter();
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 100, pageSize: 50 });
|
||||
router.setPaginator(paginator);
|
||||
|
||||
// Try to retrieve a page with an unknown resultId
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'stale-1',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'anything/tool',
|
||||
arguments: { _resultId: 'nonexistent-id', _page: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
const text = (response.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
expect(text).toContain('expired');
|
||||
expect(text).toContain('re-call');
|
||||
});
|
||||
|
||||
it('skips pagination for small responses', async () => {
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-small', name: 'smallserver', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-small:tools/list', {
|
||||
result: { tools: [{ name: 'get_status', description: 'Get status' }] },
|
||||
}],
|
||||
['srv-small:tools/call', {
|
||||
result: { status: 'ok', uptime: 12345 },
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
const paginator = new ResponsePaginator(null, { sizeThreshold: 80000, pageSize: 40000 });
|
||||
router.setPaginator(paginator);
|
||||
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'small-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'smallserver/get_status', arguments: {} },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
// Should return the raw result directly, not a pagination index
|
||||
expect(response.result).toEqual({ status: 'ok', uptime: 12345 });
|
||||
});
|
||||
|
||||
it('handles markdown-fenced LLM responses (Gemini quirk)', async () => {
|
||||
const largeResult = makeLargeToolResult();
|
||||
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-nodered:tools/list', {
|
||||
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||
}],
|
||||
['srv-nodered:tools/call', {
|
||||
result: largeResult,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
// Simulate Gemini wrapping JSON in ```json fences
|
||||
const registry = new ProviderRegistry();
|
||||
const mockProvider: LlmProvider = {
|
||||
name: 'gemini-mock',
|
||||
isAvailable: () => true,
|
||||
complete: vi.fn().mockResolvedValue({
|
||||
content: '```json\n' + JSON.stringify([
|
||||
{ page: 1, summary: 'Climate automation flows' },
|
||||
{ page: 2, summary: 'Lighting control flows' },
|
||||
]) + '\n```',
|
||||
}),
|
||||
};
|
||||
registry.register(mockProvider);
|
||||
|
||||
const paginator = new ResponsePaginator(registry, {
|
||||
sizeThreshold: 1000,
|
||||
pageSize: 8000,
|
||||
});
|
||||
router.setPaginator(paginator);
|
||||
|
||||
const response = await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'fence-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'node-red/get_flows', arguments: {} },
|
||||
});
|
||||
|
||||
expect(response.error).toBeUndefined();
|
||||
const text = (response.result as { content: Array<{ text: string }> }).content[0]!.text;
|
||||
// Fences were stripped — smart summaries should appear
|
||||
expect(text).toContain('AI-generated summaries');
|
||||
expect(text).toContain('Climate automation flows');
|
||||
expect(text).toContain('Lighting control flows');
|
||||
});
|
||||
|
||||
it('passes model override to LLM when project has custom model', async () => {
|
||||
const largeResult = makeLargeToolResult();
|
||||
|
||||
mockMcpd = await startMockMcpd({
|
||||
servers: [{ id: 'srv-nodered', name: 'node-red', transport: 'stdio' }],
|
||||
proxyResponses: new Map([
|
||||
['srv-nodered:tools/list', {
|
||||
result: { tools: [{ name: 'get_flows', description: 'Get all flows' }] },
|
||||
}],
|
||||
['srv-nodered:tools/call', {
|
||||
result: largeResult,
|
||||
}],
|
||||
]),
|
||||
});
|
||||
|
||||
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||||
router = new McpRouter();
|
||||
await refreshUpstreams(router, client);
|
||||
await router.discoverTools();
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
const completeFn = vi.fn().mockResolvedValue({
|
||||
content: JSON.stringify([{ page: 1, summary: 'test' }]),
|
||||
});
|
||||
const mockProvider: LlmProvider = {
|
||||
name: 'test-model-override',
|
||||
isAvailable: () => true,
|
||||
complete: completeFn,
|
||||
};
|
||||
registry.register(mockProvider);
|
||||
|
||||
// Paginator with per-project model override
|
||||
const paginator = new ResponsePaginator(registry, {
|
||||
sizeThreshold: 1000,
|
||||
pageSize: 80000, // One big page so we get exactly 1 summary
|
||||
}, 'gemini-2.5-pro');
|
||||
router.setPaginator(paginator);
|
||||
|
||||
await router.route({
|
||||
jsonrpc: '2.0',
|
||||
id: 'model-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'node-red/get_flows', arguments: {} },
|
||||
});
|
||||
|
||||
// Verify the model was passed through to the LLM call
|
||||
expect(completeFn).toHaveBeenCalledOnce();
|
||||
const llmOpts = completeFn.mock.calls[0]![0]!;
|
||||
expect(llmOpts.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user