diff --git a/src/utils/ai-agent.test.ts b/src/utils/ai-agent.test.ts new file mode 100644 index 00000000..5238abeb --- /dev/null +++ b/src/utils/ai-agent.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// localStorage mock (jsdom doesn't provide a full implementation) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +import { buildAgentSystemPrompt, executeToolViaWs, runAgentLoop, type AgentStepCallback } from './ai-agent' + +// --- buildAgentSystemPrompt --- + +describe('buildAgentSystemPrompt', () => { + it('returns preamble when no vault context', () => { + const prompt = buildAgentSystemPrompt() + expect(prompt).toContain('AI assistant integrated into Laputa') + expect(prompt).not.toContain('Vault context') + }) + + it('appends vault context when provided', () => { + const prompt = buildAgentSystemPrompt('Recent notes: foo, bar') + expect(prompt).toContain('AI assistant integrated into Laputa') + expect(prompt).toContain('Vault context:') + expect(prompt).toContain('Recent notes: foo, bar') + }) +}) + +// --- runAgentLoop calls Anthropic API directly --- + +describe('runAgentLoop', () => { + const mockCallbacks: AgentStepCallback = { + onThinking: vi.fn(), + onToolStart: vi.fn(), + onToolDone: vi.fn(), + onText: vi.fn(), + onError: vi.fn(), + onDone: vi.fn(), + } + + beforeEach(() => { + vi.restoreAllMocks() + localStorageMock.clear() + localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-123') + }) + + it('calls https://api.anthropic.com/v1/messages directly', async () => { + const mockResponse = { + id: 'msg_123', + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + stop_reason: 'end_turn', + } + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await runAgentLoop('test message', 'claude-3-5-haiku-20241022', 'system prompt', mockCallbacks) + + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, options] = fetchSpy.mock.calls[0] + expect(url).toBe('https://api.anthropic.com/v1/messages') + expect(options?.method).toBe('POST') + + const headers = options?.headers as Record + expect(headers['x-api-key']).toBe('test-key-123') + expect(headers['anthropic-version']).toBe('2023-06-01') + expect(headers['Content-Type']).toBe('application/json') + + const body = JSON.parse(options?.body as string) + expect(body.model).toBe('claude-3-5-haiku-20241022') + expect(body.max_tokens).toBe(4096) + expect(body.system).toBe('system prompt') + expect(body.messages).toEqual([{ role: 'user', content: 'test message' }]) + expect(body.tools).toBeDefined() + // API key must NOT be in the body + expect(body.apiKey).toBeUndefined() + }) + + it('calls onText and onDone for a text-only response', async () => { + const mockResponse = { + id: 'msg_123', + role: 'assistant', + content: [{ type: 'text', text: 'Here is your answer.' }], + stop_reason: 'end_turn', + } + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }), + ) + + await runAgentLoop('question', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + expect(mockCallbacks.onThinking).toHaveBeenCalled() + expect(mockCallbacks.onText).toHaveBeenCalledWith('Here is your answer.') + expect(mockCallbacks.onDone).toHaveBeenCalled() + expect(mockCallbacks.onError).not.toHaveBeenCalled() + }) + + it('calls onError when API key is missing', async () => { + localStorageMock.clear() + + await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + expect(mockCallbacks.onError).toHaveBeenCalledWith( + expect.stringContaining('No API key configured'), + ) + }) + + it('calls onError with API error message on non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), { status: 401 }), + ) + + await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + expect(mockCallbacks.onError).toHaveBeenCalledWith('Invalid API key') + }) + + it('does not send apiKey in the request body', async () => { + const mockResponse = { + id: 'msg_123', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + } + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }), + ) + + await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string) + expect(body).not.toHaveProperty('apiKey') + expect(body).not.toHaveProperty('maxTokens') + }) +}) + +// --- executeToolViaWs --- + +describe('executeToolViaWs', () => { + it('resolves with error when WebSocket is unavailable', async () => { + // jsdom WebSocket will fail to connect + const result = await executeToolViaWs('read_note', { path: 'test.md' }) + expect(result.isError).toBe(true) + }) +}) diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index e661addf..d8dd25f9 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -272,10 +272,20 @@ async function callAnthropicAgent( const apiKey = getApiKey() if (!apiKey) throw new Error('No API key configured. Open Settings (⌘,) to add your Anthropic key.') - const response = await fetch('/api/ai/agent', { + const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apiKey, model, messages, system, maxTokens: 4096, tools }), + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: 4096, + system: system || undefined, + messages, + tools, + }), }) if (!response.ok) { diff --git a/src/utils/ai-chat.test.ts b/src/utils/ai-chat.test.ts new file mode 100644 index 00000000..93743951 --- /dev/null +++ b/src/utils/ai-chat.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// localStorage mock (jsdom doesn't provide a full implementation) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +import { + getApiKey, setApiKey, estimateTokens, buildSystemPrompt, + nextMessageId, streamChat, +} from './ai-chat' +import type { VaultEntry } from '../types' + +// --- getApiKey / setApiKey --- + +describe('getApiKey / setApiKey', () => { + beforeEach(() => localStorageMock.clear()) + + it('returns empty string when no key set', () => { + expect(getApiKey()).toBe('') + }) + + it('round-trips an API key', () => { + setApiKey('sk-test-abc') + expect(getApiKey()).toBe('sk-test-abc') + }) +}) + +// --- estimateTokens --- + +describe('estimateTokens', () => { + it('estimates tokens from string length', () => { + expect(estimateTokens('abcd')).toBe(1) + expect(estimateTokens('abcdefgh')).toBe(2) + }) + + it('accepts a number (char count)', () => { + expect(estimateTokens(100)).toBe(25) + }) +}) + +// --- buildSystemPrompt --- + +describe('buildSystemPrompt', () => { + const makeEntry = (path: string, title: string): VaultEntry => ({ + path, title, filename: `${title}.md`, isA: 'Note', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + modifiedAt: null, createdAt: null, fileSize: 100, + snippet: '', relationships: {}, + }) + + it('returns empty prompt for no notes', () => { + const result = buildSystemPrompt([], {}) + expect(result.prompt).toBe('') + expect(result.totalTokens).toBe(0) + expect(result.truncated).toBe(false) + }) + + it('includes note content in the prompt', () => { + const notes = [makeEntry('/test.md', 'Test Note')] + const content = { '/test.md': '# Test Note\nHello world' } + const result = buildSystemPrompt(notes, content) + expect(result.prompt).toContain('Test Note') + expect(result.prompt).toContain('Hello world') + expect(result.totalTokens).toBeGreaterThan(0) + }) +}) + +// --- nextMessageId --- + +describe('nextMessageId', () => { + it('returns unique IDs', () => { + const id1 = nextMessageId() + const id2 = nextMessageId() + expect(id1).not.toBe(id2) + expect(id1).toMatch(/^msg-/) + }) +}) + +// --- streamChat calls Anthropic API directly --- + +describe('streamChat', () => { + const onChunk = vi.fn() + const onDone = vi.fn() + const onError = vi.fn() + + beforeEach(() => { + vi.restoreAllMocks() + onChunk.mockClear() + onDone.mockClear() + onError.mockClear() + localStorageMock.clear() + localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-456') + }) + + it('calls https://api.anthropic.com/v1/messages with correct headers', async () => { + const sseData = [ + 'data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ].join('') + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(sseData, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + + await streamChat( + [{ role: 'user', content: 'hello' }], 'system', 'claude-3-5-haiku-20241022', + onChunk, onDone, onError, + ) + + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, options] = fetchSpy.mock.calls[0] + expect(url).toBe('https://api.anthropic.com/v1/messages') + + const headers = options?.headers as Record + expect(headers['x-api-key']).toBe('test-key-456') + expect(headers['anthropic-version']).toBe('2023-06-01') + + const body = JSON.parse(options?.body as string) + expect(body.stream).toBe(true) + expect(body.model).toBe('claude-3-5-haiku-20241022') + expect(body.messages).toEqual([{ role: 'user', content: 'hello' }]) + // API key must NOT be in the body + expect(body.apiKey).toBeUndefined() + }) + + it('calls onError when API key is missing', async () => { + localStorageMock.clear() + + await streamChat([], '', 'model', onChunk, onDone, onError) + + expect(onError).toHaveBeenCalledWith( + expect.stringContaining('No API key configured'), + ) + expect(onChunk).not.toHaveBeenCalled() + }) + + it('calls onError with parsed error on non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid x-api-key' } }), { status: 401 }), + ) + + await streamChat( + [{ role: 'user', content: 'hi' }], '', 'model', + onChunk, onDone, onError, + ) + + expect(onError).toHaveBeenCalledWith('Invalid x-api-key') + }) + + it('does not send apiKey in the request body', async () => { + const sseData = 'data: {"type":"message_stop"}\n\n' + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(sseData, { status: 200 }), + ) + + await streamChat( + [{ role: 'user', content: 'hi' }], '', 'model', + onChunk, onDone, onError, + ) + + const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string) + expect(body).not.toHaveProperty('apiKey') + expect(body).not.toHaveProperty('maxTokens') + expect(body.max_tokens).toBe(4096) + }) +}) diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index 2c0535f3..d5bf698e 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -153,13 +153,19 @@ export async function streamChat( } try { - const response = await fetch('/api/ai/chat', { + const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, body: JSON.stringify({ - apiKey, model, messages, + model, + max_tokens: 4096, system: systemPrompt || undefined, - maxTokens: 4096, + messages, + stream: true, }), })