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:
Michal
2026-02-25 01:29:38 +00:00
parent d2dedf74e5
commit 61a07024e9
14 changed files with 786 additions and 49 deletions

View File

@@ -69,7 +69,7 @@ describe('GeminiAcpProvider', () => {
expect(result.content).toBe('padded response');
});
it('serializes concurrent calls', async () => {
it('serializes concurrent calls to same model', async () => {
const callOrder: number[] = [];
let callCount = 0;
@@ -110,6 +110,70 @@ describe('GeminiAcpProvider', () => {
});
});
describe('session pool', () => {
it('creates separate pool entries for different models', async () => {
mockPrompt.mockResolvedValue('ok');
await provider.complete({ messages: [{ role: 'user', content: 'a' }], model: 'gemini-2.5-flash' });
await provider.complete({ messages: [{ role: 'user', content: 'b' }], model: 'gemini-2.5-pro' });
expect(provider.poolSize).toBe(2);
});
it('reuses existing pool entry for same model', async () => {
mockPrompt.mockResolvedValue('ok');
await provider.complete({ messages: [{ role: 'user', content: 'a' }], model: 'gemini-2.5-flash' });
await provider.complete({ messages: [{ role: 'user', content: 'b' }], model: 'gemini-2.5-flash' });
expect(provider.poolSize).toBe(1);
});
it('uses defaultModel when no model specified', async () => {
mockPrompt.mockResolvedValue('ok');
await provider.complete({ messages: [{ role: 'user', content: 'a' }] });
expect(provider.poolSize).toBe(1);
});
it('evicts idle sessions', async () => {
// Use a very short TTL for testing
const shortTtl = new GeminiAcpProvider({
binaryPath: '/usr/bin/gemini',
defaultModel: 'gemini-2.5-flash',
idleTtlMs: 1, // 1ms TTL
});
mockPrompt.mockResolvedValue('ok');
await shortTtl.complete({ messages: [{ role: 'user', content: 'a' }], model: 'model-a' });
expect(shortTtl.poolSize).toBe(1);
// Wait for TTL to expire
await new Promise((r) => setTimeout(r, 10));
// Next complete call triggers eviction of old entry and creates new one
await shortTtl.complete({ messages: [{ role: 'user', content: 'b' }], model: 'model-b' });
// model-a should have been evicted, only model-b remains
expect(shortTtl.poolSize).toBe(1);
expect(mockDispose).toHaveBeenCalled();
shortTtl.dispose();
});
it('dispose kills all pooled clients', async () => {
mockPrompt.mockResolvedValue('ok');
await provider.complete({ messages: [{ role: 'user', content: 'a' }], model: 'model-a' });
await provider.complete({ messages: [{ role: 'user', content: 'b' }], model: 'model-b' });
expect(provider.poolSize).toBe(2);
provider.dispose();
expect(provider.poolSize).toBe(0);
expect(mockDispose).toHaveBeenCalledTimes(2);
});
});
describe('listModels', () => {
it('returns static model list', async () => {
const models = await provider.listModels();
@@ -120,7 +184,9 @@ describe('GeminiAcpProvider', () => {
});
describe('dispose', () => {
it('delegates to AcpClient', () => {
it('delegates to all pooled AcpClients', async () => {
mockPrompt.mockResolvedValue('ok');
await provider.complete({ messages: [{ role: 'user', content: 'test' }] });
provider.dispose();
expect(mockDispose).toHaveBeenCalled();
});