import { describe, it, expect, vi } from 'vitest'; import { createTools } from './tools.js'; import { makeConfig } from './test-helpers.js'; const mockContext = { sessionID: 'sess-1', messageID: 'msg-1', agent: 'default', directory: '/tmp', worktree: '/tmp', abort: new AbortController().signal, metadata: vi.fn(), ask: vi.fn(), }; describe('createTools', () => { it('creates all three tools', () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any; const tools = createTools(client, 'test-bank', makeConfig()); expect(tools.hindsight_retain).toBeDefined(); expect(tools.hindsight_recall).toBeDefined(); expect(tools.hindsight_reflect).toBeDefined(); }); it('all tools have description and execute', () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn() } as any; const tools = createTools(client, 'test-bank', makeConfig()); for (const tool of Object.values(tools)) { expect(tool.description).toBeTruthy(); expect(typeof tool.execute).toBe('function'); } }); describe('hindsight_retain', () => { it('calls client.retain with correct bank and content', async () => { const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn(), } as any; const tools = createTools(client, 'test-bank', makeConfig()); const result = await tools.hindsight_retain.execute( { content: 'User likes TypeScript' }, mockContext, ); expect(client.retain).toHaveBeenCalledWith('test-bank', 'User likes TypeScript', { context: 'opencode', tags: undefined, metadata: undefined, }); expect(result).toBe('Memory stored successfully.'); }); it('passes optional context', async () => { const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any; const tools = createTools(client, 'test-bank', makeConfig()); await tools.hindsight_retain.execute( { content: 'Fact', context: 'from conversation' }, mockContext, ); expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', { context: 'from conversation', tags: undefined, metadata: undefined, }); }); it('includes tags and metadata from config', async () => { const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn() } as any; const config = makeConfig({ retainTags: ['coding'], retainMetadata: { source: 'opencode' }, }); const tools = createTools(client, 'test-bank', config); await tools.hindsight_retain.execute({ content: 'Fact' }, mockContext); expect(client.retain).toHaveBeenCalledWith('test-bank', 'Fact', { context: 'opencode', tags: ['coding'], metadata: { source: 'opencode' }, }); }); }); describe('hindsight_recall', () => { it('calls client.recall and formats results', async () => { const client = { retain: vi.fn(), recall: vi.fn().mockResolvedValue({ results: [ { text: 'User likes Python', type: 'world', mentioned_at: '2025-01-01' }, ], }), reflect: vi.fn(), } as any; const tools = createTools(client, 'test-bank', makeConfig()); const result = await tools.hindsight_recall.execute( { query: 'user preferences' }, mockContext, ); expect(client.recall).toHaveBeenCalledWith('test-bank', 'user preferences', { budget: 'mid', maxTokens: 1024, types: ['world', 'experience'], }); expect(result).toContain('User likes Python'); expect(result).toContain('[world]'); }); it('returns no-results message when empty', async () => { const client = { retain: vi.fn(), recall: vi.fn().mockResolvedValue({ results: [] }), reflect: vi.fn(), } as any; const tools = createTools(client, 'test-bank', makeConfig()); const result = await tools.hindsight_recall.execute({ query: 'unknown' }, mockContext); expect(result).toBe('No relevant memories found.'); }); it('uses config budget settings', async () => { const client = { retain: vi.fn(), recall: vi.fn().mockResolvedValue({ results: [] }), reflect: vi.fn(), } as any; const config = makeConfig({ recallBudget: 'high', recallMaxTokens: 4096 }); const tools = createTools(client, 'test-bank', config); await tools.hindsight_recall.execute({ query: 'test' }, mockContext); expect(client.recall).toHaveBeenCalledWith('test-bank', 'test', { budget: 'high', maxTokens: 4096, types: ['world', 'experience'], }); }); }); describe('hindsight_reflect', () => { it('calls client.reflect and returns text', async () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn().mockResolvedValue({ text: 'The user is a Python developer.' }), } as any; const tools = createTools(client, 'test-bank', makeConfig()); const result = await tools.hindsight_reflect.execute( { query: 'What do I know about this user?' }, mockContext, ); expect(client.reflect).toHaveBeenCalledWith( 'test-bank', 'What do I know about this user?', { context: undefined, budget: 'mid' }, ); expect(result).toBe('The user is a Python developer.'); }); it('returns fallback when no text', async () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn().mockResolvedValue({ text: '' }), } as any; const tools = createTools(client, 'test-bank', makeConfig()); const result = await tools.hindsight_reflect.execute( { query: 'something' }, mockContext, ); expect(result).toBe('No relevant information found to reflect on.'); }); it('passes context to reflect', async () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn().mockResolvedValue({ text: 'Answer' }), } as any; const tools = createTools(client, 'test-bank', makeConfig()); await tools.hindsight_reflect.execute( { query: 'Q', context: 'We are building an app' }, mockContext, ); expect(client.reflect).toHaveBeenCalledWith('test-bank', 'Q', { context: 'We are building an app', budget: 'mid', }); }); }); describe('error propagation', () => { it('propagates retain errors', async () => { const client = { retain: vi.fn().mockRejectedValue(new Error('Network error')), recall: vi.fn(), reflect: vi.fn(), } as any; const tools = createTools(client, 'test-bank', makeConfig()); await expect( tools.hindsight_retain.execute({ content: 'test' }, mockContext), ).rejects.toThrow('Network error'); }); it('propagates recall errors', async () => { const client = { retain: vi.fn(), recall: vi.fn().mockRejectedValue(new Error('Timeout')), reflect: vi.fn(), } as any; const tools = createTools(client, 'test-bank', makeConfig()); await expect( tools.hindsight_recall.execute({ query: 'test' }, mockContext), ).rejects.toThrow('Timeout'); }); it('propagates reflect errors', async () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn().mockRejectedValue(new Error('Server error')), } as any; const tools = createTools(client, 'test-bank', makeConfig()); await expect( tools.hindsight_reflect.execute({ query: 'test' }, mockContext), ).rejects.toThrow('Server error'); }); }); describe('bank mission setup', () => { it('calls ensureBankMission before retain when missionsSet provided', async () => { const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn(), createBank: vi.fn().mockResolvedValue({}), } as any; const missionsSet = new Set(); const config = makeConfig({ bankMission: 'Extract technical decisions' }); const tools = createTools(client, 'test-bank', config, missionsSet); await tools.hindsight_retain.execute({ content: 'fact' }, mockContext); expect(client.createBank).toHaveBeenCalledWith('test-bank', { reflectMission: 'Extract technical decisions', retainMission: undefined, }); expect(missionsSet.has('test-bank')).toBe(true); expect(client.retain).toHaveBeenCalled(); }); it('calls ensureBankMission before reflect when missionsSet provided', async () => { const client = { retain: vi.fn(), recall: vi.fn(), reflect: vi.fn().mockResolvedValue({ text: 'answer' }), createBank: vi.fn().mockResolvedValue({}), } as any; const missionsSet = new Set(); const config = makeConfig({ bankMission: 'Synthesize project context' }); const tools = createTools(client, 'test-bank', config, missionsSet); await tools.hindsight_reflect.execute({ query: 'summary' }, mockContext); expect(client.createBank).toHaveBeenCalled(); expect(client.reflect).toHaveBeenCalled(); }); it('skips mission setup when missionsSet not provided (backward compat)', async () => { const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn(), reflect: vi.fn(), } as any; const tools = createTools(client, 'test-bank', makeConfig()); await tools.hindsight_retain.execute({ content: 'fact' }, mockContext); expect(client.retain).toHaveBeenCalled(); // No createBank call since missionsSet wasn't passed }); }); it('always uses constructor bankId', async () => { const client = { retain: vi.fn().mockResolvedValue({}), recall: vi.fn().mockResolvedValue({ results: [] }), reflect: vi.fn().mockResolvedValue({ text: 'ok' }), } as any; const tools = createTools(client, 'fixed-bank', makeConfig()); await tools.hindsight_retain.execute({ content: 'x' }, mockContext); await tools.hindsight_recall.execute({ query: 'x' }, mockContext); await tools.hindsight_reflect.execute({ query: 'x' }, mockContext); expect(client.retain.mock.calls[0][0]).toBe('fixed-bank'); expect(client.recall.mock.calls[0][0]).toBe('fixed-bank'); expect(client.reflect.mock.calls[0][0]).toBe('fixed-bank'); }); });