Files
mcpctl/src/mcplocal/tests/llm-selector.test.ts
Michal ecc9c48597 feat: gated project experience & prompt intelligence
Implements the full gated session flow and prompt intelligence system:

- Prisma schema: add gated, priority, summary, chapters, linkTarget fields
- Session gate: state machine (gated → begin_session → ungated) with LLM-powered
  tool selection based on prompt index
- Tag matcher: intelligent prompt-to-tool matching with project/server/action tags
- LLM selector: tiered provider selection (fast for gating, heavy for complex tasks)
- Link resolver: cross-project MCP resource references (project/server:uri format)
- Prompt summary service: LLM-generated summaries and chapter extraction
- System project bootstrap: ensures default project exists on startup
- Structural link health checks: enrichWithLinkStatus on prompt GET endpoints
- CLI: create prompt --priority/--link, create project --gated/--no-gated,
  describe project shows prompts section, get prompts shows PRI/LINK/STATUS
- Apply/edit: priority, linkTarget, gated fields supported
- Shell completions: fish updated with new flags
- 1,253 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:22:42 +00:00

167 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { LlmPromptSelector, type PromptIndexForLlm } from '../src/gate/llm-selector.js';
import { ProviderRegistry } from '../src/providers/registry.js';
import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js';
function makeMockProvider(responseContent: string): LlmProvider {
return {
name: 'mock-heavy',
complete: vi.fn().mockResolvedValue({
content: responseContent,
toolCalls: [],
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
finishReason: 'stop',
} satisfies CompletionResult),
listModels: vi.fn().mockResolvedValue(['mock-model']),
isAvailable: vi.fn().mockResolvedValue(true),
};
}
function makeRegistry(provider: LlmProvider): ProviderRegistry {
const registry = new ProviderRegistry();
registry.register(provider);
registry.assignTier(provider.name, 'heavy');
return registry;
}
const sampleIndex: PromptIndexForLlm[] = [
{ name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices', chapters: ['Setup', 'Troubleshooting'] },
{ name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration', chapters: null },
{ name: 'common-mistakes', priority: 10, summary: 'Critical safety rules', chapters: null },
];
describe('LlmPromptSelector', () => {
it('sends tags and index to heavy LLM and parses response', async () => {
const provider = makeMockProvider(
'```json\n{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }\n```',
);
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const result = await selector.selectPrompts(['zigbee', 'pairing'], sampleIndex);
expect(result.selectedNames).toContain('zigbee-pairing');
expect(result.selectedNames).toContain('common-mistakes'); // Priority 10 always included
expect(result.reasoning).toBe('User is working with zigbee');
});
it('always includes priority 10 prompts even if LLM omits them', async () => {
const provider = makeMockProvider(
'{ "selectedNames": ["mqtt-config"], "reasoning": "MQTT related" }',
);
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const result = await selector.selectPrompts(['mqtt'], sampleIndex);
expect(result.selectedNames).toContain('mqtt-config');
expect(result.selectedNames).toContain('common-mistakes');
});
it('does not duplicate priority 10 if LLM already selected them', async () => {
const provider = makeMockProvider(
'{ "selectedNames": ["common-mistakes", "mqtt-config"], "reasoning": "Both needed" }',
);
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const result = await selector.selectPrompts(['mqtt'], sampleIndex);
const count = result.selectedNames.filter((n) => n === 'common-mistakes').length;
expect(count).toBe(1);
});
it('passes system and user messages to provider.complete', async () => {
const provider = makeMockProvider(
'{ "selectedNames": [], "reasoning": "none" }',
);
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
await selector.selectPrompts(['test'], sampleIndex);
expect(provider.complete).toHaveBeenCalledOnce();
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.messages).toHaveLength(2);
expect(call.messages[0]!.role).toBe('system');
expect(call.messages[1]!.role).toBe('user');
expect(call.messages[1]!.content).toContain('test');
expect(call.temperature).toBe(0);
});
it('passes model override to complete options', async () => {
const provider = makeMockProvider(
'{ "selectedNames": [], "reasoning": "" }',
);
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry, 'gemini-pro');
await selector.selectPrompts(['test'], sampleIndex);
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
expect(call.model).toBe('gemini-pro');
});
it('throws when no heavy provider is available', async () => {
const registry = new ProviderRegistry(); // Empty registry
const selector = new LlmPromptSelector(registry);
await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow(
'No heavy LLM provider available',
);
});
it('throws when LLM response has no valid JSON', async () => {
const provider = makeMockProvider('I cannot help with that request.');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow(
'LLM response did not contain valid selection JSON',
);
});
it('handles response with empty selectedNames', async () => {
const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "nothing matched" }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
// Empty selectedNames, but priority 10 should still be included
const result = await selector.selectPrompts(['test'], sampleIndex);
expect(result.selectedNames).toEqual(['common-mistakes']);
expect(result.reasoning).toBe('nothing matched');
});
it('handles response with reasoning missing', async () => {
const provider = makeMockProvider('{ "selectedNames": ["mqtt-config"] }');
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
const result = await selector.selectPrompts(['test'], sampleIndex);
expect(result.reasoning).toBe('');
expect(result.selectedNames).toContain('mqtt-config');
});
it('includes prompt details in the user prompt', async () => {
const indexWithNull: PromptIndexForLlm[] = [
...sampleIndex,
{ name: 'no-desc', priority: 3, summary: null, chapters: null },
];
const provider = makeMockProvider(
'{ "selectedNames": [], "reasoning": "" }',
);
const registry = makeRegistry(provider);
const selector = new LlmPromptSelector(registry);
await selector.selectPrompts(['zigbee'], indexWithNull);
const call = (provider.complete as ReturnType<typeof vi.fn>).mock.calls[0]![0] as CompletionOptions;
const userMsg = call.messages[1]!.content;
expect(userMsg).toContain('zigbee-pairing');
expect(userMsg).toContain('priority: 7');
expect(userMsg).toContain('How to pair Zigbee devices');
expect(userMsg).toContain('Setup, Troubleshooting');
expect(userMsg).toContain('No summary'); // For prompts with null summary
});
});