feat: some better helps for AI assistants
This commit is contained in:
@@ -488,21 +488,60 @@ export class MCPServer {
|
||||
// ── Tool registration ──────────────────────────────────────────────
|
||||
|
||||
private registerReadTools(server: McpServer): void {
|
||||
// ── check_term ──
|
||||
server.registerTool('check_term', {
|
||||
title: 'Check Term',
|
||||
description: 'Check whether a term exists as a category, tag, or both. Returns post counts for each. Useful for disambiguation before querying with search_posts.',
|
||||
inputSchema: {
|
||||
term: z.string().describe('The term to look up'),
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}, async (args) => {
|
||||
const [categories, tags] = await Promise.all([
|
||||
this.deps.postEngine.getCategoriesWithCounts(),
|
||||
this.deps.postEngine.getTagsWithCounts(),
|
||||
]);
|
||||
const termLower = args.term.toLowerCase();
|
||||
const catMatch = categories.find(c => c.category.toLowerCase() === termLower);
|
||||
const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify({
|
||||
term: args.term,
|
||||
asCategory: !!catMatch,
|
||||
categoryPostCount: catMatch?.count ?? 0,
|
||||
asTag: !!tagMatch,
|
||||
tagPostCount: tagMatch?.count ?? 0,
|
||||
}),
|
||||
}],
|
||||
};
|
||||
});
|
||||
|
||||
// ── search_posts ──
|
||||
server.registerTool('search_posts', {
|
||||
title: 'Search Posts',
|
||||
description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it) and linksTo (posts it links to).',
|
||||
description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it) and linksTo (posts it links to). Use check_term first if unsure whether a term is a category or tag. Also available as resources: bds://categories (categories with counts), bds://tags (tags with counts).',
|
||||
inputSchema: {
|
||||
query: z.string().optional().describe('Full-text search query'),
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
|
||||
year: z.number().optional().describe('Filter by year'),
|
||||
month: z.number().optional().describe('Filter by month (1-12)'),
|
||||
month: z.number().optional().describe('Filter by month (1-12). Requires year.'),
|
||||
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
|
||||
offset: z.number().optional().describe('Pagination offset'),
|
||||
limit: z.number().optional().describe('Max results to return'),
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}, async (args) => {
|
||||
// Validate: month requires year
|
||||
if (args.month && !args.year) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const hasFilters = args.category || args.tags || args.year || args.month || args.status;
|
||||
const offset = args.offset ?? 0;
|
||||
const limit = args.limit ?? 50;
|
||||
@@ -544,17 +583,63 @@ export class MCPServer {
|
||||
args.query, filter, { offset, limit },
|
||||
);
|
||||
const enriched = await enrichWithLinks(results);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||
const content: Array<{ type: 'text'; text: string }> = [
|
||||
{ type: 'text' as const, text: JSON.stringify(enriched) },
|
||||
];
|
||||
const hints = await this.buildAmbiguityHints(args.category, args.tags);
|
||||
if (hints) {
|
||||
content.push({ type: 'text' as const, text: hints });
|
||||
}
|
||||
return { content };
|
||||
}
|
||||
|
||||
// Filter-only query (no text search)
|
||||
const results = await this.deps.postEngine.getPostsFiltered(filter);
|
||||
const paginated = results.slice(offset, offset + limit);
|
||||
const enriched = await enrichWithLinks(paginated);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||
|
||||
const content: Array<{ type: 'text'; text: string }> = [
|
||||
{ type: 'text' as const, text: JSON.stringify(enriched) },
|
||||
];
|
||||
|
||||
// Ambiguity hints: check if category/tag terms exist in the other namespace
|
||||
const hints = await this.buildAmbiguityHints(args.category, args.tags);
|
||||
if (hints) {
|
||||
content.push({ type: 'text' as const, text: hints });
|
||||
}
|
||||
|
||||
return { content };
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a hint string when category/tag terms overlap across namespaces. */
|
||||
private async buildAmbiguityHints(
|
||||
category: string | undefined,
|
||||
tags: string[] | undefined,
|
||||
): Promise<string | null> {
|
||||
const hints: string[] = [];
|
||||
|
||||
if (category) {
|
||||
const allTags = await this.deps.postEngine.getTagsWithCounts();
|
||||
const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase());
|
||||
if (tagMatch) {
|
||||
hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
const allCats = await this.deps.postEngine.getCategoriesWithCounts();
|
||||
for (const tag of tags) {
|
||||
const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase());
|
||||
if (catMatch) {
|
||||
hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints.length > 0 ? hints.join(' ') : null;
|
||||
}
|
||||
|
||||
private registerProposalTools(server: McpServer): void {
|
||||
// ── draft_post ──
|
||||
registerAppTool(server, 'draft_post', {
|
||||
|
||||
@@ -1123,15 +1123,26 @@ export class OpenCodeManager {
|
||||
*/
|
||||
private getToolDefinitions(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'check_term',
|
||||
description: 'Check whether a term exists as a category, tag, or both. Returns post counts for each. Use this before search_posts or list_posts when unsure whether a term is a category or tag.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: { type: 'string', description: 'The term to look up' },
|
||||
},
|
||||
required: ['term'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_posts',
|
||||
description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit.',
|
||||
description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit. Use check_term first if unsure whether a term is a category or tag.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'The search query text to find in posts' },
|
||||
category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by' },
|
||||
tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by (all must match)' },
|
||||
year: { type: 'number', description: 'Filter to posts created in this year (e.g., 2024)' },
|
||||
month: { type: 'number', description: 'Filter to posts created in this month (1-12). Requires year.' },
|
||||
limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' },
|
||||
@@ -1153,7 +1164,7 @@ export class OpenCodeManager {
|
||||
},
|
||||
{
|
||||
name: 'list_posts',
|
||||
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks (posts linking to it). The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results.',
|
||||
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks (posts linking to it). The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results. Use check_term first if unsure whether a term is a category or tag.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -1511,7 +1522,29 @@ export class OpenCodeManager {
|
||||
private async executeTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
try {
|
||||
switch (name) {
|
||||
case 'check_term': {
|
||||
const [categories, tags] = await Promise.all([
|
||||
this.postEngine.getCategoriesWithCounts(),
|
||||
this.postEngine.getTagsWithCounts(),
|
||||
]);
|
||||
const termLower = (args.term as string).toLowerCase();
|
||||
const catMatch = categories.find(c => c.category.toLowerCase() === termLower);
|
||||
const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower);
|
||||
return {
|
||||
success: true,
|
||||
term: args.term as string,
|
||||
asCategory: !!catMatch,
|
||||
categoryPostCount: catMatch?.count ?? 0,
|
||||
asTag: !!tagMatch,
|
||||
tagPostCount: tagMatch?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
case 'search_posts': {
|
||||
if (args.month !== undefined && args.year === undefined) {
|
||||
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||
}
|
||||
|
||||
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
|
||||
if (args.category) filter.categories = [args.category as string];
|
||||
if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) filter.tags = args.tags as string[];
|
||||
@@ -1530,8 +1563,9 @@ export class OpenCodeManager {
|
||||
);
|
||||
|
||||
const totalMatches = filteredPosts.length;
|
||||
const hints = await this.buildAmbiguityHints(args.category as string | undefined, args.tags as string[] | undefined);
|
||||
|
||||
return {
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
count: filteredPosts.length,
|
||||
totalMatches,
|
||||
@@ -1553,6 +1587,8 @@ export class OpenCodeManager {
|
||||
};
|
||||
})),
|
||||
};
|
||||
if (hints.length > 0) result.hints = hints;
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'read_post': {
|
||||
@@ -1578,6 +1614,10 @@ export class OpenCodeManager {
|
||||
}
|
||||
|
||||
case 'list_posts': {
|
||||
if (args.month !== undefined && args.year === undefined) {
|
||||
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||
}
|
||||
|
||||
const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {};
|
||||
if (args.status) filter.status = args.status as 'draft' | 'published' | 'archived';
|
||||
if (args.tags) filter.tags = args.tags as string[];
|
||||
@@ -1600,12 +1640,14 @@ export class OpenCodeManager {
|
||||
filteredTotal = allFiltered.length;
|
||||
pageItems = allFiltered.slice(offset, offset + limit);
|
||||
} else {
|
||||
const result = await this.postEngine.getAllPosts({ limit, offset });
|
||||
pageItems = result.items;
|
||||
filteredTotal = result.total;
|
||||
const listResult = await this.postEngine.getAllPosts({ limit, offset });
|
||||
pageItems = listResult.items;
|
||||
filteredTotal = listResult.total;
|
||||
}
|
||||
|
||||
return {
|
||||
const hints = await this.buildAmbiguityHints(args.category as string | undefined, args.tags as string[] | undefined);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
success: true,
|
||||
count: pageItems.length,
|
||||
total: globalTotal,
|
||||
@@ -1627,6 +1669,8 @@ export class OpenCodeManager {
|
||||
};
|
||||
})),
|
||||
};
|
||||
if (hints.length > 0) result.hints = hints;
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'get_media': {
|
||||
@@ -1645,6 +1689,10 @@ export class OpenCodeManager {
|
||||
}
|
||||
|
||||
case 'list_media': {
|
||||
if (args.month !== undefined && args.year === undefined) {
|
||||
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||
}
|
||||
|
||||
const hasMediaFilter = args.year !== undefined || (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0);
|
||||
let mediaList: MediaData[];
|
||||
|
||||
@@ -1877,6 +1925,34 @@ export class OpenCodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Build ambiguity hint strings when category/tag terms overlap across namespaces. */
|
||||
private async buildAmbiguityHints(
|
||||
category: string | undefined,
|
||||
tags: string[] | undefined,
|
||||
): Promise<string[]> {
|
||||
const hints: string[] = [];
|
||||
|
||||
if (category) {
|
||||
const allTags = await this.postEngine.getTagsWithCounts();
|
||||
const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase());
|
||||
if (tagMatch) {
|
||||
hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
const allCats = await this.postEngine.getCategoriesWithCounts();
|
||||
for (const tag of tags) {
|
||||
const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase());
|
||||
if (catMatch) {
|
||||
hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a string using a rough character heuristic.
|
||||
* ~3.5 characters per token for English text (conservative, tends to overestimate).
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user