diff --git a/OPENCODE_REFACTOR.md b/OPENCODE_REFACTOR.md index 440f26d..2620ce4 100644 --- a/OPENCODE_REFACTOR.md +++ b/OPENCODE_REFACTOR.md @@ -377,11 +377,11 @@ Domain logic only — no AI protocol code survives. - **Zod v4 schemas work with `tool()`**: Parameterized schemas, `toModelOutput()` for multimodal results - **Anthropic `providerOptions`**: Cache control on system+tools, context management — all confirmed working -### Phase 1: Tools + MCP dedup (1 session) -5. Create `ai/blog-tools.ts` — 16 tools with Zod + execute (port from `executeTool` switch) -6. Create `ai/a2ui-tools.ts` — 7 render tools -7. Wire MCPServer to `blog-tools.ts` for `check_term` / `search_posts` — delete duplication -8. Unit tests for all tools (mock engines, no AI calls) +### Phase 1: Tools + MCP dedup (1 session) ✅ DONE +5. ~~Create `ai/blog-tools.ts` — 16 tools with Zod + execute (port from `executeTool` switch)~~ ✅ +6. ~~Create `ai/a2ui-tools.ts` — 7 render tools~~ ✅ +7. ~~Wire MCPServer to `blog-tools.ts` for `check_term` / `search_posts` — delete duplication~~ ✅ +8. ~~Unit tests for all tools (mock engines, no AI calls)~~ ✅ 45 tests ### Phase 2: Providers + Chat + Tasks (1-2 sessions) 9. Create `ai/providers.ts` — `ProviderRegistry` with OpenCode gateway + Mistral direct diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index 5424fe6..d919258 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -8,6 +8,7 @@ import { } from '@modelcontextprotocol/ext-apps/server'; import { createServer as createHttpServer, type Server } from 'http'; import { z } from 'zod'; +import { buildAmbiguityHints } from './ai/blog-tools'; import { ProposalStore, type ProposalType } from './ProposalStore'; import { reviewPostHtml, @@ -586,9 +587,9 @@ export class MCPServer { 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 }); + const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags); + if (hintsList.length > 0) { + content.push({ type: 'text' as const, text: hintsList.join(' ') }); } return { content }; } @@ -603,43 +604,15 @@ export class MCPServer { ]; // 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 }); + const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags); + if (hintsList.length > 0) { + content.push({ type: 'text' as const, text: hintsList.join(' ') }); } return { content }; }); } - /** Build a hint string when category/tag terms overlap across namespaces. */ - private async buildAmbiguityHints( - category: string | undefined, - tags: string[] | undefined, - ): Promise { - 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', { diff --git a/src/main/engine/ai/a2ui-tools.ts b/src/main/engine/ai/a2ui-tools.ts new file mode 100644 index 0000000..f762207 --- /dev/null +++ b/src/main/engine/ai/a2ui-tools.ts @@ -0,0 +1,145 @@ +/** + * A2UI render tools — rich UI surfaces in the chat. + * + * These tools produce { success: true } as their execute result. + * The actual A2UI message generation happens in chat.ts via + * `experimental_onToolCallFinish`, which calls `generateFromToolCall()`. + * + * Zod schemas here are the single source of truth for the tool parameter shapes. + */ + +import { z } from 'zod'; +import { tool } from 'ai'; + +// --------------------------------------------------------------------------- +// Shared sub-schemas +// --------------------------------------------------------------------------- + +const segmentSchema = z.object({ + label: z.string().describe('Segment/column label'), + value: z.number().describe('Segment value'), +}); + +const seriesItemSchema = z.object({ + label: z.string().describe('Data point label'), + value: z.number().describe('Data point value'), + segments: z.array(segmentSchema).optional() + .describe('Segments within this data point. Required for stacked-bar and heatmap charts.'), +}); + +const chartTypeEnum = z.enum(['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap']); + +const tabContentItemSchema = z.object({ + type: z.enum(['text', 'metric', 'list', 'chart', 'table']).describe('Content type'), + // text + text: z.string().optional().describe('Text content (for type text)'), + // metric + label: z.string().optional().describe('Label (for type metric)'), + value: z.string().optional().describe('Display value (for type metric)'), + // list, chart, table + title: z.string().optional().describe('Title (for type list, chart, or table)'), + items: z.array(z.string()).optional().describe('Items (for type list)'), + // chart + chartType: chartTypeEnum.optional().describe('Chart type (for type chart)'), + series: z.array(seriesItemSchema).optional().describe('Data series (for type chart)'), + // table + columns: z.array(z.string()).optional().describe('Column headers (for type table)'), + rows: z.array(z.array(z.string())).optional().describe('Table rows (for type table)'), +}); + +// --------------------------------------------------------------------------- +// Tool factory +// --------------------------------------------------------------------------- + +export function createA2UITools() { + return { + render_chart: tool({ + description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization.', + inputSchema: z.object({ + chartType: chartTypeEnum.describe('The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations.'), + title: z.string().optional().describe('Optional chart title'), + series: z.array(seriesItemSchema).describe('Array of data points.'), + }), + execute: async (_input) => ({ success: true }), + }), + + render_table: tool({ + description: 'Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.', + inputSchema: z.object({ + title: z.string().optional().describe('Optional table title'), + columns: z.array(z.string()).describe('Column header names'), + rows: z.array(z.array(z.string())).describe('Table rows, each row is an array of cell values'), + }), + execute: async (_input) => ({ success: true }), + }), + + render_form: tool({ + description: 'Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.', + inputSchema: z.object({ + title: z.string().optional().describe('Optional form title'), + fields: z.array(z.object({ + key: z.string().describe('Field identifier'), + label: z.string().describe('Field label shown to user'), + inputType: z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']).describe('Type of input control'), + placeholder: z.string().optional().describe('Placeholder text'), + defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default value'), + options: z.array(z.object({ + label: z.string(), + value: z.string(), + })).optional().describe('Options for select fields'), + required: z.boolean().optional().describe('Whether the field is required'), + })).describe('Form fields to display'), + submitLabel: z.string().describe('Label for the submit button'), + submitAction: z.string().optional().describe('Action to dispatch on submit'), + }), + execute: async (_input) => ({ success: true }), + }), + + render_card: tool({ + description: 'Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.', + inputSchema: z.object({ + title: z.string().describe('Card title'), + body: z.string().describe('Card body text (supports markdown)'), + subtitle: z.string().optional().describe('Optional subtitle'), + actions: z.array(z.object({ + label: z.string().describe('Button label'), + action: z.string().describe('Action name to dispatch'), + payload: z.record(z.string(), z.unknown()).optional().describe('Optional action payload'), + })).optional().describe('Optional action buttons on the card'), + }), + execute: async (_input) => ({ success: true }), + }), + + render_metric: tool({ + description: 'Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.', + inputSchema: z.object({ + label: z.string().describe('Metric label'), + value: z.string().describe('Metric value (displayed prominently)'), + }), + execute: async (_input) => ({ success: true }), + }), + + render_list: tool({ + description: 'Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.', + inputSchema: z.object({ + title: z.string().optional().describe('Optional list title'), + items: z.array(z.string()).describe('List items'), + }), + execute: async (_input) => ({ success: true }), + }), + + render_tabs: tool({ + description: 'Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.', + inputSchema: z.object({ + tabs: z.array(z.object({ + label: z.string().describe('Tab label'), + content: z.array(tabContentItemSchema).describe('Content items within the tab'), + })).describe('Array of tabs'), + }), + execute: async (_input) => ({ success: true }), + }), + }; +} + +/** The return type of createA2UITools — useful for typing tool maps. */ +export type A2UITools = ReturnType; diff --git a/src/main/engine/ai/blog-tools.ts b/src/main/engine/ai/blog-tools.ts new file mode 100644 index 0000000..489982e --- /dev/null +++ b/src/main/engine/ai/blog-tools.ts @@ -0,0 +1,595 @@ +/** + * Blog data tools — single source of truth for both AI SDK chat and MCP server. + * + * Each tool is defined with a Zod input schema and an execute function. + * AI SDK uses these directly via `tool()`. + * MCPServer extracts schemas + handlers from the same definitions. + */ + +import { z } from 'zod'; +import { tool } from 'ai'; +import type { PostData, PostFilter, PaginationOptions } from '../PostEngine'; +import type { MediaData } from '../MediaEngine'; +import type { PostMediaLinkData } from '../PostMediaEngine'; + +// --------------------------------------------------------------------------- +// Dependency contracts — injected at creation time, no hard engine coupling +// --------------------------------------------------------------------------- + +export interface BlogToolDeps { + postEngine: { + getPost: (id: string) => Promise; + getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>; + getPostsFiltered: (filter: PostFilter) => Promise; + searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise; + getCategoriesWithCounts: () => Promise>; + getTagsWithCounts: () => Promise>; + getLinkedBy: (postId: string) => Promise>; + getLinksTo: (postId: string) => Promise>; + updatePost: (id: string, data: Partial) => Promise; + getBlogStats: () => Promise<{ + totalPosts: number; + draftCount: number; + publishedCount: number; + archivedCount: number; + oldestPostDate: Date | null; + newestPostDate: Date | null; + postsPerYear: Record; + tagCount: number; + categoryCount: number; + }>; + getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>; + }; + mediaEngine: { + getMedia: (id: string) => Promise; + getAllMedia: () => Promise; + getMediaFiltered: (filter: { year?: number; month?: number; tags?: string[] }) => Promise; + updateMedia: (id: string, data: Partial) => Promise; + getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise; + }; + postMediaEngine: { + getLinkedMediaDataForPost: (postId: string) => Promise>; + getLinkedPostsForMedia: (mediaId: string) => Promise; + }; +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** Enrich posts with backlinks and outlinks. */ +async function enrichWithLinks( + posts: T[], + postEngine: BlogToolDeps['postEngine'], +): Promise; linksTo: Array<{ id: string; title: string; slug: string }> }>> { + return Promise.all(posts.map(async (p) => { + const [backlinks, linksTo] = await Promise.all([ + postEngine.getLinkedBy(p.id), + postEngine.getLinksTo(p.id), + ]); + return { + ...p, + backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), + }; + })); +} + +/** Contract for ambiguity hint building — narrow so MCPServer can also use it. */ +export interface AmbiguityHintDeps { + getCategoriesWithCounts: () => Promise>; + getTagsWithCounts: () => Promise>; +} + +/** + * Build ambiguity hint strings when category/tag terms overlap across namespaces. + * Shared by both AI SDK tools and MCPServer. + */ +export async function buildAmbiguityHints( + postEngine: AmbiguityHintDeps, + category: string | undefined, + tags: string[] | undefined, +): Promise { + const hints: string[] = []; + + if (category) { + const allTags = await 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 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; +} + +// --------------------------------------------------------------------------- +// Tool factory +// --------------------------------------------------------------------------- + +export function createBlogTools(deps: BlogToolDeps) { + const { postEngine, mediaEngine, postMediaEngine } = deps; + + return { + check_term: tool({ + 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.', + inputSchema: z.object({ + term: z.string().describe('The term to look up'), + }), + execute: async ({ term }) => { + const [categories, tags] = await Promise.all([ + postEngine.getCategoriesWithCounts(), + postEngine.getTagsWithCounts(), + ]); + const termLower = term.toLowerCase(); + const catMatch = categories.find(c => c.category.toLowerCase() === termLower); + const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower); + return { + success: true, + term, + asCategory: !!catMatch, + categoryPostCount: catMatch?.count ?? 0, + asTag: !!tagMatch, + tagPostCount: tagMatch?.count ?? 0, + }; + }, + }), + + search_posts: tool({ + 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.', + inputSchema: z.object({ + query: z.string().describe('The search query text to find in posts'), + category: z.string().optional().describe('Optional category to filter by'), + tags: z.array(z.string()).optional().describe('Optional array of tags to filter by (all must match)'), + year: z.number().optional().describe('Filter to posts created in this year'), + month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'), + limit: z.number().optional().describe('Maximum number of results to return (default: 10)'), + offset: z.number().optional().describe('Offset for pagination (default: 0)'), + }), + execute: async ({ query, category, tags, year, month, limit: lim, offset: off }) => { + if (month !== undefined && year === undefined) { + return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; + } + + const filter: PostFilter = {}; + if (category) filter.categories = [category]; + if (tags && tags.length > 0) filter.tags = tags; + if (year !== undefined) filter.year = year; + if (month !== undefined && year !== undefined) filter.month = month; + + const offset = off ?? 0; + const limit = lim ?? 10; + + const filteredPosts = await postEngine.searchPostsFiltered(query, filter, { offset, limit }); + const totalMatches = filteredPosts.length; + const hints = await buildAmbiguityHints(postEngine, category, tags); + + const posts = await enrichWithLinks( + filteredPosts.map(p => ({ + id: p.id, title: p.title, slug: p.slug, + excerpt: p.excerpt, status: p.status, + categories: p.categories, tags: p.tags, + createdAt: p.createdAt, updatedAt: p.updatedAt, + })), + postEngine, + ); + + const result: Record = { + success: true, + count: posts.length, + totalMatches, + hasMore: false, + offset, + limit, + posts, + }; + if (hints.length > 0) result.hints = hints; + return result; + }, + }), + + read_post: tool({ + description: 'Read the full content and metadata of a specific blog post by its ID. Includes backlinks (posts linking to this post).', + inputSchema: z.object({ + postId: z.string().describe('The unique ID of the post to read'), + }), + execute: async ({ postId }) => { + const post = await postEngine.getPost(postId); + if (!post) return { success: false, error: 'Post not found' }; + const [backlinks, linksTo] = await Promise.all([ + postEngine.getLinkedBy(post.id), + postEngine.getLinksTo(post.id), + ]); + return { + success: true, + post: { + id: post.id, title: post.title, slug: post.slug, + content: post.content, excerpt: post.excerpt, + status: post.status, author: post.author, + categories: post.categories, tags: post.tags, + createdAt: post.createdAt, updatedAt: post.updatedAt, + publishedAt: post.publishedAt, + backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })), + linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })), + }, + }; + }, + }), + + list_posts: tool({ + description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks. The response includes "total" (global post count) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period. Use check_term first if unsure whether a term is a category or tag.', + inputSchema: z.object({ + status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'), + category: z.string().optional().describe('Filter by category'), + tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'), + year: z.number().optional().describe('Filter to posts created in this year'), + month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'), + limit: z.number().optional().describe('Maximum number of results (default: 20)'), + offset: z.number().optional().describe('Offset for pagination (default: 0)'), + }), + execute: async ({ status, category, tags, year, month, limit: lim, offset: off }) => { + if (month !== undefined && year === undefined) { + return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; + } + + const filter: PostFilter = {}; + if (status) filter.status = status; + if (tags) filter.tags = tags; + if (category) filter.categories = [category]; + if (year !== undefined) filter.year = year; + if (month !== undefined && year !== undefined) filter.month = month; + + const offset = off ?? 0; + const limit = lim ?? 20; + + const globalStats = await postEngine.getDashboardStats(); + const globalTotal = globalStats.totalPosts; + + let pageItems: PostData[]; + let filteredTotal: number; + + if (Object.keys(filter).length > 0) { + const allFiltered = await postEngine.getPostsFiltered(filter); + filteredTotal = allFiltered.length; + pageItems = allFiltered.slice(offset, offset + limit); + } else { + const listResult = await postEngine.getAllPosts({ limit, offset }); + pageItems = listResult.items; + filteredTotal = listResult.total; + } + + const hints = await buildAmbiguityHints(postEngine, category, tags); + + const posts = await enrichWithLinks( + pageItems.map(p => ({ + id: p.id, title: p.title, slug: p.slug, + status: p.status, categories: p.categories, + tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt, + })), + postEngine, + ); + + const result: Record = { + success: true, + count: posts.length, + total: globalTotal, + filteredTotal, + hasMore: offset + limit < filteredTotal, + offset, + limit, + posts, + }; + if (hints.length > 0) result.hints = hints; + return result; + }, + }), + + get_media: tool({ + description: 'Get information about a specific media file (image) by its ID.', + inputSchema: z.object({ + mediaId: z.string().describe('The unique ID of the media file'), + }), + execute: async ({ mediaId }) => { + const media = await mediaEngine.getMedia(mediaId); + if (!media) return { success: false, error: 'Media not found' }; + return { + success: true, + media: { + id: media.id, filename: media.filename, + originalName: media.originalName, mimeType: media.mimeType, + size: media.size, width: media.width, height: media.height, + title: media.title, alt: media.alt, caption: media.caption, tags: media.tags, + createdAt: media.createdAt, updatedAt: media.updatedAt, + }, + }; + }, + }), + + list_media: tool({ + description: 'List media files in the current project with optional filtering by MIME type, year, month, or tags. Returns paginated results with total count.', + inputSchema: z.object({ + mimeTypeFilter: z.string().optional().describe('Filter by MIME type prefix (e.g., "image/")'), + tags: z.array(z.string()).optional().describe('Filter by tags (media must have all specified tags)'), + year: z.number().optional().describe('Filter to media created in this year'), + month: z.number().optional().describe('Filter to media created in this month (1-12). Requires year.'), + limit: z.number().optional().describe('Maximum number of results (default: 20)'), + offset: z.number().optional().describe('Offset for pagination (default: 0)'), + }), + execute: async ({ mimeTypeFilter, tags, year, month, limit: lim, offset: off }) => { + if (month !== undefined && year === undefined) { + return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; + } + + const hasMediaFilter = year !== undefined || (tags && tags.length > 0); + let mediaList: MediaData[]; + + if (hasMediaFilter) { + const mediaFilter: { year?: number; month?: number; tags?: string[] } = {}; + if (year !== undefined) mediaFilter.year = year; + if (month !== undefined && year !== undefined) mediaFilter.month = month; + if (tags) mediaFilter.tags = tags; + mediaList = await mediaEngine.getMediaFiltered(mediaFilter); + } else { + mediaList = await mediaEngine.getAllMedia(); + } + + const totalMedia = mediaList.length; + if (mimeTypeFilter) { + mediaList = mediaList.filter(m => m.mimeType.startsWith(mimeTypeFilter)); + } + const filteredTotal = mediaList.length; + const offset = off ?? 0; + const limit = lim ?? 20; + const pageItems = mediaList.slice(offset, offset + limit); + return { + success: true, + count: pageItems.length, + total: totalMedia, + filteredTotal, + hasMore: offset + limit < filteredTotal, + offset, + limit, + media: pageItems.map(m => ({ + id: m.id, filename: m.filename, + originalName: m.originalName, mimeType: m.mimeType, + title: m.title, alt: m.alt, tags: m.tags, + })), + }; + }, + }), + + update_post_metadata: tool({ + description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.', + inputSchema: z.object({ + postId: z.string().describe('The unique ID of the post to update'), + title: z.string().optional().describe('New title for the post'), + excerpt: z.string().optional().describe('New excerpt/summary for the post'), + tags: z.array(z.string()).optional().describe('New tags for the post'), + categories: z.array(z.string()).optional().describe('New categories for the post'), + }), + execute: async ({ postId, title, excerpt, tags, categories }) => { + const updates: Record = {}; + if (title !== undefined) updates.title = title; + if (excerpt !== undefined) updates.excerpt = excerpt; + if (tags !== undefined) updates.tags = tags; + if (categories !== undefined) updates.categories = categories; + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'No updates provided' }; + } + + await postEngine.updatePost(postId, updates); + return { success: true, message: `Post ${postId} metadata updated successfully` }; + }, + }), + + update_media_metadata: tool({ + description: 'Update metadata for a media file (title, alt text, caption, tags).', + inputSchema: z.object({ + mediaId: z.string().describe('The unique ID of the media to update'), + title: z.string().optional().describe('New title for display in lists and search results'), + alt: z.string().optional().describe('New alt text for the image'), + caption: z.string().optional().describe('New caption for the image'), + tags: z.array(z.string()).optional().describe('New tags for the media'), + }), + execute: async ({ mediaId, title, alt, caption, tags }) => { + const updates: Record = {}; + if (title !== undefined) updates.title = title; + if (alt !== undefined) updates.alt = alt; + if (caption !== undefined) updates.caption = caption; + if (tags !== undefined) updates.tags = tags; + + if (Object.keys(updates).length === 0) { + return { success: false, error: 'No updates provided' }; + } + + await mediaEngine.updateMedia(mediaId, updates); + return { success: true, message: `Media ${mediaId} metadata updated successfully` }; + }, + }), + + list_tags: tool({ + description: 'List all tags used across blog posts, with the count of posts using each tag.', + inputSchema: z.object({}), + execute: async () => { + const tagsWithCounts = await postEngine.getTagsWithCounts(); + return { + success: true, + count: tagsWithCounts.length, + tags: tagsWithCounts, + }; + }, + }), + + list_categories: tool({ + description: 'List all categories used across blog posts, with the count of posts in each category.', + inputSchema: z.object({}), + execute: async () => { + const categoriesWithCounts = await postEngine.getCategoriesWithCounts(); + return { + success: true, + count: categoriesWithCounts.length, + categories: categoriesWithCounts, + }; + }, + }), + + get_blog_stats: tool({ + description: 'Get comprehensive blog statistics: total posts, drafts, published, archived counts, date range, posts per year breakdown, unique tags/categories counts, and total media count. Use this FIRST to understand the full scope of the blog before making queries.', + inputSchema: z.object({}), + execute: async () => { + const stats = await postEngine.getBlogStats(); + const mediaList = await mediaEngine.getAllMedia(); + return { + success: true, + totalPosts: stats.totalPosts, + draftCount: stats.draftCount, + publishedCount: stats.publishedCount, + archivedCount: stats.archivedCount, + dateRange: stats.oldestPostDate && stats.newestPostDate + ? { oldest: stats.oldestPostDate, newest: stats.newestPostDate } + : null, + postsPerYear: stats.postsPerYear, + tagCount: stats.tagCount, + categoryCount: stats.categoryCount, + totalMedia: mediaList.length, + }; + }, + }), + + view_image: tool({ + description: 'View an image to analyze its visual content. Returns the actual image for visual inspection. Only works with image files (not PDFs or other media types).', + inputSchema: z.object({ + mediaId: z.string().describe('The unique ID of the image to view'), + size: z.enum(['small', 'medium', 'large']).optional().describe('Image size: small (150px), medium (400px, default), large (800px)'), + }), + execute: async ({ mediaId, size: sizeArg }) => { + const size = sizeArg ?? 'medium'; + const mediaItem = await mediaEngine.getMedia(mediaId); + if (!mediaItem) { + return { success: false, error: 'Image not found' }; + } + if (!mediaItem.mimeType.startsWith('image/')) { + return { success: false, error: `Cannot view this file type: ${mediaItem.mimeType}. Only images are supported.` }; + } + const dataUrl = await mediaEngine.getThumbnailDataUrl(mediaId, size); + if (!dataUrl) { + return { success: false, error: 'Thumbnail not available. Try regenerating thumbnails from Settings.' }; + } + const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); + return { + __isImageResult: true, + success: true, + mediaType: 'image/webp', + base64: base64Data, + metadata: { + id: mediaItem.id, + filename: mediaItem.filename, + originalName: mediaItem.originalName, + width: mediaItem.width, + height: mediaItem.height, + title: mediaItem.title, + alt: mediaItem.alt, + caption: mediaItem.caption, + size, + }, + }; + }, + }), + + get_post_backlinks: tool({ + description: 'Get all posts that link TO a specific post (backlinks/inbound links). Helpful for understanding how content is interconnected.', + inputSchema: z.object({ + postId: z.string().describe('The ID of the post to find backlinks for'), + }), + execute: async ({ postId }) => { + const linkedBy = await postEngine.getLinkedBy(postId); + return { + success: true, + postId, + count: linkedBy.length, + linkedBy: linkedBy.map(p => ({ id: p.id, title: p.title, slug: p.slug })), + }; + }, + }), + + get_post_outlinks: tool({ + description: 'Get all posts that a specific post links TO (outbound links). Helpful for understanding content relationships.', + inputSchema: z.object({ + postId: z.string().describe('The ID of the post to find outbound links for'), + }), + execute: async ({ postId }) => { + const linksTo = await postEngine.getLinksTo(postId); + return { + success: true, + postId, + count: linksTo.length, + linksTo: linksTo.map(p => ({ id: p.id, title: p.title, slug: p.slug })), + }; + }, + }), + + get_post_media: tool({ + description: 'Get all media files linked to a specific post. Returns media explicitly associated with the post (featured images, galleries, etc.).', + inputSchema: z.object({ + postId: z.string().describe('The ID of the post to get linked media for'), + }), + execute: async ({ postId }) => { + const linkedMedia = await postMediaEngine.getLinkedMediaDataForPost(postId); + return { + success: true, + postId, + count: linkedMedia.length, + media: linkedMedia.map(link => ({ + id: link.media.id, + filename: link.media.filename, + originalName: link.media.originalName, + mimeType: link.media.mimeType, + title: link.media.title, + alt: link.media.alt, + caption: link.media.caption, + width: link.media.width, + height: link.media.height, + sortOrder: link.sortOrder, + })), + }; + }, + }), + + get_media_posts: tool({ + description: 'Get all posts that a specific media file is linked to. Helpful for understanding media usage across posts.', + inputSchema: z.object({ + mediaId: z.string().describe('The ID of the media file to find linked posts for'), + }), + execute: async ({ mediaId }) => { + const linkedPosts = await postMediaEngine.getLinkedPostsForMedia(mediaId); + const postsData = await Promise.all( + linkedPosts.map(async (link) => { + const post = await postEngine.getPost(link.postId); + return post ? { id: post.id, title: post.title, slug: post.slug, status: post.status } : null; + }), + ); + const validPosts = postsData.filter(p => p !== null); + return { + success: true, + mediaId, + count: validPosts.length, + posts: validPosts, + }; + }, + }), + }; +} + +/** The return type of createBlogTools — useful for typing tool maps. */ +export type BlogTools = ReturnType; diff --git a/tests/engine/a2ui-tools.test.ts b/tests/engine/a2ui-tools.test.ts new file mode 100644 index 0000000..6fd9b81 --- /dev/null +++ b/tests/engine/a2ui-tools.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for ai/a2ui-tools.ts — 7 A2UI render tools. + * Execute functions just return { success: true }; actual rendering + * is triggered externally via experimental_onToolCallFinish. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { createA2UITools } from '../../src/main/engine/ai/a2ui-tools'; + +describe('A2UI Tools — createA2UITools', () => { + let tools: ReturnType; + + beforeEach(() => { + tools = createA2UITools(); + }); + + const expectedToolNames = [ + 'render_chart', + 'render_table', + 'render_form', + 'render_card', + 'render_metric', + 'render_list', + 'render_tabs', + ]; + + it('returns all 7 tools', () => { + expect(Object.keys(tools)).toHaveLength(7); + for (const name of expectedToolNames) { + expect(tools).toHaveProperty(name); + } + }); + + it('all tools have description and inputSchema', () => { + for (const [name, t] of Object.entries(tools)) { + expect(t.description, `${name} missing description`).toBeTruthy(); + expect(t.inputSchema, `${name} missing inputSchema`).toBeDefined(); + } + }); + + // Each tool's execute should return { success: true } + describe.each(expectedToolNames)('%s returns { success: true }', (toolName) => { + it('executes successfully', async () => { + const tool = tools[toolName as keyof typeof tools]; + // Provide minimal valid input per tool + const inputs: Record = { + render_chart: { chartType: 'bar', title: 'Test', data: [{ label: 'A', value: 1 }] }, + render_table: { title: 'Test', columns: ['Col1'], rows: [['Val1']] }, + render_form: { title: 'Test', fields: [{ name: 'f1', label: 'Field 1', type: 'text' }] }, + render_card: { title: 'Test', content: 'Body' }, + render_metric: { label: 'Test', value: '42' }, + render_list: { title: 'Test', items: [{ label: 'Item', value: '1' }] }, + render_tabs: { + title: 'Test', + tabs: [{ label: 'Tab1', content: [{ type: 'text', data: 'Hello' }] }], + }, + }; + + const result = await tool.execute!( + inputs[toolName] as never, + { toolCallId: `tc-${toolName}`, messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toEqual({ success: true }); + }); + }); +}); diff --git a/tests/engine/blog-tools.test.ts b/tests/engine/blog-tools.test.ts new file mode 100644 index 0000000..556ab1e --- /dev/null +++ b/tests/engine/blog-tools.test.ts @@ -0,0 +1,648 @@ +/** + * Unit tests for ai/blog-tools.ts — 16 blog data tools. + * Tests exercise the real createBlogTools() with mocked engine dependencies. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createBlogTools, buildAmbiguityHints, type BlogToolDeps } from '../../src/main/engine/ai/blog-tools'; + +// --------------------------------------------------------------------------- +// Mock factory — creates a BlogToolDeps with all methods stubbed +// --------------------------------------------------------------------------- + +function createMockDeps(): BlogToolDeps { + return { + postEngine: { + getPost: vi.fn(), + getAllPosts: vi.fn(), + getPostsFiltered: vi.fn(), + searchPostsFiltered: vi.fn(), + getCategoriesWithCounts: vi.fn().mockResolvedValue([]), + getTagsWithCounts: vi.fn().mockResolvedValue([]), + getLinkedBy: vi.fn().mockResolvedValue([]), + getLinksTo: vi.fn().mockResolvedValue([]), + updatePost: vi.fn(), + getBlogStats: vi.fn(), + getDashboardStats: vi.fn(), + }, + mediaEngine: { + getMedia: vi.fn(), + getAllMedia: vi.fn(), + getMediaFiltered: vi.fn(), + updateMedia: vi.fn(), + getThumbnailDataUrl: vi.fn(), + }, + postMediaEngine: { + getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]), + getLinkedPostsForMedia: vi.fn().mockResolvedValue([]), + }, + }; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +const samplePost = { + id: 'post-1', projectId: 'proj-1', title: 'Hello World', slug: 'hello-world', + excerpt: 'A first post', content: '# Hello\n\nWorld', status: 'published' as const, + author: 'gb', createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'), + tags: ['intro'], categories: ['article'], +}; + +const sampleMedia = { + id: 'media-1', filename: 'photo.webp', originalName: 'photo.jpg', + mimeType: 'image/webp', size: 12345, width: 800, height: 600, + title: 'Photo', alt: 'A photo', caption: 'Nice photo', + author: 'gb', createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'), + tags: ['landscape'], +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Blog Tools — createBlogTools', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns all 16 tools', () => { + const names = Object.keys(tools); + expect(names).toHaveLength(16); + expect(names).toContain('check_term'); + expect(names).toContain('search_posts'); + expect(names).toContain('read_post'); + expect(names).toContain('list_posts'); + expect(names).toContain('get_media'); + expect(names).toContain('list_media'); + expect(names).toContain('update_post_metadata'); + expect(names).toContain('update_media_metadata'); + expect(names).toContain('list_tags'); + expect(names).toContain('list_categories'); + expect(names).toContain('get_blog_stats'); + expect(names).toContain('view_image'); + expect(names).toContain('get_post_backlinks'); + expect(names).toContain('get_post_outlinks'); + expect(names).toContain('get_post_media'); + expect(names).toContain('get_media_posts'); + }); + + it('each tool has description and inputSchema', () => { + for (const [name, t] of Object.entries(tools)) { + expect(t.description, `${name} missing description`).toBeTruthy(); + expect(t.inputSchema, `${name} missing inputSchema`).toBeDefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// check_term +// --------------------------------------------------------------------------- + +describe('Blog Tools — check_term', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('finds a term as both category and tag', async () => { + vi.mocked(deps.postEngine.getCategoriesWithCounts).mockResolvedValueOnce([ + { category: 'Travel', count: 5 }, + ]); + vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([ + { tag: 'travel', count: 3 }, + ]); + + const result = await tools.check_term.execute!({ term: 'travel' }, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }); + expect(result).toEqual({ + success: true, + term: 'travel', + asCategory: true, + categoryPostCount: 5, + asTag: true, + tagPostCount: 3, + }); + }); + + it('returns false when term not found', async () => { + const result = await tools.check_term.execute!({ term: 'nonexistent' }, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }); + expect(result).toMatchObject({ + success: true, + asCategory: false, + categoryPostCount: 0, + asTag: false, + tagPostCount: 0, + }); + }); +}); + +// --------------------------------------------------------------------------- +// search_posts +// --------------------------------------------------------------------------- + +describe('Blog Tools — search_posts', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns error when month without year', async () => { + const result = await tools.search_posts.execute!( + { query: 'test', month: 3 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') }); + }); + + it('calls searchPostsFiltered with correct filter', async () => { + vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce([samplePost]); + + const result = await tools.search_posts.execute!( + { query: 'hello', category: 'article', year: 2025 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.postEngine.searchPostsFiltered).toHaveBeenCalledWith( + 'hello', + { categories: ['article'], year: 2025 }, + { offset: 0, limit: 10 }, + ); + expect(result).toMatchObject({ success: true, count: 1 }); + }); + + it('includes ambiguity hints when category also exists as tag', async () => { + vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce([]); + vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([ + { tag: 'article', count: 2 }, + ]); + + const result = await tools.search_posts.execute!( + { query: 'test', category: 'article' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toHaveProperty('hints'); + expect((result as Record).hints).toEqual( + expect.arrayContaining([expect.stringContaining('also exists as a tag')]), + ); + }); +}); + +// --------------------------------------------------------------------------- +// read_post +// --------------------------------------------------------------------------- + +describe('Blog Tools — read_post', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns post with backlinks and outlinks', async () => { + vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(samplePost); + vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([ + { id: 'post-2', title: 'Related', slug: 'related' }, + ]); + + const result = await tools.read_post.execute!( + { postId: 'post-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ + success: true, + post: { + id: 'post-1', + title: 'Hello World', + content: '# Hello\n\nWorld', + backlinks: [{ id: 'post-2', title: 'Related' }], + }, + }); + }); + + it('returns error for nonexistent post', async () => { + vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(null); + const result = await tools.read_post.execute!( + { postId: 'nope' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: 'Post not found' }); + }); +}); + +// --------------------------------------------------------------------------- +// list_posts +// --------------------------------------------------------------------------- + +describe('Blog Tools — list_posts', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns error when month without year', async () => { + const result = await tools.list_posts.execute!( + { month: 6 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') }); + }); + + it('uses getAllPosts when no filters', async () => { + vi.mocked(deps.postEngine.getDashboardStats).mockResolvedValueOnce({ + totalPosts: 100, draftCount: 10, publishedCount: 85, archivedCount: 5, + }); + vi.mocked(deps.postEngine.getAllPosts).mockResolvedValueOnce({ + items: [samplePost], total: 100, hasMore: true, + }); + + const result = await tools.list_posts.execute!( + {}, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.postEngine.getAllPosts).toHaveBeenCalledWith({ limit: 20, offset: 0 }); + expect(result).toMatchObject({ success: true, total: 100, filteredTotal: 100 }); + }); + + it('uses getPostsFiltered when filters present', async () => { + vi.mocked(deps.postEngine.getDashboardStats).mockResolvedValueOnce({ + totalPosts: 100, draftCount: 10, publishedCount: 85, archivedCount: 5, + }); + vi.mocked(deps.postEngine.getPostsFiltered).mockResolvedValueOnce([samplePost]); + + const result = await tools.list_posts.execute!( + { status: 'published', year: 2025 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.postEngine.getPostsFiltered).toHaveBeenCalledWith({ + status: 'published', year: 2025, + }); + expect(result).toMatchObject({ success: true, filteredTotal: 1 }); + }); +}); + +// --------------------------------------------------------------------------- +// get_media / list_media +// --------------------------------------------------------------------------- + +describe('Blog Tools — get_media', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns media metadata', async () => { + vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia); + const result = await tools.get_media.execute!( + { mediaId: 'media-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, media: { id: 'media-1', filename: 'photo.webp' } }); + }); + + it('returns error for missing media', async () => { + vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(null); + const result = await tools.get_media.execute!( + { mediaId: 'nope' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: 'Media not found' }); + }); +}); + +describe('Blog Tools — list_media', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns all media when no filters', async () => { + vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia]); + const result = await tools.list_media.execute!( + {}, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, count: 1, total: 1 }); + }); + + it('filters by MIME type', async () => { + const pdfMedia = { ...sampleMedia, id: 'media-2', mimeType: 'application/pdf' }; + vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia, pdfMedia]); + const result = await tools.list_media.execute!( + { mimeTypeFilter: 'image/' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, count: 1, filteredTotal: 1, total: 2 }); + }); + + it('uses getMediaFiltered when year is provided', async () => { + vi.mocked(deps.mediaEngine.getMediaFiltered).mockResolvedValueOnce([sampleMedia]); + await tools.list_media.execute!( + { year: 2025 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.mediaEngine.getMediaFiltered).toHaveBeenCalledWith({ year: 2025 }); + }); + + it('returns error when month without year', async () => { + const result = await tools.list_media.execute!( + { month: 3 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') }); + }); +}); + +// --------------------------------------------------------------------------- +// update_post_metadata / update_media_metadata +// --------------------------------------------------------------------------- + +describe('Blog Tools — update_post_metadata', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('calls updatePost with provided fields', async () => { + await tools.update_post_metadata.execute!( + { postId: 'post-1', title: 'New Title', tags: ['updated'] }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { title: 'New Title', tags: ['updated'] }); + }); + + it('returns error when no updates provided', async () => { + const result = await tools.update_post_metadata.execute!( + { postId: 'post-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: 'No updates provided' }); + }); +}); + +describe('Blog Tools — update_media_metadata', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('calls updateMedia with provided fields', async () => { + await tools.update_media_metadata.execute!( + { mediaId: 'media-1', alt: 'New alt', tags: ['nature'] }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { alt: 'New alt', tags: ['nature'] }); + }); + + it('returns error when no updates provided', async () => { + const result = await tools.update_media_metadata.execute!( + { mediaId: 'media-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: 'No updates provided' }); + }); +}); + +// --------------------------------------------------------------------------- +// list_tags / list_categories +// --------------------------------------------------------------------------- + +describe('Blog Tools — list_tags & list_categories', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('list_tags returns tags with counts', async () => { + vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([ + { tag: 'travel', count: 10 }, + { tag: 'food', count: 5 }, + ]); + const result = await tools.list_tags.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }); + expect(result).toMatchObject({ success: true, count: 2 }); + }); + + it('list_categories returns categories with counts', async () => { + vi.mocked(deps.postEngine.getCategoriesWithCounts).mockResolvedValueOnce([ + { category: 'article', count: 20 }, + ]); + const result = await tools.list_categories.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }); + expect(result).toMatchObject({ success: true, count: 1 }); + }); +}); + +// --------------------------------------------------------------------------- +// get_blog_stats +// --------------------------------------------------------------------------- + +describe('Blog Tools — get_blog_stats', () => { + it('returns comprehensive stats', async () => { + const deps = createMockDeps(); + const tools = createBlogTools(deps); + + vi.mocked(deps.postEngine.getBlogStats).mockResolvedValueOnce({ + totalPosts: 50, draftCount: 5, publishedCount: 40, archivedCount: 5, + oldestPostDate: new Date('2020-01-01'), newestPostDate: new Date('2025-06-01'), + postsPerYear: { 2020: 10, 2021: 10, 2022: 10, 2023: 10, 2024: 10 }, + tagCount: 25, categoryCount: 4, + }); + vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia, sampleMedia]); + + const result = await tools.get_blog_stats.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }); + expect(result).toMatchObject({ + success: true, + totalPosts: 50, + totalMedia: 2, + tagCount: 25, + }); + }); +}); + +// --------------------------------------------------------------------------- +// view_image +// --------------------------------------------------------------------------- + +describe('Blog Tools — view_image', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('returns base64 image data', async () => { + vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia); + vi.mocked(deps.mediaEngine.getThumbnailDataUrl).mockResolvedValueOnce( + 'data:image/webp;base64,iVBORw0KGgo', + ); + + const result = await tools.view_image.execute!( + { mediaId: 'media-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ + __isImageResult: true, + success: true, + base64: 'iVBORw0KGgo', + mediaType: 'image/webp', + }); + }); + + it('rejects non-image media', async () => { + vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce({ + ...sampleMedia, mimeType: 'application/pdf', + }); + const result = await tools.view_image.execute!( + { mediaId: 'media-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: expect.stringContaining('Only images') }); + }); + + it('returns error when thumbnail unavailable', async () => { + vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia); + vi.mocked(deps.mediaEngine.getThumbnailDataUrl).mockResolvedValueOnce(null); + const result = await tools.view_image.execute!( + { mediaId: 'media-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: expect.stringContaining('Thumbnail not available') }); + }); +}); + +// --------------------------------------------------------------------------- +// Link tools +// --------------------------------------------------------------------------- + +describe('Blog Tools — link tools', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('get_post_backlinks returns linked-by posts', async () => { + vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([ + { id: 'post-2', title: 'Ref', slug: 'ref' }, + ]); + const result = await tools.get_post_backlinks.execute!( + { postId: 'post-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, count: 1, linkedBy: [{ id: 'post-2' }] }); + }); + + it('get_post_outlinks returns links-to posts', async () => { + vi.mocked(deps.postEngine.getLinksTo).mockResolvedValueOnce([ + { id: 'post-3', title: 'Target', slug: 'target' }, + ]); + const result = await tools.get_post_outlinks.execute!( + { postId: 'post-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, count: 1, linksTo: [{ id: 'post-3' }] }); + }); + + it('get_post_media returns linked media', async () => { + vi.mocked(deps.postMediaEngine.getLinkedMediaDataForPost).mockResolvedValueOnce([{ + id: 'link-1', projectId: 'proj-1', postId: 'post-1', mediaId: 'media-1', + sortOrder: 0, createdAt: new Date(), + media: sampleMedia, + }]); + const result = await tools.get_post_media.execute!( + { postId: 'post-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, count: 1, media: [{ id: 'media-1' }] }); + }); + + it('get_media_posts returns linked posts', async () => { + vi.mocked(deps.postMediaEngine.getLinkedPostsForMedia).mockResolvedValueOnce([{ + id: 'link-1', projectId: 'proj-1', postId: 'post-1', mediaId: 'media-1', + sortOrder: 0, createdAt: new Date(), + }]); + vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(samplePost); + + const result = await tools.get_media_posts.execute!( + { mediaId: 'media-1' }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: true, count: 1, posts: [{ id: 'post-1' }] }); + }); +}); + +// --------------------------------------------------------------------------- +// buildAmbiguityHints (shared helper) +// --------------------------------------------------------------------------- + +describe('buildAmbiguityHints', () => { + it('returns hint when category also exists as tag', async () => { + const engine = { + getCategoriesWithCounts: vi.fn().mockResolvedValue([]), + getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'travel', count: 3 }]), + }; + const hints = await buildAmbiguityHints(engine, 'travel', undefined); + expect(hints).toHaveLength(1); + expect(hints[0]).toContain('also exists as a tag'); + }); + + it('returns hint when tag also exists as category', async () => { + const engine = { + getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'photo', count: 5 }]), + getTagsWithCounts: vi.fn().mockResolvedValue([]), + }; + const hints = await buildAmbiguityHints(engine, undefined, ['photo']); + expect(hints).toHaveLength(1); + expect(hints[0]).toContain('also exists as a category'); + }); + + it('returns empty when no overlaps', async () => { + const engine = { + getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'article', count: 10 }]), + getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'travel', count: 3 }]), + }; + const hints = await buildAmbiguityHints(engine, 'article', ['travel']); + expect(hints).toHaveLength(0); + }); + + it('returns empty when no category or tags given', async () => { + const engine = { + getCategoriesWithCounts: vi.fn().mockResolvedValue([]), + getTagsWithCounts: vi.fn().mockResolvedValue([]), + }; + const hints = await buildAmbiguityHints(engine, undefined, undefined); + expect(hints).toHaveLength(0); + }); +});