feat: some better helps for AI assistants

This commit is contained in:
2026-03-01 17:29:45 +01:00
parent c8d72486f1
commit 7cc50e35ee
4 changed files with 486 additions and 13 deletions

View File

@@ -463,6 +463,11 @@ describe('MCPServer', () => {
const annotations = getToolAnnotations('discard_proposal');
expect(annotations).toEqual({ readOnlyHint: false, destructiveHint: true, idempotentHint: true });
});
it('check_term has readOnlyHint true', () => {
const annotations = getToolAnnotations('check_term');
expect(annotations).toEqual({ readOnlyHint: true, openWorldHint: false });
});
});
// ── Tool visibility ─────────────────────────────────────────────────
@@ -974,6 +979,140 @@ describe('MCPServer', () => {
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('DB error');
});
// ── check_term tool ──────────────────────────────────────────────
it('registers check_term tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'check_term')).toBe(true);
});
it('check_term returns category and tag info for a term that exists as both', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'wiki', count: 3 },
{ category: 'tech', count: 5 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 1 },
{ tag: 'python', count: 4 },
]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'check_term');
const result = await tool.handler({ term: 'wiki' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.term).toBe('wiki');
expect(parsed.asCategory).toBe(true);
expect(parsed.categoryPostCount).toBe(3);
expect(parsed.asTag).toBe(true);
expect(parsed.tagPostCount).toBe(1);
});
it('check_term returns false for a term that does not exist', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'tech', count: 5 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'python', count: 4 },
]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'check_term');
const result = await tool.handler({ term: 'nonexistent' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.term).toBe('nonexistent');
expect(parsed.asCategory).toBe(false);
expect(parsed.categoryPostCount).toBe(0);
expect(parsed.asTag).toBe(false);
expect(parsed.tagPostCount).toBe(0);
});
it('check_term is case-insensitive', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'Wiki', count: 3 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'check_term');
const result = await tool.handler({ term: 'wiki' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed.asCategory).toBe(true);
expect(parsed.categoryPostCount).toBe(3);
});
// ── search_posts month validation ────────────────────────────────
it('search_posts returns error when month is given without year', async () => {
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ month: 3 }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.error).toContain('month');
expect(parsed.error).toContain('year');
});
it('search_posts accepts month when year is also given', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ year: 2025, month: 3 }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBeUndefined();
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith(
expect.objectContaining({ year: 2025, month: 3 }),
);
});
// ── search_posts ambiguity hints ─────────────────────────────────
it('search_posts includes hint when category term also exists as tag', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', categories: ['wiki'], tags: [] },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 2 },
]);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ category: 'wiki' }, {}) as { content: Array<{ text: string }> };
// Should have a second content item with the hint
expect(result.content.length).toBeGreaterThan(1);
const hintText = result.content.find(c => c.text.includes('wiki'))?.text ?? '';
expect(hintText).toContain('tag');
});
it('search_posts includes hint when tag terms also exist as categories', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', categories: [], tags: ['wiki'] },
]);
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'wiki', count: 3 },
]);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ tags: ['wiki'] }, {}) as { content: Array<{ text: string }> };
expect(result.content.length).toBeGreaterThan(1);
const hintText = result.content[1].text;
expect(hintText).toContain('wiki');
expect(hintText).toContain('category');
});
it('search_posts does not include hint when no ambiguity exists', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', categories: ['tech'], tags: [] },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'python', count: 4 },
]);
mockPostEngine.getLinkedBy.mockResolvedValue([]);
mockPostEngine.getLinksTo.mockResolvedValue([]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ category: 'tech' }, {}) as { content: Array<{ text: string }> };
expect(result.content).toHaveLength(1);
});
});
// ── Prompt handler behavior ────────────────────────────────────────

View File

