/** * OpenCodeManager Tool Execution Tests * * Tests the executeTool method for post-related tools, * specifically that backlinks and linksTo are included in results. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; // Mock dependencies before importing the class vi.mock('../../src/main/engine/ChatEngine', () => ({ ChatEngine: class { getSetting = vi.fn(); setSetting = vi.fn(); getSelectedModel = vi.fn(); getDefaultSystemPrompt = vi.fn(); }, })); vi.mock('../../src/main/engine/PostEngine', () => ({ getPostEngine: vi.fn(() => ({})), })); vi.mock('../../src/main/engine/MediaEngine', () => ({ getMediaEngine: vi.fn(() => ({})), })); vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({})), })); import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager'; function createMockPostEngine() { return { getPost: vi.fn(), searchPosts: vi.fn(), searchPostsFiltered: vi.fn(), getAllPosts: vi.fn(), getPostsFiltered: vi.fn(), getDashboardStats: vi.fn().mockResolvedValue({ totalPosts: 0 }), getLinkedBy: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]), getTagsWithCounts: vi.fn().mockResolvedValue([]), getCategoriesWithCounts: vi.fn().mockResolvedValue([]), getBlogStats: vi.fn().mockResolvedValue({}), }; } function createMockMediaEngine() { return { getAllMedia: vi.fn(), getMedia: vi.fn(), getThumbnailDataUrl: vi.fn(), }; } function createMockPostMediaEngine() { return { getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]), getLinkedPostsForMedia: vi.fn().mockResolvedValue([]), }; } function createManager(postEngine: ReturnType, mediaEngine?: ReturnType, postMediaEngine?: ReturnType) { const manager = new OpenCodeManager( { getSetting: vi.fn(), setSetting: vi.fn() } as never, postEngine as never, (mediaEngine ?? createMockMediaEngine()) as never, (postMediaEngine ?? createMockPostMediaEngine()) as never, () => null, ); return manager; } describe('OpenCodeManager tool execution – backlinks & linksTo', () => { let mockPostEngine: ReturnType; let manager: OpenCodeManager; beforeEach(() => { vi.clearAllMocks(); mockPostEngine = createMockPostEngine(); manager = createManager(mockPostEngine); }); describe('read_post', () => { it('includes backlinks and linksTo in the response', async () => { const post = { id: 'p1', title: 'Target Post', slug: 'target-post', content: '# Hello', excerpt: 'Hello', status: 'published', author: 'Test', categories: ['article'], tags: ['test'], createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'), publishedAt: new Date('2025-01-01'), }; mockPostEngine.getPost.mockResolvedValue(post); mockPostEngine.getLinkedBy.mockResolvedValue([ { id: 'p2', title: 'Linking Post A', slug: 'linking-a' }, { id: 'p3', title: 'Linking Post B', slug: 'linking-b' }, ]); mockPostEngine.getLinksTo.mockResolvedValue([ { id: 'p4', title: 'Linked Target', slug: 'linked-target' }, ]); const result = await (manager as any).executeTool('read_post', { postId: 'p1' }); expect(result.success).toBe(true); expect(result.post.backlinks).toEqual([ { id: 'p2', title: 'Linking Post A', slug: 'linking-a' }, { id: 'p3', title: 'Linking Post B', slug: 'linking-b' }, ]); expect(result.post.linksTo).toEqual([ { id: 'p4', title: 'Linked Target', slug: 'linked-target' }, ]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1'); expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1'); }); it('returns empty backlinks and linksTo arrays when none exist', async () => { const post = { id: 'p1', title: 'Lonely Post', slug: 'lonely-post', content: '# Alone', excerpt: '', status: 'draft', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date(), }; mockPostEngine.getPost.mockResolvedValue(post); mockPostEngine.getLinkedBy.mockResolvedValue([]); mockPostEngine.getLinksTo.mockResolvedValue([]); const result = await (manager as any).executeTool('read_post', { postId: 'p1' }); expect(result.success).toBe(true); expect(result.post.backlinks).toEqual([]); expect(result.post.linksTo).toEqual([]); }); }); describe('search_posts', () => { it('includes backlinks and linksTo for each post in search results', async () => { const posts = [ { id: 'p1', title: 'Post One', slug: 'post-one', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() }, { id: 'p2', title: 'Post Two', slug: 'post-two', excerpt: '', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() }, ]; mockPostEngine.searchPostsFiltered.mockResolvedValue(posts); mockPostEngine.getLinkedBy .mockResolvedValueOnce([{ id: 'p3', title: 'Linker', slug: 'linker' }]) .mockResolvedValueOnce([]); mockPostEngine.getLinksTo .mockResolvedValueOnce([{ id: 'p4', title: 'Target', slug: 'target' }]) .mockResolvedValueOnce([{ id: 'p5', title: 'Other', slug: 'other' }]); const result = await (manager as any).executeTool('search_posts', { query: 'test' }); expect(result.success).toBe(true); expect(result.posts[0].backlinks).toEqual([{ id: 'p3', title: 'Linker', slug: 'linker' }]); expect(result.posts[0].linksTo).toEqual([{ id: 'p4', title: 'Target', slug: 'target' }]); expect(result.posts[1].backlinks).toEqual([]); expect(result.posts[1].linksTo).toEqual([{ id: 'p5', title: 'Other', slug: 'other' }]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledTimes(2); expect(mockPostEngine.getLinksTo).toHaveBeenCalledTimes(2); }); }); describe('list_posts', () => { it('includes backlinks and linksTo for each post in listed results', async () => { const posts = [ { id: 'p1', title: 'Post A', slug: 'post-a', status: 'published', categories: [], tags: [], createdAt: new Date(), updatedAt: new Date() }, ]; mockPostEngine.getAllPosts.mockResolvedValue({ items: posts, total: 1 }); mockPostEngine.getLinkedBy.mockResolvedValue([ { id: 'px', title: 'Cross Ref', slug: 'cross-ref' }, ]); mockPostEngine.getLinksTo.mockResolvedValue([ { id: 'py', title: 'Forward Ref', slug: 'forward-ref' }, ]); const result = await (manager as any).executeTool('list_posts', {}); expect(result.success).toBe(true); expect(result.posts[0].backlinks).toEqual([{ id: 'px', title: 'Cross Ref', slug: 'cross-ref' }]); expect(result.posts[0].linksTo).toEqual([{ id: 'py', title: 'Forward Ref', slug: 'forward-ref' }]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p1'); expect(mockPostEngine.getLinksTo).toHaveBeenCalledWith('p1'); }); it('includes backlinks and linksTo for filtered list results', async () => { const posts = [ { id: 'p5', title: 'Tagged Post', slug: 'tagged', status: 'published', categories: [], tags: ['js'], createdAt: new Date(), updatedAt: new Date() }, ]; mockPostEngine.getPostsFiltered.mockResolvedValue(posts); mockPostEngine.getLinkedBy.mockResolvedValue([]); mockPostEngine.getLinksTo.mockResolvedValue([]); const result = await (manager as any).executeTool('list_posts', { tags: ['js'] }); expect(result.success).toBe(true); expect(result.posts[0].backlinks).toEqual([]); expect(result.posts[0].linksTo).toEqual([]); expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p5'); 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)', () => { let manager: OpenCodeManager; beforeEach(() => { vi.clearAllMocks(); manager = createManager(createMockPostEngine()); }); it('delegates to ModelCatalogEngine.getMaxOutputTokens', async () => { const engine = (manager as any).modelCatalogEngine; vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(64000); const result = await (manager as any).getMaxOutputTokens('claude-sonnet-4-5'); expect(result).toBe(64000); expect(engine.getMaxOutputTokens).toHaveBeenCalledWith('claude-sonnet-4-5'); }); it('returns default when ModelCatalogEngine has no data', async () => { const engine = (manager as any).modelCatalogEngine; vi.spyOn(engine, 'getMaxOutputTokens').mockResolvedValue(16384); const result = await (manager as any).getMaxOutputTokens('unknown-model'); expect(result).toBe(16384); }); it('exposes ModelCatalogEngine via getModelCatalogEngine()', () => { const engine = manager.getModelCatalogEngine(); expect(engine).toBeDefined(); expect(engine).toBeInstanceOf(Object); }); });