diff --git a/OPENCODE_REFACTOR.md b/OPENCODE_REFACTOR.md index e8e7004..6c3871c 100644 --- a/OPENCODE_REFACTOR.md +++ b/OPENCODE_REFACTOR.md @@ -391,11 +391,11 @@ Domain logic only — no AI protocol code survives. 13. ~~Update IPC handlers: generic provider management, wire to new modules~~ ✅ 14. ~~Integration tests~~ ✅ 34 tests -### Phase 3: Delete + ship (1 session) -15. Delete `OpenCodeManager.ts` (2,745 lines) -16. Delete `streaming.ts` (621 lines) -17. Delete old MCPServer duplication -18. Update all tests, full build pass +### Phase 3: Delete + ship (1 session) ✅ DONE +15. ~~Delete `OpenCodeManager.ts` (2,745 lines)~~ ✅ +16. ~~Delete `streaming.ts` (621 lines)~~ ✅ +17. ~~Delete old MCPServer duplication~~ ✅ (shared `enrichWithLinks`, `executeCheckTerm`, `buildAmbiguityHints`) +18. ~~Update all tests, full build pass~~ ✅ 2599 tests, 0 failures 19. Smoke test: chat conversation end-to-end, taxonomy analysis, image analysis --- diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index d919258..3571945 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -8,7 +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 { buildAmbiguityHints, enrichWithLinks, executeCheckTerm } from './ai/blog-tools'; import { ProposalStore, type ProposalType } from './ProposalStore'; import { reviewPostHtml, @@ -498,25 +498,8 @@ export class MCPServer { }, annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { - const [categories, tags] = await Promise.all([ - this.deps.postEngine.getCategoriesWithCounts(), - this.deps.postEngine.getTagsWithCounts(), - ]); - const termLower = args.term.toLowerCase(); - const catMatch = categories.find(c => c.category.toLowerCase() === termLower); - const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower); - return { - content: [{ - type: 'text' as const, - text: JSON.stringify({ - term: args.term, - asCategory: !!catMatch, - categoryPostCount: catMatch?.count ?? 0, - asTag: !!tagMatch, - tagPostCount: tagMatch?.count ?? 0, - }), - }], - }; + const result = await executeCheckTerm(args.term, this.deps.postEngine); + return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; }); // ── search_posts ── @@ -535,7 +518,6 @@ export class MCPServer { }, annotations: { readOnlyHint: true, openWorldHint: false }, }, async (args) => { - // Validate: month requires year if (args.month && !args.year) { return { content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }], @@ -547,30 +529,13 @@ export class MCPServer { const offset = args.offset ?? 0; const limit = args.limit ?? 50; - // Helper: enrich posts with backlinks and linksTo - const enrichWithLinks = async (posts: T[]) => { - return Promise.all(posts.map(async (p) => { - const [backlinks, linksTo] = await Promise.all([ - this.deps.postEngine.getLinkedBy(p.id), - this.deps.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 })), - }; - })); - }; - if (args.query && !hasFilters) { - // Pure text search — use FTS const results = await this.deps.postEngine.searchPosts(args.query); const paginated = results.slice(offset, offset + limit); - const enriched = await enrichWithLinks(paginated); + const enriched = await enrichWithLinks(paginated, this.deps.postEngine); return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] }; } - // Build structural filter const filter: PostFilter = {}; if (args.category) filter.categories = [args.category]; if (args.tags) filter.tags = args.tags; @@ -578,37 +543,23 @@ export class MCPServer { if (args.month) filter.month = args.month; if (args.status) filter.status = args.status; + let enriched; if (args.query && hasFilters) { - // FTS + structural filters: single SQL JOIN query, ranked by FTS score - const results = await this.deps.postEngine.searchPostsFiltered( - args.query, filter, { offset, limit }, - ); - const enriched = await enrichWithLinks(results); - const content: Array<{ type: 'text'; text: string }> = [ - { type: 'text' as const, text: JSON.stringify(enriched) }, - ]; - 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 }; + const results = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit }); + enriched = await enrichWithLinks(results, this.deps.postEngine); + } else { + const results = await this.deps.postEngine.getPostsFiltered(filter); + const paginated = results.slice(offset, offset + limit); + enriched = await enrichWithLinks(paginated, this.deps.postEngine); } - // Filter-only query (no text search) - const results = await this.deps.postEngine.getPostsFiltered(filter); - const paginated = results.slice(offset, offset + limit); - const enriched = await enrichWithLinks(paginated); - const content: Array<{ type: 'text'; text: string }> = [ { type: 'text' as const, text: JSON.stringify(enriched) }, ]; - - // Ambiguity hints: check if category/tag terms exist in the other namespace const 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 }; }); } diff --git a/src/main/engine/ModelCatalogEngine.ts b/src/main/engine/ModelCatalogEngine.ts index c968a30..f6c237a 100644 --- a/src/main/engine/ModelCatalogEngine.ts +++ b/src/main/engine/ModelCatalogEngine.ts @@ -135,7 +135,7 @@ export class ModelCatalogEngine { } /** - * Get the max output tokens for a model (used by OpenCodeManager for max_tokens). + * Get the max output tokens for a model (used for maxOutputTokens). * Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog. */ async getMaxOutputTokens(modelId: string, provider?: string): Promise { diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts deleted file mode 100644 index 6046d77..0000000 --- a/src/main/engine/OpenCodeManager.ts +++ /dev/null @@ -1,2744 +0,0 @@ -/** - * OpenCodeManager - Handles AI chat using OpenCode Zen API gateway - * - * Supports Anthropic Claude (Messages API with native tool_use) and - * OpenAI-compatible models via the OpenCode Zen gateway. - * - * Tools are provided as proper Anthropic tool definitions so the AI - * can call them natively via tool_use blocks. - */ - -import https from 'https'; -import http from 'http'; -import { URL } from 'url'; -import { BrowserWindow } from 'electron'; -import { - parseAnthropicStreamEvent, - parseOpenAIStreamEvent, - createAnthropicStreamAccumulator, - createOpenAIStreamAccumulator, - httpRequestStream, - withRetry, -} from './streaming'; -import { ChatEngine } from './ChatEngine'; -import { PostEngine, type PostData } from './PostEngine'; -import { MediaEngine, type MediaData } from './MediaEngine'; -import type { PostMediaEngine } from './PostMediaEngine'; -import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine'; -import { isRenderTool, generateFromToolCall } from '../a2ui/generator'; -import type { A2UIServerMessage } from '../a2ui/types'; -import type { ChatModel } from '../shared/electronApi'; - -// OpenCode Zen API endpoints -const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; -const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions'; -const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; - -// Mistral API endpoints -const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; -const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; - - - -export interface SendMessageOptions { - metadata?: { - surface?: 'tab' | 'sidebar'; - }; - onDelta?: (delta: string) => void; - onToolCall?: (toolCall: { name: string; args: unknown }) => void; - onToolResult?: (result: { name: string; result: unknown }) => void; - onA2UIMessage?: (message: A2UIServerMessage) => void; - onTokenUsage?: (usage: { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - totalTokens: number; - cumulativeInputTokens: number; - cumulativeOutputTokens: number; - cumulativeCacheReadTokens: number; - cumulativeCacheWriteTokens: number; - cumulativeTotalTokens: number; - }) => void; -} - -export interface SendMessageResult { - success: boolean; - message?: string; - error?: string; - toolCalls?: Array<{ name: string; args: unknown }>; -} - -interface ToolDefinition { - name: string; - description: string; - input_schema: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} - -interface AnthropicMessage { - role: 'user' | 'assistant'; - content: string | AnthropicContentBlock[]; -} - -interface AnthropicContentBlock { - type: string; - text?: string; - id?: string; - name?: string; - input?: unknown; - tool_use_id?: string; - content?: string | AnthropicToolResultContent[]; - is_error?: boolean; - signature?: string; - source?: { - type: 'base64'; - media_type: string; - data: string; - }; -} - -interface AnthropicToolResultContent { - type: 'text' | 'image'; - text?: string; - source?: { - type: 'base64'; - media_type: string; - data: string; - }; -} - -interface HttpResponse { - statusCode: number; - body: string; -} - -export class OpenCodeManager { - private chatEngine: ChatEngine; - private postEngine: PostEngine; - private mediaEngine: MediaEngine; - private postMediaEngine: PostMediaEngine; - private getMainWindow: () => BrowserWindow | null; - private apiKey: string = ''; - private mistralApiKey: string = ''; - private abortControllers: Map = new Map(); - private cachedModels: ChatModel[] | null = null; - private cachedModelsAt: number = 0; - private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - private modelCatalogEngine = new ModelCatalogEngine(); - private conversationUsage: Map = new Map(); - - constructor( - chatEngine: ChatEngine, - postEngine: PostEngine, - mediaEngine: MediaEngine, - postMediaEngine: PostMediaEngine, - getMainWindow: () => BrowserWindow | null - ) { - this.chatEngine = chatEngine; - this.postEngine = postEngine; - this.mediaEngine = mediaEngine; - this.postMediaEngine = postMediaEngine; - this.getMainWindow = getMainWindow; - } - - /** - * Set API key for OpenCode Zen - */ - setApiKey(key: string): void { - this.apiKey = key; - // Invalidate model cache so merged list is re-fetched - this.cachedModels = null; - this.cachedModelsAt = 0; - } - - /** - * Get current API key - */ - getApiKey(): string { - return this.apiKey; - } - - /** - * Set API key for Mistral AI - */ - setMistralApiKey(key: string): void { - this.mistralApiKey = key; - // Invalidate model cache so merged list is re-fetched - this.cachedModels = null; - this.cachedModelsAt = 0; - } - - /** - * Get current Mistral API key - */ - getMistralApiKey(): string { - return this.mistralApiKey; - } - - /** - * Check if the service is configured and ready - */ - async checkReady(): Promise<{ ready: boolean; error?: string; providers?: { opencode: boolean; mistral: boolean } }> { - const providers = { - opencode: !!this.apiKey, - mistral: !!this.mistralApiKey, - }; - if (!this.apiKey && !this.mistralApiKey) { - return { ready: false, error: 'API key not configured', providers }; - } - return { ready: true, providers }; - } - - /** - * Validate an OpenCode API key by calling the models endpoint - */ - async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> { - if (!apiKey || apiKey.length < 3) { - return { isValid: false, models: [] }; - } - - // Try both auth header styles (OpenCode Zen quirk) - const attempts: Record[] = [ - { 'Authorization': `Bearer ${apiKey}` }, - { 'x-api-key': apiKey }, - ]; - - const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); - - for (const headers of attempts) { - try { - const response = await this.httpRequest(ZEN_MODELS_URL, { - method: 'GET', - headers, - }); - if (response.statusCode >= 200 && response.statusCode < 300) { - const data = JSON.parse(response.body); - const models = (data.data && Array.isArray(data.data)) - ? (data.data as Array<{ id: string }>).map(m => ({ - id: m.id, - name: this.resolveName(m.id, catalogNames), - provider: this.detectProvider(m.id), - vision: this.resolveVision(m.id, catalogVision), - })) - : []; - return { isValid: true, models }; - } - } catch { - // Try next auth method - } - } - - return { isValid: false, models: [] }; - } - - /** - * Validate a Mistral API key by calling the Mistral models endpoint - */ - async validateMistralApiKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> { - if (!apiKey || apiKey.length < 3) { - return { isValid: false, models: [] }; - } - - const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); - - try { - const response = await this.httpRequest(MISTRAL_MODELS_URL, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - }); - - if (response.statusCode >= 200 && response.statusCode < 300) { - const data = JSON.parse(response.body); - if (data.data && Array.isArray(data.data) && data.data.length > 0) { - const models = (data.data as Array<{ id: string }>) - .filter(m => this.detectProvider(m.id) === 'mistral') - .map(m => ({ - id: m.id, - name: this.resolveName(m.id, catalogNames), - provider: 'mistral', - vision: this.resolveVision(m.id, catalogVision), - })); - return { isValid: true, models }; - } - } - } catch { - // Fall through - } - - return { isValid: false, models: [] }; - } - - /** - * Get available models (cached with 5-minute TTL) - * Merges models from all configured providers. - */ - async getAvailableModels(): Promise { - // Return cached models if within TTL - if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) { - return this.cachedModels; - } - - const allModels: ChatModel[] = []; - let fetched = false; - - // Load catalog for vision + name cross-referencing - const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); - - // Fetch OpenCode models - if (this.apiKey) { - try { - const response = await this.httpRequest(ZEN_MODELS_URL, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'x-api-key': this.apiKey, - }, - }); - if (response.statusCode === 200) { - const data = JSON.parse(response.body); - if (data.data && Array.isArray(data.data)) { - for (const m of data.data as Array<{ id: string }>) { - allModels.push({ - id: m.id, - name: this.resolveName(m.id, catalogNames), - provider: this.detectProvider(m.id), - vision: this.resolveVision(m.id, catalogVision), - }); - } - fetched = true; - } - } - } catch { - // Fall through to fallback - } - } - - // Fetch Mistral models - if (this.mistralApiKey) { - try { - const response = await this.httpRequest(MISTRAL_MODELS_URL, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${this.mistralApiKey}`, - }, - }); - if (response.statusCode === 200) { - const data = JSON.parse(response.body); - if (data.data && Array.isArray(data.data)) { - for (const m of data.data as Array<{ id: string }>) { - if (this.detectProvider(m.id) === 'mistral') { - allModels.push({ - id: m.id, - name: this.resolveName(m.id, catalogNames), - provider: 'mistral', - vision: this.resolveVision(m.id, catalogVision), - }); - } - } - fetched = true; - } - } - } catch { - // Fall through to fallback - } - } - - if (fetched && allModels.length > 0) { - this.cachedModels = allModels; - this.cachedModelsAt = Date.now(); - return allModels; - } - - // Fallback: build from model catalog database (models.dev), filtered by available provider keys - try { - const catalog = await this.modelCatalogEngine.getAll(); - if (catalog.length > 0) { - return catalog - .map(m => ({ - id: m.id, - name: m.name, - provider: this.detectProvider(m.id), - vision: m.inputModalities.includes('image'), - })) - .filter(m => this.isProviderKeySet(m.provider)); - } - } catch { - // Fall through to empty - } - return []; - } - - /** - * Send a message to a conversation with tool use support - */ - async sendMessage( - conversationId: string, - userMessage: string, - options: SendMessageOptions = {} - ): Promise { - const { metadata, onDelta, onToolCall, onToolResult, onA2UIMessage, onTokenUsage } = options; - - try { - const readyCheck = await this.checkReady(); - if (!readyCheck.ready) { - return { success: false, error: readyCheck.error }; - } - - // Get conversation from database - const conversation = await this.chatEngine.getConversation(conversationId); - if (!conversation) { - return { success: false, error: 'Conversation not found' }; - } - - // Add user message to database - await this.chatEngine.addMessage({ - conversationId, - role: 'user', - content: userMessage, - createdAt: new Date(), - }); - - // Set up abort controller - const abortController = new AbortController(); - this.abortControllers.set(conversationId, abortController); - - const modelId = conversation.model || 'claude-sonnet-4'; - const provider = this.detectProvider(modelId); - - // Check that the provider's API key is available - if (!this.isProviderKeySet(provider)) { - const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; - return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` }; - } - - // Get system prompt - const systemMessage = conversation.messages.find(m => m.role === 'system'); - const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); - - // Inject live blog stats into system prompt for data volume awareness - const systemPrompt = await this.appendBlogStats(basePrompt); - - // Build message history from DB (excluding system messages) - const dbMessages = conversation.messages.filter(m => m.role !== 'system'); - - // Add the new user message - dbMessages.push({ - conversationId, - role: 'user', - content: userMessage, - createdAt: new Date(), - }); - - let fullResponse = ''; - const toolCallsCollected: Array<{ name: string; args: unknown }> = []; - - // Compute turn index for surface-to-message association - const turnIndex = dbMessages.filter(m => m.role === 'user').length - 1; - - // Wrap onA2UIMessage emission for render tools - const emitA2UIMessages = (messages: A2UIServerMessage[]) => { - if (onA2UIMessage) { - for (const msg of messages) { - if (msg.type === 'createSurface') { - msg.metadata = { ...msg.metadata, turnIndex }; - } - onA2UIMessage(msg); - } - } - }; - - const requestProvider = async ( - prompt: string, - messages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, - ) => { - if (provider === 'anthropic') { - return this.sendAnthropicMessage( - modelId, - prompt, - messages, - abortController.signal, - { onDelta, onToolCall, onToolResult, onTokenUsage }, - conversationId, - emitA2UIMessages, - ); - } - - // Get provider-specific config (URL, key, options) - const config = this.getProviderConfig(provider); - return this.sendOpenAIMessage( - modelId, - prompt, - messages, - abortController.signal, - { onDelta, onToolCall, onToolResult, onTokenUsage }, - conversationId, - emitA2UIMessages, - config.apiUrl, - config.apiKey, - config.options, - ); - }; - - try { - console.log('[OpenCodeManager] Sending to provider:', provider, 'model:', modelId); - const firstResult = await requestProvider(systemPrompt, dbMessages); - fullResponse = firstResult.content; - toolCallsCollected.push(...firstResult.toolCalls); - console.log('[OpenCodeManager] fullResponse length:', fullResponse.length); - } catch (error) { - console.error('[OpenCodeManager] Request error:', (error as Error).message); - const isAborted = abortController.signal.aborted || (error as Error).message === 'Request cancelled'; - if (!isAborted) { - throw error; - } - } finally { - this.abortControllers.delete(conversationId); - } - - // Save assistant response to history - if (fullResponse) { - await this.chatEngine.addMessage({ - conversationId, - role: 'assistant', - content: fullResponse, - toolCalls: toolCallsCollected.length > 0 ? JSON.stringify(toolCallsCollected) : undefined, - createdAt: new Date(), - }); - } - - // Generate title after first exchange - const userMsgCount = conversation.messages.filter(m => m.role === 'user').length; - if (userMsgCount === 0 && fullResponse) { - this.generateConversationTitle(conversationId, userMessage, fullResponse).catch(err => - console.error('[OpenCodeManager] Error generating title:', err) - ); - } - - return { - success: true, - message: fullResponse, - toolCalls: toolCallsCollected.length > 0 ? toolCallsCollected : undefined, - }; - } catch (error) { - console.error('[OpenCodeManager] Error sending message:', error); - return { success: false, error: (error as Error).message }; - } - } - - /** - * Send via Anthropic Messages API with native tool_use - */ - private async sendAnthropicMessage( - modelId: string, - systemPrompt: string, - dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, - signal: AbortSignal, - callbacks: { - onDelta?: (delta: string) => void; - onToolCall?: (toolCall: { name: string; args: unknown }) => void; - onToolResult?: (result: { name: string; result: unknown }) => void; - onTokenUsage?: SendMessageOptions['onTokenUsage']; - }, - conversationId: string, - emitA2UIMessages: (messages: A2UIServerMessage[]) => void, - ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { - const tools = this.getToolDefinitions(); - const allToolCalls: Array<{ name: string; args: unknown }> = []; - let accumulatedText = ''; - - // Convert DB messages to Anthropic format - let messages = this.buildAnthropicMessages(dbMessages); - - // Truncate to fit within context window - messages = this.truncateToTokenBudget(messages, systemPrompt, tools); - - // Tool use loop - keep going until the model stops calling tools - const MAX_TOOL_ROUNDS = 10; - let round = 0; - - while (round < MAX_TOOL_ROUNDS) { - round++; - if (signal.aborted) break; - - const body: Record = { - model: modelId, - max_tokens: await this.getMaxOutputTokens(modelId), - system: systemPrompt, - messages, - tools, - stream: true, - cache_control: { type: 'ephemeral' }, - }; - - // Retry only the HTTP connection (429/502/503 are caught before any events are emitted). - // Event processing is outside retry scope to prevent double-emission of onDelta on retry. - const { events } = await withRetry(async () => { - return httpRequestStream(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - signal, - }); - }); - - // Process stream events outside retry scope — onDelta is never called twice for the same text - const streamAccumulator = createAnthropicStreamAccumulator(); - let stopReason = ''; - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - let roundText = ''; - let receivedUsage = false; - - try { - for await (const event of events) { - const result = parseAnthropicStreamEvent(event, streamAccumulator); - - if (result.textDelta) { - roundText += result.textDelta; - if (callbacks.onDelta) { - callbacks.onDelta(result.textDelta); - } - } - - if (result.usage) { - receivedUsage = true; - if (result.usage.inputTokens !== undefined) inputTokens = result.usage.inputTokens; - if (result.usage.cacheReadTokens !== undefined) cacheReadTokens = result.usage.cacheReadTokens; - if (result.usage.cacheWriteTokens !== undefined) cacheWriteTokens = result.usage.cacheWriteTokens; - if (result.usage.outputTokens !== undefined) outputTokens = result.usage.outputTokens; - } - - if (result.finishReason) { - stopReason = result.finishReason; - } - - if (result.done) break; - } - } finally { - // Preserve text already emitted via onDelta even if the stream errors mid-round - accumulatedText += roundText; - } - - const streamToolCalls = streamAccumulator.toolCalls; - const streamThinkingBlocks = streamAccumulator.thinkingBlocks; - - // Emit token usage after stream completes (only when usage data was received) - if (callbacks.onTokenUsage && receivedUsage) { - const adjustedInputTokens = inputTokens - cacheReadTokens - cacheWriteTokens; - const totalTokens = inputTokens + outputTokens; - - const prev = this.conversationUsage.get(conversationId) || { - inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, - }; - const cumulative = { - inputTokens: prev.inputTokens + adjustedInputTokens, - outputTokens: prev.outputTokens + outputTokens, - cacheReadTokens: prev.cacheReadTokens + cacheReadTokens, - cacheWriteTokens: prev.cacheWriteTokens + cacheWriteTokens, - }; - this.conversationUsage.set(conversationId, cumulative); - - callbacks.onTokenUsage({ - inputTokens: adjustedInputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, totalTokens, - cumulativeInputTokens: cumulative.inputTokens, - cumulativeOutputTokens: cumulative.outputTokens, - cumulativeCacheReadTokens: cumulative.cacheReadTokens, - cumulativeCacheWriteTokens: cumulative.cacheWriteTokens, - cumulativeTotalTokens: cumulative.inputTokens + cumulative.outputTokens + cumulative.cacheReadTokens + cumulative.cacheWriteTokens, - }); - } - - // Collect tool calls from stream accumulator - const toolUseBlocks: Array<{ id: string; name: string; input: unknown; parseError?: string }> = []; - for (const [, tc] of streamToolCalls) { - try { - toolUseBlocks.push({ id: tc.id, name: tc.name, input: JSON.parse(tc.arguments) }); - } catch (e) { - console.error(`[OpenCodeManager] Failed to parse tool arguments for ${tc.name}:`, tc.arguments); - toolUseBlocks.push({ id: tc.id, name: tc.name, input: {}, parseError: `Failed to parse tool arguments: ${(e as Error).message}` }); - } - } - - console.log('[OpenCodeManager] Round', round, 'stopReason:', stopReason, 'accumulatedText length:', accumulatedText.length, 'toolCalls:', toolUseBlocks.length); - - if (toolUseBlocks.length === 0 || stopReason !== 'tool_use') { - // No more tool calls - return all accumulated text - console.log('[OpenCodeManager] Returning accumulated text length:', accumulatedText.length); - return { content: accumulatedText, toolCalls: allToolCalls }; - } - - // Execute tool calls - const toolResults: AnthropicContentBlock[] = []; - // Build assistant content blocks for the next message round - const assistantContentBlocks: AnthropicContentBlock[] = []; - - // Add thinking blocks first (Anthropic requires thinking before text when extended thinking is enabled) - for (const [, tb] of streamThinkingBlocks) { - if (tb.text) { - const thinkingBlock: AnthropicContentBlock = { type: 'thinking', text: tb.text }; - if (tb.signature) { - thinkingBlock.signature = tb.signature; - } - assistantContentBlocks.push(thinkingBlock); - } - } - - // Add text block with text from this round - if (roundText) { - assistantContentBlocks.push({ type: 'text', text: roundText }); - } - - for (const toolBlock of toolUseBlocks) { - const toolName = toolBlock.name; - const toolArgs = toolBlock.input; - const toolUseId = toolBlock.id; - - // Add tool_use block to assistant content - assistantContentBlocks.push({ - type: 'tool_use', - id: toolUseId, - name: toolName, - input: toolArgs, - }); - - allToolCalls.push({ name: toolName, args: toolArgs }); - - if (callbacks.onToolCall) { - callbacks.onToolCall({ name: toolName, args: toolArgs }); - } - - // If JSON parsing of tool arguments failed, report the error to the model - if (toolBlock.parseError) { - const errorResult = { error: true, message: toolBlock.parseError }; - if (callbacks.onToolResult) { - callbacks.onToolResult({ name: toolName, result: errorResult }); - } - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUseId, - content: JSON.stringify(errorResult), - is_error: true, - }); - continue; - } - - // Check if this is a render tool — generate A2UI messages instead of executing - if (isRenderTool(toolName)) { - const a2uiMessages = generateFromToolCall( - conversationId, - toolName, - toolArgs as Record, - ); - if (a2uiMessages) { - emitA2UIMessages(a2uiMessages); - } - - if (callbacks.onToolResult) { - callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } }); - } - - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUseId, - content: JSON.stringify({ success: true, rendered: true }), - }); - continue; - } - - // Execute the tool (check abort before each tool execution) - if (signal.aborted) break; - const result = await this.executeTool(toolName, toolArgs as Record); - - if (callbacks.onToolResult) { - callbacks.onToolResult({ name: toolName, result }); - } - - // Check if this is an image result that needs special formatting - if (result && typeof result === 'object' && (result as Record).__isImageResult) { - const imageResult = result as { - __isImageResult: boolean; - success: boolean; - mediaType: string; - base64: string; - metadata: Record; - }; - - // Format as Anthropic multimodal content (image + text) - const imageContent: AnthropicToolResultContent[] = [ - { - type: 'image', - source: { - type: 'base64', - media_type: imageResult.mediaType, - data: imageResult.base64, - }, - }, - { - type: 'text', - text: JSON.stringify({ success: true, metadata: imageResult.metadata }), - }, - ]; - - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUseId, - content: imageContent, - }); - } else { - toolResults.push({ - type: 'tool_result', - tool_use_id: toolUseId, - content: JSON.stringify(result), - }); - } - } - - if (signal.aborted) break; - - // Add assistant response and tool results to messages for next round - messages = [ - ...messages, - { role: 'assistant' as const, content: assistantContentBlocks }, - { role: 'user' as const, content: toolResults }, - ]; - } - - // If we hit max rounds, return whatever we have - const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.'; - return { content: fallbackText, toolCalls: allToolCalls }; - } - - /** - * Send via OpenAI-compatible API (non-Claude models, including Mistral) - * Parameterized to support multiple providers with identical API format. - */ - private async sendOpenAIMessage( - modelId: string, - systemPrompt: string, - dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>, - signal: AbortSignal, - callbacks: { - onDelta?: (delta: string) => void; - onToolCall?: (toolCall: { name: string; args: unknown }) => void; - onToolResult?: (result: { name: string; result: unknown }) => void; - onTokenUsage?: (usage: { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; totalTokens: number; cumulativeInputTokens: number; cumulativeOutputTokens: number; cumulativeCacheReadTokens: number; cumulativeCacheWriteTokens: number; cumulativeTotalTokens: number }) => void; - }, - conversationId: string, - emitA2UIMessages: (messages: A2UIServerMessage[]) => void, - apiUrl: string = ZEN_OPENAI_URL, - apiKey: string = this.apiKey, - providerOptions?: { parallelToolCalls?: boolean }, - ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { - // Build OpenAI-format messages (with tool-call summaries for context parity with Anthropic path) - const allMessages: Array> = [ - { role: 'system', content: systemPrompt }, - ...dbMessages - .filter(m => m.role === 'user' || m.role === 'assistant') - .map(m => { - let content = m.content || ''; - if (m.role === 'assistant') { - content += this.buildToolCallSummary(m.toolCalls); - } - return { role: m.role, content }; - }), - ]; - - // Build OpenAI tools format - const anthropicTools = this.getToolDefinitions(); - const openaiTools = anthropicTools.map(t => ({ - type: 'function' as const, - function: { - name: t.name, - description: t.description, - parameters: t.input_schema, - }, - })); - - // Truncate conversation history to fit within context window - // Keep system message (index 0), truncate from oldest conversation messages - const contextBudget = (await this.modelCatalogEngine.getContextWindow(modelId)) ?? 150000; - const conversationMessages = allMessages.slice(1); - const anthropicFmt = conversationMessages.map(m => ({ - role: m.role as 'user' | 'assistant', - content: (m.content as string) || '', - })); - const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools, contextBudget); - const messages: Array> = [ - allMessages[0], - ...truncated.map(m => ({ role: m.role, content: m.content })), - ]; - - let accumulatedText = ''; - const allToolCalls: Array<{ name: string; args: unknown }> = []; - const MAX_TOOL_ROUNDS = 10; - let round = 0; - - while (round < MAX_TOOL_ROUNDS) { - round++; - if (signal.aborted) break; - - const body: Record = { - model: modelId, - max_tokens: await this.getMaxOutputTokens(modelId), - messages, - tools: openaiTools, - stream: true, - stream_options: { include_usage: true }, - }; - - // Set parallel_tool_calls based on provider options (Mistral needs false) - if (providerOptions?.parallelToolCalls === false) { - body.parallel_tool_calls = false; - } - - // Retry only the HTTP connection (429/502/503 are caught before any events are emitted). - // Event processing is outside retry scope to prevent double-emission of onDelta on retry. - const { events } = await withRetry(async () => { - return httpRequestStream(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify(body), - signal, - }); - }); - - // Process stream events outside retry scope — onDelta is never called twice for the same text - const streamAccumulator = createOpenAIStreamAccumulator(); - let finishReason = ''; - let promptTokens = 0; - let completionTokens = 0; - let totalTokens = 0; - let cacheReadTokens = 0; - let roundText = ''; - let receivedUsage = false; - - try { - for await (const event of events) { - const result = parseOpenAIStreamEvent(event, streamAccumulator); - - if (result.textDelta) { - roundText += result.textDelta; - if (callbacks.onDelta) { - callbacks.onDelta(result.textDelta); - } - } - - if (result.usage) { - receivedUsage = true; - if (result.usage.promptTokens !== undefined) promptTokens = result.usage.promptTokens; - if (result.usage.completionTokens !== undefined) completionTokens = result.usage.completionTokens; - if (result.usage.totalTokens !== undefined) totalTokens = result.usage.totalTokens; - if (result.usage.cacheReadTokens !== undefined) cacheReadTokens = result.usage.cacheReadTokens; - } - - if (result.finishReason) { - finishReason = result.finishReason; - } - - if (result.done) break; - } - } finally { - // Preserve text already emitted via onDelta even if the stream errors mid-round - accumulatedText += roundText; - } - - const streamToolCalls = streamAccumulator.toolCalls; - - // Emit token usage after stream completes (only when usage data was received) - if (callbacks.onTokenUsage && receivedUsage) { - const inputTokens = promptTokens - cacheReadTokens; - const outputTokens = completionTokens; - - const prev = this.conversationUsage.get(conversationId) || { - inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, - }; - const cumulative = { - inputTokens: prev.inputTokens + inputTokens, - outputTokens: prev.outputTokens + outputTokens, - cacheReadTokens: prev.cacheReadTokens + cacheReadTokens, - cacheWriteTokens: prev.cacheWriteTokens, - }; - this.conversationUsage.set(conversationId, cumulative); - - callbacks.onTokenUsage({ - inputTokens, outputTokens, cacheReadTokens, - cacheWriteTokens: 0, // OpenAI streaming does not report cache write tokens - totalTokens: totalTokens || inputTokens + outputTokens, - cumulativeInputTokens: cumulative.inputTokens, - cumulativeOutputTokens: cumulative.outputTokens, - cumulativeCacheReadTokens: cumulative.cacheReadTokens, - cumulativeCacheWriteTokens: cumulative.cacheWriteTokens, - cumulativeTotalTokens: cumulative.inputTokens + cumulative.outputTokens + cumulative.cacheReadTokens + cumulative.cacheWriteTokens, - }); - } - - // Collect tool calls from stream accumulator - const parsedToolCalls: Array<{ id: string; name: string; args: unknown; parseError?: string }> = []; - for (const [, tc] of streamToolCalls) { - try { - parsedToolCalls.push({ id: tc.id, name: tc.name, args: JSON.parse(tc.arguments) }); - } catch (e) { - console.error(`[OpenCodeManager:OpenAI] Failed to parse tool arguments for ${tc.name}:`, tc.arguments); - parsedToolCalls.push({ id: tc.id, name: tc.name, args: {}, parseError: `Failed to parse tool arguments: ${(e as Error).message}` }); - } - } - - console.log('[OpenCodeManager:OpenAI] Round', round, 'finishReason:', finishReason, 'text length:', accumulatedText.length, 'toolCalls:', parsedToolCalls.length); - - // If no tool calls, we're done - if (parsedToolCalls.length === 0 || finishReason !== 'tool_calls') { - console.log('[OpenCodeManager:OpenAI] Done. Accumulated text length:', accumulatedText.length); - return { content: accumulatedText, toolCalls: allToolCalls }; - } - - // Build the assistant message with tool_calls for conversation history - const assistantMessage: Record = { - role: 'assistant', - content: roundText || null, - tool_calls: parsedToolCalls.map((tc) => ({ - id: tc.id, - type: 'function', - function: { name: tc.name, arguments: JSON.stringify(tc.args) }, - })), - }; - messages.push(assistantMessage); - - // Execute tool calls and add results - for (const toolCall of parsedToolCalls) { - const toolName = toolCall.name; - const toolArgs = toolCall.args; - - allToolCalls.push({ name: toolName, args: toolArgs }); - if (callbacks.onToolCall) { - callbacks.onToolCall({ name: toolName, args: toolArgs }); - } - - // If JSON parsing of tool arguments failed, report the error to the model - if (toolCall.parseError) { - const errorResult = { error: true, message: toolCall.parseError }; - if (callbacks.onToolResult) { - callbacks.onToolResult({ name: toolName, result: errorResult }); - } - messages.push({ - role: 'tool', - content: JSON.stringify(errorResult), - tool_call_id: toolCall.id, - }); - continue; - } - - // Check if this is a render tool - if (isRenderTool(toolName)) { - const a2uiMessages = generateFromToolCall(conversationId, toolName, toolArgs as Record); - if (a2uiMessages) { - emitA2UIMessages(a2uiMessages); - } - - if (callbacks.onToolResult) { - callbacks.onToolResult({ name: toolName, result: { success: true, rendered: true } }); - } - - messages.push({ - role: 'tool', - content: JSON.stringify({ success: true, rendered: true }), - tool_call_id: toolCall.id, - }); - continue; - } - - // Check abort before each tool execution - if (signal.aborted) break; - const result = await this.executeTool(toolName, toolArgs as Record); - - if (callbacks.onToolResult) { - callbacks.onToolResult({ name: toolName, result }); - } - - // Check for image result that needs multimodal formatting (OpenAI image_url format) - if (result && typeof result === 'object' && (result as Record).__isImageResult) { - const imageResult = result as { - __isImageResult: boolean; - success: boolean; - mediaType: string; - base64: string; - metadata: Record; - }; - - messages.push({ - role: 'tool', - content: [ - { - type: 'image_url', - image_url: { - url: `data:${imageResult.mediaType};base64,${imageResult.base64}`, - }, - }, - { - type: 'text', - text: JSON.stringify({ success: true, metadata: imageResult.metadata }), - }, - ], - tool_call_id: toolCall.id, - }); - } else { - messages.push({ - role: 'tool', - content: JSON.stringify(result), - tool_call_id: toolCall.id, - }); - } - } - - if (signal.aborted) break; - } - - // Hit max rounds - const fallbackText = accumulatedText || 'I reached the maximum number of tool calls. Please try again.'; - return { content: fallbackText, toolCalls: allToolCalls }; - } - - /** - * Get Anthropic-format tool definitions for all available tools - */ - private getToolDefinitions(): ToolDefinition[] { - return [ - { - name: 'check_term', - description: 'Check whether a term exists as a category, tag, or both. Returns post counts for each. Use this before search_posts or list_posts when unsure whether a term is a category or tag.', - input_schema: { - type: 'object', - properties: { - term: { type: 'string', description: 'The term to look up' }, - }, - required: ['term'], - }, - }, - { - name: 'search_posts', - description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit. Use check_term first if unsure whether a term is a category or tag.', - input_schema: { - type: 'object', - properties: { - query: { type: 'string', description: 'The search query text to find in posts' }, - category: { type: 'string', description: 'Optional category to filter by (e.g., "article", "picture", "aside", "page")' }, - tags: { type: 'array', items: { type: 'string' }, description: 'Optional array of tags to filter by (all must match)' }, - year: { type: 'number', description: 'Filter to posts created in this year (e.g., 2024)' }, - month: { type: 'number', description: 'Filter to posts created in this month (1-12). Requires year.' }, - limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' }, - offset: { type: 'number', description: 'Offset for pagination (default: 0). Use with limit to page through results.' }, - }, - required: ['query'], - }, - }, - { - name: 'read_post', - description: 'Read the full content and metadata of a specific blog post by its ID. Includes backlinks (posts linking to this post).', - input_schema: { - type: 'object', - properties: { - postId: { type: 'string', description: 'The unique ID of the post to read' }, - }, - required: ['postId'], - }, - }, - { - name: 'list_posts', - description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks (posts linking to it). The response includes "total" (global post count in the blog) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period instead of paginating through all posts. Use offset/limit to page through filtered results. Use check_term first if unsure whether a term is a category or tag.', - input_schema: { - type: 'object', - properties: { - status: { type: 'string', enum: ['draft', 'published', 'archived'], description: 'Filter by post status' }, - category: { type: 'string', description: 'Filter by category' }, - tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (posts must have all specified tags)' }, - year: { type: 'number', description: 'Filter to posts created in this year (e.g., 2024). Use this to efficiently narrow results by time period.' }, - month: { type: 'number', description: 'Filter to posts created in this month (1-12). Requires year.' }, - limit: { type: 'number', description: 'Maximum number of results (default: 20)' }, - offset: { type: 'number', description: 'Offset for pagination (default: 0)' }, - }, - }, - }, - { - name: 'get_media', - description: 'Get information about a specific media file (image) by its ID.', - input_schema: { - type: 'object', - properties: { - mediaId: { type: 'string', description: 'The unique ID of the media file' }, - }, - required: ['mediaId'], - }, - }, - { - name: 'list_media', - description: 'List media files in the current project with optional filtering by MIME type, year, month, or tags. Returns paginated results with total count. Use year/month filters to efficiently narrow to a time period.', - input_schema: { - type: 'object', - properties: { - mimeTypeFilter: { type: 'string', description: 'Filter by MIME type prefix (e.g., "image/")' }, - tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (media must have all specified tags)' }, - year: { type: 'number', description: 'Filter to media created in this year (e.g., 2024)' }, - month: { type: 'number', description: 'Filter to media created in this month (1-12). Requires year.' }, - limit: { type: 'number', description: 'Maximum number of results (default: 20)' }, - offset: { type: 'number', description: 'Offset for pagination (default: 0)' }, - }, - }, - }, - { - name: 'update_post_metadata', - description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.', - input_schema: { - type: 'object', - properties: { - postId: { type: 'string', description: 'The unique ID of the post to update' }, - title: { type: 'string', description: 'New title for the post' }, - excerpt: { type: 'string', description: 'New excerpt/summary for the post' }, - tags: { type: 'array', items: { type: 'string' }, description: 'New tags for the post' }, - categories: { type: 'array', items: { type: 'string' }, description: 'New categories for the post' }, - }, - required: ['postId'], - }, - }, - { - name: 'update_media_metadata', - description: 'Update metadata for a media file (title, alt text, caption, tags).', - input_schema: { - type: 'object', - properties: { - mediaId: { type: 'string', description: 'The unique ID of the media to update' }, - title: { type: 'string', description: 'New title for display in lists and search results' }, - alt: { type: 'string', description: 'New alt text for the image' }, - caption: { type: 'string', description: 'New caption for the image' }, - tags: { type: 'array', items: { type: 'string' }, description: 'New tags for the media' }, - }, - required: ['mediaId'], - }, - }, - { - name: 'list_tags', - description: 'List all tags used across blog posts, with the count of posts using each tag. Useful for understanding the tag taxonomy.', - input_schema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'list_categories', - description: 'List all categories used across blog posts, with the count of posts in each category. Useful for understanding the category structure.', - input_schema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'get_blog_stats', - description: 'Get comprehensive blog statistics: total posts, drafts, published, archived counts, date range (oldest to newest post), posts per year breakdown, number of unique tags and categories, and total media count. Use this FIRST to understand the full scope of the blog before making queries. This is essential to understand the data volume.', - input_schema: { - type: 'object', - properties: {}, - }, - }, - { - name: 'view_image', - description: 'View an image to analyze its visual content. Returns the actual image for visual inspection. Use this when you need to see, describe, or analyze what an image looks like. Only works with image files (not PDFs or other media types).', - input_schema: { - type: 'object', - properties: { - mediaId: { type: 'string', description: 'The unique ID of the image to view' }, - size: { - type: 'string', - enum: ['small', 'medium', 'large'], - description: 'Image size: small (150px) for quick reference, medium (400px, default) for details, large (800px) for full analysis', - }, - }, - required: ['mediaId'], - }, - }, - { - name: 'get_post_backlinks', - description: 'Get all posts that link TO a specific post (backlinks/inbound links). Use this to discover which other posts reference or cite a given post. Helpful for understanding how content is interconnected and finding related posts.', - input_schema: { - type: 'object', - properties: { - postId: { type: 'string', description: 'The ID of the post to find backlinks for' }, - }, - required: ['postId'], - }, - }, - { - name: 'get_post_outlinks', - description: 'Get all posts that a specific post links TO (outbound links). Use this to discover what other posts are referenced or cited by a given post. Helpful for understanding content relationships and traversing linked posts.', - input_schema: { - type: 'object', - properties: { - postId: { type: 'string', description: 'The ID of the post to find outbound links for' }, - }, - required: ['postId'], - }, - }, - { - name: 'get_post_media', - description: 'Get all media files linked to a specific post. Returns media that has been explicitly associated with the post (featured images, galleries, etc.). Use this to discover images and other media attached to a post.', - input_schema: { - type: 'object', - properties: { - postId: { type: 'string', description: 'The ID of the post to get linked media for' }, - }, - required: ['postId'], - }, - }, - { - name: 'get_media_posts', - description: 'Get all posts that a specific media file is linked to. Use this to find which posts use or reference a particular image or media file. Helpful for understanding media usage across posts.', - input_schema: { - type: 'object', - properties: { - mediaId: { type: 'string', description: 'The ID of the media file to find linked posts for' }, - }, - required: ['mediaId'], - }, - }, - // ── A2UI Render Tools ── - { - name: 'render_chart', - description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. The chart will be displayed as a rich UI element in the conversation.', - input_schema: { - type: 'object', - properties: { - chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap'], description: 'The type of chart to render. Use stacked-bar when each bar has multiple segments (categories). Use area for trend/cumulative data. Use donut for proportional data with a total in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude (e.g., posts per month across years). Prefer heatmap over tables with emojis for intensity data.' }, - title: { type: 'string', description: 'Optional chart title' }, - series: { - type: 'array', - items: { - type: 'object', - properties: { - label: { type: 'string', description: 'Data point label (row label for heatmaps, e.g., year)' }, - value: { type: 'number', description: 'Data point value (total for stacked bars, ignored for heatmaps)' }, - segments: { - type: 'array', - items: { - type: 'object', - properties: { - label: { type: 'string', description: 'Segment/column label (e.g., month name for heatmaps)' }, - value: { type: 'number', description: 'Segment value (color intensity for heatmaps)' }, - }, - required: ['label', 'value'], - }, - description: 'Segments within this data point. Required for stacked-bar and heatmap charts. For heatmaps, each segment becomes a cell in that row.', - }, - }, - required: ['label', 'value'], - }, - description: 'Array of data points. For stacked-bar and heatmap charts, include segments. For heatmaps, each entry is a row and segments are columns.', - }, - }, - required: ['chartType', 'series'], - }, - }, - { - name: 'render_table', - description: 'Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information. The table will be displayed as a rich UI element.', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Optional table title' }, - columns: { type: 'array', items: { type: 'string' }, description: 'Column header names' }, - rows: { type: 'array', items: { type: 'array', items: { type: 'string' } }, description: 'Table rows, each row is an array of cell values' }, - }, - required: ['columns', 'rows'], - }, - }, - { - name: 'render_form', - description: 'Render an interactive form in the chat UI. Use this when you need to collect structured input from the user, such as metadata updates, configuration, or multi-field data entry.', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Optional form title' }, - fields: { - type: 'array', - items: { - type: 'object', - properties: { - key: { type: 'string', description: 'Field identifier' }, - label: { type: 'string', description: 'Field label shown to user' }, - inputType: { type: 'string', enum: ['text', 'textarea', 'select', 'checkbox', 'date', 'number'], description: 'Type of input control' }, - placeholder: { type: 'string', description: 'Placeholder text' }, - defaultValue: { description: 'Default value for the field' }, - options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, value: { type: 'string' } }, required: ['label', 'value'] }, description: 'Options for select fields' }, - required: { type: 'boolean', description: 'Whether the field is required' }, - }, - required: ['key', 'label', 'inputType'], - }, - description: 'Form fields to display', - }, - submitLabel: { type: 'string', description: 'Label for the submit button' }, - submitAction: { type: 'string', description: 'Action to dispatch on submit' }, - }, - required: ['fields', 'submitLabel'], - }, - }, - { - name: 'render_card', - description: 'Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item with a title, body, and optional action buttons.', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Card title' }, - body: { type: 'string', description: 'Card body text (supports markdown)' }, - subtitle: { type: 'string', description: 'Optional subtitle' }, - actions: { - type: 'array', - items: { - type: 'object', - properties: { - label: { type: 'string', description: 'Button label' }, - action: { type: 'string', description: 'Action name to dispatch (e.g., openPost, openMedia)' }, - payload: { type: 'object', description: 'Optional action payload' }, - }, - required: ['label', 'action'], - }, - description: 'Optional action buttons on the card', - }, - }, - required: ['title', 'body'], - }, - }, - { - name: 'render_metric', - description: 'Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label, such as post counts, statistics, or status indicators.', - input_schema: { - type: 'object', - properties: { - label: { type: 'string', description: 'Metric label' }, - value: { type: 'string', description: 'Metric value (displayed prominently)' }, - }, - required: ['label', 'value'], - }, - }, - { - name: 'render_list', - description: 'Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.', - input_schema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Optional list title' }, - items: { type: 'array', items: { type: 'string' }, description: 'List items' }, - }, - required: ['items'], - }, - }, - { - name: 'render_tabs', - description: 'Render a tabbed interface in the chat UI. Use this when you want to organize information into multiple tabs that the user can switch between. Each tab can contain any combination of text, metrics, lists, charts, and tables.', - input_schema: { - type: 'object', - properties: { - tabs: { - type: 'array', - items: { - type: 'object', - properties: { - label: { type: 'string', description: 'Tab label' }, - content: { - type: 'array', - items: { - type: 'object', - properties: { - type: { type: 'string', enum: ['text', 'metric', 'list', 'chart', 'table'], description: 'Content type' }, - text: { type: 'string', description: 'Text content (for type text)' }, - label: { type: 'string', description: 'Label (for type metric)' }, - value: { type: 'string', description: 'Display value (for type metric)' }, - title: { type: 'string', description: 'Title (for type list, chart, or table)' }, - items: { type: 'array', items: { type: 'string' }, description: 'Items (for type list)' }, - chartType: { type: 'string', enum: ['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap'], description: 'Chart type (for type chart). Use heatmap for intensity grids.' }, - series: { - type: 'array', - items: { - type: 'object', - properties: { - label: { type: 'string' }, - value: { type: 'number' }, - segments: { - type: 'array', - items: { - type: 'object', - properties: { label: { type: 'string' }, value: { type: 'number' } }, - required: ['label', 'value'], - }, - description: 'Segments for stacked-bar and heatmap charts', - }, - }, - required: ['label', 'value'], - }, - description: 'Data series (for type chart)', - }, - columns: { type: 'array', items: { type: 'string' }, description: 'Column headers (for type table)' }, - rows: { - type: 'array', - items: { type: 'array', items: { type: 'string' } }, - description: 'Table rows (for type table)', - }, - }, - required: ['type'], - }, - description: 'Content items within the tab', - }, - }, - required: ['label', 'content'], - }, - description: 'Array of tabs', - }, - }, - required: ['tabs'], - }, - }, - ]; - } - - /** - * Execute a tool by name with given arguments - */ - private async executeTool(name: string, args: Record): Promise { - try { - switch (name) { - case 'check_term': { - const [categories, tags] = await Promise.all([ - this.postEngine.getCategoriesWithCounts(), - this.postEngine.getTagsWithCounts(), - ]); - const termLower = (args.term as string).toLowerCase(); - const catMatch = categories.find(c => c.category.toLowerCase() === termLower); - const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower); - return { - success: true, - term: args.term as string, - asCategory: !!catMatch, - categoryPostCount: catMatch?.count ?? 0, - asTag: !!tagMatch, - tagPostCount: tagMatch?.count ?? 0, - }; - } - - case 'search_posts': { - if (args.month !== undefined && args.year === undefined) { - return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; - } - - const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {}; - if (args.category) filter.categories = [args.category as string]; - if (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0) filter.tags = args.tags as string[]; - if (args.year !== undefined) filter.year = args.year as number; - if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number; - - const hasFilters = Object.keys(filter).length > 0; - const offset = (args.offset as number) || 0; - const limit = (args.limit as number) || 10; - - let filteredPosts; - // Use searchPostsFiltered for all paths — it handles FTS + structural - // filters in a single SQL JOIN and returns full PostData[] - filteredPosts = await this.postEngine.searchPostsFiltered( - args.query as string, filter, { offset, limit }, - ); - - const totalMatches = filteredPosts.length; - const hints = await this.buildAmbiguityHints(args.category as string | undefined, args.tags as string[] | undefined); - - const result: Record = { - success: true, - count: filteredPosts.length, - totalMatches, - hasMore: false, - offset, - limit, - posts: await Promise.all(filteredPosts.map(async p => { - const [backlinks, linksTo] = await Promise.all([ - this.postEngine.getLinkedBy(p.id), - this.postEngine.getLinksTo(p.id), - ]); - return { - 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, - 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 })), - }; - })), - }; - if (hints.length > 0) result.hints = hints; - return result; - } - - case 'read_post': { - const post = await this.postEngine.getPost(args.postId as string); - if (!post) return { success: false, error: 'Post not found' }; - const [backlinks, linksTo] = await Promise.all([ - this.postEngine.getLinkedBy(post.id), - this.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 })), - }, - }; - } - - case 'list_posts': { - if (args.month !== undefined && args.year === undefined) { - return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; - } - - const filter: { status?: 'draft' | 'published' | 'archived'; tags?: string[]; categories?: string[]; year?: number; month?: number } = {}; - if (args.status) filter.status = args.status as 'draft' | 'published' | 'archived'; - if (args.tags) filter.tags = args.tags as string[]; - if (args.category) filter.categories = [args.category as string]; - if (args.year !== undefined) filter.year = args.year as number; - if (args.month !== undefined && args.year !== undefined) filter.month = args.month as number; - - const offset = (args.offset as number) || 0; - const limit = (args.limit as number) || 20; - - // Always get global total for awareness - const globalStats = await this.postEngine.getDashboardStats(); - const globalTotal = globalStats.totalPosts; - - let pageItems: PostData[]; - let filteredTotal: number; - - if (Object.keys(filter).length > 0) { - const allFiltered = await this.postEngine.getPostsFiltered(filter); - filteredTotal = allFiltered.length; - pageItems = allFiltered.slice(offset, offset + limit); - } else { - const listResult = await this.postEngine.getAllPosts({ limit, offset }); - pageItems = listResult.items; - filteredTotal = listResult.total; - } - - const hints = await this.buildAmbiguityHints(args.category as string | undefined, args.tags as string[] | undefined); - - const result: Record = { - success: true, - count: pageItems.length, - total: globalTotal, - filteredTotal, - hasMore: offset + limit < filteredTotal, - offset, - limit, - posts: await Promise.all(pageItems.map(async p => { - const [backlinks, linksTo] = await Promise.all([ - this.postEngine.getLinkedBy(p.id), - this.postEngine.getLinksTo(p.id), - ]); - return { - id: p.id, title: p.title, slug: p.slug, - status: p.status, categories: p.categories, - tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt, - 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 })), - }; - })), - }; - if (hints.length > 0) result.hints = hints; - return result; - } - - case 'get_media': { - const media = await this.mediaEngine.getMedia(args.mediaId as string); - 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, - }, - }; - } - - case 'list_media': { - if (args.month !== undefined && args.year === undefined) { - return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; - } - - const hasMediaFilter = args.year !== undefined || (args.tags && Array.isArray(args.tags) && (args.tags as string[]).length > 0); - let mediaList: MediaData[]; - - if (hasMediaFilter) { - const mediaFilter: { year?: number; month?: number; tags?: string[] } = {}; - if (args.year !== undefined) mediaFilter.year = args.year as number; - if (args.month !== undefined && args.year !== undefined) mediaFilter.month = args.month as number; - if (args.tags) mediaFilter.tags = args.tags as string[]; - mediaList = await this.mediaEngine.getMediaFiltered(mediaFilter); - } else { - mediaList = await this.mediaEngine.getAllMedia(); - } - - const totalMedia = mediaList.length; - if (args.mimeTypeFilter) { - mediaList = mediaList.filter(m => m.mimeType.startsWith(args.mimeTypeFilter as string)); - } - const filteredTotal = mediaList.length; - const offset = (args.offset as number) || 0; - const limit = (args.limit as number) || 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, - })), - }; - } - - case 'update_post_metadata': { - const updates: Record = {}; - if (args.title !== undefined) updates.title = args.title; - if (args.excerpt !== undefined) updates.excerpt = args.excerpt; - if (args.tags !== undefined) updates.tags = args.tags; - if (args.categories !== undefined) updates.categories = args.categories; - - if (Object.keys(updates).length === 0) { - return { success: false, error: 'No updates provided' }; - } - - await this.postEngine.updatePost(args.postId as string, updates); - return { success: true, message: `Post ${args.postId} metadata updated successfully` }; - } - - case 'update_media_metadata': { - const updates: Record = {}; - if (args.title !== undefined) updates.title = args.title; - if (args.alt !== undefined) updates.alt = args.alt; - if (args.caption !== undefined) updates.caption = args.caption; - if (args.tags !== undefined) updates.tags = args.tags; - - if (Object.keys(updates).length === 0) { - return { success: false, error: 'No updates provided' }; - } - - await this.mediaEngine.updateMedia(args.mediaId as string, updates); - return { success: true, message: `Media ${args.mediaId} metadata updated successfully` }; - } - - case 'list_tags': { - const tagsWithCounts = await this.postEngine.getTagsWithCounts(); - return { - success: true, - count: tagsWithCounts.length, - tags: tagsWithCounts, - }; - } - - case 'list_categories': { - const categoriesWithCounts = await this.postEngine.getCategoriesWithCounts(); - return { - success: true, - count: categoriesWithCounts.length, - categories: categoriesWithCounts, - }; - } - - case 'view_image': { - const mediaId = args.mediaId as string; - const size = (args.size as 'small' | 'medium' | 'large') || 'medium'; - - // Get media metadata first - const mediaItem = await this.mediaEngine.getMedia(mediaId); - if (!mediaItem) { - return { success: false, error: 'Image not found' }; - } - - // Verify it's an image (not PDF, video, etc) - if (!mediaItem.mimeType.startsWith('image/')) { - return { success: false, error: `Cannot view this file type: ${mediaItem.mimeType}. Only images are supported.` }; - } - - // Get thumbnail data URL (base64) - const dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, size); - if (!dataUrl) { - return { success: false, error: 'Thumbnail not available. Try regenerating thumbnails from Settings.' }; - } - - // Extract base64 data (remove data:image/webp;base64, prefix) - const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); - - // Return special image result format - 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: size, - }, - }; - } - - case 'get_post_backlinks': { - const linkedBy = await this.postEngine.getLinkedBy(args.postId as string); - return { - success: true, - postId: args.postId, - count: linkedBy.length, - linkedBy: linkedBy.map(p => ({ - id: p.id, - title: p.title, - slug: p.slug, - })), - }; - } - - case 'get_post_outlinks': { - const linksTo = await this.postEngine.getLinksTo(args.postId as string); - return { - success: true, - postId: args.postId, - count: linksTo.length, - linksTo: linksTo.map(p => ({ - id: p.id, - title: p.title, - slug: p.slug, - })), - }; - } - - case 'get_post_media': { - const postMediaEngine = this.postMediaEngine; - const linkedMedia = await postMediaEngine.getLinkedMediaDataForPost(args.postId as string); - return { - success: true, - postId: args.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, - })), - }; - } - - case 'get_media_posts': { - const postMediaEngine = this.postMediaEngine; - const linkedPosts = await postMediaEngine.getLinkedPostsForMedia(args.mediaId as string); - - // Fetch full post data for each linked post - const postsData = await Promise.all( - linkedPosts.map(async (link) => { - const post = await this.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: args.mediaId, - count: validPosts.length, - posts: validPosts, - }; - } - - case 'get_blog_stats': { - const stats = await this.postEngine.getBlogStats(); - const mediaList = await this.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, - }; - } - - default: - return { success: false, error: `Unknown tool: ${name}` }; - } - } catch (error) { - return { success: false, error: (error as Error).message }; - } - } - - /** Build ambiguity hint strings 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.postEngine.getTagsWithCounts(); - const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase()); - if (tagMatch) { - hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`); - } - } - - if (tags && tags.length > 0) { - const allCats = await this.postEngine.getCategoriesWithCounts(); - for (const tag of tags) { - const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase()); - if (catMatch) { - hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`); - } - } - } - - return hints; - } - - /** - * Estimate token count for a string using a rough character heuristic. - * ~3.5 characters per token for English text (conservative, tends to overestimate). - */ - private estimateTokens(text: string): number { - return Math.ceil(text.length / 3.5); - } - - /** - * Estimate total tokens for an array of Anthropic messages. - */ - private estimateMessageTokens(messages: AnthropicMessage[]): number { - let total = 0; - for (const msg of messages) { - if (typeof msg.content === 'string') { - total += this.estimateTokens(msg.content); - } else if (Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.text) total += this.estimateTokens(block.text); - if (typeof block.content === 'string') total += this.estimateTokens(block.content); - } - } - } - return total; - } - - /** - * Truncate messages to fit within a token budget. - * Drops oldest user/assistant pairs first, keeping the most recent messages. - */ - private truncateToTokenBudget( - messages: AnthropicMessage[], - systemPrompt: string, - tools: ToolDefinition[], - maxContextTokens: number = 150000, - ): AnthropicMessage[] { - const systemTokens = this.estimateTokens(systemPrompt); - const toolsTokens = this.estimateTokens(JSON.stringify(tools)); - const responseReserve = 4096; - const availableBudget = maxContextTokens - systemTokens - toolsTokens - responseReserve; - - if (availableBudget <= 0) { - return messages.slice(-1); - } - - if (this.estimateMessageTokens(messages) <= availableBudget) { - return messages; - } - - // Drop oldest pairs until we fit - let truncated = [...messages]; - while (truncated.length > 2 && this.estimateMessageTokens(truncated) > availableBudget) { - // Ensure valid message structure (must start with user for Anthropic) - if (truncated[0].role === 'user') { - truncated = truncated.slice(2); // Drop user + assistant pair - } else { - truncated = truncated.slice(1); - } - } - - if (truncated.length !== messages.length) { - console.log(`[OpenCodeManager] Truncated conversation from ${messages.length} to ${truncated.length} messages (budget: ${availableBudget} tokens)`); - } - - return truncated; - } - - /** - * Build a human-readable summary of tool calls from a serialized JSON string. - * Used by both Anthropic and OpenAI message builders to annotate assistant - * messages with tool-use context when resuming a conversation from DB history. - */ - private buildToolCallSummary(toolCallsJson?: string): string { - if (!toolCallsJson) return ''; - try { - const toolCalls = JSON.parse(toolCallsJson) as Array<{ name: string; args: unknown }>; - if (toolCalls.length === 0) return ''; - const summary = toolCalls - .map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`) - .join('\n'); - return `\n\n[Tools used in this turn:\n${summary}\n]`; - } catch { - return ''; - } - } - - /** - * Build Anthropic-format messages from DB message history. - * For assistant messages that had tool calls, appends a summary annotation - * so the model retains context about what tools were used on resume. - */ - private buildAnthropicMessages( - dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }> - ): AnthropicMessage[] { - const messages: AnthropicMessage[] = []; - - for (const msg of dbMessages) { - if (msg.role === 'user') { - messages.push({ role: 'user', content: msg.content || '' }); - } else if (msg.role === 'assistant') { - const content = (msg.content || '') + this.buildToolCallSummary(msg.toolCalls); - messages.push({ role: 'assistant', content }); - } - } - - return messages; - } - - /** - * Generate a title for a conversation. - * Uses the configured title model (fallback: claude-haiku-4-5) and routes - * the request to the correct provider API. - */ - private async generateConversationTitle( - conversationId: string, - userMessage: string, - _assistantResponse: string - ): Promise { - try { - // Read configured title model, with smart fallback based on available keys - let titleModel = await this.chatEngine.getSetting('chat_title_model'); - if (!titleModel || !this.isProviderKeySet(this.detectProvider(titleModel))) { - titleModel = this.apiKey ? 'claude-haiku-4-5' : this.mistralApiKey ? 'mistral-small-latest' : null; - } - if (!titleModel) return; - const provider = this.detectProvider(titleModel); - - const promptText = `Topic: ${userMessage.substring(0, 100)}`; - const systemText = 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.'; - - let title = ''; - - if (provider === 'anthropic') { - const body = { - model: titleModel, - max_tokens: 20, - system: systemText, - messages: [{ role: 'user', content: promptText }], - }; - - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) return; - - const data = JSON.parse(response.body); - if (Array.isArray(data.content)) { - title = data.content - .filter((b: AnthropicContentBlock) => b.type === 'text') - .map((b: AnthropicContentBlock) => b.text || '') - .join(''); - } else { - title = data.content || ''; - } - } else { - // OpenAI-compatible (includes Mistral) - const config = this.getProviderConfig(provider); - const body = { - model: titleModel, - max_tokens: 20, - messages: [ - { role: 'system', content: systemText }, - { role: 'user', content: promptText }, - ], - }; - - const response = await this.httpRequest(config.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${config.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) return; - - const data = JSON.parse(response.body); - title = data.choices?.[0]?.message?.content || ''; - } - - // Clean up and truncate title - title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, ''); - - const MAX_TITLE_LENGTH = 30; - if (title.length > MAX_TITLE_LENGTH) { - title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…'; - } - - if (title) { - await this.chatEngine.updateConversation(conversationId, { title }); - - const mainWindow = this.getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send('chat-title-updated', { conversationId, title }); - } - } - } catch (error) { - console.error('[OpenCodeManager] Error generating title:', error); - } - } - - /** - * Abort a running message - */ - async abortMessage(conversationId: string): Promise<{ success: boolean; error?: string }> { - const controller = this.abortControllers.get(conversationId); - if (!controller) { - return { success: false, error: 'No active request for this conversation' }; - } - - controller.abort(); - this.abortControllers.delete(conversationId); - return { success: true }; - } - - /** - * Stop/cleanup - */ - async stop(): Promise { - for (const [, controller] of this.abortControllers) { - controller.abort(); - } - this.abortControllers.clear(); - } - - // ── Helpers ── - - /** - * Append live blog statistics to the system prompt so the AI - * knows the true scale of the data before its first tool call. - */ - private async appendBlogStats(basePrompt: string): Promise { - try { - const stats = await this.postEngine.getBlogStats(); - const mediaList = await this.mediaEngine.getAllMedia(); - - if (stats.totalPosts === 0) { - return basePrompt; - } - - const dateRange = stats.oldestPostDate && stats.newestPostDate - ? `from ${stats.oldestPostDate.toISOString().split('T')[0]} to ${stats.newestPostDate.toISOString().split('T')[0]}` - : 'unknown'; - - const yearBreakdown = Object.entries(stats.postsPerYear) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([year, count]) => `${year}: ${count}`) - .join(', '); - - const statsSummary = ` - ---- CURRENT BLOG DATA SUMMARY --- -Total posts: ${stats.totalPosts} (${stats.publishedCount} published, ${stats.draftCount} drafts, ${stats.archivedCount} archived) -Date range: ${dateRange} -Posts per year: ${yearBreakdown} -Unique tags: ${stats.tagCount}, Unique categories: ${stats.categoryCount} -Total media files: ${mediaList.length} -NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all data. Default page size is 20.`; - - return basePrompt + statsSummary; - } catch (error) { - console.error('[OpenCodeManager] Failed to append blog stats:', error); - return basePrompt; - } - } - - /** - * Get max output tokens for a model from the model catalog (DB-backed). - * Falls back to DEFAULT_MAX_OUTPUT_TOKENS (16384) when not catalogued. - */ - private async getMaxOutputTokens(modelId: string): Promise { - return this.modelCatalogEngine.getMaxOutputTokens(modelId); - } - - /** - * Access the model catalog engine (used by IPC handlers). - */ - getModelCatalogEngine(): ModelCatalogEngine { - return this.modelCatalogEngine; - } - - /** - * Invalidate the in-memory model cache so the next getAvailableModels() - * re-fetches and re-cross-references with the catalog. - */ - invalidateModelCache(): void { - this.cachedModels = null; - this.cachedModelsAt = 0; - } - - /** - * Check whether the given provider's API key is configured. - * All non-mistral providers are routed through OpenCode Zen and share apiKey. - */ - private isProviderKeySet(provider: string): boolean { - if (provider === 'mistral') return !!this.mistralApiKey; - return !!this.apiKey; - } - - /** - * Load model catalog into maps for quick vision and name lookups. - * Vision = model has 'image' in its input modalities. - */ - private async getCatalogLookups(): Promise<{ vision: Map; names: Map }> { - const vision = new Map(); - const names = new Map(); - try { - const catalog = await this.modelCatalogEngine.getAll(); - for (const m of catalog) { - vision.set(m.id, m.inputModalities.includes('image')); - names.set(m.id, m.name); - } - } catch { - // Catalog unavailable — maps stay empty - } - return { vision, names }; - } - - /** - * Resolve vision capability for a model ID. - * Vision = 'image' is in the model's input modalities from the catalog. - */ - private resolveVision(modelId: string, catalogVision: Map): boolean { - return catalogVision.get(modelId) ?? false; - } - - /** - * Resolve display name for a model ID. Falls back to raw model ID. - */ - private resolveName(modelId: string, catalogNames: Map): string { - return catalogNames.get(modelId) ?? modelId; - } - - /** - * Return API URL, key and provider-specific options for a given provider. - * Used to parameterise sendOpenAIMessage() for non-Anthropic providers. - */ - private getProviderConfig(provider: string): { apiUrl: string; apiKey: string; options?: { parallelToolCalls?: boolean } } { - if (provider === 'mistral') { - return { apiUrl: MISTRAL_API_URL, apiKey: this.mistralApiKey, options: { parallelToolCalls: false } }; - } - return { apiUrl: ZEN_OPENAI_URL, apiKey: this.apiKey }; - } - - private detectProvider(modelId: string): string { - const id = modelId.toLowerCase(); - if (id.startsWith('claude')) return 'anthropic'; - if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai'; - if (id.startsWith('gemini')) return 'google'; - if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral'; - return 'other'; - } - - - - private parseErrorResponse(response: HttpResponse): string { - let errorMsg = `API error: ${response.statusCode}`; - try { - const errorBody = JSON.parse(response.body); - const rawMsg = errorBody.error?.message || errorBody.message || ''; - if (rawMsg.includes('prompt_tokens') || rawMsg.includes('usage')) { - errorMsg = `Model is currently unavailable on the API gateway. Try a different model.`; - } else { - errorMsg = rawMsg || errorMsg; - } - } catch { - errorMsg = `${errorMsg}: ${response.body.slice(0, 200)}`; - } - return errorMsg; - } - - /** - * Analyze taxonomy items (tags and categories) and suggest mappings - * This is a one-shot request without conversation context - */ - async analyzeTaxonomy( - categories: Array<{ name: string; slug: string; existsInProject: boolean }>, - tags: Array<{ name: string; slug: string; existsInProject: boolean }>, - modelId: string - ): Promise<{ - success: boolean; - categoryMappings?: Record; - tagMappings?: Record; - error?: string; - }> { - const provider = this.detectProvider(modelId); - if (!this.isProviderKeySet(provider)) { - const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; - return { success: false, error: `${providerLabel} API key not set` }; - } - - // Build the prompt for taxonomy analysis - const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name); - const newCategories = categories.filter(c => !c.existsInProject).map(c => c.name); - const existingTags = tags.filter(t => t.existsInProject).map(t => t.name); - const newTags = tags.filter(t => !t.existsInProject).map(t => t.name); - - const systemPrompt = `You are an expert at analyzing taxonomy terms (tags and categories) for a blog import system. - -Your task is to identify NEW tags/categories from an import that should be mapped to EXISTING tags/categories in the project to avoid creating duplicates. - -CRITICAL RULES: -1. ONLY map NEW items to EXISTING items - never map new to new -2. The goal is to prevent duplicate creation, NOT to reduce the number of new items -3. A new item should only map to an existing item if they represent the same concept -4. Consider language differences: a new tag can match an existing tag in a different language (e.g., "Photography" should map to "Fotografie" if that exists) -5. Consider variations like: different casing, singular/plural, abbreviations, hyphenation, synonyms -6. Only suggest mappings where there is a clear semantic match - not every new item needs a mapping - -EXAMPLES OF VALID MAPPINGS (new → existing): -- "Photos" → "Photography" (if Photography exists) -- "Fotografie" → "Photography" (language variation, if Photography exists) -- "tech" → "Technology" (abbreviation, if Technology exists) -- "Web Dev" → "Web Development" (abbreviation, if Web Development exists) - -DO NOT: -- Map a new item to another new item -- Suggest mappings just because items are in the same topic area -- Create mappings for items that are distinct concepts - -RESPONSE FORMAT: -You MUST respond with valid JSON only, no other text. Use this exact structure: -{ - "categoryMappings": { "New Category": "Existing Category", ... }, - "tagMappings": { "New Tag": "Existing Tag", ... } -} - -The source (key) MUST be from the NEW items list, and the target (value) MUST be from the EXISTING items list. -If there are no sensible mappings to suggest, return empty objects.`; - - const userPrompt = `Analyze these taxonomy items from a WordPress import. Identify NEW items that should be mapped to EXISTING items to avoid duplicates. - -EXISTING CATEGORIES IN PROJECT (map TO these): -${existingCategories.length > 0 ? existingCategories.join(', ') : '(none)'} - -NEW CATEGORIES FROM IMPORT (map FROM these): -${newCategories.length > 0 ? newCategories.join(', ') : '(none)'} - -EXISTING TAGS IN PROJECT (map TO these): -${existingTags.length > 0 ? existingTags.join(', ') : '(none)'} - -NEW TAGS FROM IMPORT (map FROM these): -${newTags.length > 0 ? newTags.join(', ') : '(none)'} - -Remember: Only suggest mappings from NEW items to EXISTING items. Consider language differences (e.g., German/English equivalents). Response must be valid JSON only.`; - - try { - let responseText = ''; - - if (provider === 'anthropic') { - const body = { - model: modelId, - max_tokens: 4096, - system: systemPrompt, - messages: [{ role: 'user', content: userPrompt }], - }; - - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) { - console.error('[OpenCodeManager] Taxonomy analysis failed:', response.body); - return { success: false, error: `API request failed: ${response.statusCode}` }; - } - - const data = JSON.parse(response.body); - // Extract text from Anthropic response - for (const block of data.content || []) { - if (block.type === 'text') { - responseText += block.text; - } - } - } else { - // OpenAI-compatible (includes Mistral) - const config = this.getProviderConfig(provider); - const body = { - model: modelId, - max_tokens: 4096, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - }; - - const response = await this.httpRequest(config.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) { - console.error('[OpenCodeManager] Taxonomy analysis failed:', response.body); - return { success: false, error: `API request failed: ${response.statusCode}` }; - } - - const data = JSON.parse(response.body); - responseText = data.choices?.[0]?.message?.content || ''; - } - - // Parse the JSON response - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - console.error('[OpenCodeManager] No JSON found in response:', responseText); - return { success: false, error: 'Invalid response format from AI' }; - } - - const result = JSON.parse(jsonMatch[0]); - - // Validate and filter mappings to ensure they follow the new→existing rule - const validatedCategoryMappings: Record = {}; - const validatedTagMappings: Record = {}; - - // Filter category mappings: source must be new, target must be existing - const newCatSet = new Set(newCategories); - const existingCatSet = new Set(existingCategories); - for (const [source, target] of Object.entries(result.categoryMappings || {})) { - if (newCatSet.has(source) && existingCatSet.has(target as string)) { - validatedCategoryMappings[source] = target as string; - } else { - console.log(`[OpenCodeManager] Filtered out invalid category mapping: "${source}" → "${target}"`); - } - } - - // Filter tag mappings: source must be new, target must be existing - const newTagSet = new Set(newTags); - const existingTagSet = new Set(existingTags); - for (const [source, target] of Object.entries(result.tagMappings || {})) { - if (newTagSet.has(source) && existingTagSet.has(target as string)) { - validatedTagMappings[source] = target as string; - } else { - console.log(`[OpenCodeManager] Filtered out invalid tag mapping: "${source}" → "${target}"`); - } - } - - return { - success: true, - categoryMappings: validatedCategoryMappings, - tagMappings: validatedTagMappings, - }; - } catch (error) { - console.error('[OpenCodeManager] Error analyzing taxonomy:', error); - return { success: false, error: (error as Error).message }; - } - } - - /** - * Analyze a media image and generate title, alt text, and caption using AI - * This is a one-shot request that looks at the image and suggests metadata. - * Uses the configured image analysis model and routes to the correct provider. - */ - async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{ - success: boolean; - title?: string; - alt?: string; - caption?: string; - error?: string; - }> { - // Read configured image analysis model, with smart fallback based on available keys - let modelId = await this.chatEngine.getSetting('chat_image_analysis_model'); - if (!modelId || !this.isProviderKeySet(this.detectProvider(modelId))) { - modelId = this.apiKey ? 'claude-sonnet-4-5' : this.mistralApiKey ? 'mistral-large-latest' : null; - } - if (!modelId) { - return { success: false, error: 'API key not configured. Please set an API key in Settings.' }; - } - const provider = this.detectProvider(modelId); - - // Get media metadata - const mediaItem = await this.mediaEngine.getMedia(mediaId); - if (!mediaItem) { - return { success: false, error: 'Media item not found' }; - } - - // Verify it's an image - if (!mediaItem.mimeType.startsWith('image/')) { - return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` }; - } - - // Get the large thumbnail for better quality analysis (or medium as fallback) - let dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'large'); - if (!dataUrl) { - dataUrl = await this.mediaEngine.getThumbnailDataUrl(mediaId, 'medium'); - } - if (!dataUrl) { - return { success: false, error: 'Image thumbnail not available. Try regenerating thumbnails from Settings.' }; - } - - // Extract base64 data (remove data:image/webp;base64, prefix) - const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, ''); - - // Map language code to full name for clearer instructions - const languageNames: Record = { - en: 'English', de: 'German', es: 'Spanish', fr: 'French', it: 'Italian', - pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', ja: 'Japanese', - zh: 'Chinese', ko: 'Korean', ar: 'Arabic', hi: 'Hindi', tr: 'Turkish', - sv: 'Swedish', da: 'Danish', no: 'Norwegian', fi: 'Finnish', cs: 'Czech', - }; - const languageName = languageNames[language] || language; - - const systemPrompt = `Generate title, alt text, and caption for this image in ${languageName}. - -TITLE: A short, descriptive title for display in lists and search results (3-8 words). Should identify the main subject. -ALT: Describe ONLY what is visually present in the image. Be factual, neutral, and concise (5-12 words max). No interpretations, emotions, or "Image of" prefix. Example: "Red bicycle leaning against white brick wall" -CAPTION: Short, engaging blog caption (5-20 words). - -Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`; - - try { - let responseText = ''; - - if (provider === 'anthropic') { - const body = { - model: modelId, - max_tokens: 200, - system: systemPrompt, - messages: [ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/webp', - data: base64Data, - }, - }, - { - type: 'text', - text: 'Analyze and respond with JSON.', - }, - ], - }, - ], - }; - - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) { - console.error('[OpenCodeManager] Image analysis failed:', response.body); - const errorMsg = this.parseErrorResponse(response); - return { success: false, error: errorMsg }; - } - - const data = JSON.parse(response.body); - for (const block of data.content || []) { - if (block.type === 'text') { - responseText += block.text; - } - } - } else { - // OpenAI-compatible (includes Mistral) - const config = this.getProviderConfig(provider); - const body = { - model: modelId, - max_tokens: 200, - messages: [ - { role: 'system', content: systemPrompt }, - { - role: 'user', - content: [ - { - type: 'image_url', - image_url: { - url: `data:image/webp;base64,${base64Data}`, - }, - }, - { - type: 'text', - text: 'Analyze and respond with JSON.', - }, - ], - }, - ], - }; - - const response = await this.httpRequest(config.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${config.apiKey}`, - }, - body: JSON.stringify(body), - }); - - if (response.statusCode !== 200) { - console.error('[OpenCodeManager] Image analysis failed:', response.body); - const errorMsg = this.parseErrorResponse(response); - return { success: false, error: errorMsg }; - } - - const data = JSON.parse(response.body); - responseText = data.choices?.[0]?.message?.content || ''; - } - - // Parse the JSON response - const jsonMatch = responseText.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - console.error('[OpenCodeManager] No JSON found in image analysis response:', responseText); - return { success: false, error: 'Invalid response format from AI' }; - } - - const result = JSON.parse(jsonMatch[0]); - - return { - success: true, - title: result.title || undefined, - alt: result.alt || undefined, - caption: result.caption || undefined, - }; - } catch (error) { - console.error('[OpenCodeManager] Error analyzing media image:', error); - return { success: false, error: (error as Error).message }; - } - } - - private httpRequest( - urlStr: string, - options: { - method?: string; - headers?: Record; - body?: string; - signal?: AbortSignal; - } - ): Promise { - return new Promise((resolve, reject) => { - const url = new URL(urlStr); - const protocol = url.protocol === 'https:' ? https : http; - - const req = protocol.request(url, { - method: options.method || 'POST', - headers: options.headers || {}, - timeout: 120000, - }, (res) => { - let body = ''; - res.on('data', (chunk: Buffer) => { body += chunk; }); - res.on('end', () => { - resolve({ statusCode: res.statusCode || 0, body }); - }); - }); - - req.on('error', reject); - req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timed out')); - }); - - if (options.signal) { - options.signal.addEventListener('abort', () => { - req.destroy(); - reject(new Error('Request cancelled')); - }, { once: true }); - } - - if (options.body) { - req.write(options.body); - } - req.end(); - }); - } -} diff --git a/src/main/engine/ai/blog-tools.ts b/src/main/engine/ai/blog-tools.ts index 489982e..5384199 100644 --- a/src/main/engine/ai/blog-tools.ts +++ b/src/main/engine/ai/blog-tools.ts @@ -57,10 +57,16 @@ export interface BlogToolDeps { // Shared helpers // --------------------------------------------------------------------------- +/** Deps contract for link enrichment — narrow so MCPServer can also use it. */ +export interface LinkEnrichmentDeps { + getLinkedBy: (postId: string) => Promise>; + getLinksTo: (postId: string) => Promise>; +} + /** Enrich posts with backlinks and outlinks. */ -async function enrichWithLinks( +export async function enrichWithLinks( posts: T[], - postEngine: BlogToolDeps['postEngine'], + postEngine: LinkEnrichmentDeps, ): Promise; linksTo: Array<{ id: string; title: string; slug: string }> }>> { return Promise.all(posts.map(async (p) => { const [backlinks, linksTo] = await Promise.all([ @@ -113,6 +119,27 @@ export async function buildAmbiguityHints( return hints; } +/** Shared check_term logic — returns a plain result object. */ +export async function executeCheckTerm( + term: string, + postEngine: AmbiguityHintDeps, +): Promise<{ term: string; asCategory: boolean; categoryPostCount: number; asTag: boolean; tagPostCount: number }> { + 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 { + term, + asCategory: !!catMatch, + categoryPostCount: catMatch?.count ?? 0, + asTag: !!tagMatch, + tagPostCount: tagMatch?.count ?? 0, + }; +} + // --------------------------------------------------------------------------- // Tool factory // --------------------------------------------------------------------------- @@ -127,21 +154,8 @@ export function createBlogTools(deps: BlogToolDeps) { 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, - }; + const result = await executeCheckTerm(term, postEngine); + return { success: true, ...result }; }, }), diff --git a/src/main/engine/ai/chat.ts b/src/main/engine/ai/chat.ts index 0942f1c..ca88a81 100644 --- a/src/main/engine/ai/chat.ts +++ b/src/main/engine/ai/chat.ts @@ -1,8 +1,7 @@ /** * ChatService — streaming chat using AI SDK's streamText(). * - * Replaces OpenCodeManager's sendAnthropicMessage/sendOpenAIMessage/ - * streaming.ts with a single, provider-agnostic code path. + * Streaming chat service using AI SDK v6 streamText(). * * AI SDK handles: * - SSE parsing, reconnection, abort @@ -78,7 +77,7 @@ function dbMessagesToAIMessages( messages.push({ role: 'user', content: msg.content || '' }); } else if (msg.role === 'assistant') { let content = msg.content || ''; - // Append tool-call annotation from previous turns (same as OpenCodeManager) + // Append tool-call annotation from previous turns if (msg.toolCalls) { try { const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>; @@ -216,7 +215,7 @@ export class ChatService { /** * Send a user message, stream the AI response with tool use. - * This is the main entry point — replaces OpenCodeManager.sendMessage(). + * Send a message in a conversation, streaming the response. */ async sendMessage( conversationId: string, diff --git a/src/main/engine/ai/tasks.ts b/src/main/engine/ai/tasks.ts index dfb8819..719986f 100644 --- a/src/main/engine/ai/tasks.ts +++ b/src/main/engine/ai/tasks.ts @@ -1,7 +1,7 @@ /** * OneShotTasks — non-streaming AI tasks using generateText(). * - * Replaces OpenCodeManager.analyzeTaxonomy() and analyzeMediaImage() + * One-shot AI tasks: taxonomy analysis and image analysis. * with provider-agnostic AI SDK calls. */ diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index d1dab43..57cd467 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -29,11 +29,6 @@ export { type ChatMessageData, type CreateConversationInput, } from './ChatEngine'; -export { - OpenCodeManager, - type SendMessageOptions, - type SendMessageResult, -} from './OpenCodeManager'; export { WxrParser, type WxrData, diff --git a/src/main/engine/streaming.ts b/src/main/engine/streaming.ts deleted file mode 100644 index 5f40ac8..0000000 --- a/src/main/engine/streaming.ts +++ /dev/null @@ -1,620 +0,0 @@ -/** - * SSE Streaming Infrastructure - * - * Provides SSE line parsing, event parsers for OpenAI/Mistral and Anthropic - * stream formats, tool-call accumulation, and retry-with-exponential-backoff. - * - * Used by OpenCodeManager to convert buffered HTTP calls to real-time - * token-by-token streaming for all chat providers. - */ - -import https from 'https'; -import http from 'http'; -import { URL } from 'url'; - -// ── Types ── - -export interface SSEEvent { - event?: string; - data: string; -} - -export interface StreamEventResult { - /** Text content delta to emit to UI */ - textDelta?: string; - /** Whether the stream is complete */ - done: boolean; - /** Finish reason from the model */ - finishReason?: string; - /** Token usage information */ - usage?: { - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - inputTokens?: number; - outputTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - }; -} - -interface ToolCallAccumulator { - id: string; - name: string; - arguments: string; -} - -export interface OpenAIStreamAccumulator { - toolCalls: Map; -} - -export interface AnthropicStreamAccumulator { - toolCalls: Map; - thinkingBlocks: Map; -} - -export interface HttpStreamError extends Error { - statusCode?: number; - retryAfter?: number; - isAbort?: boolean; -} - -// ── SSE Line Parsing ── - -/** - * Parse raw SSE text into structured events. - * - * SSE protocol: events are separated by double-newlines (\n\n). - * Each event can have `event:` and `data:` lines. - * Multiple `data:` lines within one event are concatenated with newlines. - * Lines starting with `:` are comments (ignored). - * - * Returns parsed events and any remaining incomplete text (buffer). - */ -export function parseSSELines(text: string): { events: SSEEvent[]; remaining: string } { - const events: SSEEvent[] = []; - - // Normalize \r\n to \n - const normalized = text.replace(/\r\n/g, '\n'); - - // Split on double-newline (event boundary) - const parts = normalized.split('\n\n'); - - // Last part may be incomplete (no trailing \n\n) - const remaining = normalized.endsWith('\n\n') ? '' : parts.pop() || ''; - - for (const part of parts) { - if (!part.trim()) continue; - - let eventType: string | undefined; - const dataLines: string[] = []; - - for (const line of part.split('\n')) { - // Comment lines start with ':' - if (line.startsWith(':')) continue; - - if (line.startsWith('event: ') || line.startsWith('event:')) { - const afterColon = line.slice(line.indexOf(':') + 1); - eventType = afterColon.startsWith(' ') ? afterColon.slice(1) : afterColon; - } else if (line.startsWith('data: ') || line.startsWith('data:')) { - const afterColon = line.slice(line.indexOf(':') + 1); - dataLines.push(afterColon.startsWith(' ') ? afterColon.slice(1) : afterColon); - } - } - - if (dataLines.length > 0) { - events.push({ - event: eventType, - data: dataLines.join('\n'), - }); - } - } - - return { events, remaining }; -} - -// ── Accumulator Factories ── - -export function createOpenAIStreamAccumulator(): OpenAIStreamAccumulator { - return { toolCalls: new Map() }; -} - -export function createAnthropicStreamAccumulator(): AnthropicStreamAccumulator { - return { toolCalls: new Map(), thinkingBlocks: new Map() }; -} - -// ── OpenAI/Mistral SSE Parser ── - -/** - * Parse a single OpenAI/Mistral SSE event and update the accumulator. - * - * OpenAI streaming format: - * - Text deltas: choices[0].delta.content - * - Tool call start: delta.tool_calls[i] with id + function.name - * - Tool call fragments: delta.tool_calls[i].function.arguments (append) - * - Finish reason: choices[0].finish_reason - * - Usage: usage object in final chunk (requires stream_options.include_usage) - * - [DONE] sentinel: stop iteration - */ -export function parseOpenAIStreamEvent( - event: SSEEvent, - accumulator: OpenAIStreamAccumulator, -): StreamEventResult { - // Handle [DONE] sentinel - if (event.data === '[DONE]') { - return { done: true }; - } - - let data: Record; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data = JSON.parse(event.data) as any; - } catch { - // Skip corrupted SSE events (e.g. partial JSON from TCP split) - return { done: false }; - } - const choice = (data as any).choices?.[0]; - const result: StreamEventResult = { done: false }; - - if (choice) { - const delta = choice.delta; - - // Text content delta - if (delta?.content && delta.content.length > 0) { - result.textDelta = delta.content; - } - - // Tool calls - if (delta?.tool_calls) { - for (const tc of delta.tool_calls) { - const idx = tc.index; - const existing = accumulator.toolCalls.get(idx); - - if (tc.id || tc.function?.name) { - // New tool call or update - if (!existing) { - accumulator.toolCalls.set(idx, { - id: tc.id || '', - name: tc.function?.name || '', - arguments: tc.function?.arguments || '', - }); - } else { - if (tc.id) existing.id = tc.id; - if (tc.function?.name) existing.name = tc.function.name; - if (tc.function?.arguments) existing.arguments += tc.function.arguments; - } - } else if (existing && tc.function?.arguments) { - // Append argument fragment - existing.arguments += tc.function.arguments; - } - } - } - - // Finish reason - if (choice.finish_reason) { - result.finishReason = choice.finish_reason; - } - } - - // Token usage (arrives in final chunk with stream_options.include_usage) - if ((data as any).usage) { - const usage = (data as any).usage; - const promptDetails = usage.prompt_tokens_details; - result.usage = { - promptTokens: usage.prompt_tokens, - completionTokens: usage.completion_tokens, - totalTokens: usage.total_tokens, - cacheReadTokens: promptDetails?.cached_tokens, - }; - } - - return result; -} - -// ── Anthropic SSE Parser ── - -/** - * Parse a single Anthropic SSE event and update the accumulator. - * - * Anthropic streaming format uses named event types: - * - message_start: input token usage - * - content_block_start: text, tool_use, or thinking block begins - * - content_block_delta: text_delta, input_json_delta, or thinking_delta - * - content_block_stop: block ends - * - message_delta: output tokens + stop_reason - * - message_stop: stream complete - * - ping: keep-alive (ignored) - * - error: server error mid-stream - */ -export function parseAnthropicStreamEvent( - event: SSEEvent, - accumulator: AnthropicStreamAccumulator, -): StreamEventResult { - let data: Record; - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data = JSON.parse(event.data) as any; - } catch { - // Skip corrupted SSE events (e.g. partial JSON from TCP split) - return { done: false }; - } - const result: StreamEventResult = { done: false }; - - switch (event.event) { - case 'message_start': { - const usage = (data as any).message?.usage; - if (usage) { - result.usage = { - inputTokens: usage.input_tokens || 0, - cacheReadTokens: usage.cache_read_input_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || 0, - }; - } - break; - } - - case 'content_block_start': { - const block = (data as any).content_block; - if (block?.type === 'tool_use') { - accumulator.toolCalls.set(data.index as number, { - id: block.id, - name: block.name, - arguments: '', - }); - } else if (block?.type === 'thinking') { - accumulator.thinkingBlocks.set(data.index as number, { text: '' }); - } - // text block start is a no-op (empty initial text) - break; - } - - case 'content_block_delta': { - const delta = (data as any).delta; - if (delta?.type === 'text_delta' && delta.text) { - result.textDelta = delta.text; - } else if (delta?.type === 'input_json_delta' && delta.partial_json) { - const tc = accumulator.toolCalls.get(data.index as number); - if (tc) { - tc.arguments += delta.partial_json; - } - } else if (delta?.type === 'thinking_delta' && delta.thinking) { - const tb = accumulator.thinkingBlocks.get(data.index as number); - if (tb) { - tb.text += delta.thinking; - } - } - break; - } - - case 'content_block_stop': { - // Block is complete. Tool arguments can now be parsed by the caller. - // For thinking blocks, capture the signature (required by Anthropic when replaying thinking blocks). - const stopBlock = (data as any).content_block; - if (stopBlock?.type === 'thinking' && stopBlock.signature) { - const tb = accumulator.thinkingBlocks.get(data.index as number); - if (tb) { - tb.signature = stopBlock.signature; - } - } - break; - } - - case 'message_delta': { - if ((data as any).usage) { - result.usage = { - outputTokens: (data as any).usage.output_tokens || 0, - }; - } - if ((data as any).delta?.stop_reason) { - result.finishReason = (data as any).delta.stop_reason; - } - break; - } - - case 'message_stop': - result.done = true; - break; - - case 'ping': - // Keep-alive, ignore - break; - - case 'error': { - const errorMsg = (data as any).error?.message || 'Unknown streaming error'; - throw new Error(errorMsg); - } - - default: - // Unknown event type, ignore - break; - } - - return result; -} - -// ── Retry with Exponential Backoff ── - -const RETRYABLE_STATUS_CODES = new Set([429, 502, 503]); - -/** - * Retry a function with exponential backoff for transient HTTP errors. - * - * Retries on 429 (rate limit), 502 (bad gateway), 503 (service unavailable). - * Also retries errors without a statusCode (e.g. ECONNRESET, EPIPE) since - * these indicate transient network failures during connection. - * - * Does NOT retry on other 4xx errors (400, 401, 403 — client errors) or abort. - * Respects Retry-After header for 429 responses. - * - * Best practice: wrap only the HTTP connection (httpRequestStream) in withRetry, - * NOT the event processing loop. This ensures onDelta callbacks are never - * called twice for the same text on retry. - */ -export async function withRetry( - fn: () => Promise, - options: { maxRetries?: number; onRetry?: (attempt: number, error: Error) => void; signal?: AbortSignal } = {}, -): Promise { - const maxRetries = options.maxRetries ?? 3; - let lastError: Error | undefined; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error as Error; - const httpError = error as HttpStreamError; - - // Don't retry on abort - if (httpError.isAbort || httpError.message === 'Request cancelled') { - throw error; - } - - // Check signal before retrying - if (options.signal?.aborted) { - const abortError: HttpStreamError = new Error('Request cancelled') as HttpStreamError; - abortError.isAbort = true; - throw abortError; - } - - // Don't retry on non-retryable status codes - if (httpError.statusCode && !RETRYABLE_STATUS_CODES.has(httpError.statusCode)) { - throw error; - } - - // Don't retry if we've exhausted retries - if (attempt >= maxRetries) { - throw error; - } - - // Calculate delay with exponential backoff and jitter - const baseDelay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s - const jitter = Math.random() * 500; - let delay = baseDelay + jitter; - - // Respect Retry-After header for 429 - if (httpError.retryAfter && httpError.retryAfter > 0) { - delay = Math.max(delay, httpError.retryAfter * 1000); - } - - if (options.onRetry) { - options.onRetry(attempt + 1, lastError); - } - - // Abort-aware delay: reject immediately if signal fires during wait - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, delay); - if (options.signal) { - const onAbort = () => { - clearTimeout(timer); - const abortError: HttpStreamError = new Error('Request cancelled') as HttpStreamError; - abortError.isAbort = true; - reject(abortError); - }; - if (options.signal.aborted) { - clearTimeout(timer); - const abortError: HttpStreamError = new Error('Request cancelled') as HttpStreamError; - abortError.isAbort = true; - reject(abortError); - return; - } - options.signal.addEventListener('abort', onAbort, { once: true }); - } - }); - } - } - - throw lastError; -} - -// ── HTTP Streaming Request ── - -interface HttpStreamOptions { - method?: string; - headers?: Record; - body?: string; - signal?: AbortSignal; - timeout?: number; -} - -/** - * Make an HTTP request that returns an async iterable of SSE events. - * - * Uses Node.js http/https modules directly, reading the response - * as a readable stream and parsing SSE events incrementally. - * - * On non-2xx status: collects the error body and throws. - * Supports AbortSignal for cancellation. - */ -export function httpRequestStream( - urlStr: string, - options: HttpStreamOptions, -): Promise<{ - statusCode: number; - events: AsyncIterable; -}> { - return new Promise((resolve, reject) => { - const url = new URL(urlStr); - const protocol = url.protocol === 'https:' ? https : http; - const timeout = options.timeout ?? 120000; - - const req = protocol.request(url, { - method: options.method || 'POST', - headers: options.headers || {}, - timeout, - }, (res) => { - const statusCode = res.statusCode || 0; - - // Non-2xx: collect error body and throw - if (statusCode < 200 || statusCode >= 300) { - let errorBody = ''; - res.on('data', (chunk: Buffer) => { errorBody += chunk; }); - res.on('end', () => { - const error: HttpStreamError = new Error(`API error: ${statusCode}`) as HttpStreamError; - error.statusCode = statusCode; - - // Parse Retry-After for 429 - if (statusCode === 429) { - const retryAfter = res.headers['retry-after']; - if (retryAfter) { - const seconds = parseInt(retryAfter, 10); - if (!isNaN(seconds)) { - error.retryAfter = seconds; - } - } - } - - // Try to extract a better error message - try { - const parsed = JSON.parse(errorBody); - error.message = parsed.error?.message || parsed.message || error.message; - } catch { - if (errorBody.length > 0) { - error.message = `${error.message}: ${errorBody.slice(0, 200)}`; - } - } - reject(error); - }); - return; - } - - // 2xx: create async iterable of SSE events - const events: AsyncIterable = { - [Symbol.asyncIterator]() { - let buffer = ''; - let done = false; - let pendingError: Error | null = null; - const eventQueue: SSEEvent[] = []; - let resolveNext: ((value: IteratorResult) => void) | null = null; - let rejectNext: ((error: Error) => void) | null = null; - - res.on('data', (chunk: Buffer) => { - buffer += chunk.toString('utf-8'); - const { events: parsed, remaining } = parseSSELines(buffer); - buffer = remaining; - - for (const event of parsed) { - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - rejectNext = null; - resolve({ value: event, done: false }); - } else { - eventQueue.push(event); - } - } - }); - - res.on('end', () => { - done = true; - if (resolveNext) { - const resolve = resolveNext; - resolveNext = null; - rejectNext = null; - resolve({ value: undefined as unknown as SSEEvent, done: true }); - } - }); - - res.on('error', (err: Error) => { - done = true; - if (rejectNext) { - const reject = rejectNext; - resolveNext = null; - rejectNext = null; - reject(err); - } else { - // Store error for next .next() call so it's not silently swallowed - pendingError = err; - } - }); - - return { - next(): Promise> { - // Return queued event immediately - if (eventQueue.length > 0) { - return Promise.resolve({ value: eventQueue.shift()!, done: false }); - } - - // Throw stored error from a previous event that fired with no consumer waiting - if (pendingError) { - const err = pendingError; - pendingError = null; - return Promise.reject(err); - } - - // Stream already ended - if (done) { - return Promise.resolve({ value: undefined as unknown as SSEEvent, done: true }); - } - - // Wait for next event - return new Promise>((resolve, reject) => { - resolveNext = resolve; - rejectNext = reject; - }); - }, - return(): Promise> { - // Called when for-await-of exits early (break, return, throw). - // Destroy the response stream to free the socket immediately. - done = true; - res.destroy(); - return Promise.resolve({ value: undefined as unknown as SSEEvent, done: true }); - }, - }; - }, - }; - - resolve({ statusCode, events }); - }); - - req.on('error', (err: Error) => { - const error: HttpStreamError = err as HttpStreamError; - if (options.signal?.aborted) { - error.isAbort = true; - } - reject(error); - }); - - req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timed out')); - }); - - if (options.signal) { - if (options.signal.aborted) { - req.destroy(); - const error: HttpStreamError = new Error('Request cancelled') as HttpStreamError; - error.isAbort = true; - reject(error); - return; - } - options.signal.addEventListener('abort', () => { - req.destroy(); - }, { once: true }); - } - - if (options.body) { - req.write(options.body); - } - req.end(); - }); -} diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 5a5ece0..0b7ef5c 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -1,7 +1,7 @@ /** * Chat IPC handlers — AI chat via AI SDK v6. * - * Uses ProviderRegistry, ChatService, and OneShotTasks instead of OpenCodeManager. + * Uses ProviderRegistry, ChatService, and OneShotTasks. */ import { ipcMain, BrowserWindow } from 'electron'; diff --git a/tests/engine/OpenCodeManagerMistral.test.ts b/tests/engine/OpenCodeManagerMistral.test.ts deleted file mode 100644 index 6745df1..0000000 --- a/tests/engine/OpenCodeManagerMistral.test.ts +++ /dev/null @@ -1,679 +0,0 @@ -/** - * OpenCodeManager Mistral Integration Tests - * - * Tests for Mistral AI as a first-class alternative provider: - * - detectProvider() for Mistral model prefixes - * - Mistral API key storage and retrieval - * - checkReady() multi-provider support - * - getAvailableModels() merge from both providers - * - getProviderConfig() helper - * - isProviderKeySet() helper - * - Vision from catalog modalities - * - validateMistralApiKey() - * - Provider-aware routing in sendOpenAIMessage() - * - generateConversationTitle() provider routing - * - analyzeMediaImage() provider-aware routing - * - analyzeTaxonomy() provider-aware guards - */ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// Mock dependencies before importing the class -vi.mock('../../src/main/engine/ChatEngine', () => ({ - ChatEngine: class { - getSetting = vi.fn().mockResolvedValue(null); - setSetting = vi.fn().mockResolvedValue(undefined); - deleteSetting = vi.fn().mockResolvedValue(undefined); - getSelectedModel = vi.fn().mockResolvedValue('claude-sonnet-4-5'); - getDefaultSystemPrompt = vi.fn().mockResolvedValue('You are a helpful assistant.'); - getConversation = vi.fn(); - addMessage = vi.fn(); - updateConversation = 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'; -import type { ChatModel } from '../../src/main/shared/electronApi'; - -// Helper to create manager with mocked httpRequest -function createManager(): OpenCodeManager { - const manager = new OpenCodeManager( - { - getSetting: vi.fn().mockResolvedValue(null), - setSetting: vi.fn().mockResolvedValue(undefined), - deleteSetting: vi.fn().mockResolvedValue(undefined), - getSelectedModel: vi.fn().mockResolvedValue('claude-sonnet-4-5'), - getDefaultSystemPrompt: vi.fn().mockResolvedValue('You are a helpful assistant.'), - } as never, - {} as never, - {} as never, - {} as never, - () => null, - ); - return manager; -} - -// Mock Mistral models API response -function createMistralModelResponse(ids: string[]) { - return { - object: 'list', - data: ids.map(id => ({ - id, - object: 'model', - created: 1772132920, - owned_by: 'mistralai', - })), - }; -} - -// Mock Zen models API response -function createZenModelResponse(ids: string[]) { - return { - object: 'list', - data: ids.map(id => ({ - id, - object: 'model', - created: 1772132920, - owned_by: 'opencode', - })), - }; -} - -describe('OpenCodeManager Mistral integration', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('detectProvider', () => { - it('detects mistral model prefixes', () => { - const manager = createManager(); - const detect = (manager as any).detectProvider.bind(manager); - - expect(detect('mistral-large-latest')).toBe('mistral'); - expect(detect('mistral-medium-latest')).toBe('mistral'); - expect(detect('mistral-small-latest')).toBe('mistral'); - }); - - it('detects devstral model prefix', () => { - const manager = createManager(); - const detect = (manager as any).detectProvider.bind(manager); - - expect(detect('devstral-small-latest')).toBe('mistral'); - expect(detect('devstral-large-latest')).toBe('mistral'); - }); - - it('detects codestral model prefix', () => { - const manager = createManager(); - const detect = (manager as any).detectProvider.bind(manager); - - expect(detect('codestral-latest')).toBe('mistral'); - }); - - it('detects pixtral model prefix', () => { - const manager = createManager(); - const detect = (manager as any).detectProvider.bind(manager); - - expect(detect('pixtral-large-latest')).toBe('mistral'); - }); - - it('detects ministral model prefix', () => { - const manager = createManager(); - const detect = (manager as any).detectProvider.bind(manager); - - expect(detect('ministral-8b-latest')).toBe('mistral'); - }); - - it('still detects anthropic, openai, google providers', () => { - const manager = createManager(); - const detect = (manager as any).detectProvider.bind(manager); - - expect(detect('claude-sonnet-4')).toBe('anthropic'); - expect(detect('gpt-5')).toBe('openai'); - expect(detect('gemini-3-pro')).toBe('google'); - }); - }); - - describe('Mistral API key management', () => { - it('stores and retrieves Mistral API key', () => { - const manager = createManager(); - - expect(manager.getMistralApiKey()).toBe(''); - - manager.setMistralApiKey('mist-test-key-123'); - expect(manager.getMistralApiKey()).toBe('mist-test-key-123'); - }); - - it('invalidates model cache when Mistral key changes', async () => { - const manager = createManager(); - manager.setApiKey('opencode-key'); - - // Prime the cache - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])), - }); - await manager.getAvailableModels(); - - // Set Mistral key — should clear cache - manager.setMistralApiKey('mist-key'); - expect((manager as any).cachedModels).toBeNull(); - }); - }); - - describe('checkReady', () => { - it('returns ready when only OpenCode key is set', async () => { - const manager = createManager(); - manager.setApiKey('opencode-key'); - - const result = await manager.checkReady(); - expect(result.ready).toBe(true); - expect(result.providers?.opencode).toBe(true); - expect(result.providers?.mistral).toBe(false); - }); - - it('returns ready when only Mistral key is set', async () => { - const manager = createManager(); - manager.setMistralApiKey('mistral-key'); - - const result = await manager.checkReady(); - expect(result.ready).toBe(true); - expect(result.providers?.opencode).toBe(false); - expect(result.providers?.mistral).toBe(true); - }); - - it('returns ready when both keys are set', async () => { - const manager = createManager(); - manager.setApiKey('opencode-key'); - manager.setMistralApiKey('mistral-key'); - - const result = await manager.checkReady(); - expect(result.ready).toBe(true); - expect(result.providers?.opencode).toBe(true); - expect(result.providers?.mistral).toBe(true); - }); - - it('returns not ready when no keys are set', async () => { - const manager = createManager(); - - const result = await manager.checkReady(); - expect(result.ready).toBe(false); - expect(result.providers?.opencode).toBe(false); - expect(result.providers?.mistral).toBe(false); - }); - }); - - describe('isProviderKeySet', () => { - it('checks OpenCode key availability', () => { - const manager = createManager(); - const check = (manager as any).isProviderKeySet.bind(manager); - - expect(check('opencode')).toBe(false); - expect(check('anthropic')).toBe(false); - expect(check('openai')).toBe(false); - - manager.setApiKey('key'); - expect(check('opencode')).toBe(true); - expect(check('anthropic')).toBe(true); - expect(check('openai')).toBe(true); - expect(check('google')).toBe(true); - expect(check('other')).toBe(true); - }); - - it('checks Mistral key availability', () => { - const manager = createManager(); - const check = (manager as any).isProviderKeySet.bind(manager); - - expect(check('mistral')).toBe(false); - - manager.setMistralApiKey('key'); - expect(check('mistral')).toBe(true); - }); - }); - - describe('getProviderConfig', () => { - it('returns OpenCode config for anthropic provider', () => { - const manager = createManager(); - manager.setApiKey('oc-key'); - const config = (manager as any).getProviderConfig.call(manager, 'anthropic'); - - expect(config.apiKey).toBe('oc-key'); - }); - - it('returns Mistral config for mistral provider', () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - const config = (manager as any).getProviderConfig.call(manager, 'mistral'); - - expect(config.apiKey).toBe('mist-key'); - expect(config.apiUrl).toContain('mistral.ai'); - expect(config.options?.parallelToolCalls).toBe(false); - }); - }); - - describe('getAvailableModels', () => { - it('returns only OpenCode models when only OpenCode key is set', async () => { - const manager = createManager(); - manager.setApiKey('oc-key'); - - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createZenModelResponse(['claude-sonnet-4', 'gpt-5'])), - }); - - const models = await manager.getAvailableModels(); - const providers = new Set(models.map((m: ChatModel) => m.provider)); - expect(providers.has('mistral')).toBe(false); - }); - - it('returns only Mistral models when only Mistral key is set', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - - (manager as any).httpRequest = vi.fn().mockImplementation((url: string) => { - if (url.includes('mistral.ai')) { - return Promise.resolve({ - statusCode: 200, - body: JSON.stringify(createMistralModelResponse([ - 'mistral-large-latest', - 'mistral-small-latest', - ])), - }); - } - return Promise.reject(new Error('No key')); - }); - - const models = await manager.getAvailableModels(); - expect(models.length).toBe(2); - expect(models.every((m: ChatModel) => m.provider === 'mistral')).toBe(true); - }); - - it('merges models from both providers when both keys are set', async () => { - const manager = createManager(); - manager.setApiKey('oc-key'); - manager.setMistralApiKey('mist-key'); - - let callCount = 0; - (manager as any).httpRequest = vi.fn().mockImplementation((url: string) => { - callCount++; - if (url.includes('mistral.ai')) { - return Promise.resolve({ - statusCode: 200, - body: JSON.stringify(createMistralModelResponse([ - 'mistral-large-latest', - 'mistral-small-latest', - ])), - }); - } - return Promise.resolve({ - statusCode: 200, - body: JSON.stringify(createZenModelResponse(['claude-sonnet-4', 'gpt-5'])), - }); - }); - - const models = await manager.getAvailableModels(); - expect(models.length).toBe(4); - - const providers = new Set(models.map((m: ChatModel) => m.provider)); - expect(providers.has('anthropic')).toBe(true); - expect(providers.has('mistral')).toBe(true); - }); - - it('includes vision field from catalog modalities', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - - // Mock catalog with modality data for vision resolution - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - (manager as any).httpRequest = vi.fn().mockImplementation((url: string) => { - if (url.includes('mistral.ai')) { - return Promise.resolve({ - statusCode: 200, - body: JSON.stringify(createMistralModelResponse([ - 'mistral-large-latest', - 'devstral-small-latest', - ])), - }); - } - return Promise.reject(new Error('No key')); - }); - - const models = await manager.getAvailableModels(); - const large = models.find((m: ChatModel) => m.id === 'mistral-large-latest'); - const devstral = models.find((m: ChatModel) => m.id === 'devstral-small-latest'); - - expect(large?.vision).toBe(true); - expect(devstral?.vision).toBe(false); - }); - - it('fallback model list filters by available provider keys', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - // No OpenCode key set - - (manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error')); - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - const models = await manager.getAvailableModels(); - // Should only have Mistral models from fallback - const providers = new Set(models.map((m: ChatModel) => m.provider)); - expect(providers.has('mistral')).toBe(true); - expect(providers.has('anthropic')).toBe(false); - expect(providers.has('openai')).toBe(false); - }); - }); - - describe('validateMistralApiKey', () => { - it('validates a correct Mistral API key', async () => { - const manager = createManager(); - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createMistralModelResponse(['mistral-large-latest'])), - }); - - const result = await manager.validateMistralApiKey('valid-key'); - expect(result.isValid).toBe(true); - expect(result.models.length).toBeGreaterThan(0); - }); - - it('rejects an invalid Mistral API key', async () => { - const manager = createManager(); - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 401, - body: '{"message":"Unauthorized"}', - }); - - const result = await manager.validateMistralApiKey('bad-key'); - expect(result.isValid).toBe(false); - expect(result.models).toEqual([]); - }); - - it('rejects empty key', async () => { - const manager = createManager(); - const result = await manager.validateMistralApiKey(''); - expect(result.isValid).toBe(false); - }); - }); - - describe('generateConversationTitle provider routing', () => { - it('uses Mistral API when conversation model is a Mistral model', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - - const httpMock = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify({ - choices: [{ message: { content: 'Travel Blog' } }], - }), - }); - (manager as any).httpRequest = httpMock; - - // Set the title model to mistral - (manager as any).chatEngine.getSetting = vi.fn().mockImplementation(async (key: string) => { - if (key === 'chat_title_model') return 'mistral-small-latest'; - return null; - }); - - await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response'); - - expect(httpMock).toHaveBeenCalled(); - const callUrl = httpMock.mock.calls[0][0]; - expect(callUrl).toContain('mistral.ai'); - }); - - it('uses Anthropic API when title model is an Anthropic model', async () => { - const manager = createManager(); - manager.setApiKey('oc-key'); - - const httpMock = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify({ - content: [{ type: 'text', text: 'Travel Blog' }], - }), - }); - (manager as any).httpRequest = httpMock; - - // No title model set — defaults to claude-haiku-4-5 - await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response'); - - expect(httpMock).toHaveBeenCalled(); - const callUrl = httpMock.mock.calls[0][0]; - expect(callUrl).toContain('opencode.ai'); - }); - }); - - describe('analyzeTaxonomy provider-aware guards', () => { - it('returns error when model is Mistral but no Mistral key is set', async () => { - const manager = createManager(); - manager.setApiKey('oc-key'); // only OpenCode key - - const result = await manager.analyzeTaxonomy( - [{ name: 'Travel', slug: 'travel', existsInProject: true }], - [], - 'mistral-large-latest' - ); - - expect(result.success).toBe(false); - expect(result.error).toContain('API key'); - }); - - it('returns error when model is OpenCode but no OpenCode key is set', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); // only Mistral key - - const result = await manager.analyzeTaxonomy( - [{ name: 'Travel', slug: 'travel', existsInProject: true }], - [], - 'claude-sonnet-4' - ); - - expect(result.success).toBe(false); - expect(result.error).toContain('API key'); - }); - }); - - describe('analyzeMediaImage provider-aware routing', () => { - it('returns error when no API key is available for the configured model', async () => { - const manager = createManager(); - // No keys set at all - - const result = await manager.analyzeMediaImage('media-1', 'en'); - expect(result.success).toBe(false); - expect(result.error).toContain('API key'); - }); - }); - - describe('setApiKey cache invalidation', () => { - it('invalidates model cache when OpenCode key changes', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - - // Prime the cache - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createMistralModelResponse(['mistral-large-latest'])), - }); - await manager.getAvailableModels(); - expect((manager as any).cachedModels).not.toBeNull(); - - // Set OpenCode key — should clear cache - manager.setApiKey('oc-key'); - expect((manager as any).cachedModels).toBeNull(); - }); - }); - - describe('vision from catalog modalities', () => { - it('vision flags are derived from catalog input modalities via getAvailableModels', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - - // Mock catalog with modality data - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'mistral-medium-latest', name: 'Mistral Medium', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'mistral-small-latest', name: 'Mistral Small', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] }, - { id: 'devstral-large-latest', name: 'Devstral Large', inputModalities: ['text'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - (manager as any).httpRequest = vi.fn().mockImplementation((url: string) => { - if (url.includes('mistral.ai')) { - return Promise.resolve({ - statusCode: 200, - body: JSON.stringify(createMistralModelResponse([ - 'mistral-large-latest', - 'mistral-medium-latest', - 'mistral-small-latest', - 'devstral-small-latest', - 'devstral-large-latest', - ])), - }); - } - return Promise.reject(new Error('No key')); - }); - - const models = await manager.getAvailableModels(); - - // Vision-capable models (image in input modalities) - expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true); - expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true); - expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true); - - // Non-vision models (no image in input modalities) - expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false); - expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false); - }); - }); - - describe('generateConversationTitle smart defaults', () => { - it('falls back to mistral-small-latest when only Mistral key is set and no title model configured', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - // No OpenCode key set - - const httpMock = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify({ - choices: [{ message: { content: 'Blog Post' } }], - }), - }); - (manager as any).httpRequest = httpMock; - - // No title model configured (returns null) - (manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null); - (manager as any).chatEngine.updateConversation = vi.fn(); - - await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response'); - - expect(httpMock).toHaveBeenCalled(); - const callUrl = httpMock.mock.calls[0][0]; - expect(callUrl).toContain('mistral.ai'); - // Verify it used mistral-small-latest - const body = JSON.parse(httpMock.mock.calls[0][1].body); - expect(body.model).toBe('mistral-small-latest'); - }); - - it('does not generate title when no keys are set', async () => { - const manager = createManager(); - // No keys at all - - const httpMock = vi.fn(); - (manager as any).httpRequest = httpMock; - (manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null); - - await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response'); - - expect(httpMock).not.toHaveBeenCalled(); - }); - }); - - describe('analyzeMediaImage smart defaults', () => { - it('falls back to mistral-large-latest when only Mistral key is set and no image model configured', async () => { - const manager = createManager(); - manager.setMistralApiKey('mist-key'); - // No OpenCode key set - - // Mock getSetting to return null (no configured model) - (manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null); - - // Mock mediaEngine — return a valid image - (manager as any).mediaEngine = { - getMedia: vi.fn().mockResolvedValue({ id: 'media-1', mimeType: 'image/jpeg' }), - getThumbnailDataUrl: vi.fn().mockResolvedValue('data:image/webp;base64,dGVzdA=='), - }; - - const httpMock = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify({ - choices: [{ - message: { - content: JSON.stringify({ title: 'Sunset', alt: 'A sunset', caption: 'Beautiful sunset' }), - }, - }], - }), - }); - (manager as any).httpRequest = httpMock; - - await manager.analyzeMediaImage('media-1', 'en'); - - expect(httpMock).toHaveBeenCalled(); - const callUrl = httpMock.mock.calls[0][0]; - expect(callUrl).toContain('mistral.ai'); - const body = JSON.parse(httpMock.mock.calls[0][1].body); - expect(body.model).toBe('mistral-large-latest'); - }); - }); - - describe('validateApiKey returns models from API response', () => { - it('returns models from the actual API response', async () => { - const manager = createManager(); - manager.setApiKey('oc-key'); - - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])), - }); - - const result = await manager.validateApiKey('oc-key'); - expect(result.isValid).toBe(true); - expect(result.models).toHaveLength(1); - expect(result.models[0].id).toBe('claude-sonnet-4'); - expect(result.models[0].provider).toBe('anthropic'); - }); - }); -}); diff --git a/tests/engine/OpenCodeManagerTools.test.ts b/tests/engine/OpenCodeManagerTools.test.ts deleted file mode 100644 index a3d3fee..0000000 --- a/tests/engine/OpenCodeManagerTools.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -/** - * 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); - }); -}); diff --git a/tests/engine/OpenCodeModelDiscovery.test.ts b/tests/engine/OpenCodeModelDiscovery.test.ts deleted file mode 100644 index c716f51..0000000 --- a/tests/engine/OpenCodeModelDiscovery.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * OpenCodeManager Model Discovery Tests - * - * Tests the model discovery, display name formatting, and caching behavior. - * Following TDD: these tests describe the expected behavior. - */ - -import { describe, it, expect, beforeEach, vi, afterEach } 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'; -import type { ChatModel } from '../../src/main/shared/electronApi'; - -// Helper to create manager with mocked httpRequest -function createManager(): OpenCodeManager { - const manager = new OpenCodeManager( - { getSetting: vi.fn(), setSetting: vi.fn() } as never, - {} as never, - {} as never, - () => null, - ); - manager.setApiKey('test-key'); - return manager; -} - -// Mock API response in the Zen format (id, object, created, owned_by — no name field) -function createZenModelResponse(ids: string[]) { - return { - object: 'list', - data: ids.map(id => ({ - id, - object: 'model', - created: 1772132920, - owned_by: 'opencode', - })), - }; -} - -describe('OpenCodeManager model discovery', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('getAvailableModels', () => { - it('returns models from API with catalog names and catalog-derived vision', async () => { - const manager = createManager(); - - // Mock catalog with modality data and display names - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image', 'pdf'], outputModalities: ['text'] }, - { id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', inputModalities: ['text'], outputModalities: ['text'] }, - { id: 'gemini-3-pro', name: 'Gemini 3 Pro', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - const zenResponse = createZenModelResponse([ - 'claude-sonnet-4', - 'gpt-5.1-codex', - 'gemini-3-pro', - ]); - - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(zenResponse), - }); - - const models = await manager.getAvailableModels(); - - expect(models).toHaveLength(3); - expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic', vision: true }); - expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai', vision: false }); - expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true }); - }); - - it('falls back to model catalog when API fails', async () => { - const manager = createManager(); - (manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error')); - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'gpt-5', name: 'GPT 5', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - const models = await manager.getAvailableModels(); - - expect(models.length).toBeGreaterThan(0); - const ids = models.map((m: ChatModel) => m.id); - expect(ids).toContain('claude-sonnet-4'); - expect(ids).toContain('gpt-5'); - const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4'); - expect(claudeModel?.provider).toBe('anthropic'); - expect(claudeModel?.name).toBe('Claude Sonnet 4'); - const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5'); - expect(gptModel?.provider).toBe('openai'); - expect(gptModel?.name).toBe('GPT 5'); - }); - - it('falls back to model catalog when API returns non-200 status', async () => { - const manager = createManager(); - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 401, - body: '{"error":"unauthorized"}', - }); - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - const models = await manager.getAvailableModels(); - - expect(models.length).toBeGreaterThan(0); - const ids = models.map((m: ChatModel) => m.id); - expect(ids).toContain('claude-sonnet-4'); - }); - - it('caches models and does not re-fetch within TTL', async () => { - const manager = createManager(); - const httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])), - }); - (manager as any).httpRequest = httpRequest; - - await manager.getAvailableModels(); - await manager.getAvailableModels(); - - expect(httpRequest).toHaveBeenCalledTimes(1); - }); - - it('re-fetches after cache TTL expires', async () => { - const manager = createManager(); - const httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])), - }); - (manager as any).httpRequest = httpRequest; - - await manager.getAvailableModels(); - expect(httpRequest).toHaveBeenCalledTimes(1); - - // Advance past 5-minute TTL - vi.advanceTimersByTime(6 * 60 * 1000); - - await manager.getAvailableModels(); - expect(httpRequest).toHaveBeenCalledTimes(2); - }); - - it('handles unknown model IDs from API with raw IDs as fallback names', async () => { - const manager = createManager(); - const zenResponse = createZenModelResponse(['some-new-model-v3']); - - (manager as any).httpRequest = vi.fn().mockResolvedValue({ - statusCode: 200, - body: JSON.stringify(zenResponse), - }); - - const models = await manager.getAvailableModels(); - - expect(models).toHaveLength(1); - expect(models[0].name).toBe('some-new-model-v3'); - expect(models[0].provider).toBe('other'); - }); - - it('falls back to model catalog when no API key is set', async () => { - const manager = createManager(); - (manager as any).apiKey = ''; - manager.setMistralApiKey('test-key'); - (manager as any).modelCatalogEngine = { - getAll: vi.fn().mockResolvedValue([ - { id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] }, - ]), - getMaxOutputTokens: vi.fn().mockResolvedValue(16384), - getContextWindow: vi.fn().mockResolvedValue(null), - }; - - const models = await manager.getAvailableModels(); - - // Only Mistral models will be in fallback since only Mistral key is set - expect(models.length).toBeGreaterThan(0); - const providers = new Set(models.map((m: ChatModel) => m.provider)); - expect(providers.has('mistral')).toBe(true); - // OpenCode/Anthropic models should be filtered out (no OpenCode key) - expect(providers.has('anthropic')).toBe(false); - }); - }); -}); diff --git a/tests/engine/streaming.test.ts b/tests/engine/streaming.test.ts deleted file mode 100644 index 77e5358..0000000 --- a/tests/engine/streaming.test.ts +++ /dev/null @@ -1,1598 +0,0 @@ -/** - * Tests for SSE streaming infrastructure (PR 1) - * - * Covers: - * - SSE line parsing (buffering partial lines across TCP chunks) - * - OpenAI/Mistral SSE event parsing (text deltas, tool calls, usage, [DONE]) - * - Anthropic SSE event parsing (message_start, content_block_delta, etc.) - * - Tool-call argument accumulation during streaming - * - Error handling (mid-stream errors, non-2xx status, abort) - * - Retry with exponential backoff (429/502/503, Retry-After, no retry on 4xx/abort) - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import http from 'http'; -import { - parseSSELines, - parseOpenAIStreamEvent, - parseAnthropicStreamEvent, - withRetry, - httpRequestStream, - type SSEEvent, - type OpenAIStreamAccumulator, - type AnthropicStreamAccumulator, - createOpenAIStreamAccumulator, - createAnthropicStreamAccumulator, -} from '../../src/main/engine/streaming'; - -// ── SSE Line Parsing ── - -describe('parseSSELines', () => { - it('parses a complete SSE event from a single chunk', () => { - const buffer = ''; - const chunk = 'data: {"id":"1","choices":[{"delta":{"content":"Hello"}}]}\n\n'; - const { events, remaining } = parseSSELines(buffer + chunk); - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ event: undefined, data: '{"id":"1","choices":[{"delta":{"content":"Hello"}}]}' }); - expect(remaining).toBe(''); - }); - - it('handles partial lines across TCP chunks', () => { - // First chunk ends mid-line - const chunk1 = 'data: {"id":"1","cho'; - const { events: events1, remaining: rem1 } = parseSSELines(chunk1); - expect(events1).toHaveLength(0); - expect(rem1).toBe('data: {"id":"1","cho'); - - // Second chunk completes the line - const chunk2 = 'ices":[{"delta":{"content":"Hello"}}]}\n\n'; - const { events: events2, remaining: rem2 } = parseSSELines(rem1 + chunk2); - expect(events2).toHaveLength(1); - expect(events2[0].data).toBe('{"id":"1","choices":[{"delta":{"content":"Hello"}}]}'); - expect(rem2).toBe(''); - }); - - it('handles multiple events in a single chunk', () => { - const chunk = 'data: {"a":1}\n\ndata: {"b":2}\n\n'; - const { events, remaining } = parseSSELines(chunk); - expect(events).toHaveLength(2); - expect(events[0].data).toBe('{"a":1}'); - expect(events[1].data).toBe('{"b":2}'); - expect(remaining).toBe(''); - }); - - it('handles named event types (Anthropic format)', () => { - const chunk = 'event: message_start\ndata: {"type":"message_start"}\n\n'; - const { events, remaining } = parseSSELines(chunk); - expect(events).toHaveLength(1); - expect(events[0].event).toBe('message_start'); - expect(events[0].data).toBe('{"type":"message_start"}'); - expect(remaining).toBe(''); - }); - - it('handles [DONE] sentinel', () => { - const chunk = 'data: [DONE]\n\n'; - const { events, remaining } = parseSSELines(chunk); - expect(events).toHaveLength(1); - expect(events[0].data).toBe('[DONE]'); - expect(remaining).toBe(''); - }); - - it('ignores empty data lines (keep-alive pings)', () => { - const chunk = ':\n\ndata: {"a":1}\n\n'; - const { events, remaining } = parseSSELines(chunk); - // The comment line ':' should be ignored - expect(events).toHaveLength(1); - expect(events[0].data).toBe('{"a":1}'); - expect(remaining).toBe(''); - }); - - it('handles multiple data lines for a single event (concatenation per SSE spec)', () => { - const chunk = 'data: line1\ndata: line2\n\n'; - const { events, remaining } = parseSSELines(chunk); - expect(events).toHaveLength(1); - expect(events[0].data).toBe('line1\nline2'); - expect(remaining).toBe(''); - }); - - it('returns incomplete data as remaining buffer', () => { - const chunk = 'data: {"partial'; - const { events, remaining } = parseSSELines(chunk); - expect(events).toHaveLength(0); - expect(remaining).toBe('data: {"partial'); - }); - - it('handles \\r\\n line endings', () => { - const chunk = 'data: {"a":1}\r\n\r\n'; - const { events, remaining } = parseSSELines(chunk); - expect(events).toHaveLength(1); - expect(events[0].data).toBe('{"a":1}'); - expect(remaining).toBe(''); - }); -}); - -// ── OpenAI/Mistral Stream Event Parsing ── - -describe('parseOpenAIStreamEvent', () => { - let accumulator: OpenAIStreamAccumulator; - - beforeEach(() => { - accumulator = createOpenAIStreamAccumulator(); - }); - - it('extracts text delta from content field', () => { - const event: SSEEvent = { - data: JSON.stringify({ - id: 'chatcmpl-1', - choices: [{ delta: { content: 'Hello' }, index: 0 }], - }), - }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.textDelta).toBe('Hello'); - expect(result.done).toBe(false); - }); - - it('accumulates tool call start (id + name)', () => { - const event: SSEEvent = { - data: JSON.stringify({ - id: 'chatcmpl-1', - choices: [{ - delta: { - tool_calls: [{ - index: 0, - id: 'call_abc', - function: { name: 'search_posts', arguments: '' }, - }], - }, - index: 0, - }], - }), - }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.textDelta).toBeUndefined(); - expect(accumulator.toolCalls.get(0)).toEqual({ - id: 'call_abc', - name: 'search_posts', - arguments: '', - }); - }); - - it('accumulates tool call argument fragments', () => { - // First event: tool call start - parseOpenAIStreamEvent({ - data: JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, id: 'call_abc', - function: { name: 'search_posts', arguments: '' }, - }], - }, - index: 0, - }], - }), - }, accumulator); - - // Second event: argument fragment - parseOpenAIStreamEvent({ - data: JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, - function: { arguments: '{"query"' }, - }], - }, - index: 0, - }], - }), - }, accumulator); - - // Third event: more arguments - parseOpenAIStreamEvent({ - data: JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, - function: { arguments: ': "test"}' }, - }], - }, - index: 0, - }], - }), - }, accumulator); - - expect(accumulator.toolCalls.get(0)?.arguments).toBe('{"query": "test"}'); - }); - - it('handles multiple concurrent tool calls', () => { - // Tool call 0 - parseOpenAIStreamEvent({ - data: JSON.stringify({ - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'call_1', function: { name: 'search_posts', arguments: '{"q":"a"}' } }, - { index: 1, id: 'call_2', function: { name: 'list_posts', arguments: '{"limit":5}' } }, - ], - }, - index: 0, - }], - }), - }, accumulator); - - expect(accumulator.toolCalls.get(0)?.name).toBe('search_posts'); - expect(accumulator.toolCalls.get(1)?.name).toBe('list_posts'); - }); - - it('detects finish_reason stop', () => { - const event: SSEEvent = { - data: JSON.stringify({ - choices: [{ delta: {}, finish_reason: 'stop', index: 0 }], - }), - }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.finishReason).toBe('stop'); - }); - - it('detects finish_reason tool_calls', () => { - const event: SSEEvent = { - data: JSON.stringify({ - choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }], - }), - }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.finishReason).toBe('tool_calls'); - }); - - it('extracts token usage from final chunk', () => { - const event: SSEEvent = { - data: JSON.stringify({ - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 150, - completion_tokens: 42, - total_tokens: 192, - }, - }), - }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.usage).toEqual({ - promptTokens: 150, - completionTokens: 42, - totalTokens: 192, - }); - }); - - it('handles [DONE] sentinel', () => { - const event: SSEEvent = { data: '[DONE]' }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.done).toBe(true); - }); - - it('returns empty result for empty content delta', () => { - const event: SSEEvent = { - data: JSON.stringify({ - choices: [{ delta: { content: '' }, index: 0 }], - }), - }; - const result = parseOpenAIStreamEvent(event, accumulator); - expect(result.textDelta).toBeUndefined(); - }); -}); - -// ── Anthropic Stream Event Parsing ── - -describe('parseAnthropicStreamEvent', () => { - let accumulator: AnthropicStreamAccumulator; - - beforeEach(() => { - accumulator = createAnthropicStreamAccumulator(); - }); - - it('extracts input_tokens from message_start', () => { - const event: SSEEvent = { - event: 'message_start', - data: JSON.stringify({ - type: 'message_start', - message: { - id: 'msg_1', - model: 'claude-sonnet-4-5', - usage: { - input_tokens: 150, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 10, - }, - }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.usage).toEqual({ - inputTokens: 150, - cacheReadTokens: 50, - cacheWriteTokens: 10, - }); - }); - - it('handles text content_block_start (no-op)', () => { - const event: SSEEvent = { - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'text', text: '' }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.textDelta).toBeUndefined(); - }); - - it('handles tool_use content_block_start', () => { - const event: SSEEvent = { - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 1, - content_block: { type: 'tool_use', id: 'toolu_abc', name: 'search_posts' }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.textDelta).toBeUndefined(); - expect(accumulator.toolCalls.get(1)).toEqual({ - id: 'toolu_abc', - name: 'search_posts', - arguments: '', - }); - }); - - it('extracts text_delta from content_block_delta', () => { - const event: SSEEvent = { - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'text_delta', text: 'Hello world' }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.textDelta).toBe('Hello world'); - }); - - it('accumulates tool input_json_delta fragments', () => { - // Start tool block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 1, - content_block: { type: 'tool_use', id: 'toolu_abc', name: 'search_posts' }, - }), - }, accumulator); - - // First argument fragment - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 1, - delta: { type: 'input_json_delta', partial_json: '{"query"' }, - }), - }, accumulator); - - // Second argument fragment - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 1, - delta: { type: 'input_json_delta', partial_json: ': "test"}' }, - }), - }, accumulator); - - expect(accumulator.toolCalls.get(1)?.arguments).toBe('{"query": "test"}'); - }); - - it('extracts output_tokens from message_delta', () => { - const event: SSEEvent = { - event: 'message_delta', - data: JSON.stringify({ - type: 'message_delta', - delta: { stop_reason: 'end_turn' }, - usage: { output_tokens: 42 }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.usage).toEqual({ outputTokens: 42 }); - expect(result.finishReason).toBe('end_turn'); - }); - - it('signals done on message_stop', () => { - const event: SSEEvent = { - event: 'message_stop', - data: JSON.stringify({ type: 'message_stop' }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.done).toBe(true); - }); - - it('ignores ping events', () => { - const event: SSEEvent = { - event: 'ping', - data: JSON.stringify({ type: 'ping' }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.textDelta).toBeUndefined(); - expect(result.done).toBe(false); - }); - - it('throws on error events', () => { - const event: SSEEvent = { - event: 'error', - data: JSON.stringify({ - type: 'error', - error: { type: 'overloaded_error', message: 'Server is overloaded' }, - }), - }; - expect(() => parseAnthropicStreamEvent(event, accumulator)).toThrow('Server is overloaded'); - }); - - it('signals tool_use finish reason from message_delta', () => { - const event: SSEEvent = { - event: 'message_delta', - data: JSON.stringify({ - type: 'message_delta', - delta: { stop_reason: 'tool_use' }, - usage: { output_tokens: 10 }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.finishReason).toBe('tool_use'); - }); - - it('handles thinking content_block_start', () => { - const event: SSEEvent = { - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'thinking', thinking: '' }, - }), - }; - const result = parseAnthropicStreamEvent(event, accumulator); - expect(result.textDelta).toBeUndefined(); - expect(accumulator.thinkingBlocks.get(0)).toEqual({ text: '' }); - }); - - it('accumulates thinking_delta fragments', () => { - // Start thinking block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'thinking', thinking: '' }, - }), - }, accumulator); - - // First thinking fragment - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'thinking_delta', thinking: 'Let me think' }, - }), - }, accumulator); - - // Second thinking fragment - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'thinking_delta', thinking: ' about this...' }, - }), - }, accumulator); - - expect(accumulator.thinkingBlocks.get(0)?.text).toBe('Let me think about this...'); - }); - - it('does not emit thinking_delta as textDelta', () => { - // Start thinking block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'thinking', thinking: '' }, - }), - }, accumulator); - - const result = parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'thinking_delta', thinking: 'Internal reasoning' }, - }), - }, accumulator); - - // thinking_delta must NOT leak to textDelta — it's internal model reasoning - expect(result.textDelta).toBeUndefined(); - }); - - it('accumulates thinking and text blocks independently', () => { - // Thinking block at index 0 - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'thinking', thinking: '' }, - }), - }, accumulator); - - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'thinking_delta', thinking: 'Reasoning...' }, - }), - }, accumulator); - - // Text block at index 1 - const textResult = parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 1, - delta: { type: 'text_delta', text: 'Here is my answer' }, - }), - }, accumulator); - - // Thinking accumulated separately - expect(accumulator.thinkingBlocks.get(0)?.text).toBe('Reasoning...'); - // Text still emitted as textDelta - expect(textResult.textDelta).toBe('Here is my answer'); - }); -}); - -// ── Tool Call Accumulation ── - -describe('tool call accumulation', () => { - it('OpenAI: builds complete tool calls from fragments', () => { - const acc = createOpenAIStreamAccumulator(); - - // Start - parseOpenAIStreamEvent({ - data: JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, id: 'call_1', - function: { name: 'search_posts', arguments: '' }, - }], - }, - index: 0, - }], - }), - }, acc); - - // Fragments - for (const frag of ['{"', 'query', '": "', 'hello', '"}']) { - parseOpenAIStreamEvent({ - data: JSON.stringify({ - choices: [{ - delta: { tool_calls: [{ index: 0, function: { arguments: frag } }] }, - index: 0, - }], - }), - }, acc); - } - - const tc = acc.toolCalls.get(0)!; - expect(tc.id).toBe('call_1'); - expect(tc.name).toBe('search_posts'); - expect(JSON.parse(tc.arguments)).toEqual({ query: 'hello' }); - }); - - it('Anthropic: builds complete tool calls from fragments', () => { - const acc = createAnthropicStreamAccumulator(); - - // Start block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 1, - content_block: { type: 'tool_use', id: 'toolu_1', name: 'list_posts' }, - }), - }, acc); - - // Fragments - for (const frag of ['{"', 'limit', '": ', '5}']) { - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 1, - delta: { type: 'input_json_delta', partial_json: frag }, - }), - }, acc); - } - - const tc = acc.toolCalls.get(1)!; - expect(tc.id).toBe('toolu_1'); - expect(tc.name).toBe('list_posts'); - expect(JSON.parse(tc.arguments)).toEqual({ limit: 5 }); - }); -}); - -// ── Retry with Exponential Backoff ── - -describe('withRetry', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - it('returns result on first successful call', async () => { - const fn = vi.fn().mockResolvedValue('success'); - const promise = withRetry(fn, { maxRetries: 3 }); - const result = await promise; - expect(result).toBe('success'); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('retries on 429 status and succeeds', async () => { - const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 }); - const fn = vi.fn() - .mockRejectedValueOnce(error429) - .mockResolvedValue('success'); - - const promise = withRetry(fn, { maxRetries: 3 }); - // Advance past the retry delay - await vi.advanceTimersByTimeAsync(2000); - const result = await promise; - expect(result).toBe('success'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('retries on 502 status', async () => { - const error502 = Object.assign(new Error('Bad Gateway'), { statusCode: 502 }); - const fn = vi.fn() - .mockRejectedValueOnce(error502) - .mockResolvedValue('ok'); - - const promise = withRetry(fn, { maxRetries: 3 }); - await vi.advanceTimersByTimeAsync(2000); - const result = await promise; - expect(result).toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('retries on 503 status', async () => { - const error503 = Object.assign(new Error('Service Unavailable'), { statusCode: 503 }); - const fn = vi.fn() - .mockRejectedValueOnce(error503) - .mockResolvedValue('ok'); - - const promise = withRetry(fn, { maxRetries: 3 }); - await vi.advanceTimersByTimeAsync(2000); - const result = await promise; - expect(result).toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('does NOT retry on 400 status', async () => { - const error400 = Object.assign(new Error('Bad Request'), { statusCode: 400 }); - const fn = vi.fn().mockRejectedValue(error400); - - await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Bad Request'); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('does NOT retry on 401 status', async () => { - const error401 = Object.assign(new Error('Unauthorized'), { statusCode: 401 }); - const fn = vi.fn().mockRejectedValue(error401); - - await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Unauthorized'); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('does NOT retry on 403 status', async () => { - const error403 = Object.assign(new Error('Forbidden'), { statusCode: 403 }); - const fn = vi.fn().mockRejectedValue(error403); - - await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Forbidden'); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('does NOT retry on abort', async () => { - const abortError = Object.assign(new Error('Request cancelled'), { isAbort: true }); - const fn = vi.fn().mockRejectedValue(abortError); - - await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Request cancelled'); - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('exhausts max retries and throws last error', async () => { - vi.useRealTimers(); // Real timers work better for this test - const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 }); - let callCount = 0; - const fn = vi.fn().mockImplementation(() => { - callCount++; - return Promise.reject(error429); - }); - - await expect(withRetry(fn, { maxRetries: 2 })).rejects.toThrow('Rate limited'); - expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries - vi.useFakeTimers(); // Restore for afterEach - }); - - it('respects Retry-After header for 429', async () => { - const error429 = Object.assign(new Error('Rate limited'), { - statusCode: 429, - retryAfter: 5, - }); - const fn = vi.fn() - .mockRejectedValueOnce(error429) - .mockResolvedValue('ok'); - - const promise = withRetry(fn, { maxRetries: 3 }); - // Should NOT have retried yet at 3 seconds (Retry-After is 5) - await vi.advanceTimersByTimeAsync(3000); - expect(fn).toHaveBeenCalledTimes(1); - // Advance past the Retry-After - await vi.advanceTimersByTimeAsync(3000); - const result = await promise; - expect(result).toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - }); - - afterEach(() => { - vi.useRealTimers(); - }); -}); - -// ── Full stream-to-result integration ── - -describe('stream event sequences', () => { - it('OpenAI: processes a complete text response stream', () => { - const acc = createOpenAIStreamAccumulator(); - const textChunks: string[] = []; - - const events: SSEEvent[] = [ - { data: JSON.stringify({ choices: [{ delta: { role: 'assistant' }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: { content: 'Hello' }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: { content: ' world' }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: { content: '!' }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop', index: 0 }], usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 } }) }, - { data: '[DONE]' }, - ]; - - for (const event of events) { - const result = parseOpenAIStreamEvent(event, acc); - if (result.textDelta) textChunks.push(result.textDelta); - } - - expect(textChunks.join('')).toBe('Hello world!'); - }); - - it('Anthropic: processes a complete text response stream', () => { - const acc = createAnthropicStreamAccumulator(); - const textChunks: string[] = []; - - const events: SSEEvent[] = [ - { event: 'message_start', data: JSON.stringify({ type: 'message_start', message: { id: 'msg_1', model: 'claude-sonnet-4', usage: { input_tokens: 100 } } }) }, - { event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) }, - { event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }) }, - { event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: ' world!' } }) }, - { event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 0 }) }, - { event: 'message_delta', data: JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } }) }, - { event: 'message_stop', data: JSON.stringify({ type: 'message_stop' }) }, - ]; - - for (const event of events) { - const result = parseAnthropicStreamEvent(event, acc); - if (result.textDelta) textChunks.push(result.textDelta); - } - - expect(textChunks.join('')).toBe('Hello world!'); - }); - - it('OpenAI: processes a tool call response stream', () => { - const acc = createOpenAIStreamAccumulator(); - - const events: SSEEvent[] = [ - { data: JSON.stringify({ choices: [{ delta: { role: 'assistant', tool_calls: [{ index: 0, id: 'call_1', function: { name: 'search_posts', arguments: '' } }] }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"query"' } }] }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "test"}' } }] }, index: 0 }] }) }, - { data: JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls', index: 0 }] }) }, - { data: '[DONE]' }, - ]; - - for (const event of events) { - parseOpenAIStreamEvent(event, acc); - } - - expect(acc.toolCalls.size).toBe(1); - const tc = acc.toolCalls.get(0)!; - expect(tc.name).toBe('search_posts'); - expect(JSON.parse(tc.arguments)).toEqual({ query: 'test' }); - }); - - it('Anthropic: processes a tool call response stream', () => { - const acc = createAnthropicStreamAccumulator(); - - const events: SSEEvent[] = [ - { event: 'message_start', data: JSON.stringify({ type: 'message_start', message: { id: 'msg_1', usage: { input_tokens: 100 } } }) }, - { event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) }, - { event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Let me search.' } }) }, - { event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 0 }) }, - { event: 'content_block_start', data: JSON.stringify({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'toolu_1', name: 'search_posts' } }) }, - { event: 'content_block_delta', data: JSON.stringify({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"query": "test"}' } }) }, - { event: 'content_block_stop', data: JSON.stringify({ type: 'content_block_stop', index: 1 }) }, - { event: 'message_delta', data: JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 20 } }) }, - { event: 'message_stop', data: JSON.stringify({ type: 'message_stop' }) }, - ]; - - const textChunks: string[] = []; - for (const event of events) { - const result = parseAnthropicStreamEvent(event, acc); - if (result.textDelta) textChunks.push(result.textDelta); - } - - expect(textChunks.join('')).toBe('Let me search.'); - expect(acc.toolCalls.size).toBe(1); - const tc = acc.toolCalls.get(1)!; - expect(tc.name).toBe('search_posts'); - expect(JSON.parse(tc.arguments)).toEqual({ query: 'test' }); - }); -}); - -// ── JSON Parse Error Resilience ── - -describe('parser JSON error resilience', () => { - it('OpenAI: skips corrupted SSE events with invalid JSON', () => { - const acc = createOpenAIStreamAccumulator(); - const event: SSEEvent = { data: '{corrupted json' }; - const result = parseOpenAIStreamEvent(event, acc); - expect(result.done).toBe(false); - expect(result.textDelta).toBeUndefined(); - }); - - it('OpenAI: recovers from corrupted event and processes subsequent valid events', () => { - const acc = createOpenAIStreamAccumulator(); - - // Corrupted event - parseOpenAIStreamEvent({ data: 'not-json' }, acc); - - // Valid event after corruption - const result = parseOpenAIStreamEvent({ - data: JSON.stringify({ choices: [{ delta: { content: 'OK' }, index: 0 }] }), - }, acc); - expect(result.textDelta).toBe('OK'); - }); - - it('Anthropic: skips corrupted SSE events with invalid JSON', () => { - const acc = createAnthropicStreamAccumulator(); - const event: SSEEvent = { event: 'content_block_delta', data: '{broken' }; - const result = parseAnthropicStreamEvent(event, acc); - expect(result.done).toBe(false); - expect(result.textDelta).toBeUndefined(); - }); - - it('Anthropic: recovers from corrupted event and processes subsequent valid events', () => { - const acc = createAnthropicStreamAccumulator(); - - // Corrupted event - parseAnthropicStreamEvent({ event: 'ping', data: 'not-json' }, acc); - - // Valid event after corruption - const result = parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'text_delta', text: 'Recovered' }, - }), - }, acc); - expect(result.textDelta).toBe('Recovered'); - }); -}); - -// ── OpenAI Cache Token Extraction ── - -describe('OpenAI cache token extraction', () => { - it('extracts cached_tokens from prompt_tokens_details', () => { - const acc = createOpenAIStreamAccumulator(); - const event: SSEEvent = { - data: JSON.stringify({ - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 150, - completion_tokens: 42, - total_tokens: 192, - prompt_tokens_details: { cached_tokens: 100 }, - }, - }), - }; - const result = parseOpenAIStreamEvent(event, acc); - expect(result.usage).toEqual({ - promptTokens: 150, - completionTokens: 42, - totalTokens: 192, - cacheReadTokens: 100, - }); - }); - - it('returns undefined cacheReadTokens when prompt_tokens_details is absent', () => { - const acc = createOpenAIStreamAccumulator(); - const event: SSEEvent = { - data: JSON.stringify({ - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 150, - completion_tokens: 42, - total_tokens: 192, - }, - }), - }; - const result = parseOpenAIStreamEvent(event, acc); - expect(result.usage?.cacheReadTokens).toBeUndefined(); - }); -}); - -// ── httpRequestStream ── - -describe('httpRequestStream', () => { - // Use a real HTTP server for integration tests (avoids ESM spyOn limitations) - - function startTestServer(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<{ url: string; close: () => Promise }> { - return new Promise((resolve) => { - const server = http.createServer(handler); - server.listen(0, () => { - const addr = server.address() as { port: number }; - resolve({ - url: `http://localhost:${addr.port}`, - close: () => new Promise((r) => server.close(() => r())), - }); - }); - }); - } - - it('parses streamed SSE events from response data chunks', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'); - res.write('data: {"choices":[{"delta":{"content":" world"}}]}\n\n'); - res.write('data: [DONE]\n\n'); - res.end(); - }); - - try { - const { events } = await httpRequestStream(srv.url, { method: 'POST', body: '{}' }); - const collected: SSEEvent[] = []; - for await (const event of events) { - collected.push(event); - } - expect(collected).toHaveLength(3); - expect(collected[0].data).toBe('{"choices":[{"delta":{"content":"Hello"}}]}'); - expect(collected[1].data).toBe('{"choices":[{"delta":{"content":" world"}}]}'); - expect(collected[2].data).toBe('[DONE]'); - } finally { - await srv.close(); - } - }); - - it('collects error body and rejects on non-2xx status', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '5' }); - res.end(JSON.stringify({ error: { message: 'Rate limited' } })); - }); - - try { - await expect(httpRequestStream(srv.url, {})).rejects.toMatchObject({ - message: 'Rate limited', - statusCode: 429, - retryAfter: 5, - }); - } finally { - await srv.close(); - } - }); - - it('propagates mid-stream errors to async iterable consumer', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n'); - // Destroy the socket to simulate TCP disconnect - setTimeout(() => res.destroy(), 20); - }); - - try { - const { events } = await httpRequestStream(srv.url, {}); - const collected: SSEEvent[] = []; - await expect(async () => { - for await (const event of events) { - collected.push(event); - } - }).rejects.toThrow(); - - // Should have received the first event before the error - expect(collected).toHaveLength(1); - expect(collected[0].data).toBe('{"choices":[{"delta":{"content":"Hi"}}]}'); - } finally { - await srv.close(); - } - }); - - it('propagates stored error when no consumer was waiting (pendingError fix)', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - // Send data and immediately destroy — error fires before consumer calls .next() - res.write('data: {"ok":true}\n\n'); - // Give a tiny delay so the data event fires first - setTimeout(() => res.destroy(), 5); - }); - - try { - const { events } = await httpRequestStream(srv.url, {}); - const iter = events[Symbol.asyncIterator](); - - // Wait a bit for both data and error to fire - await new Promise(resolve => setTimeout(resolve, 50)); - - // First call should return the queued event - const first = await iter.next(); - expect(first.done).toBe(false); - expect(first.value.data).toBe('{"ok":true}'); - - // Second call should throw the stored (pending) error - await expect(iter.next()).rejects.toThrow(); - } finally { - await srv.close(); - } - }); - - it('handles already-aborted signal', async () => { - // No server needed — should reject immediately - const controller = new AbortController(); - controller.abort(); - - await expect(httpRequestStream('http://localhost:1/test', { - signal: controller.signal, - })).rejects.toMatchObject({ - isAbort: true, - }); - }); - - it('handles non-JSON error body', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(500, { 'Content-Type': 'text/plain' }); - res.end('Internal Server Error'); - }); - - try { - await expect(httpRequestStream(srv.url, {})).rejects.toMatchObject({ - statusCode: 500, - }); - } finally { - await srv.close(); - } - }); -}); - -// ── withRetry onRetry callback ── - -describe('withRetry onRetry callback', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('calls onRetry callback before each retry attempt', async () => { - const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 }); - const onRetry = vi.fn(); - const fn = vi.fn() - .mockRejectedValueOnce(error429) - .mockRejectedValueOnce(error429) - .mockResolvedValue('success'); - - const promise = withRetry(fn, { maxRetries: 3, onRetry }); - await vi.advanceTimersByTimeAsync(10000); - const result = await promise; - - expect(result).toBe('success'); - expect(onRetry).toHaveBeenCalledTimes(2); - expect(onRetry).toHaveBeenCalledWith(1, error429); - expect(onRetry).toHaveBeenCalledWith(2, error429); - }); - - it('does not call onRetry when first attempt succeeds', async () => { - const onRetry = vi.fn(); - const fn = vi.fn().mockResolvedValue('ok'); - - const result = await withRetry(fn, { maxRetries: 3, onRetry }); - - expect(result).toBe('ok'); - expect(onRetry).not.toHaveBeenCalled(); - }); -}); - -// ── Mid-stream retry integration ── - -describe('mid-stream retry with withRetry', () => { - it('retries stream consumption on transient mid-stream error', async () => { - vi.useRealTimers(); - - let attempt = 0; - const fn = async () => { - attempt++; - if (attempt === 1) { - // First attempt: simulate partial stream then error - const error = Object.assign(new Error('Service temporarily unavailable'), { statusCode: 503 }); - throw error; - } - // Second attempt: succeed - return { text: 'Hello world!', toolCalls: [] }; - }; - - const result = await withRetry(fn, { maxRetries: 2 }); - expect(result).toEqual({ text: 'Hello world!', toolCalls: [] }); - expect(attempt).toBe(2); - }); - - it('retries on mid-stream TCP error (no status code)', async () => { - vi.useRealTimers(); - - let attempt = 0; - const fn = async () => { - attempt++; - if (attempt === 1) { - throw new Error('ECONNRESET'); - } - return 'recovered'; - }; - - const result = await withRetry(fn, { maxRetries: 2 }); - expect(result).toBe('recovered'); - expect(attempt).toBe(2); - }); - - it('does not retry mid-stream abort errors', async () => { - const abortError = Object.assign(new Error('Request cancelled'), { isAbort: true }); - - let attempt = 0; - const fn = async () => { - attempt++; - throw abortError; - }; - - await expect(withRetry(fn, { maxRetries: 3 })).rejects.toThrow('Request cancelled'); - expect(attempt).toBe(1); - }); -}); - -// ── Connection-only retry (no double-emission) ── - -describe('connection-only retry pattern (withRetry wrapping httpRequestStream)', () => { - function startTestServer(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<{ url: string; close: () => Promise }> { - return new Promise((resolve) => { - const server = http.createServer(handler); - server.listen(0, () => { - const addr = server.address() as { port: number }; - resolve({ - url: `http://localhost:${addr.port}`, - close: () => new Promise((r) => server.close(() => r())), - }); - }); - }); - } - - it('retries 429 at connection time without emitting duplicate deltas', async () => { - let requestCount = 0; - const srv = await startTestServer((_req, res) => { - requestCount++; - if (requestCount === 1) { - res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '0' }); - res.end(JSON.stringify({ error: { message: 'Rate limited' } })); - return; - } - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n'); - res.write('data: {"choices":[{"delta":{"content":" world"}}]}\n\n'); - res.write('data: [DONE]\n\n'); - res.end(); - }); - - try { - const deltas: string[] = []; - - // Retry only the connection, process events outside retry - const { events } = await withRetry(() => httpRequestStream(srv.url, { method: 'POST', body: '{}' })); - - const acc = createOpenAIStreamAccumulator(); - for await (const event of events) { - const result = parseOpenAIStreamEvent(event, acc); - if (result.textDelta) deltas.push(result.textDelta); - } - - // Each delta appears exactly once — no double-emission - expect(deltas).toEqual(['Hello', ' world']); - expect(requestCount).toBe(2); // 1 failed + 1 success - } finally { - await srv.close(); - } - }); - - it('mid-stream TCP error propagates without retry when only connection is wrapped', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n'); - // Destroy socket to simulate mid-stream TCP disconnect - setTimeout(() => res.destroy(), 20); - }); - - try { - const deltas: string[] = []; - - // Only connection is retried — mid-stream errors propagate - const { events } = await withRetry(() => httpRequestStream(srv.url, { method: 'POST', body: '{}' })); - - const acc = createOpenAIStreamAccumulator(); - await expect(async () => { - for await (const event of events) { - const result = parseOpenAIStreamEvent(event, acc); - if (result.textDelta) deltas.push(result.textDelta); - } - }).rejects.toThrow(); - - // Partial delta was received before the error — no duplication - expect(deltas).toEqual(['Hi']); - } finally { - await srv.close(); - } - }); - - it('retries 502 at connection time then streams successfully', async () => { - let requestCount = 0; - const srv = await startTestServer((_req, res) => { - requestCount++; - if (requestCount === 1) { - res.writeHead(502); - res.end('Bad Gateway'); - return; - } - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":10}}}\n\n'); - res.write('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}\n\n'); - res.write('event: message_stop\ndata: {"type":"message_stop"}\n\n'); - res.end(); - }); - - try { - const deltas: string[] = []; - - const { events } = await withRetry(() => httpRequestStream(srv.url, { method: 'POST', body: '{}' })); - - const acc = createAnthropicStreamAccumulator(); - for await (const event of events) { - const result = parseAnthropicStreamEvent(event, acc); - if (result.textDelta) deltas.push(result.textDelta); - } - - expect(deltas).toEqual(['OK']); - expect(requestCount).toBe(2); - } finally { - await srv.close(); - } - }); -}); - -// ── SSE spec compliance ── - -describe('SSE spec compliance - single space removal', () => { - it('removes exactly one leading space after colon in data field', () => { - const chunk = 'data: {"key": "value"}\n\n'; - const { events } = parseSSELines(chunk); - expect(events[0].data).toBe('{"key": "value"}'); - }); - - it('preserves data when no space after colon', () => { - const chunk = 'data:{"key":"value"}\n\n'; - const { events } = parseSSELines(chunk); - expect(events[0].data).toBe('{"key":"value"}'); - }); - - it('preserves extra leading spaces after removing one', () => { - const chunk = 'data: two spaces\n\n'; - const { events } = parseSSELines(chunk); - // Per SSE spec: only one leading space is removed - expect(events[0].data).toBe(' two spaces'); - }); - - it('removes exactly one leading space from event type', () => { - const chunk = 'event: message_start\ndata: {}\n\n'; - const { events } = parseSSELines(chunk); - expect(events[0].event).toBe('message_start'); - }); - - it('handles event type with no space after colon', () => { - const chunk = 'event:ping\ndata: {}\n\n'; - const { events } = parseSSELines(chunk); - expect(events[0].event).toBe('ping'); - }); -}); - -// ── Async iterator return() cleanup ── - -describe('async iterator return() cleanup', () => { - function startTestServer(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<{ url: string; close: () => Promise }> { - return new Promise((resolve) => { - const server = http.createServer(handler); - server.listen(0, () => { - const addr = server.address() as { port: number }; - resolve({ - url: `http://localhost:${addr.port}`, - close: () => new Promise((r) => server.close(() => r())), - }); - }); - }); - } - - it('destroys response stream when for-await-of breaks early', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"choices":[{"delta":{"content":"A"}}]}\n\n'); - res.write('data: {"choices":[{"delta":{"content":"B"}}]}\n\n'); - res.write('data: {"choices":[{"delta":{"content":"C"}}]}\n\n'); - // Don't end the response — the client should destroy it via return() - // Keep connection alive for a bit - setTimeout(() => res.end(), 5000); - }); - - try { - const { events } = await httpRequestStream(srv.url, { method: 'POST', body: '{}' }); - const collected: SSEEvent[] = []; - for await (const event of events) { - collected.push(event); - if (collected.length === 1) break; // Early exit triggers return() - } - - expect(collected).toHaveLength(1); - expect(collected[0].data).toBe('{"choices":[{"delta":{"content":"A"}}]}'); - } finally { - await srv.close(); - } - }); - - it('return() method signals done and is idempotent', async () => { - const srv = await startTestServer((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - res.write('data: {"ok":true}\n\n'); - setTimeout(() => res.end(), 5000); - }); - - try { - const { events } = await httpRequestStream(srv.url, { method: 'POST', body: '{}' }); - const iter = events[Symbol.asyncIterator](); - - // Consume one event - const first = await iter.next(); - expect(first.done).toBe(false); - - // Call return() explicitly - const returnResult = await iter.return!(undefined as unknown as SSEEvent); - expect(returnResult.done).toBe(true); - - // Subsequent next() should return done - const after = await iter.next(); - expect(after.done).toBe(true); - } finally { - await srv.close(); - } - }); -}); - -// ── Thinking block signature capture ── - -describe('Anthropic thinking block signature', () => { - let accumulator: AnthropicStreamAccumulator; - - beforeEach(() => { - accumulator = createAnthropicStreamAccumulator(); - }); - - it('captures signature from content_block_stop for thinking blocks', () => { - // Start thinking block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'thinking', thinking: '' }, - }), - }, accumulator); - - // Accumulate thinking - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'thinking_delta', thinking: 'Let me reason...' }, - }), - }, accumulator); - - // content_block_stop with signature - parseAnthropicStreamEvent({ - event: 'content_block_stop', - data: JSON.stringify({ - type: 'content_block_stop', - index: 0, - content_block: { - type: 'thinking', - thinking: 'Let me reason...', - signature: 'ErUBCkYIAxgCIkD+ybfICm10kSig...', - }, - }), - }, accumulator); - - const tb = accumulator.thinkingBlocks.get(0); - expect(tb).toBeDefined(); - expect(tb!.text).toBe('Let me reason...'); - expect(tb!.signature).toBe('ErUBCkYIAxgCIkD+ybfICm10kSig...'); - }); - - it('leaves signature undefined when content_block_stop has no signature', () => { - // Start thinking block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'thinking', thinking: '' }, - }), - }, accumulator); - - // content_block_stop without signature - parseAnthropicStreamEvent({ - event: 'content_block_stop', - data: JSON.stringify({ - type: 'content_block_stop', - index: 0, - }), - }, accumulator); - - const tb = accumulator.thinkingBlocks.get(0); - expect(tb).toBeDefined(); - expect(tb!.signature).toBeUndefined(); - }); - - it('does not affect tool_call blocks on content_block_stop', () => { - // Start tool_use block - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ - type: 'content_block_start', - index: 0, - content_block: { type: 'tool_use', id: 'toolu_1', name: 'search_posts' }, - }), - }, accumulator); - - // Tool argument fragment - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ - type: 'content_block_delta', - index: 0, - delta: { type: 'input_json_delta', partial_json: '{"query":"test"}' }, - }), - }, accumulator); - - // content_block_stop (no signature for tool blocks) - parseAnthropicStreamEvent({ - event: 'content_block_stop', - data: JSON.stringify({ - type: 'content_block_stop', - index: 0, - }), - }, accumulator); - - // Tool call should be unaffected - const tc = accumulator.toolCalls.get(0); - expect(tc).toBeDefined(); - expect(tc!.arguments).toBe('{"query":"test"}'); - }); - - it('full thinking sequence produces signature on accumulator', () => { - // Full realistic sequence: thinking block -> text block -> tool_use - // Thinking at index 0 - parseAnthropicStreamEvent({ - event: 'content_block_start', - data: JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }), - }, accumulator); - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'Step 1. ' } }), - }, accumulator); - parseAnthropicStreamEvent({ - event: 'content_block_delta', - data: JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'Step 2.' } }), - }, accumulator); - parseAnthropicStreamEvent({ - event: 'content_block_stop', - data: JSON.stringify({ type: 'content_block_stop', index: 0, content_block: { type: 'thinking', thinking: 'Step 1. Step 2.', signature: 'sig_abc123' } }), - }, accumulator); - - expect(accumulator.thinkingBlocks.get(0)).toEqual({ text: 'Step 1. Step 2.', signature: 'sig_abc123' }); - }); -}); - -// ── withRetry abort-aware delay ── - -describe('withRetry abort during delay', () => { - it('rejects quickly when signal is aborted during retry delay', async () => { - const controller = new AbortController(); - const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 }); - const fn = vi.fn().mockRejectedValue(error429); - - const promise = withRetry(fn, { - maxRetries: 3, - signal: controller.signal, - }); - - // First attempt fails immediately, then enters retry delay. - // Wait a small amount for the first attempt to fail and delay to start. - await new Promise(r => setTimeout(r, 50)); - expect(fn).toHaveBeenCalledTimes(1); - - // Abort during the delay - controller.abort(); - - // Should reject with abort error, not wait for delay to finish - await expect(promise).rejects.toThrow(); - // Should NOT have made a second attempt — aborted during delay - expect(fn).toHaveBeenCalledTimes(1); - }); - - it('does not abort delay when no signal is provided', async () => { - vi.useFakeTimers(); - const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 }); - const fn = vi.fn() - .mockRejectedValueOnce(error429) - .mockResolvedValue('ok'); - - const promise = withRetry(fn, { maxRetries: 3 }); - await vi.advanceTimersByTimeAsync(2000); - const result = await promise; - expect(result).toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - vi.useRealTimers(); - }); - - it('works normally when signal is not aborted', async () => { - vi.useFakeTimers(); - const controller = new AbortController(); - const error429 = Object.assign(new Error('Rate limited'), { statusCode: 429 }); - const fn = vi.fn() - .mockRejectedValueOnce(error429) - .mockResolvedValue('ok'); - - const promise = withRetry(fn, { - maxRetries: 3, - signal: controller.signal, - }); - await vi.advanceTimersByTimeAsync(2000); - const result = await promise; - expect(result).toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - vi.useRealTimers(); - }); -});