@@ -199,6 +199,179 @@ describe('OpenCodeManager tool execution backlinks & linksTo', () => {
expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p5');
});
});
// ── check_term tool ──────────────────────────────────────────────
describe('check_term', () => {
it('returns category and tag info for a term that exists as both', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'wiki', count: 3 },
{ category: 'tech', count: 5 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 1 },
{ tag: 'python', count: 4 },
]);
const result = await (manager as any).executeTool('check_term', { term: 'wiki' });
expect(result.success).toBe(true);
expect(result.term).toBe('wiki');
expect(result.asCategory).toBe(true);
expect(result.categoryPostCount).toBe(3);
expect(result.asTag).toBe(true);
expect(result.tagPostCount).toBe(1);
});
it('returns false for a term that does not exist', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'tech', count: 5 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'python', count: 4 },
]);
const result = await (manager as any).executeTool('check_term', { term: 'nonexistent' });
expect(result.success).toBe(true);
expect(result.term).toBe('nonexistent');
expect(result.asCategory).toBe(false);
expect(result.categoryPostCount).toBe(0);
expect(result.asTag).toBe(false);
expect(result.tagPostCount).toBe(0);
});
it('is case-insensitive', async () => {
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'Wiki', count: 3 },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([]);
const result = await (manager as any).executeTool('check_term', { term: 'wiki' });
expect(result.success).toBe(true);
expect(result.asCategory).toBe(true);
expect(result.categoryPostCount).toBe(3);
});
});
// ── month validation ────────────────────────────────────────────────
describe('month validation', () => {
it('search_posts returns error when month is given without year', async () => {
const result = await (manager as any).executeTool('search_posts', { query: 'test', month: 3 });
expect(result.success).toBe(false);
expect(result.error).toContain('month');
expect(result.error).toContain('year');
});
it('list_posts returns error when month is given without year', async () => {
const result = await (manager as any).executeTool('list_posts', { month: 3 });
expect(result.success).toBe(false);
expect(result.error).toContain('month');
expect(result.error).toContain('year');
});
it('list_media returns error when month is given without year', async () => {
const result = await (manager as any).executeTool('list_media', { month: 3 });
expect(result.success).toBe(false);
expect(result.error).toContain('month');
expect(result.error).toContain('year');
});
it('search_posts accepts month when year is also given', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
const result = await (manager as any).executeTool('search_posts', { query: 'test', year: 2025, month: 3 });
expect(result.success).toBe(true);
});
it('list_posts accepts month when year is also given', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
const result = await (manager as any).executeTool('list_posts', { year: 2025, month: 3 });
expect(result.success).toBe(true);
});
});
// ── ambiguity hints ─────────────────────────────────────────────────
describe('ambiguity hints', () => {
it('search_posts includes hint when category also exists as tag', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', slug: 'post', excerpt: '', status: 'published', categories: ['wiki'], tags: [], createdAt: new Date(), updatedAt: new Date() },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 2 },
]);
const result = await (manager as any).executeTool('search_posts', { query: 'test', category: 'wiki' });
expect(result.success).toBe(true);
expect(result.hints).toBeDefined();
expect(result.hints.length).toBeGreaterThan(0);
expect(result.hints[0]).toContain('wiki');
expect(result.hints[0]).toContain('tag');
});
it('list_posts includes hint when category also exists as tag', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([
{ id: 'p1', title: 'Post', slug: 'post', status: 'published', categories: ['wiki'], tags: [], createdAt: new Date(), updatedAt: new Date() },
]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'wiki', count: 2 },
]);
const result = await (manager as any).executeTool('list_posts', { category: 'wiki' });
expect(result.success).toBe(true);
expect(result.hints).toBeDefined();
expect(result.hints[0]).toContain('wiki');
expect(result.hints[0]).toContain('tag');
});
it('list_posts includes hint when tags also exist as categories', async () => {
mockPostEngine.getPostsFiltered.mockResolvedValue([]);
mockPostEngine.getCategoriesWithCounts.mockResolvedValue([
{ category: 'wiki', count: 3 },
]);
const result = await (manager as any).executeTool('list_posts', { tags: ['wiki'] });
expect(result.success).toBe(true);
expect(result.hints).toBeDefined();
expect(result.hints[0]).toContain('wiki');
expect(result.hints[0]).toContain('category');
});
it('search_posts does not include hints when no ambiguity exists', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
mockPostEngine.getTagsWithCounts.mockResolvedValue([
{ tag: 'python', count: 4 },
]);
const result = await (manager as any).executeTool('search_posts', { query: 'test', category: 'tech' });
expect(result.success).toBe(true);
expect(result.hints).toBeUndefined();
});
});
});
// ── check_term tool definition ──────────────────────────────────────
describe('OpenCodeManager tool definitions', () => {
let manager: OpenCodeManager;
beforeEach(() => {
vi.clearAllMocks();
manager = createManager(createMockPostEngine());
});
it('includes check_term in tool definitions', () => {
const tools = (manager as any).getToolDefinitions();
const checkTerm = tools.find((t: any) => t.name === 'check_term');
expect(checkTerm).toBeDefined();
expect(checkTerm.input_schema.required).toContain('term');
});
});
describe('OpenCodeManager getMaxOutputTokens (ModelCatalogEngine delegate)', () => {