Merge pull request #29 from rfc1437/feat/ai-sdk-rewrite

Feat/ai sdk rewrite
This commit is contained in:
Georg Bauer
2026-03-01 20:57:39 +01:00
committed by GitHub
32 changed files with 5202 additions and 6581 deletions

View File

@@ -25,8 +25,6 @@ Decision: which model is better for my usecases?
3. Integration
Ollama is using OpenAI protocols, so should be easy to integrate as a third AI provider.
Important: models for different defaults in Preferences must be able to be configured to span multiple providers if multiple ones are set up.
Ollama is using OpenAI protocols, so should be easy to integrate with AI SDK.
Ollama integration - if activated - must do a check if ollama is serving the model, and if not give a message to the user, so they can fire up ollama, since it won't always be running.

410
OPENCODE_REFACTOR.md Normal file
View File

@@ -0,0 +1,410 @@
# AI Integration Rewrite
## Goal
Delete `OpenCodeManager.ts` (2,745 lines) and `streaming.ts` (621 lines). Replace all AI plumbing with **Vercel AI SDK v6**. Multi-provider from day 1.
## Principles
- AI SDK owns all protocol work: streaming, retry, token tracking, message format, tool loop
- We own: tools, prompts, persistence, key management, A2UI, model catalog
- No provider-specific code in business logic — AI SDK abstracts providers
- Zod schemas shared between AI SDK `tool()` and MCP server — single source of truth
- Provider = configuration, not code. Adding Anthropic Direct or OpenAI Direct = adding a config entry
---
## Architecture
```
src/main/engine/
├── ai/
│ ├── providers.ts # Provider registry, model resolution
│ ├── blog-tools.ts # 16 data tools (shared with MCP)
│ ├── a2ui-tools.ts # 7 render_* tools
│ ├── chat.ts # sendMessage, abort, title gen (streamText)
│ └── tasks.ts # One-shot: taxonomy, image analysis (generateText)
├── MCPServer.ts # Imports blog-tools.ts — zero duplication
├── ChatEngine.ts # Unchanged
├── ModelCatalogEngine.ts # Unchanged
├── SecureKeyStore.ts # Extended for multi-provider keys
└── a2ui/ # Unchanged
```
### DELETE entirely
| File | Lines | Why |
|------|-------|-----|
| `OpenCodeManager.ts` | 2,745 | Replaced by `ai/` modules |
| `streaming.ts` | 621 | AI SDK providers handle all streaming |
| MCPServer duplicated tools | ~165 | Uses `blog-tools.ts` |
| **Total** | **~3,530** | |
---
## Provider System
### Dependencies
```
ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mistral
```
### Provider types
| Provider | SDK package | baseURL | Models | Key |
|----------|-------------|---------|--------|-----|
| OpenCode (gateway) | `@ai-sdk/anthropic` + `@ai-sdk/openai` | Zen URLs | claude\*, gpt\*, gemini\*, o3\*, o4\* | OpenCode key |
| Mistral (direct) | `@ai-sdk/mistral` | default | mistral\*, codestral\*, pixtral\* | Mistral key |
| Anthropic (direct) | `@ai-sdk/anthropic` | default | claude\* | Anthropic key |
| OpenAI (direct) | `@ai-sdk/openai` | default | gpt\*, o3\*, o4\* | OpenAI key |
Start with OpenCode + Mistral. Adding direct Anthropic/OpenAI = registering a new provider entry, zero code changes.
### OpenCode is a gateway, not a provider
OpenCode Zen exposes two API-compatible endpoints behind one key:
- `https://opencode.ai/zen/v1/messages` — Anthropic Messages API
- `https://opencode.ai/zen/v1/chat/completions` — OpenAI Chat Completions API
We use standard `@ai-sdk/anthropic` and `@ai-sdk/openai` with `baseURL` override. No community provider needed — the existing one (`ai-sdk-provider-opencode-sdk`) wraps the OpenCode CLI, not Zen.
### `ai/providers.ts`
Uses `createProviderRegistry` + `customProvider` with `fallbackProvider`. Model IDs carry a provider prefix (`opencode:claude-sonnet-4-5`, `mistral:mistral-large-latest`) — the prefix IS the routing. No static model maps.
```ts
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { createMistral } from '@ai-sdk/mistral';
import { createProviderRegistry, customProvider } from 'ai';
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
function createOpenCodeGateway(apiKey: string) {
const anthropicProvider = createAnthropic({ baseURL: ZEN_BASE_URL, apiKey });
// CRITICAL: .chat() = Chat Completions API. Default = Responses API (incompatible with Zen).
const openaiProvider = createOpenAI({ baseURL: ZEN_BASE_URL, apiKey });
return customProvider({
fallbackProvider: {
languageModel: (modelId: string) => {
if (modelId.startsWith('claude')) return anthropicProvider(modelId);
return openaiProvider.chat(modelId); // .chat() required for Chat Completions
},
},
});
}
function buildRegistry(keys: { opencode?: string; mistral?: string }) {
const providers: Record<string, any> = {};
if (keys.opencode) providers.opencode = createOpenCodeGateway(keys.opencode);
if (keys.mistral) providers.mistral = createMistral({ apiKey: keys.mistral });
// Future direct providers: just add more entries
// if (keys.anthropic) providers.anthropic = createAnthropic({ apiKey: keys.anthropic });
return createProviderRegistry(providers);
}
// Usage: registry.languageModel('opencode:claude-sonnet-4-5')
// Usage: registry.languageModel('mistral:mistral-large-latest')
```
Gateway (OpenCode) routes `claude*` → Anthropic Messages API, everything else → OpenAI Chat Completions API. Direct providers (Mistral) are 1:1. Adding a new provider = one config entry, zero code changes.
---
## Modules
### `ai/blog-tools.ts` — 16 data tools
Single source of truth. AI SDK `tool()` + Zod. Shared between chat and MCP.
```ts
export function createBlogTools(deps: BlogToolDeps) {
return {
check_term: tool({
description: 'Check whether a term exists as a category, tag, or both',
inputSchema: z.object({ term: z.string() }),
execute: async ({ term }) => { /* PostEngine queries */ },
}),
search_posts: tool({ ... }),
read_post: tool({ ... }),
list_posts: tool({ ... }),
get_media: tool({ ... }),
list_media: tool({ ... }),
update_post_metadata: tool({ ... }),
update_media_metadata: tool({ ... }),
list_tags: tool({ ... }),
list_categories: tool({ ... }),
get_blog_stats: tool({ ... }),
view_image: tool({
// Uses toModelOutput() for multimodal result — works across all providers
inputSchema: z.object({ media_id: z.number(), size: z.enum(['small','medium','large']) }),
execute: async ({ media_id, size }) => ({ base64, mediaType, caption }),
toModelOutput: ({ output }) => ({
type: 'content',
value: [
{ type: 'image', data: output.base64, mediaType: output.mediaType },
{ type: 'text', text: output.caption },
],
}),
}),
get_post_backlinks: tool({ ... }),
get_post_outlinks: tool({ ... }),
get_post_media: tool({ ... }),
get_media_posts: tool({ ... }),
};
}
// Shared helper consumed by both tools and MCP
export function buildAmbiguityHints(...): Promise<string[]> { ... }
```
MCPServer integration: `createBlogTools(deps)` → extract schemas + handlers → register as MCP tools. Zero duplication.
### `ai/a2ui-tools.ts` — 7 render tools
```ts
export function createA2UITools() {
return {
render_chart: tool({ ... }),
render_table: tool({ ... }),
render_form: tool({ ... }),
render_card: tool({ ... }),
render_metric: tool({ ... }),
render_list: tool({ ... }),
render_tabs: tool({ ... }),
};
}
```
A2UI message dispatch happens in `chat.ts` via `experimental_onToolCallFinish` — the tool itself just returns `{ success: true }`.
### `ai/chat.ts` — ChatService
The core. One `streamText()` call replaces both `sendAnthropicMessage()` and `sendOpenAIMessage()`.
```ts
import { streamText, stepCountIs } from 'ai';
class ChatService {
private abortControllers = new Map<string, AbortController>();
private tokenUsage = new Map<string, TokenUsage>();
constructor(
private chatEngine: ChatEngine,
private providers: ProviderRegistry,
private blogTools: ReturnType<typeof createBlogTools>,
private a2uiTools: ReturnType<typeof createA2UITools>,
) {}
async sendMessage(conversationId: string, content: string, callbacks: StreamCallbacks) {
const conv = await this.chatEngine.getConversation(conversationId);
const model = this.providers.getModel(conv.model);
const ac = new AbortController();
this.abortControllers.set(conversationId, ac);
const result = streamText({
model,
system: await this.buildSystemPrompt(conv),
messages: await this.loadMessages(conversationId),
tools: { ...this.blogTools, ...this.a2uiTools },
maxRetries: 3,
stopWhen: stepCountIs(10),
abortSignal: ac.signal,
// Anthropic: server-side context management (replaces truncateToTokenBudget)
providerOptions: {
anthropic: {
cacheControl: { type: 'ephemeral' }, // cache system + tools
contextManagement: {
edits: [
{ type: 'clear_tool_uses_20250919', trigger: { type: 'input_tokens', value: 50000 },
keep: { type: 'tool_uses', value: 5 }, clearToolInputs: true },
{ type: 'compact_20260112', trigger: { type: 'input_tokens', value: 80000 },
instructions: 'Summarize preserving editorial decisions and tool results.' },
],
},
},
},
// Non-Anthropic: simple message window
prepareStep: async ({ messages }) => {
if (messages.length > 30) return { messages: [messages[0], ...messages.slice(-15)] };
return {};
},
onChunk: ({ chunk }) => {
if (chunk.type === 'text') callbacks.onDelta?.(chunk.text);
if (chunk.type === 'reasoning') callbacks.onReasoning?.(chunk.text);
},
experimental_onToolCallFinish: ({ toolCall, output }) => {
callbacks.onToolResult?.({ name: toolCall.toolName, result: output });
if (isRenderTool(toolCall.toolName)) {
const msg = generateFromToolCall(toolCall.toolName, toolCall.input);
if (msg) callbacks.onA2UIMessage?.(msg);
}
},
onStepFinish: ({ usage }) => {
this.accumulateUsage(conversationId, usage);
callbacks.onTokenUsage?.(this.tokenUsage.get(conversationId)!);
},
});
// Persist — response.messages gives clean provider-agnostic format
const messages = await result.response;
await this.chatEngine.persistMessages(conversationId, messages.messages);
this.abortControllers.delete(conversationId);
}
abort(conversationId: string) {
this.abortControllers.get(conversationId)?.abort();
}
async generateTitle(conversationId: string) {
const { text } = await generateText({
model: this.providers.getModel(titleModel),
system: 'Generate a concise title...',
messages: await this.loadMessages(conversationId),
maxTokens: 60,
});
await this.chatEngine.updateTitle(conversationId, text.trim());
}
}
```
~80 lines replaces ~560 lines of provider-specific streaming code.
### `ai/tasks.ts` — One-shot tasks
```ts
class OneShotTasks {
constructor(private providers: ProviderRegistry) {}
async analyzeTaxonomy(items: TaxonomyItem[], modelId: string) {
const { text } = await generateText({
model: this.providers.getModel(modelId),
system: TAXONOMY_SYSTEM_PROMPT,
prompt: buildTaxonomyPrompt(items),
maxTokens: 4096,
});
return parseTaxonomyResponse(text);
}
async analyzeMediaImage(imageBase64: string, mediaType: string, language: string, modelId: string) {
const { text } = await generateText({
model: this.providers.getModel(modelId),
system: imageAnalysisPrompt(language),
messages: [{
role: 'user',
content: [
{ type: 'image', image: imageBase64, mimeType: mediaType },
{ type: 'text', text: 'Analyze. Respond with JSON.' },
],
}],
maxTokens: 200,
});
return parseImageAnalysisResponse(text);
}
}
```
---
## What Carries Over
Domain logic only — no AI protocol code survives.
| What | Source | Destination |
|------|--------|-------------|
| 16 blog tool execute functions | `OpenCodeManager.executeTool()` | `ai/blog-tools.ts` |
| 7 A2UI tool definitions | `OpenCodeManager.getToolDefinitions()` | `ai/a2ui-tools.ts` |
| System prompt construction | `OpenCodeManager.buildSystemPrompt()` | `ai/chat.ts` |
| One-shot prompts (taxonomy, image) | `OpenCodeManager.analyze*()` | `ai/tasks.ts` |
| A2UI generator + catalog | `a2ui/` | `a2ui/` (unchanged) |
| Conversation persistence | `ChatEngine` | `ChatEngine` (unchanged) |
| Model catalog | `ModelCatalogEngine` | `ModelCatalogEngine` (unchanged) |
| Key encryption | `SecureKeyStore` | `SecureKeyStore` (extended) |
| MCP proposal tools | `MCPServer` | `MCPServer` (gains shared blog-tools) |
| Model listing HTTP | `OpenCodeManager.getAvailableModels()` | `ai/providers.ts` (thin HTTP for model lists) |
## IPC Changes
### Remove (provider-specific)
- `chat:validateApiKey`, `chat:setApiKey`, `chat:getApiKey` — replaced by generic
- `chat:validateMistralApiKey`, `chat:setMistralApiKey`, `chat:getMistralApiKey` — replaced by generic
### Add (provider-agnostic)
- `chat:getProviders` — list configured provider entries
- `chat:setProviderKey` / `chat:getProviderKey` — per-provider key management
- `chat:validateProvider` — test provider connectivity
### Keep (unchanged)
- `chat:sendMessage`, `chat:abortMessage` — wire to `ChatService`
- `chat:analyzeTaxonomy`, `chat:analyzeMediaImage` — wire to `OneShotTasks`
- All conversation CRUD, model catalog, system prompt handlers
- `a2ui:dispatch`
---
## Key Design Decisions
1. **No façade** — IPC handlers wire directly to `ChatService`, `ProviderRegistry`, `OneShotTasks`
2. **Anthropic context management** replaces `truncateToTokenBudget()` — server-side compaction, smarter than client-side estimation
3. **Cache control** via `providerOptions.anthropic.cacheControl` at message + tool level
4. **Extended thinking** — not now, but architecture supports it (just add `providerOptions.anthropic.thinking`)
5. **Electron `fetch`** — AI SDK uses Node `fetch` (works in Electron 40). Escape hatch: `net.fetch` as custom `fetch` for proxy/SSL
6. **Provider as config** — no per-provider classes. `ProviderRegistry` maps config → AI SDK instance. Add providers without code changes
7. **`toModelOutput`** on `view_image` — single definition works for all providers, eliminates per-provider image formatting hack
---
## Execution Plan
### Phase 0: Validate AI SDK + Electron (1 session) ✅ DONE
1. ~~`npm install ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/mistral`~~
2. ~~Write integration test: `generateText()` through Zen gateway with `baseURL` override~~ ✅ 31 tests
3. ~~Verify Electron `fetch` works (or set up `net.fetch` fallback)~~ ✅ Node fetch works
4. ~~Verify Zen baseURL path conventions match SDK expectations~~ ✅ See findings below
**Phase 0 Findings:**
- **BaseURL paths confirmed**: `@ai-sdk/anthropic` appends `/messages`, `@ai-sdk/openai` appends `/chat/completions` — Zen-compatible
- **CRITICAL: OpenAI Responses API vs Chat Completions**: `@ai-sdk/openai` v6 defaults to **Responses API** (`/responses`). Must use `provider.chat(modelId)` for Chat Completions (`/chat/completions`). All gateways (Zen, Azure, etc.) require Chat Completions.
- **`providerId:modelId` routing works**: `createProviderRegistry` resolves via prefix — no static model maps needed
- **`customProvider` with `fallbackProvider`**: Proven pattern for gateway routing with one rule: `startsWith('claude') → Anthropic, else → OpenAI`
- **Zod v4 schemas work with `tool()`**: Parameterized schemas, `toModelOutput()` for multimodal results
- **Anthropic `providerOptions`**: Cache control on system+tools, context management — all confirmed working
### Phase 1: Tools + MCP dedup (1 session) ✅ DONE
5. ~~Create `ai/blog-tools.ts` — 16 tools with Zod + execute (port from `executeTool` switch)~~
6. ~~Create `ai/a2ui-tools.ts` — 7 render tools~~
7. ~~Wire MCPServer to `blog-tools.ts` for `check_term` / `search_posts` — delete duplication~~
8. ~~Unit tests for all tools (mock engines, no AI calls)~~ ✅ 45 tests
### Phase 2: Providers + Chat + Tasks (1-2 sessions) ✅ DONE
9. ~~Create `ai/providers.ts` — `ProviderRegistry` with OpenCode gateway + Mistral direct~~
10. ~~Extend `SecureKeyStore` for multi-provider keys~~ ✅ (no changes needed — existing SecureKeyStore works)
11. ~~Create `ai/chat.ts` — `ChatService` with `streamText()`~~
12. ~~Create `ai/tasks.ts` — `OneShotTasks` with `generateText()`~~
13. ~~Update IPC handlers: generic provider management, wire to new modules~~
14. ~~Integration tests~~ ✅ 34 tests
### 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
---
## Open Questions
1. ~~**Zen baseURL paths**~~**RESOLVED**: `@ai-sdk/anthropic` appends `/messages`, `@ai-sdk/openai.chat()` appends `/chat/completions`. Verified from SDK source code and mock tests.
2. ~~**Model listing**~~**RESOLVED**: AI SDK has no model listing/discovery API. Keep thin HTTP GET to `ZEN_MODELS_URL` (`/v1/models`) and `MISTRAL_MODELS_URL`. Move into `providers.ts` as a utility method alongside the registry. This is provider-agnostic already (OpenAI-compatible `/models` endpoint).
3. ~~**DB message format**~~**RESOLVED**: Formats differ significantly. Adapt at persistence layer — no schema migration.
- **Current DB**: flat rows with `role` (string), `content` (string), `toolCallId` (string), `toolCalls` (JSON string of `[{name, args}]`)
- **AI SDK `ResponseMessage`**: `AssistantModelMessage | ToolModelMessage` with structured `content` arrays (`TextPart | ToolCallPart | ReasoningPart | ToolResultPart`)
- **Decision**: Serialize/deserialize in `ChatService` at the persistence boundary. On save: flatten AI SDK messages → flat DB rows. On load: reconstruct AI SDK `ModelMessage[]` from DB rows. This avoids a schema migration, keeps DB simple for UI queries, and the adapter is ~30 lines. Schema migration would buy us nothing — the UI reads these same rows directly for rendering.

142
package-lock.json generated
View File

@@ -9,6 +9,9 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.50",
"@ai-sdk/mistral": "^3.0.21",
"@ai-sdk/openai": "^3.0.37",
"@braintree/sanitize-url": "^7.1.2",
"@floating-ui/dom": "^1.7.5",
"@highlightjs/cdn-assets": "^11.11.1",
@@ -28,6 +31,7 @@
"@monaco-editor/react": "^4.7.0",
"@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11",
"ai": "^6.0.105",
"chokidar": "^5.0.0",
"d3-cloud": "^1.2.8",
"date-fns": "^4.1.0",
@@ -104,6 +108,100 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.50",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.50.tgz",
"integrity": "sha512-BkCUgGTp/iZJuuFBF1wv7GGnrEJg7X7hqbaa+/t4HTBt9dZn3e6NFn5NhPUvo2p5SreUeHEl0As0r2uaVn3K9Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.16"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.59",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.59.tgz",
"integrity": "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.16",
"@vercel/oidc": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/mistral": {
"version": "3.0.21",
"resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.21.tgz",
"integrity": "sha512-GhLRyfXghnKpt51t8ELU3cXwbCUcGOxZFUQFUZvNBt+y298PlOhaAK6pQtihfpNRGUycrdNotKwVajJm0a6uwg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.16"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.37",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.37.tgz",
"integrity": "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.16"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.16.tgz",
"integrity": "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
@@ -4765,6 +4863,16 @@
"url": "https://github.com/sponsors/ocavue"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@oven/bun-darwin-aarch64": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz",
@@ -5318,7 +5426,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@szmarczak/http-timer": {
@@ -6008,6 +6115,15 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
@@ -6416,6 +6532,24 @@
"node": ">= 14"
}
},
"node_modules/ai": {
"version": "6.0.105",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.105.tgz",
"integrity": "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.59",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.16",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -10884,6 +11018,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",

View File

@@ -70,6 +70,9 @@
"wait-on": "^9.0.3"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.50",
"@ai-sdk/mistral": "^3.0.21",
"@ai-sdk/openai": "^3.0.37",
"@braintree/sanitize-url": "^7.1.2",
"@floating-ui/dom": "^1.7.5",
"@highlightjs/cdn-assets": "^11.11.1",
@@ -89,6 +92,7 @@
"@monaco-editor/react": "^4.7.0",
"@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11",
"ai": "^6.0.105",
"chokidar": "^5.0.0",
"d3-cloud": "^1.2.8",
"date-fns": "^4.1.0",
@@ -163,7 +167,9 @@
{
"from": "src/main/engine/assets",
"to": "assets",
"filter": ["*.css"]
"filter": [
"*.css"
]
}
],
"protocols": [

View File

@@ -8,6 +8,7 @@ import {
} from '@modelcontextprotocol/ext-apps/server';
import { createServer as createHttpServer, type Server } from 'http';
import { z } from 'zod';
import { buildAmbiguityHints, enrichWithLinks, executeCheckTerm } from './ai/blog-tools';
import { ProposalStore, type ProposalType } from './ProposalStore';
import {
reviewPostHtml,
@@ -52,7 +53,7 @@ interface PostEngineContract {
getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>;
getPost: (id: string) => Promise<PostData | null>;
searchPosts: (query: string) => Promise<SearchResult[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<PostData[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>;
createPost: (data: Partial<PostData>) => Promise<PostData>;
updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
publishPost: (id: string) => Promise<PostData | null>;
@@ -73,6 +74,7 @@ interface PostEngineContract {
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
}
interface MediaEngineContract {
@@ -497,31 +499,14 @@ 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 ──
server.registerTool('search_posts', {
title: 'Search Posts',
description: 'Search blog posts by query, category, tags, or date range. Each result includes backlinks (posts linking to it) and linksTo (posts it links to). Use check_term first if unsure whether a term is a category or tag. Also available as resources: bds://categories (categories with counts), bds://tags (tags with counts).',
description: 'Search blog posts by query, category, tags, or date range. Returns a paginated envelope with total (matching count), offset, limit, hasMore, and posts array. Each post includes backlinks (posts linking to it) and linksTo (posts it links to). When hasMore is true, increase offset by limit to fetch the next page. Use check_term first if unsure whether a term is a category or tag. Also available as resources: bds://categories (categories with counts), bds://tags (tags with counts).',
inputSchema: {
query: z.string().optional().describe('Full-text search query'),
category: z.string().optional().describe('Filter by category'),
@@ -534,7 +519,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' }) }],
@@ -546,98 +530,82 @@ export class MCPServer {
const offset = args.offset ?? 0;
const limit = args.limit ?? 50;
// Helper: enrich posts with backlinks and linksTo
const enrichWithLinks = async <T extends { id: string }>(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 })),
};
}));
};
let enriched;
let total: number;
if (args.query && !hasFilters) {
// Pure text search — use FTS
const results = await this.deps.postEngine.searchPosts(args.query);
total = results.length;
const paginated = results.slice(offset, offset + limit);
const enriched = await enrichWithLinks(paginated);
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
}
enriched = await enrichWithLinks(paginated, this.deps.postEngine);
} else {
const filter: PostFilter = {};
if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags;
if (args.year) filter.year = args.year;
if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status;
// Build structural filter
const filter: PostFilter = {};
if (args.category) filter.categories = [args.category];
if (args.tags) filter.tags = args.tags;
if (args.year) filter.year = args.year;
if (args.month) filter.month = args.month;
if (args.status) filter.status = args.status;
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 hints = await this.buildAmbiguityHints(args.category, args.tags);
if (hints) {
content.push({ type: 'text' as const, text: hints });
if (args.query && hasFilters) {
const { posts, total: t } = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit });
total = t;
enriched = await enrichWithLinks(posts, this.deps.postEngine);
} else {
const results = await this.deps.postEngine.getPostsFiltered(filter);
total = results.length;
const paginated = results.slice(offset, offset + limit);
enriched = await enrichWithLinks(paginated, this.deps.postEngine);
}
return { content };
}
// 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 envelope = { total, offset, limit, hasMore: offset + limit < total, posts: enriched };
const content: Array<{ type: 'text'; text: string }> = [
{ type: 'text' as const, text: JSON.stringify(enriched) },
{ type: 'text' as const, text: JSON.stringify(envelope) },
];
// Ambiguity hints: check if category/tag terms exist in the other namespace
const hints = await this.buildAmbiguityHints(args.category, args.tags);
if (hints) {
content.push({ type: 'text' as const, text: hints });
const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags);
if (hintsList.length > 0) {
content.push({ type: 'text' as const, text: hintsList.join(' ') });
}
return { content };
});
}
/** Build a hint string when category/tag terms overlap across namespaces. */
private async buildAmbiguityHints(
category: string | undefined,
tags: string[] | undefined,
): Promise<string | null> {
const hints: string[] = [];
if (category) {
const allTags = await this.deps.postEngine.getTagsWithCounts();
const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase());
if (tagMatch) {
hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`);
// ── count_posts ──
server.registerTool('count_posts', {
title: 'Count Posts',
description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without full post data — ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.',
inputSchema: {
groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'),
year: z.number().optional().describe('Filter to posts in this year'),
month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
if (args.month && !args.year) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }],
isError: true,
};
}
}
if (tags && tags.length > 0) {
const allCats = await this.deps.postEngine.getCategoriesWithCounts();
for (const tag of tags) {
const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase());
if (catMatch) {
hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`);
}
}
}
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
if (args.year !== undefined) filter.year = args.year;
if (args.month !== undefined) filter.month = args.month;
if (args.status) filter.status = args.status;
if (args.category) filter.category = args.category;
if (args.tags) filter.tags = args.tags;
return hints.length > 0 ? hints.join(' ') : null;
const result = await this.deps.postEngine.getPostCounts(
args.groupBy,
Object.keys(filter).length > 0 ? filter : undefined,
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
};
});
}
private registerProposalTools(server: McpServer): void {

View File

@@ -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<number> {

File diff suppressed because it is too large Load Diff

View File

@@ -891,11 +891,11 @@ export class PostEngine extends EventEmitter {
query: string,
filter: PostFilter,
pagination?: PaginationOptions,
): Promise<PostData[]> {
if (!query.trim()) return [];
): Promise<{ posts: PostData[]; total: number }> {
if (!query.trim()) return { posts: [], total: 0 };
const client = getDatabase().getLocalClient();
if (!client) return [];
if (!client) return { posts: [], total: 0 };
try {
const stemmedQuery = stemQuery(query, this.searchLanguage);
@@ -974,12 +974,134 @@ export class PostEngine extends EventEmitter {
}
// Apply pagination
const total = postDataList.length;
const offset = pagination?.offset ?? 0;
const limit = pagination?.limit ?? postDataList.length;
return postDataList.slice(offset, offset + limit);
return { posts: postDataList.slice(offset, offset + limit), total };
} catch (error) {
console.error('Search with filters failed:', error);
return [];
return { posts: [], total: 0 };
}
}
/**
* Server-side aggregation: count posts grouped by one or more dimensions.
* Returns flat groups with counts — avoids transferring full post data for analytics.
*/
async getPostCounts(
groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>,
filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] },
): Promise<{ groups: Record<string, string | number>[]; totalPosts: number }> {
const client = getDatabase().getLocalClient();
if (!client) return { groups: [], totalPosts: 0 };
// Build SELECT expressions and GROUP BY columns
const selectExprs: string[] = [];
const groupByCols: string[] = [];
const joins: string[] = [];
for (const dim of groupBy) {
switch (dim) {
case 'year':
selectExprs.push("CAST(strftime('%Y', posts.created_at, 'unixepoch') AS INTEGER) AS g_year");
groupByCols.push('g_year');
break;
case 'month':
selectExprs.push("CAST(strftime('%m', posts.created_at, 'unixepoch') AS INTEGER) AS g_month");
groupByCols.push('g_month');
break;
case 'tag':
selectExprs.push('t.value AS g_tag');
joins.push('JOIN json_each(posts.tags) AS t');
groupByCols.push('g_tag');
break;
case 'category':
selectExprs.push('c.value AS g_category');
joins.push('JOIN json_each(posts.categories) AS c');
groupByCols.push('g_category');
break;
case 'status':
selectExprs.push('posts.status AS g_status');
groupByCols.push('g_status');
break;
}
}
selectExprs.push('COUNT(*) AS cnt');
// Build WHERE conditions
const conditions: string[] = ['posts.project_id = ?'];
const args: (string | number)[] = [this.currentProjectId];
if (filter?.year !== undefined) {
const startEpoch = Math.floor(new Date(`${filter.year}-01-01T00:00:00Z`).getTime() / 1000);
const endEpoch = Math.floor(new Date(`${filter.year + 1}-01-01T00:00:00Z`).getTime() / 1000);
conditions.push('posts.created_at >= ?');
args.push(startEpoch);
conditions.push('posts.created_at < ?');
args.push(endEpoch);
}
if (filter?.month !== undefined && filter?.year !== undefined) {
const startEpoch = Math.floor(new Date(`${filter.year}-${String(filter.month).padStart(2, '0')}-01T00:00:00Z`).getTime() / 1000);
const endMonth = filter.month === 12 ? 1 : filter.month + 1;
const endYear = filter.month === 12 ? filter.year + 1 : filter.year;
const endEpoch = Math.floor(new Date(`${endYear}-${String(endMonth).padStart(2, '0')}-01T00:00:00Z`).getTime() / 1000);
conditions.push('posts.created_at >= ?');
args.push(startEpoch);
conditions.push('posts.created_at < ?');
args.push(endEpoch);
}
if (filter?.status) {
conditions.push('posts.status = ?');
args.push(filter.status);
}
if (filter?.category) {
conditions.push(
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS fc WHERE fc.value = ?)`,
);
args.push(filter.category);
}
if (filter?.tags && filter.tags.length > 0) {
for (const tag of filter.tags) {
conditions.push(
`EXISTS (SELECT 1 FROM json_each(posts.tags) AS ft WHERE ft.value = ?)`,
);
args.push(tag);
}
}
const sql = `
SELECT ${selectExprs.join(', ')}
FROM posts
${joins.join(' ')}
WHERE ${conditions.join(' AND ')}
GROUP BY ${groupByCols.join(', ')}
ORDER BY cnt DESC
`;
try {
const result = await client.execute({ sql, args });
// Map dimension aliases back to clean names
const dimMap: Record<string, string> = {
g_year: 'year', g_month: 'month', g_tag: 'tag',
g_category: 'category', g_status: 'status',
};
const groups: Record<string, string | number>[] = result.rows.map((row: any) => {
const group: Record<string, string | number> = {};
for (const col of groupByCols) {
group[dimMap[col]] = row[col];
}
group.count = Number(row.cnt);
return group;
});
const totalPosts = groups.reduce((sum, g) => sum + (g.count as number), 0);
return { groups, totalPosts };
} catch (error) {
console.error('getPostCounts failed:', error);
return { groups: [], totalPosts: 0 };
}
}

View File

@@ -0,0 +1,145 @@
/**
* A2UI render tools — rich UI surfaces in the chat.
*
* These tools produce { success: true } as their execute result.
* The actual A2UI message generation happens in chat.ts via
* `experimental_onToolCallFinish`, which calls `generateFromToolCall()`.
*
* Zod schemas here are the single source of truth for the tool parameter shapes.
*/
import { z } from 'zod';
import { tool } from 'ai';
// ---------------------------------------------------------------------------
// Shared sub-schemas
// ---------------------------------------------------------------------------
const segmentSchema = z.object({
label: z.string().describe('Segment/column label'),
value: z.number().describe('Segment value'),
});
const seriesItemSchema = z.object({
label: z.string().describe('Data point label'),
value: z.number().describe('Data point value'),
segments: z.array(segmentSchema).optional()
.describe('Segments within this data point. Required for stacked-bar and heatmap charts.'),
});
const chartTypeEnum = z.enum(['bar', 'stacked-bar', 'line', 'area', 'pie', 'donut', 'heatmap']);
const tabContentItemSchema = z.object({
type: z.enum(['text', 'metric', 'list', 'chart', 'table']).describe('Content type'),
// text
text: z.string().optional().describe('Text content (for type text)'),
// metric
label: z.string().optional().describe('Label (for type metric)'),
value: z.string().optional().describe('Display value (for type metric)'),
// list, chart, table
title: z.string().optional().describe('Title (for type list, chart, or table)'),
items: z.array(z.string()).optional().describe('Items (for type list)'),
// chart
chartType: chartTypeEnum.optional().describe('Chart type (for type chart)'),
series: z.array(seriesItemSchema).optional().describe('Data series (for type chart)'),
// table
columns: z.array(z.string()).optional().describe('Column headers (for type table)'),
rows: z.array(z.array(z.string())).optional().describe('Table rows (for type table)'),
});
// ---------------------------------------------------------------------------
// Tool factory
// ---------------------------------------------------------------------------
export function createA2UITools() {
return {
render_chart: tool({
description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization.',
inputSchema: z.object({
chartType: chartTypeEnum.describe('The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations.'),
title: z.string().optional().describe('Optional chart title'),
series: z.array(seriesItemSchema).describe('Array of data points.'),
}),
execute: async (_input) => ({ success: true }),
}),
render_table: tool({
description: 'Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.',
inputSchema: z.object({
title: z.string().optional().describe('Optional table title'),
columns: z.array(z.string()).describe('Column header names'),
rows: z.array(z.array(z.string())).describe('Table rows, each row is an array of cell values'),
}),
execute: async (_input) => ({ success: true }),
}),
render_form: tool({
description: 'Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.',
inputSchema: z.object({
title: z.string().optional().describe('Optional form title'),
fields: z.array(z.object({
key: z.string().describe('Field identifier'),
label: z.string().describe('Field label shown to user'),
inputType: z.enum(['text', 'textarea', 'select', 'checkbox', 'date', 'number']).describe('Type of input control'),
placeholder: z.string().optional().describe('Placeholder text'),
defaultValue: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Default value'),
options: z.array(z.object({
label: z.string(),
value: z.string(),
})).optional().describe('Options for select fields'),
required: z.boolean().optional().describe('Whether the field is required'),
})).describe('Form fields to display'),
submitLabel: z.string().describe('Label for the submit button'),
submitAction: z.string().optional().describe('Action to dispatch on submit'),
}),
execute: async (_input) => ({ success: true }),
}),
render_card: tool({
description: 'Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.',
inputSchema: z.object({
title: z.string().describe('Card title'),
body: z.string().describe('Card body text (supports markdown)'),
subtitle: z.string().optional().describe('Optional subtitle'),
actions: z.array(z.object({
label: z.string().describe('Button label'),
action: z.string().describe('Action name to dispatch'),
payload: z.record(z.string(), z.unknown()).optional().describe('Optional action payload'),
})).optional().describe('Optional action buttons on the card'),
}),
execute: async (_input) => ({ success: true }),
}),
render_metric: tool({
description: 'Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.',
inputSchema: z.object({
label: z.string().describe('Metric label'),
value: z.string().describe('Metric value (displayed prominently)'),
}),
execute: async (_input) => ({ success: true }),
}),
render_list: tool({
description: 'Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.',
inputSchema: z.object({
title: z.string().optional().describe('Optional list title'),
items: z.array(z.string()).describe('List items'),
}),
execute: async (_input) => ({ success: true }),
}),
render_tabs: tool({
description: 'Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.',
inputSchema: z.object({
tabs: z.array(z.object({
label: z.string().describe('Tab label'),
content: z.array(tabContentItemSchema).describe('Content items within the tab'),
})).describe('Array of tabs'),
}),
execute: async (_input) => ({ success: true }),
}),
};
}
/** The return type of createA2UITools — useful for typing tool maps. */
export type A2UITools = ReturnType<typeof createA2UITools>;

View File

@@ -0,0 +1,640 @@
/**
* Blog data tools — single source of truth for both AI SDK chat and MCP server.
*
* Each tool is defined with a Zod input schema and an execute function.
* AI SDK uses these directly via `tool()`.
* MCPServer extracts schemas + handlers from the same definitions.
*/
import { z } from 'zod';
import { tool } from 'ai';
import type { PostData, PostFilter, PaginationOptions } from '../PostEngine';
import type { MediaData } from '../MediaEngine';
import type { PostMediaLinkData } from '../PostMediaEngine';
// ---------------------------------------------------------------------------
// Dependency contracts — injected at creation time, no hard engine coupling
// ---------------------------------------------------------------------------
export interface BlogToolDeps {
postEngine: {
getPost: (id: string) => Promise<PostData | null>;
getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>;
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
updatePost: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
getBlogStats: () => Promise<{
totalPosts: number;
draftCount: number;
publishedCount: number;
archivedCount: number;
oldestPostDate: Date | null;
newestPostDate: Date | null;
postsPerYear: Record<number, number>;
tagCount: number;
categoryCount: number;
}>;
getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>;
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
};
mediaEngine: {
getMedia: (id: string) => Promise<MediaData | null>;
getAllMedia: () => Promise<MediaData[]>;
getMediaFiltered: (filter: { year?: number; month?: number; tags?: string[] }) => Promise<MediaData[]>;
updateMedia: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
getThumbnailDataUrl: (mediaId: string, size: 'small' | 'medium' | 'large') => Promise<string | null>;
};
postMediaEngine: {
getLinkedMediaDataForPost: (postId: string) => Promise<Array<PostMediaLinkData & { media: MediaData }>>;
getLinkedPostsForMedia: (mediaId: string) => Promise<PostMediaLinkData[]>;
};
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/** Deps contract for link enrichment — narrow so MCPServer can also use it. */
export interface LinkEnrichmentDeps {
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
}
/** Enrich posts with backlinks and outlinks. */
export async function enrichWithLinks<T extends { id: string }>(
posts: T[],
postEngine: LinkEnrichmentDeps,
): Promise<Array<T & { backlinks: Array<{ id: string; title: string; slug: string }>; linksTo: Array<{ id: string; title: string; slug: string }> }>> {
return Promise.all(posts.map(async (p) => {
const [backlinks, linksTo] = await Promise.all([
postEngine.getLinkedBy(p.id),
postEngine.getLinksTo(p.id),
]);
return {
...p,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
};
}));
}
/** Contract for ambiguity hint building — narrow so MCPServer can also use it. */
export interface AmbiguityHintDeps {
getCategoriesWithCounts: () => Promise<Array<{ category: string; count: number }>>;
getTagsWithCounts: () => Promise<Array<{ tag: string; count: number }>>;
}
/**
* Build ambiguity hint strings when category/tag terms overlap across namespaces.
* Shared by both AI SDK tools and MCPServer.
*/
export async function buildAmbiguityHints(
postEngine: AmbiguityHintDeps,
category: string | undefined,
tags: string[] | undefined,
): Promise<string[]> {
const hints: string[] = [];
if (category) {
const allTags = await postEngine.getTagsWithCounts();
const tagMatch = allTags.find(t => t.tag.toLowerCase() === category.toLowerCase());
if (tagMatch) {
hints.push(`Note: "${category}" also exists as a tag (${tagMatch.count} post${tagMatch.count !== 1 ? 's' : ''}). Use the tags parameter to filter by tag instead.`);
}
}
if (tags && tags.length > 0) {
const allCats = await postEngine.getCategoriesWithCounts();
for (const tag of tags) {
const catMatch = allCats.find(c => c.category.toLowerCase() === tag.toLowerCase());
if (catMatch) {
hints.push(`Note: "${tag}" also exists as a category (${catMatch.count} post${catMatch.count !== 1 ? 's' : ''}). Use the category parameter to filter by category instead.`);
}
}
}
return hints;
}
/** 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
// ---------------------------------------------------------------------------
export function createBlogTools(deps: BlogToolDeps) {
const { postEngine, mediaEngine, postMediaEngine } = deps;
return {
check_term: tool({
description: 'Check whether a term exists as a category, tag, or both. Returns post counts for each. Use this before search_posts or list_posts when unsure whether a term is a category or tag.',
inputSchema: z.object({
term: z.string().describe('The term to look up'),
}),
execute: async ({ term }) => {
const result = await executeCheckTerm(term, postEngine);
return { success: true, ...result };
},
}),
search_posts: tool({
description: 'Search blog posts using full-text search. Can filter by category, tags, year, or month. Returns paginated results with totalMatches count. Use offset to page through results when totalMatches > limit. Use check_term first if unsure whether a term is a category or tag.',
inputSchema: z.object({
query: z.string().describe('The search query text to find in posts'),
category: z.string().optional().describe('Optional category to filter by'),
tags: z.array(z.string()).optional().describe('Optional array of tags to filter by (all must match)'),
year: z.number().optional().describe('Filter to posts created in this year'),
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results to return (default: 10)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ query, category, tags, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const filter: PostFilter = {};
if (category) filter.categories = [category];
if (tags && tags.length > 0) filter.tags = tags;
if (year !== undefined) filter.year = year;
if (month !== undefined && year !== undefined) filter.month = month;
const offset = off ?? 0;
const limit = lim ?? 10;
const { posts: filteredPosts, total: totalMatches } = await postEngine.searchPostsFiltered(query, filter, { offset, limit });
const hints = await buildAmbiguityHints(postEngine, category, tags);
const posts = await enrichWithLinks(
filteredPosts.map(p => ({
id: p.id, title: p.title, slug: p.slug,
excerpt: p.excerpt, status: p.status,
categories: p.categories, tags: p.tags,
createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
postEngine,
);
const result: Record<string, unknown> = {
success: true,
count: posts.length,
totalMatches,
hasMore: offset + limit < totalMatches,
offset,
limit,
posts,
};
if (hints.length > 0) result.hints = hints;
return result;
},
}),
read_post: tool({
description: 'Read the full content and metadata of a specific blog post by its ID. Includes backlinks (posts linking to this post).',
inputSchema: z.object({
postId: z.string().describe('The unique ID of the post to read'),
}),
execute: async ({ postId }) => {
const post = await postEngine.getPost(postId);
if (!post) return { success: false, error: 'Post not found' };
const [backlinks, linksTo] = await Promise.all([
postEngine.getLinkedBy(post.id),
postEngine.getLinksTo(post.id),
]);
return {
success: true,
post: {
id: post.id, title: post.title, slug: post.slug,
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
},
};
},
}),
list_posts: tool({
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks. The response includes "total" (global post count) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period. Use check_term first if unsure whether a term is a category or tag.',
inputSchema: z.object({
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'),
year: z.number().optional().describe('Filter to posts created in this year'),
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ status, category, tags, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const filter: PostFilter = {};
if (status) filter.status = status;
if (tags) filter.tags = tags;
if (category) filter.categories = [category];
if (year !== undefined) filter.year = year;
if (month !== undefined && year !== undefined) filter.month = month;
const offset = off ?? 0;
const limit = lim ?? 20;
const globalStats = await postEngine.getDashboardStats();
const globalTotal = globalStats.totalPosts;
let pageItems: PostData[];
let filteredTotal: number;
if (Object.keys(filter).length > 0) {
const allFiltered = await postEngine.getPostsFiltered(filter);
filteredTotal = allFiltered.length;
pageItems = allFiltered.slice(offset, offset + limit);
} else {
const listResult = await postEngine.getAllPosts({ limit, offset });
pageItems = listResult.items;
filteredTotal = listResult.total;
}
const hints = await buildAmbiguityHints(postEngine, category, tags);
const posts = await enrichWithLinks(
pageItems.map(p => ({
id: p.id, title: p.title, slug: p.slug,
status: p.status, categories: p.categories,
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
})),
postEngine,
);
const result: Record<string, unknown> = {
success: true,
count: posts.length,
total: globalTotal,
filteredTotal,
hasMore: offset + limit < filteredTotal,
offset,
limit,
posts,
};
if (hints.length > 0) result.hints = hints;
return result;
},
}),
get_media: tool({
description: 'Get information about a specific media file (image) by its ID.',
inputSchema: z.object({
mediaId: z.string().describe('The unique ID of the media file'),
}),
execute: async ({ mediaId }) => {
const media = await mediaEngine.getMedia(mediaId);
if (!media) return { success: false, error: 'Media not found' };
return {
success: true,
media: {
id: media.id, filename: media.filename,
originalName: media.originalName, mimeType: media.mimeType,
size: media.size, width: media.width, height: media.height,
title: media.title, alt: media.alt, caption: media.caption, tags: media.tags,
createdAt: media.createdAt, updatedAt: media.updatedAt,
},
};
},
}),
list_media: tool({
description: 'List media files in the current project with optional filtering by MIME type, year, month, or tags. Returns paginated results with total count.',
inputSchema: z.object({
mimeTypeFilter: z.string().optional().describe('Filter by MIME type prefix (e.g., "image/")'),
tags: z.array(z.string()).optional().describe('Filter by tags (media must have all specified tags)'),
year: z.number().optional().describe('Filter to media created in this year'),
month: z.number().optional().describe('Filter to media created in this month (1-12). Requires year.'),
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
}),
execute: async ({ mimeTypeFilter, tags, year, month, limit: lim, offset: off }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const hasMediaFilter = year !== undefined || (tags && tags.length > 0);
let mediaList: MediaData[];
if (hasMediaFilter) {
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
if (year !== undefined) mediaFilter.year = year;
if (month !== undefined && year !== undefined) mediaFilter.month = month;
if (tags) mediaFilter.tags = tags;
mediaList = await mediaEngine.getMediaFiltered(mediaFilter);
} else {
mediaList = await mediaEngine.getAllMedia();
}
const totalMedia = mediaList.length;
if (mimeTypeFilter) {
mediaList = mediaList.filter(m => m.mimeType.startsWith(mimeTypeFilter));
}
const filteredTotal = mediaList.length;
const offset = off ?? 0;
const limit = lim ?? 20;
const pageItems = mediaList.slice(offset, offset + limit);
return {
success: true,
count: pageItems.length,
total: totalMedia,
filteredTotal,
hasMore: offset + limit < filteredTotal,
offset,
limit,
media: pageItems.map(m => ({
id: m.id, filename: m.filename,
originalName: m.originalName, mimeType: m.mimeType,
title: m.title, alt: m.alt, tags: m.tags,
})),
};
},
}),
update_post_metadata: tool({
description: 'Update metadata for a blog post (title, excerpt, tags, categories). Does NOT update post content.',
inputSchema: z.object({
postId: z.string().describe('The unique ID of the post to update'),
title: z.string().optional().describe('New title for the post'),
excerpt: z.string().optional().describe('New excerpt/summary for the post'),
tags: z.array(z.string()).optional().describe('New tags for the post'),
categories: z.array(z.string()).optional().describe('New categories for the post'),
}),
execute: async ({ postId, title, excerpt, tags, categories }) => {
const updates: Record<string, unknown> = {};
if (title !== undefined) updates.title = title;
if (excerpt !== undefined) updates.excerpt = excerpt;
if (tags !== undefined) updates.tags = tags;
if (categories !== undefined) updates.categories = categories;
if (Object.keys(updates).length === 0) {
return { success: false, error: 'No updates provided' };
}
await postEngine.updatePost(postId, updates);
return { success: true, message: `Post ${postId} metadata updated successfully` };
},
}),
update_media_metadata: tool({
description: 'Update metadata for a media file (title, alt text, caption, tags).',
inputSchema: z.object({
mediaId: z.string().describe('The unique ID of the media to update'),
title: z.string().optional().describe('New title for display in lists and search results'),
alt: z.string().optional().describe('New alt text for the image'),
caption: z.string().optional().describe('New caption for the image'),
tags: z.array(z.string()).optional().describe('New tags for the media'),
}),
execute: async ({ mediaId, title, alt, caption, tags }) => {
const updates: Record<string, unknown> = {};
if (title !== undefined) updates.title = title;
if (alt !== undefined) updates.alt = alt;
if (caption !== undefined) updates.caption = caption;
if (tags !== undefined) updates.tags = tags;
if (Object.keys(updates).length === 0) {
return { success: false, error: 'No updates provided' };
}
await mediaEngine.updateMedia(mediaId, updates);
return { success: true, message: `Media ${mediaId} metadata updated successfully` };
},
}),
list_tags: tool({
description: 'List all tags used across blog posts, with the count of posts using each tag.',
inputSchema: z.object({}),
execute: async () => {
const tagsWithCounts = await postEngine.getTagsWithCounts();
return {
success: true,
count: tagsWithCounts.length,
tags: tagsWithCounts,
};
},
}),
count_posts: tool({
description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without transferring full post data. Ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.',
inputSchema: z.object({
groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'),
year: z.number().optional().describe('Filter to posts in this year'),
month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'),
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
category: z.string().optional().describe('Filter by category'),
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
}),
execute: async ({ groupBy, year, month, status, category, tags }) => {
if (month !== undefined && year === undefined) {
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
}
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
if (year !== undefined) filter.year = year;
if (month !== undefined) filter.month = month;
if (status) filter.status = status;
if (category) filter.category = category;
if (tags && tags.length > 0) filter.tags = tags;
const result = await postEngine.getPostCounts(groupBy, Object.keys(filter).length > 0 ? filter : undefined);
return {
success: true,
groupCount: result.groups.length,
totalPosts: result.totalPosts,
groups: result.groups,
};
},
}),
list_categories: tool({
description: 'List all categories used across blog posts, with the count of posts in each category.',
inputSchema: z.object({}),
execute: async () => {
const categoriesWithCounts = await postEngine.getCategoriesWithCounts();
return {
success: true,
count: categoriesWithCounts.length,
categories: categoriesWithCounts,
};
},
}),
get_blog_stats: tool({
description: 'Get comprehensive blog statistics: total posts, drafts, published, archived counts, date range, posts per year breakdown, unique tags/categories counts, and total media count. Use this FIRST to understand the full scope of the blog before making queries.',
inputSchema: z.object({}),
execute: async () => {
const stats = await postEngine.getBlogStats();
const mediaList = await mediaEngine.getAllMedia();
return {
success: true,
totalPosts: stats.totalPosts,
draftCount: stats.draftCount,
publishedCount: stats.publishedCount,
archivedCount: stats.archivedCount,
dateRange: stats.oldestPostDate && stats.newestPostDate
? { oldest: stats.oldestPostDate, newest: stats.newestPostDate }
: null,
postsPerYear: stats.postsPerYear,
tagCount: stats.tagCount,
categoryCount: stats.categoryCount,
totalMedia: mediaList.length,
};
},
}),
view_image: tool({
description: 'View an image to analyze its visual content. Returns the actual image for visual inspection. Only works with image files (not PDFs or other media types).',
inputSchema: z.object({
mediaId: z.string().describe('The unique ID of the image to view'),
size: z.enum(['small', 'medium', 'large']).optional().describe('Image size: small (150px), medium (400px, default), large (800px)'),
}),
execute: async ({ mediaId, size: sizeArg }) => {
const size = sizeArg ?? 'medium';
const mediaItem = await mediaEngine.getMedia(mediaId);
if (!mediaItem) {
return { success: false, error: 'Image not found' };
}
if (!mediaItem.mimeType.startsWith('image/')) {
return { success: false, error: `Cannot view this file type: ${mediaItem.mimeType}. Only images are supported.` };
}
const dataUrl = await mediaEngine.getThumbnailDataUrl(mediaId, size);
if (!dataUrl) {
return { success: false, error: 'Thumbnail not available. Try regenerating thumbnails from Settings.' };
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
return {
__isImageResult: true,
success: true,
mediaType: 'image/webp',
base64: base64Data,
metadata: {
id: mediaItem.id,
filename: mediaItem.filename,
originalName: mediaItem.originalName,
width: mediaItem.width,
height: mediaItem.height,
title: mediaItem.title,
alt: mediaItem.alt,
caption: mediaItem.caption,
size,
},
};
},
}),
get_post_backlinks: tool({
description: 'Get all posts that link TO a specific post (backlinks/inbound links). Helpful for understanding how content is interconnected.',
inputSchema: z.object({
postId: z.string().describe('The ID of the post to find backlinks for'),
}),
execute: async ({ postId }) => {
const linkedBy = await postEngine.getLinkedBy(postId);
return {
success: true,
postId,
count: linkedBy.length,
linkedBy: linkedBy.map(p => ({ id: p.id, title: p.title, slug: p.slug })),
};
},
}),
get_post_outlinks: tool({
description: 'Get all posts that a specific post links TO (outbound links). Helpful for understanding content relationships.',
inputSchema: z.object({
postId: z.string().describe('The ID of the post to find outbound links for'),
}),
execute: async ({ postId }) => {
const linksTo = await postEngine.getLinksTo(postId);
return {
success: true,
postId,
count: linksTo.length,
linksTo: linksTo.map(p => ({ id: p.id, title: p.title, slug: p.slug })),
};
},
}),
get_post_media: tool({
description: 'Get all media files linked to a specific post. Returns media explicitly associated with the post (featured images, galleries, etc.).',
inputSchema: z.object({
postId: z.string().describe('The ID of the post to get linked media for'),
}),
execute: async ({ postId }) => {
const linkedMedia = await postMediaEngine.getLinkedMediaDataForPost(postId);
return {
success: true,
postId,
count: linkedMedia.length,
media: linkedMedia.map(link => ({
id: link.media.id,
filename: link.media.filename,
originalName: link.media.originalName,
mimeType: link.media.mimeType,
title: link.media.title,
alt: link.media.alt,
caption: link.media.caption,
width: link.media.width,
height: link.media.height,
sortOrder: link.sortOrder,
})),
};
},
}),
get_media_posts: tool({
description: 'Get all posts that a specific media file is linked to. Helpful for understanding media usage across posts.',
inputSchema: z.object({
mediaId: z.string().describe('The ID of the media file to find linked posts for'),
}),
execute: async ({ mediaId }) => {
const linkedPosts = await postMediaEngine.getLinkedPostsForMedia(mediaId);
const postsData = await Promise.all(
linkedPosts.map(async (link) => {
const post = await postEngine.getPost(link.postId);
return post ? { id: post.id, title: post.title, slug: post.slug, status: post.status } : null;
}),
);
const validPosts = postsData.filter(p => p !== null);
return {
success: true,
mediaId,
count: validPosts.length,
posts: validPosts,
};
},
}),
};
}
/** The return type of createBlogTools — useful for typing tool maps. */
export type BlogTools = ReturnType<typeof createBlogTools>;

512
src/main/engine/ai/chat.ts Normal file
View File

@@ -0,0 +1,512 @@
/**
* ChatService — streaming chat using AI SDK's streamText().
*
* Streaming chat service using AI SDK v6 streamText().
*
* AI SDK handles:
* - SSE parsing, reconnection, abort
* - Provider-specific request/response format (Anthropic Messages, OpenAI Chat Completions)
* - Tool call/result loop (maxSteps)
* - Token usage extraction
*/
import { streamText, generateText, stepCountIs } from 'ai';
import type { ModelMessage, LanguageModelUsage } from 'ai';
import type { BrowserWindow } from 'electron';
import type { ChatEngine, ChatMessageData } from '../ChatEngine';
import { isRenderTool, generateFromToolCall } from '../../a2ui/generator';
import type { A2UIServerMessage } from '../../a2ui/types';
import { ProviderRegistry, detectProvider } from './providers';
import { createBlogTools, type BlogToolDeps } from './blog-tools';
import { createA2UITools } from './a2ui-tools';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ChatCallbacks {
onDelta?: (delta: string) => void;
onToolCall?: (toolCall: { name: string; args: unknown }) => void;
onToolResult?: (result: { name: string; result: unknown }) => void;
onA2UIMessage?: (message: A2UIServerMessage) => void;
onTokenUsage?: (usage: TokenUsageReport) => void;
}
export interface TokenUsageReport {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
totalTokens: number;
cumulativeInputTokens: number;
cumulativeOutputTokens: number;
cumulativeCacheReadTokens: number;
cumulativeCacheWriteTokens: number;
cumulativeTotalTokens: number;
}
export interface SendResult {
success: boolean;
message?: string;
error?: string;
toolCalls?: Array<{ name: string; args: unknown }>;
}
// Maximum tool-call rounds per request
const MAX_TOOL_ROUNDS = 10;
// ---------------------------------------------------------------------------
// Message serialization — DB flat rows ↔ AI SDK messages
// ---------------------------------------------------------------------------
/**
* Convert DB message rows into AI SDK Message[] for `streamText({ messages })`.
* DB stores flat rows: role, content, toolCallId, toolCalls (JSON).
* AI SDK expects structured messages with content parts.
*
* Per Open Questions #3: only user/assistant messages are sent, tool call
* details from previous turns are appended as text annotations.
*/
function dbMessagesToAIMessages(
dbMessages: Pick<ChatMessageData, 'role' | 'content' | 'toolCalls'>[],
): ModelMessage[] {
const messages: ModelMessage[] = [];
for (const msg of dbMessages) {
if (msg.role === 'user') {
messages.push({ role: 'user', content: msg.content || '' });
} else if (msg.role === 'assistant') {
let content = msg.content || '';
// Append tool-call annotation from previous turns
if (msg.toolCalls) {
try {
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>;
if (calls.length > 0) {
const summary = calls
.map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`)
.join('\n');
content += `\n\n[Tools used in this turn:\n${summary}\n]`;
}
} catch {
// Ignore malformed tool call JSON
}
}
messages.push({ role: 'assistant', content });
}
// System and tool messages from DB are not sent — system is passed separately,
// tool results are only used within the same request via maxSteps.
}
return messages;
}
// ---------------------------------------------------------------------------
// System prompt augmentation
// ---------------------------------------------------------------------------
/** Append live blog stats to the system prompt for data-volume awareness. */
async function appendBlogStats(
basePrompt: string,
blogToolDeps: BlogToolDeps,
): Promise<string> {
try {
const stats = await blogToolDeps.postEngine.getBlogStats();
const mediaList = await blogToolDeps.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(', ');
return basePrompt + `
--- 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.`;
} catch {
return basePrompt;
}
}
// ---------------------------------------------------------------------------
// Token estimation (for context truncation)
// ---------------------------------------------------------------------------
function estimateTokens(text: string): number {
return Math.ceil(text.length / 3.5);
}
/**
* Drop oldest user+assistant pairs to fit within context budget.
* Preserves the most recent messages for continuity.
*/
function truncateMessages(
messages: ModelMessage[],
systemPrompt: string,
toolsJson: string,
maxContextTokens: number,
): ModelMessage[] {
const systemTokens = estimateTokens(systemPrompt);
const toolsTokens = estimateTokens(toolsJson);
const responseReserve = 4096;
const availableBudget = maxContextTokens - systemTokens - toolsTokens - responseReserve;
if (availableBudget <= 0) return messages.slice(-1);
const messageTokens = () =>
messages.reduce((sum, m) => sum + estimateTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0);
if (messageTokens() <= availableBudget) return messages;
let truncated = [...messages];
while (truncated.length > 2 && messageTokens.call(null) > availableBudget) {
if (truncated[0].role === 'user') {
truncated = truncated.slice(2); // Drop user + assistant pair
} else {
truncated = truncated.slice(1);
}
}
return truncated;
}
// ---------------------------------------------------------------------------
// ChatService
// ---------------------------------------------------------------------------
export class ChatService {
private chatEngine: ChatEngine;
private providers: ProviderRegistry;
private blogToolDeps: BlogToolDeps;
private getMainWindow: () => BrowserWindow | null;
// Abort controllers per conversation
private abortControllers = new Map<string, AbortController>();
// Cumulative token usage per conversation
private conversationUsage = new Map<string, {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
}>();
constructor(
chatEngine: ChatEngine,
providers: ProviderRegistry,
blogToolDeps: BlogToolDeps,
getMainWindow: () => BrowserWindow | null,
) {
this.chatEngine = chatEngine;
this.providers = providers;
this.blogToolDeps = blogToolDeps;
this.getMainWindow = getMainWindow;
}
/**
* Send a user message, stream the AI response with tool use.
* Send a message in a conversation, streaming the response.
*/
async sendMessage(
conversationId: string,
userMessage: string,
callbacks: ChatCallbacks = {},
): Promise<SendResult> {
try {
// Readiness check
if (!this.providers.isReady()) {
return { success: false, error: 'API key not configured' };
}
// Load conversation
const conversation = await this.chatEngine.getConversation(conversationId);
if (!conversation) {
return { success: false, error: 'Conversation not found' };
}
// Add user message to DB
await this.chatEngine.addMessage({
conversationId,
role: 'user',
content: userMessage,
createdAt: new Date(),
});
// Abort controller
const abortController = new AbortController();
this.abortControllers.set(conversationId, abortController);
const modelId = conversation.model || 'claude-sonnet-4';
const provider = detectProvider(modelId);
// Verify provider key is available
if (!this.providers.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` };
}
// Build system prompt with live blog stats
const systemMessage = conversation.messages.find(m => m.role === 'system');
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
const systemPrompt = await appendBlogStats(basePrompt, this.blogToolDeps);
// Convert DB messages to AI SDK format
const dbMessages = conversation.messages.filter(m => m.role !== 'system');
dbMessages.push({
conversationId,
role: 'user',
content: userMessage,
createdAt: new Date(),
});
const aiMessages = dbMessagesToAIMessages(dbMessages);
// Build tools
const blogTools = createBlogTools(this.blogToolDeps);
const a2uiToolsRaw = createA2UITools();
const allTools = { ...blogTools, ...a2uiToolsRaw };
// Get context window for truncation
const contextWindow = await this.providers.getModelCatalogEngine().getContextWindow(modelId) ?? 150_000;
const truncatedMessages = truncateMessages(
aiMessages,
systemPrompt,
JSON.stringify(Object.keys(allTools)),
contextWindow,
);
// Resolve model
const model = this.providers.resolveModel(modelId);
// Compute turn index for A2UI messages
const turnIndex = dbMessages.filter(m => m.role === 'user').length - 1;
// Track tool calls for response
const allToolCalls: Array<{ name: string; args: unknown }> = [];
// Build Anthropic-specific provider options for cache control
const providerOptions = modelId.startsWith('claude')
? { anthropic: { cacheControl: { type: 'ephemeral' as const } } }
: undefined;
try {
// --- streamText: the AI SDK replaces our entire SSE/accumulator/tool-loop ---
const result = streamText({
model,
system: systemPrompt,
messages: truncatedMessages,
tools: allTools,
stopWhen: stepCountIs(MAX_TOOL_ROUNDS),
abortSignal: abortController.signal,
maxRetries: 3,
providerOptions,
onChunk: ({ chunk }) => {
if (chunk.type === 'text-delta' && callbacks.onDelta) {
callbacks.onDelta(chunk.text);
}
},
onStepFinish: ({ staticToolCalls: stepToolCalls, staticToolResults: stepToolResults }) => {
// Emit tool call/result events for each step
if (stepToolCalls) {
for (const tc of stepToolCalls) {
allToolCalls.push({ name: tc.toolName, args: tc.input });
callbacks.onToolCall?.({ name: tc.toolName, args: tc.input });
}
}
if (stepToolResults) {
for (const tr of stepToolResults) {
const toolName = tr.toolName;
const toolResult = tr.output;
// Handle A2UI render tools
if (isRenderTool(toolName)) {
// Find the matching tool call args
const matchingCall = stepToolCalls?.find(tc => tc.toolName === toolName);
if (matchingCall) {
const a2uiMessages = generateFromToolCall(
conversationId,
toolName,
matchingCall.input as Record<string, unknown>,
);
if (a2uiMessages && callbacks.onA2UIMessage) {
for (const msg of a2uiMessages) {
if (msg.type === 'createSurface') {
msg.metadata = { ...msg.metadata, turnIndex };
}
callbacks.onA2UIMessage(msg);
}
}
}
}
callbacks.onToolResult?.({ name: toolName, result: toolResult });
}
}
},
});
// Consume the stream to completion
const finalResult = await result.response;
// Extract usage from the response
const usage = await result.usage;
this.emitUsage(conversationId, usage, callbacks);
// Get the final text
const fullResponse = await result.text;
// Save assistant response to DB
if (fullResponse) {
await this.chatEngine.addMessage({
conversationId,
role: 'assistant',
content: fullResponse,
toolCalls: allToolCalls.length > 0 ? JSON.stringify(allToolCalls) : undefined,
createdAt: new Date(),
});
}
// Generate title after first user message
const userMsgCount = conversation.messages.filter(m => m.role === 'user').length;
if (userMsgCount === 0 && fullResponse) {
this.generateConversationTitle(conversationId, userMessage).catch(err =>
console.error('[ChatService] Error generating title:', err),
);
}
return {
success: true,
message: fullResponse,
toolCalls: allToolCalls.length > 0 ? allToolCalls : undefined,
};
} catch (error) {
const isAborted = abortController.signal.aborted || (error as Error).message === 'Request cancelled';
if (!isAborted) throw error;
return { success: true, message: '' };
} finally {
this.abortControllers.delete(conversationId);
}
} catch (error) {
console.error('[ChatService] Error sending message:', error);
return { success: false, error: (error as Error).message };
}
}
/** Abort an in-flight request for a conversation. */
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 };
}
/** Abort all in-flight requests. */
async stop(): Promise<void> {
for (const [, controller] of this.abortControllers) {
controller.abort();
}
this.abortControllers.clear();
}
// ---- Private helpers ----
/**
* Generate a short conversation title from the first user message.
* Non-streaming one-shot call using the configured title model.
*/
private async generateConversationTitle(
conversationId: string,
userMessage: string,
): Promise<void> {
try {
let titleModel = await this.chatEngine.getSetting('chat_title_model');
// Fallback chain: setting → haiku → mistral-small
if (!titleModel || !this.providers.isProviderKeySet(detectProvider(titleModel))) {
titleModel = this.providers.getOpencodeKey()
? 'claude-haiku-4-5'
: this.providers.getMistralKey()
? 'mistral-small-latest'
: null;
}
if (!titleModel) return;
const model = this.providers.resolveModel(titleModel);
const { text } = await generateText({
model,
system: '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.',
prompt: `Topic: ${userMessage.substring(0, 100)}`,
maxOutputTokens: 20,
maxRetries: 2,
});
let title = text.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('[ChatService] Error generating title:', error);
}
}
/** Emit per-turn + cumulative token usage. */
private emitUsage(
conversationId: string,
usage: LanguageModelUsage | undefined,
callbacks: ChatCallbacks,
): void {
if (!usage || !callbacks.onTokenUsage) return;
// AI SDK v6 normalizes usage into inputTokens/outputTokens
// Cache tokens are in inputTokenDetails
const inputTokens = usage.inputTokens ?? 0;
const outputTokens = usage.outputTokens ?? 0;
const cacheReadTokens = usage.inputTokenDetails?.cacheReadTokens ?? 0;
const cacheWriteTokens = usage.inputTokenDetails?.cacheWriteTokens ?? 0;
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,
});
}
}

View File

@@ -0,0 +1,347 @@
/**
* Provider registry — single source of truth for AI provider routing.
*
* Two provider sources:
* 1. OpenCode Zen gateway — routes claude* → Anthropic Messages API,
* everything else → OpenAI Chat Completions API
* 2. Mistral direct — uses Mistral's native API
*
* Model listing uses raw HTTP (AI SDK has no listing API).
*
* IMPORTANT: OpenAI SDK v6 defaults to Responses API (/responses).
* OpenCode Zen only supports Chat Completions. Use provider.chat(modelId).
*/
import { customProvider } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { createMistral } from '@ai-sdk/mistral';
import type { LanguageModel, Provider } from 'ai';
import { ModelCatalogEngine } from '../ModelCatalogEngine';
import type { ChatModel } from '../../shared/electronApi';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
export const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
export const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
const MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// ---------------------------------------------------------------------------
// Gateway factory
// ---------------------------------------------------------------------------
/**
* Creates the OpenCode Zen gateway custom provider.
* Routes claude* → Anthropic Messages API, everything else → OpenAI Chat Completions.
*/
export function createOpenCodeGateway(apiKey: string): Provider {
const anthropicProvider = createAnthropic({
baseURL: ZEN_BASE_URL,
apiKey,
});
const openaiProvider = createOpenAI({
baseURL: ZEN_BASE_URL,
apiKey,
});
// Build a ProviderV3 that routes claude* → Anthropic, else → OpenAI Chat Completions
const gatewayRouter: import('@ai-sdk/provider').ProviderV3 = {
specificationVersion: 'v3',
languageModel: (modelId: string) => {
if (modelId.startsWith('claude')) {
return anthropicProvider(modelId);
}
// Use .chat() for Chat Completions — Zen doesn't support Responses API
return openaiProvider.chat(modelId);
},
embeddingModel: () => {
throw new Error('Embeddings not supported via OpenCode gateway');
},
imageModel: () => {
throw new Error('Image models not supported via OpenCode gateway');
},
};
return customProvider({
languageModels: {},
fallbackProvider: gatewayRouter,
});
}
// ---------------------------------------------------------------------------
// Provider detection — shared utility
// ---------------------------------------------------------------------------
/** Determine which provider backend a model ID belongs to. */
export function 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';
}
// ---------------------------------------------------------------------------
// ProviderRegistry — manages keys, providers, model resolution
// ---------------------------------------------------------------------------
export class ProviderRegistry {
private opencodeKey = '';
private mistralKey = '';
private opencodeGateway: Provider | null = null;
private mistralProvider: ReturnType<typeof createMistral> | null = null;
private modelCatalogEngine = new ModelCatalogEngine();
// Model cache
private cachedModels: ChatModel[] | null = null;
private cachedModelsAt = 0;
// ---- Key management ----
setOpencodeKey(key: string): void {
this.opencodeKey = key;
this.opencodeGateway = null; // rebuild on next use
this.invalidateModelCache();
}
getOpencodeKey(): string {
return this.opencodeKey;
}
setMistralKey(key: string): void {
this.mistralKey = key;
this.mistralProvider = null; // rebuild on next use
this.invalidateModelCache();
}
getMistralKey(): string {
return this.mistralKey;
}
/** Check whether at least one provider key is configured. */
isReady(): boolean {
return !!(this.opencodeKey || this.mistralKey);
}
/** Check whether the key for a specific provider is set. */
isProviderKeySet(provider: string): boolean {
if (provider === 'mistral') return !!this.mistralKey;
return !!this.opencodeKey;
}
/** Returns status of all configured providers. */
getProviderStatus(): { opencode: boolean; mistral: boolean } {
return {
opencode: !!this.opencodeKey,
mistral: !!this.mistralKey,
};
}
// ---- Provider resolution ----
/** Resolve a model ID to an AI SDK LanguageModel. */
resolveModel(modelId: string): LanguageModel {
const provider = detectProvider(modelId);
if (provider === 'mistral') {
if (!this.mistralKey) {
throw new Error(`Mistral API key not configured for model '${modelId}'`);
}
if (!this.mistralProvider) {
this.mistralProvider = createMistral({ apiKey: this.mistralKey });
}
return this.mistralProvider(modelId);
}
// Everything else goes through the OpenCode gateway
if (!this.opencodeKey) {
throw new Error(`OpenCode API key not configured for model '${modelId}'`);
}
if (!this.opencodeGateway) {
this.opencodeGateway = createOpenCodeGateway(this.opencodeKey);
}
return this.opencodeGateway.languageModel(modelId);
}
// ---- Model listing (raw HTTP — AI SDK has no listing API) ----
invalidateModelCache(): void {
this.cachedModels = null;
this.cachedModelsAt = 0;
}
/** Get the model catalog engine for context window lookups. */
getModelCatalogEngine(): ModelCatalogEngine {
return this.modelCatalogEngine;
}
/** Get available models across all configured providers (cached 5 min). */
async getAvailableModels(): Promise<ChatModel[]> {
if (this.cachedModels && Date.now() - this.cachedModelsAt < MODEL_CACHE_TTL) {
return this.cachedModels;
}
const allModels: ChatModel[] = [];
let fetched = false;
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
// Fetch OpenCode models
if (this.opencodeKey) {
try {
const models = await this.fetchModelsFromEndpoint(
ZEN_MODELS_URL,
{ Authorization: `Bearer ${this.opencodeKey}`, 'x-api-key': this.opencodeKey },
catalogVision,
catalogNames,
);
allModels.push(...models);
fetched = true;
} catch {
// Fall through
}
}
// Fetch Mistral models
if (this.mistralKey) {
try {
const models = await this.fetchModelsFromEndpoint(
MISTRAL_MODELS_URL,
{ Authorization: `Bearer ${this.mistralKey}` },
catalogVision,
catalogNames,
'mistral', // only keep mistral-family models
);
allModels.push(...models);
fetched = true;
} catch {
// Fall through
}
}
if (fetched && allModels.length > 0) {
this.cachedModels = allModels;
this.cachedModelsAt = Date.now();
return allModels;
}
// Fallback: model catalog DB, filtered by available provider keys
return this.getModelsFromCatalog();
}
/** Validate an OpenCode API key against the models endpoint. */
async validateOpencodeKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
if (!apiKey || apiKey.length < 3) return { isValid: false, models: [] };
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
const headerSets: Record<string, string>[] = [
{ Authorization: `Bearer ${apiKey}` },
{ 'x-api-key': apiKey },
];
for (const headers of headerSets) {
try {
const models = await this.fetchModelsFromEndpoint(
ZEN_MODELS_URL, headers, catalogVision, catalogNames,
);
return { isValid: true, models };
} catch {
// Try next
}
}
return { isValid: false, models: [] };
}
/** Validate a Mistral API key against the Mistral models endpoint. */
async validateMistralKey(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 models = await this.fetchModelsFromEndpoint(
MISTRAL_MODELS_URL,
{ Authorization: `Bearer ${apiKey}` },
catalogVision,
catalogNames,
'mistral',
);
return { isValid: true, models };
} catch {
return { isValid: false, models: [] };
}
}
// ---- Private helpers ----
private async fetchModelsFromEndpoint(
url: string,
headers: Record<string, string>,
catalogVision: Map<string, boolean>,
catalogNames: Map<string, string>,
filterProvider?: string,
): Promise<ChatModel[]> {
const response = await fetch(url, { method: 'GET', headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json() as { data?: Array<{ id: string }> };
if (!data.data || !Array.isArray(data.data)) return [];
let models = data.data;
if (filterProvider) {
models = models.filter(m => detectProvider(m.id) === filterProvider);
}
return models.map(m => ({
id: m.id,
name: catalogNames.get(m.id) ?? m.id,
provider: detectProvider(m.id),
vision: catalogVision.get(m.id) ?? false,
}));
}
private async getCatalogLookups(): Promise<{ vision: Map<string, boolean>; names: Map<string, string> }> {
const vision = new Map<string, boolean>();
const names = new Map<string, string>();
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
}
return { vision, names };
}
private async getModelsFromCatalog(): Promise<ChatModel[]> {
try {
const catalog = await this.modelCatalogEngine.getAll();
if (catalog.length > 0) {
return catalog
.map(m => ({
id: m.id,
name: m.name,
provider: detectProvider(m.id),
vision: m.inputModalities.includes('image'),
}))
.filter(m => this.isProviderKeySet(m.provider));
}
} catch {
// Fall through
}
return [];
}
}

258
src/main/engine/ai/tasks.ts Normal file
View File

@@ -0,0 +1,258 @@
/**
* OneShotTasks — non-streaming AI tasks using generateText().
*
* One-shot AI tasks: taxonomy analysis and image analysis.
* with provider-agnostic AI SDK calls.
*/
import { generateText } from 'ai';
import type { ChatEngine } from '../ChatEngine';
import type { MediaEngine } from '../MediaEngine';
import { ProviderRegistry, detectProvider } from './providers';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TaxonomyAnalysisResult {
success: boolean;
categoryMappings?: Record<string, string>;
tagMappings?: Record<string, string>;
error?: string;
}
export interface ImageAnalysisResult {
success: boolean;
title?: string;
alt?: string;
caption?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Language map for image analysis prompts
// ---------------------------------------------------------------------------
const LANGUAGE_NAMES: Record<string, string> = {
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',
};
// ---------------------------------------------------------------------------
// OneShotTasks
// ---------------------------------------------------------------------------
export class OneShotTasks {
private providers: ProviderRegistry;
private chatEngine: ChatEngine;
private mediaEngine: MediaEngine;
constructor(
providers: ProviderRegistry,
chatEngine: ChatEngine,
mediaEngine: MediaEngine,
) {
this.providers = providers;
this.chatEngine = chatEngine;
this.mediaEngine = mediaEngine;
}
/**
* Analyze taxonomy items from a WordPress import and suggest mappings
* from NEW items to EXISTING items to avoid duplicates.
*/
async analyzeTaxonomy(
categories: Array<{ name: string; slug: string; existsInProject: boolean }>,
tags: Array<{ name: string; slug: string; existsInProject: boolean }>,
modelId: string,
): Promise<TaxonomyAnalysisResult> {
const provider = detectProvider(modelId);
if (!this.providers.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `${providerLabel} API key not set` };
}
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 {
const model = this.providers.resolveModel(modelId);
const { text } = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
maxOutputTokens: 4096,
maxRetries: 2,
});
// Extract and parse JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
return { success: false, error: 'Invalid response format from AI' };
}
const result = JSON.parse(jsonMatch[0]);
// Validate mappings: only new→existing allowed
const validatedCategoryMappings: Record<string, string> = {};
const validatedTagMappings: Record<string, string> = {};
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;
}
}
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;
}
}
return {
success: true,
categoryMappings: validatedCategoryMappings,
tagMappings: validatedTagMappings,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/**
* Analyze an image and generate title, alt text, and caption.
* Uses multimodal input — AI SDK handles the provider-specific format.
*/
async analyzeMediaImage(
mediaId: string,
language: string = 'en',
): Promise<ImageAnalysisResult> {
// Determine model with smart fallback
let modelId = await this.chatEngine.getSetting('chat_image_analysis_model');
if (!modelId || !this.providers.isProviderKeySet(detectProvider(modelId))) {
modelId = this.providers.getOpencodeKey()
? 'claude-sonnet-4-5'
: this.providers.getMistralKey()
? 'mistral-large-latest'
: null;
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
// Get media metadata
const mediaItem = await this.mediaEngine.getMedia(mediaId);
if (!mediaItem) return { success: false, error: 'Media item not found' };
if (!mediaItem.mimeType.startsWith('image/')) {
return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` };
}
// Get thumbnail
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.' };
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const languageName = LANGUAGE_NAMES[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 {
const model = this.providers.resolveModel(modelId);
// AI SDK handles provider-specific multimodal format automatically
const { text } = await generateText({
model,
system: systemPrompt,
messages: [{
role: 'user',
content: [
{ type: 'image', image: `data:image/webp;base64,${base64Data}` },
{ type: 'text', text: 'Analyze and respond with JSON.' },
],
}],
maxOutputTokens: 200,
maxRetries: 2,
});
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) 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) {
return { success: false, error: (error as Error).message };
}
}
}

View File

@@ -29,11 +29,6 @@ export {
type ChatMessageData,
type CreateConversationInput,
} from './ChatEngine';
export {
OpenCodeManager,
type SendMessageOptions,
type SendMessageResult,
} from './OpenCodeManager';
export {
WxrParser,
type WxrData,

View File

@@ -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<number, ToolCallAccumulator>;
}
export interface AnthropicStreamAccumulator {
toolCalls: Map<number, ToolCallAccumulator>;
thinkingBlocks: Map<number, { text: string; signature?: string }>;
}
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<string, unknown>;
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<string, unknown>;
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<T>(
fn: () => Promise<T>,
options: { maxRetries?: number; onRetry?: (attempt: number, error: Error) => void; signal?: AbortSignal } = {},
): Promise<T> {
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<void>((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<string, string>;
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<SSEEvent>;
}> {
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<SSEEvent> = {
[Symbol.asyncIterator]() {
let buffer = '';
let done = false;
let pendingError: Error | null = null;
const eventQueue: SSEEvent[] = [];
let resolveNext: ((value: IteratorResult<SSEEvent>) => 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<IteratorResult<SSEEvent>> {
// 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<IteratorResult<SSEEvent>>((resolve, reject) => {
resolveNext = resolve;
rejectNext = reject;
});
},
return(): Promise<IteratorResult<SSEEvent>> {
// 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();
});
}

View File

@@ -1,18 +1,25 @@
/**
* Chat IPC handlers - AI chat functionality using OpenCode Zen API
* Chat IPC handlers AI chat via AI SDK v6.
*
* Uses ProviderRegistry, ChatService, and OneShotTasks.
*/
import { ipcMain, BrowserWindow } from 'electron';
import { ChatEngine } from '../engine/ChatEngine';
import { OpenCodeManager } from '../engine/OpenCodeManager';
import { SecureKeyStore } from '../engine/SecureKeyStore';
import { ProviderRegistry } from '../engine/ai/providers';
import { ChatService } from '../engine/ai/chat';
import { OneShotTasks } from '../engine/ai/tasks';
import { getDatabase } from '../database';
import type { EngineBundle } from '../engine/EngineBundle';
import type { BlogToolDeps } from '../engine/ai/blog-tools';
let chatEngine: ChatEngine | null = null;
let openCodeManager: OpenCodeManager | null = null;
let secureKeyStore: SecureKeyStore | null = null;
let openCodeManagerInitPromise: Promise<void> | null = null;
let providers: ProviderRegistry | null = null;
let chatService: ChatService | null = null;
let oneShotTasks: OneShotTasks | null = null;
let initPromise: Promise<void> | null = null;
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
let engineBundle: EngineBundle | null = null;
@@ -45,58 +52,66 @@ function getChatEngine(): ChatEngine {
}
/**
* Get or create the OpenCodeManager instance.
* Returns a promise that resolves when the manager is fully initialized
* (including loading the API key from settings).
* Get the ProviderRegistry (lazy-init + load keys from encrypted storage).
*/
async function getOpenCodeManager(): Promise<OpenCodeManager> {
if (!openCodeManager) {
openCodeManager = new OpenCodeManager(
getChatEngine(),
engineBundle!.postEngine,
engineBundle!.mediaEngine,
engineBundle!.postMediaEngine,
() => mainWindowGetter?.() || null
);
function getProviders(): ProviderRegistry {
if (!providers) {
providers = new ProviderRegistry();
}
return providers;
}
// Load API key from encrypted storage
/**
* Get the ChatService (lazy-init).
*/
function getChatService(): ChatService {
if (!chatService) {
const engine = getChatEngine();
const reg = getProviders();
const deps: BlogToolDeps = {
postEngine: engineBundle!.postEngine,
mediaEngine: engineBundle!.mediaEngine,
postMediaEngine: engineBundle!.postMediaEngine,
};
chatService = new ChatService(engine, reg, deps, () => mainWindowGetter?.() || null);
}
return chatService;
}
/**
* Get the OneShotTasks helper (lazy-init).
*/
function getOneShotTasks(): OneShotTasks {
if (!oneShotTasks) {
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine);
}
return oneShotTasks;
}
/**
* Ensure API keys are loaded from encrypted storage exactly once.
*/
async function ensureInitialized(): Promise<void> {
if (!initPromise) {
const reg = getProviders();
const keyStore = getSecureKeyStore();
openCodeManagerInitPromise = (async () => {
// Clean up old plain-text key from settings (pre-keychain storage)
try {
await keyStore.cleanupPlainTextKey('opencode_api_key');
} catch {
// Best-effort cleanup; not critical
}
// Load API key from encrypted storage
initPromise = (async () => {
// Clean up old plain-text key from settings (pre-keychain storage)
try { await keyStore.cleanupPlainTextKey('opencode_api_key'); } catch { /* best-effort */ }
try {
const key = await keyStore.retrieve('opencode_api_key');
if (key) {
openCodeManager!.setApiKey(key);
}
} catch {
// Silently ignore errors loading the key
}
if (key) reg.setOpencodeKey(key);
} catch { /* ignore */ }
// Load Mistral API key from encrypted storage
try {
const mistralKey = await keyStore.retrieve('mistral_api_key');
if (mistralKey) {
openCodeManager!.setMistralApiKey(mistralKey);
}
} catch {
// Silently ignore errors loading the Mistral key
}
if (mistralKey) reg.setMistralKey(mistralKey);
} catch { /* ignore */ }
})();
}
// Always wait for initialization to complete before returning
if (openCodeManagerInitPromise) {
await openCodeManagerInitPromise;
}
return openCodeManager;
await initPromise;
}
/**
@@ -108,13 +123,14 @@ export function registerChatHandlers(): void {
// Check if service is ready
ipcMain.handle('chat:checkReady', async () => {
try {
const manager = await getOpenCodeManager();
const result = await manager.checkReady();
await ensureInitialized();
const reg = getProviders();
const ready = reg.isReady();
return {
ready: result.ready,
error: result.error,
ready,
error: ready ? undefined : 'API key not configured',
backend: 'opencode',
providers: result.providers,
providers: reg.getProviderStatus(),
};
} catch (error) {
console.error('[Chat IPC] Error checking ready:', error);
@@ -125,9 +141,9 @@ export function registerChatHandlers(): void {
// Validate API key
ipcMain.handle('chat:validateApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const result = await manager.validateApiKey(apiKey);
return result;
await ensureInitialized();
const reg = getProviders();
return await reg.validateOpencodeKey(apiKey);
} catch (error) {
console.error('[Chat IPC] Error validating API key:', error);
return { isValid: false, models: [] };
@@ -137,15 +153,16 @@ export function registerChatHandlers(): void {
// Set API key
ipcMain.handle('chat:setApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const previousKey = manager.getApiKey();
manager.setApiKey(apiKey);
await ensureInitialized();
const reg = getProviders();
const previousKey = reg.getOpencodeKey();
reg.setOpencodeKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('opencode_api_key', apiKey);
} catch (storeError) {
manager.setApiKey(previousKey);
reg.setOpencodeKey(previousKey);
throw storeError;
}
@@ -159,10 +176,9 @@ export function registerChatHandlers(): void {
// Get API key (masked)
ipcMain.handle('chat:getApiKey', async () => {
try {
const manager = await getOpenCodeManager();
const key = manager.getApiKey();
await ensureInitialized();
const key = getProviders().getOpencodeKey();
if (!key) return { hasKey: false, maskedKey: '' };
// Mask all but last 4 characters
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
} catch (error) {
@@ -176,9 +192,8 @@ export function registerChatHandlers(): void {
// Validate Mistral API key
ipcMain.handle('chat:validateMistralApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const result = await manager.validateMistralApiKey(apiKey);
return result;
await ensureInitialized();
return await getProviders().validateMistralKey(apiKey);
} catch (error) {
console.error('[Chat IPC] Error validating Mistral API key:', error);
return { isValid: false, models: [] };
@@ -188,15 +203,16 @@ export function registerChatHandlers(): void {
// Set Mistral API key
ipcMain.handle('chat:setMistralApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const previousKey = manager.getMistralApiKey();
manager.setMistralApiKey(apiKey);
await ensureInitialized();
const reg = getProviders();
const previousKey = reg.getMistralKey();
reg.setMistralKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('mistral_api_key', apiKey);
} catch (storeError) {
manager.setMistralApiKey(previousKey);
reg.setMistralKey(previousKey);
throw storeError;
}
@@ -210,8 +226,8 @@ export function registerChatHandlers(): void {
// Get Mistral API key (masked)
ipcMain.handle('chat:getMistralApiKey', async () => {
try {
const manager = await getOpenCodeManager();
const key = manager.getMistralApiKey();
await ensureInitialized();
const key = getProviders().getMistralKey();
if (!key) return { hasKey: false, maskedKey: '' };
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
@@ -276,8 +292,8 @@ export function registerChatHandlers(): void {
// Get available models
ipcMain.handle('chat:getAvailableModels', async () => {
try {
const manager = await getOpenCodeManager();
const models = await manager.getAvailableModels();
await ensureInitialized();
const models = await getProviders().getAvailableModels();
const engine = getChatEngine();
const selectedModel = await engine.getSelectedModel();
return { success: true, models, selectedModel };
@@ -328,11 +344,12 @@ export function registerChatHandlers(): void {
// Refresh model catalog from models.dev (conditional GET with ETag)
ipcMain.handle('chat:refreshModelCatalog', async () => {
try {
const manager = await getOpenCodeManager();
const result = await manager.getModelCatalogEngine().refresh();
await ensureInitialized();
const reg = getProviders();
const result = await reg.getModelCatalogEngine().refresh();
// Invalidate the in-memory model cache so vision/name data
// from the freshly populated catalog is picked up immediately.
manager.invalidateModelCache();
reg.invalidateModelCache();
return result;
} catch (error) {
console.error('[Chat IPC] Error refreshing model catalog:', error);
@@ -343,8 +360,8 @@ export function registerChatHandlers(): void {
// Get all model catalog entries
ipcMain.handle('chat:getModelCatalog', async () => {
try {
const manager = await getOpenCodeManager();
const entries = await manager.getModelCatalogEngine().getAll();
await ensureInitialized();
const entries = await getProviders().getModelCatalogEngine().getAll();
return { success: true, entries };
} catch (error) {
console.error('[Chat IPC] Error getting model catalog:', error);
@@ -423,13 +440,13 @@ export function registerChatHandlers(): void {
// ============ Chat Messaging ============
// Send a message
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => {
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, _metadata?: { surface?: 'tab' | 'sidebar' }) => {
try {
const manager = await getOpenCodeManager();
await ensureInitialized();
const service = getChatService();
const mainWindow = mainWindowGetter?.();
const result = await manager.sendMessage(conversationId, message, {
metadata,
const result = await service.sendMessage(conversationId, message, {
onDelta: (delta) => {
if (mainWindow) {
mainWindow.webContents.send('chat-stream-delta', { conversationId, delta });
@@ -483,8 +500,8 @@ export function registerChatHandlers(): void {
// Abort a running message
ipcMain.handle('chat:abortMessage', async (_, conversationId: string) => {
try {
const manager = await getOpenCodeManager();
return await manager.abortMessage(conversationId);
await ensureInitialized();
return await getChatService().abortMessage(conversationId);
} catch (error) {
console.error('[Chat IPC] Error aborting message:', error);
return { success: false, error: (error as Error).message };
@@ -531,8 +548,8 @@ export function registerChatHandlers(): void {
// Analyze taxonomy items (tags/categories) and suggest mappings
ipcMain.handle('chat:analyzeTaxonomy', async (_, categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => {
try {
const manager = await getOpenCodeManager();
return await manager.analyzeTaxonomy(categories, tags, modelId);
await ensureInitialized();
return await getOneShotTasks().analyzeTaxonomy(categories, tags, modelId);
} catch (error) {
console.error('[Chat IPC] Error analyzing taxonomy:', error);
return { success: false, error: (error as Error).message };
@@ -544,8 +561,8 @@ export function registerChatHandlers(): void {
// Analyze a media image and generate title, alt text, and caption
ipcMain.handle('chat:analyzeMediaImage', async (_, mediaId: string, language?: string) => {
try {
const manager = await getOpenCodeManager();
return await manager.analyzeMediaImage(mediaId, language || 'en');
await ensureInitialized();
return await getOneShotTasks().analyzeMediaImage(mediaId, language || 'en');
} catch (error) {
console.error('[Chat IPC] Error analyzing media image:', error);
return { success: false, error: (error as Error).message };
@@ -571,11 +588,13 @@ export function registerChatHandlers(): void {
* Cleanup chat resources
*/
export async function cleanupChatHandlers(): Promise<void> {
if (openCodeManager) {
await openCodeManager.stop();
openCodeManager = null;
if (chatService) {
await chatService.stop();
chatService = null;
}
openCodeManagerInitPromise = null;
initPromise = null;
providers = null;
oneShotTasks = null;
secureKeyStore = null;
chatEngine = null;
}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { type ReactNode } from 'react';
import Markdown from 'marked-react';
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
@@ -10,7 +10,16 @@ interface A2UIComponentProps {
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
}
const safeRenderer = {
image(src: string, alt: string): ReactNode {
if (/^https?:\/\//i.test(src)) {
return <a href={src} key={src} title={alt}>{alt || src}</a>;
}
return <img src={src} alt={alt} key={src} />;
},
};
export const A2UIText: React.FC<A2UIComponentProps> = ({ component }) => {
const text = String(component.properties.text ?? '');
return <Markdown>{text}</Markdown>;
return <Markdown renderer={safeRenderer}>{text}</Markdown>;
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { type ReactNode } from 'react';
import Markdown from 'marked-react';
import type { ChatMessage } from '../../types/electron';
import type { ChatToolEvent } from '../../navigation/useChatSurfaceState';
@@ -49,6 +49,17 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
onSurfaceDataChange,
currentTurnIndex,
}) => {
// Block external images — CSP only allows self/data/file/blob/bds-media/bds-thumb
const safeRenderer = {
image(src: string, alt: string): ReactNode {
if (/^https?:\/\//i.test(src)) {
// Show alt text as a link instead of trying to load the image
return <a href={src} key={src} title={alt}>{alt || src}</a>;
}
return <img src={src} alt={alt} key={src} />;
},
};
const renderToolMarkers = (events: ChatToolEvent[]) => {
if (events.length === 0) {
return null;
@@ -160,7 +171,7 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
</div>
)}
<div className="chat-message-text">
{message.role === 'assistant' ? <Markdown gfm>{message.content}</Markdown> : message.content}
{message.role === 'assistant' ? <Markdown gfm renderer={safeRenderer}>{message.content}</Markdown> : message.content}
</div>
</div>
</div>
@@ -199,7 +210,7 @@ export const ChatTranscript: React.FC<ChatTranscriptProps> = ({
{showToolMarkers ? renderToolMarkers(toolEvents) : null}
{streamingContent && (
<div className="chat-message-text">
<Markdown gfm>{streamingContent}</Markdown>
<Markdown gfm renderer={safeRenderer}>{streamingContent}</Markdown>
</div>
)}
</div>

View File

@@ -22,7 +22,8 @@ function createMockDeps(): MCPServerDependencies {
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
}),
getMediaEngine: () => ({
getAllMedia: vi.fn().mockResolvedValue([]),

View File

@@ -8,7 +8,7 @@ function createMockPostEngine() {
getAllPosts: vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }),
getPost: vi.fn().mockResolvedValue(null),
searchPosts: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue([]),
searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }),
createPost: vi.fn().mockResolvedValue({
id: 'post-1', title: 'Test', slug: 'test', content: '', status: 'draft',
tags: [], categories: [], createdAt: new Date(), updatedAt: new Date(),
@@ -25,6 +25,7 @@ function createMockPostEngine() {
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
getPostsFiltered: vi.fn().mockResolvedValue([]),
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
};
}
@@ -190,6 +191,11 @@ describe('MCPServer', () => {
expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true);
});
it('registers count_posts tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'count_posts')).toBe(true);
});
it('registers accept_proposal tool', () => {
const mcpServer = server.createMcpServer();
expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true);
@@ -727,7 +733,11 @@ describe('MCPServer', () => {
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.searchPosts).toHaveBeenCalledWith('test');
const parsed = JSON.parse(result.content[0].text);
expect(parsed[0].backlinks).toEqual([{ id: 'px', title: 'Ref', slug: 'ref' }]);
expect(parsed.posts[0].backlinks).toEqual([{ id: 'px', title: 'Ref', slug: 'ref' }]);
expect(parsed.total).toBe(1);
expect(parsed.offset).toBe(0);
expect(parsed.limit).toBe(50);
expect(parsed.hasMore).toBe(false);
});
it('search_posts with query applies offset and limit', async () => {
@@ -737,9 +747,11 @@ describe('MCPServer', () => {
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test', offset: 2, limit: 3 }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(3);
expect(parsed[0].id).toBe('p2');
expect(parsed[2].id).toBe('p4');
expect(parsed.posts).toHaveLength(3);
expect(parsed.posts[0].id).toBe('p2');
expect(parsed.posts[2].id).toBe('p4');
expect(parsed.total).toBe(10);
expect(parsed.hasMore).toBe(true);
});
it('search_posts defaults to limit 50 when not specified', async () => {
@@ -749,7 +761,9 @@ describe('MCPServer', () => {
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ query: 'test' }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(50);
expect(parsed.posts).toHaveLength(50);
expect(parsed.total).toBe(60);
expect(parsed.hasMore).toBe(true);
});
it('search_posts with filters only calls getPostsFiltered and includes backlinks', async () => {
@@ -761,7 +775,9 @@ describe('MCPServer', () => {
const result = await tool.handler({ category: 'tech', status: 'published' }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ categories: ['tech'], status: 'published' });
const parsed = JSON.parse(result.content[0].text);
expect(parsed[0].backlinks).toEqual([]);
expect(parsed.posts[0].backlinks).toEqual([]);
expect(parsed.total).toBe(1);
expect(parsed.hasMore).toBe(false);
expect(mockPostEngine.getLinkedBy).toHaveBeenCalledWith('p2');
});
@@ -772,15 +788,17 @@ describe('MCPServer', () => {
const tool = getTool(mcpServer, 'search_posts');
const result = await tool.handler({ category: 'tech', offset: 3, limit: 2 }, {}) as { content: Array<{ text: string }> };
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(2);
expect(parsed[0].id).toBe('p3');
expect(parsed.posts).toHaveLength(2);
expect(parsed.posts[0].id).toBe('p3');
expect(parsed.total).toBe(10);
expect(parsed.hasMore).toBe(true);
});
it('search_posts with query + filters calls searchPostsFiltered and includes backlinks', async () => {
const combined = [
{ id: 'p1', title: 'TypeScript Guide', categories: ['tech'] },
];
mockPostEngine.searchPostsFiltered.mockResolvedValue(combined);
mockPostEngine.searchPostsFiltered.mockResolvedValue({ posts: combined, total: 1 });
mockPostEngine.getLinkedBy.mockResolvedValue([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
@@ -794,25 +812,30 @@ describe('MCPServer', () => {
expect(mockPostEngine.searchPosts).not.toHaveBeenCalled();
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(parsed).toHaveLength(1);
expect(parsed[0].id).toBe('p1');
expect(parsed[0].backlinks).toEqual([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
expect(parsed.posts).toHaveLength(1);
expect(parsed.posts[0].id).toBe('p1');
expect(parsed.posts[0].backlinks).toEqual([{ id: 'p9', title: 'See Also', slug: 'see-also' }]);
expect(parsed.total).toBe(1);
expect(parsed.hasMore).toBe(false);
});
it('search_posts with query + filters passes pagination to searchPostsFiltered', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([{ id: 'p3', title: 'Result' }]);
mockPostEngine.searchPostsFiltered.mockResolvedValue({ posts: [{ id: 'p3', title: 'Result' }], total: 20 });
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {});
const result = await tool.handler({ query: 'keyword', status: 'published', offset: 5, limit: 10 }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.searchPostsFiltered).toHaveBeenCalledWith(
'keyword',
{ status: 'published' },
{ offset: 5, limit: 10 },
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.total).toBe(20);
expect(parsed.hasMore).toBe(true);
});
it('search_posts with query + multiple filters builds correct filter', async () => {
mockPostEngine.searchPostsFiltered.mockResolvedValue([]);
mockPostEngine.searchPostsFiltered.mockResolvedValue({ posts: [], total: 0 });
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'search_posts');
await tool.handler({ query: 'test', category: 'tech', tags: ['js'], year: 2025, status: 'published' }, {});
@@ -823,6 +846,35 @@ describe('MCPServer', () => {
);
});
// ── count_posts ──────────────────────────────────────────────────
it('count_posts calls getPostCounts with correct args', async () => {
mockPostEngine.getPostCounts.mockResolvedValue({
groups: [
{ month: 1, tag: 'Politik', count: 12 },
{ month: 2, tag: 'Politik', count: 5 },
],
totalPosts: 300,
});
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'count_posts');
const result = await tool.handler({ groupBy: ['month', 'tag'], year: 2004 }, {}) as { content: Array<{ text: string }> };
expect(mockPostEngine.getPostCounts).toHaveBeenCalledWith(
['month', 'tag'],
{ year: 2004 },
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalPosts).toBe(300);
expect(parsed.groups).toHaveLength(2);
});
it('count_posts returns error when month without year', async () => {
const mcpServer = server.createMcpServer();
const tool = getTool(mcpServer, 'count_posts');
const result = await tool.handler({ groupBy: ['tag'], month: 6 }, {}) as { content: Array<{ text: string }>; isError?: boolean };
expect(result.isError).toBe(true);
});
it('draft_post creates a draft and stores proposal', async () => {
const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' };
mockPostEngine.createPost.mockResolvedValue(createdPost);

View File

@@ -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');
});
});
});

View File

@@ -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<typeof createMockPostEngine>, mediaEngine?: ReturnType<typeof createMockMediaEngine>, postMediaEngine?: ReturnType<typeof createMockPostMediaEngine>) {
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<typeof createMockPostEngine>;
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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -2419,9 +2419,9 @@ Published snapshot content`);
});
describe('searchPostsFiltered', () => {
it('should return empty array for empty query', async () => {
it('should return empty result for empty query', async () => {
const result = await postEngine.searchPostsFiltered('', {});
expect(result).toEqual([]);
expect(result).toEqual({ posts: [], total: 0 });
});
it('should use FTS JOIN with posts table to combine search and filters', async () => {
@@ -2432,8 +2432,9 @@ Published snapshot content`);
});
const result = await postEngine.searchPostsFiltered('search term', { status: 'published' });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('p1');
expect(result.posts).toHaveLength(1);
expect(result.posts[0].id).toBe('p1');
expect(result.total).toBe(1);
// Verify SQL includes both MATCH and status filter
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
@@ -2464,8 +2465,9 @@ Published snapshot content`);
});
const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('p1');
expect(result.posts).toHaveLength(1);
expect(result.posts[0].id).toBe('p1');
expect(result.total).toBe(1);
});
it('should apply pagination with offset and limit', async () => {
@@ -2475,9 +2477,123 @@ Published snapshot content`);
mockLocalClient.execute.mockResolvedValueOnce({ rows });
const result = await postEngine.searchPostsFiltered('term', {}, { offset: 1, limit: 2 });
expect(result).toHaveLength(2);
expect(result[0].id).toBe('p1');
expect(result[1].id).toBe('p2');
expect(result.posts).toHaveLength(2);
expect(result.posts[0].id).toBe('p1');
expect(result.posts[1].id).toBe('p2');
expect(result.total).toBe(5);
});
it('should return total count reflecting tag filtering but not pagination', async () => {
const rows = Array.from({ length: 4 }, (_, i) => ({
id: `p${i}`, projectId: 'test-project', title: `Post ${i}`, slug: `post-${i}`, excerpt: null, content: '', status: 'published', author: null, createdAt: new Date(), updatedAt: new Date(), publishedAt: null, tags: i < 3 ? '["js"]' : '["python"]', categories: '[]', filePath: null, version: 1, stemmedTitle: '', stemmedContent: '', language: 'en', translationOfId: null, templateSlug: null,
}));
mockLocalClient.execute.mockResolvedValueOnce({ rows });
const result = await postEngine.searchPostsFiltered('term', { tags: ['js'] }, { offset: 0, limit: 2 });
expect(result.posts).toHaveLength(2);
expect(result.total).toBe(3); // 3 posts have 'js' tag, not 4 total
});
});
describe('getPostCounts', () => {
it('should return empty groups when no posts exist', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
const result = await postEngine.getPostCounts(['year']);
expect(result).toEqual({ groups: [], totalPosts: 0 });
});
it('should group by year', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ g_year: 2024, cnt: 15 },
{ g_year: 2023, cnt: 10 },
],
});
const result = await postEngine.getPostCounts(['year']);
expect(result.groups).toEqual([
{ year: 2024, count: 15 },
{ year: 2023, count: 10 },
]);
expect(result.totalPosts).toBe(25);
});
it('should group by month and tag with year filter', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ g_month: 1, g_tag: 'Politik', cnt: 12 },
{ g_month: 1, g_tag: 'Medien', cnt: 8 },
{ g_month: 2, g_tag: 'Politik', cnt: 5 },
],
});
const result = await postEngine.getPostCounts(['month', 'tag'], { year: 2004 });
expect(result.groups).toEqual([
{ month: 1, tag: 'Politik', count: 12 },
{ month: 1, tag: 'Medien', count: 8 },
{ month: 2, tag: 'Politik', count: 5 },
]);
expect(result.totalPosts).toBe(25);
});
it('should group by category and status', async () => {
mockLocalClient.execute.mockResolvedValueOnce({
rows: [
{ g_category: 'article', g_status: 'published', cnt: 20 },
{ g_category: 'wiki', g_status: 'draft', cnt: 3 },
],
});
const result = await postEngine.getPostCounts(['category', 'status']);
expect(result.groups).toEqual([
{ category: 'article', status: 'published', count: 20 },
{ category: 'wiki', status: 'draft', count: 3 },
]);
});
it('should include year and month filters in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['tag'], { year: 2004, month: 6 });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('json_each');
expect(sql).toContain('group by');
expect(sql).toContain('created_at >=');
expect(sql).toContain('created_at <');
});
it('should include status filter in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['year'], { status: 'published' });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain("status = ?");
});
it('should include category filter in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['month'], { year: 2024, category: 'tech' });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('json_each');
expect(sql).toContain('categories');
});
it('should include tags filter in SQL WHERE', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.getPostCounts(['year'], { tags: ['js', 'react'] });
const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined;
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('json_each');
});
});

View File

@@ -0,0 +1,66 @@
/**
* Unit tests for ai/a2ui-tools.ts — 7 A2UI render tools.
* Execute functions just return { success: true }; actual rendering
* is triggered externally via experimental_onToolCallFinish.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { createA2UITools } from '../../src/main/engine/ai/a2ui-tools';
describe('A2UI Tools — createA2UITools', () => {
let tools: ReturnType<typeof createA2UITools>;
beforeEach(() => {
tools = createA2UITools();
});
const expectedToolNames = [
'render_chart',
'render_table',
'render_form',
'render_card',
'render_metric',
'render_list',
'render_tabs',
];
it('returns all 7 tools', () => {
expect(Object.keys(tools)).toHaveLength(7);
for (const name of expectedToolNames) {
expect(tools).toHaveProperty(name);
}
});
it('all tools have description and inputSchema', () => {
for (const [name, t] of Object.entries(tools)) {
expect(t.description, `${name} missing description`).toBeTruthy();
expect(t.inputSchema, `${name} missing inputSchema`).toBeDefined();
}
});
// Each tool's execute should return { success: true }
describe.each(expectedToolNames)('%s returns { success: true }', (toolName) => {
it('executes successfully', async () => {
const tool = tools[toolName as keyof typeof tools];
// Provide minimal valid input per tool
const inputs: Record<string, unknown> = {
render_chart: { chartType: 'bar', title: 'Test', data: [{ label: 'A', value: 1 }] },
render_table: { title: 'Test', columns: ['Col1'], rows: [['Val1']] },
render_form: { title: 'Test', fields: [{ name: 'f1', label: 'Field 1', type: 'text' }] },
render_card: { title: 'Test', content: 'Body' },
render_metric: { label: 'Test', value: '42' },
render_list: { title: 'Test', items: [{ label: 'Item', value: '1' }] },
render_tabs: {
title: 'Test',
tabs: [{ label: 'Tab1', content: [{ type: 'text', data: 'Hello' }] }],
},
};
const result = await tool.execute!(
inputs[toolName] as never,
{ toolCallId: `tc-${toolName}`, messages: [], abortSignal: new AbortController().signal },
);
expect(result).toEqual({ success: true });
});
});
});

View File

@@ -0,0 +1,769 @@
/**
* Phase 0: AI SDK Integration Validation
*
* Validates that AI SDK works in our Node.js/Electron-compatible runtime:
* - Provider imports and instantiation work
* - createProviderRegistry resolves providerId:modelId correctly
* - customProvider with fallback routes gateway models to correct SDK type
* - baseURL override produces correct request URLs
* - generateText and streamText produce correct shaped request bodies
* - No static model-to-provider maps needed — provider prefix is the routing
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
generateText,
streamText,
tool,
createProviderRegistry,
customProvider,
} from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { createMistral } from '@ai-sdk/mistral';
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Helpers: capture what the SDK would send without hitting a real API
// ---------------------------------------------------------------------------
/**
* Create a mock fetch that captures the request details and returns a
* provider-appropriate response so the SDK doesn't throw.
*/
function createCapturingFetch(providerType: 'anthropic' | 'openai') {
const captured: { url: string; body: any; headers: any }[] = [];
const mockFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const body = init?.body ? JSON.parse(init.body as string) : undefined;
const headers = init?.headers;
captured.push({ url, body, headers });
// Return a minimal valid response for the provider type
if (providerType === 'anthropic') {
return new Response(
JSON.stringify({
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Hello from mock' }],
model: body?.model ?? 'claude-sonnet-4-5',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5 },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}
// OpenAI / Mistral (OpenAI-compatible)
return new Response(
JSON.stringify({
id: 'chatcmpl-test',
object: 'chat.completion',
model: body?.model ?? 'gpt-4.1',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello from mock' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
});
return { mockFetch, captured };
}
// ---------------------------------------------------------------------------
// 1. Provider imports and instantiation
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Provider Imports', () => {
it('creates Anthropic provider with baseURL override', () => {
const provider = createAnthropic({
baseURL: 'https://opencode.ai/zen/v1',
apiKey: 'test-key',
});
expect(provider).toBeDefined();
expect(typeof provider).toBe('function');
});
it('creates OpenAI provider with baseURL override', () => {
const provider = createOpenAI({
baseURL: 'https://opencode.ai/zen/v1',
apiKey: 'test-key',
});
expect(provider).toBeDefined();
expect(typeof provider).toBe('function');
});
it('creates Mistral provider with defaults', () => {
const provider = createMistral({
apiKey: 'test-key',
});
expect(provider).toBeDefined();
expect(typeof provider).toBe('function');
});
it('creates a LanguageModel from Anthropic provider', () => {
const provider = createAnthropic({ apiKey: 'test-key' });
const model = provider('claude-sonnet-4-5');
expect(model).toBeDefined();
expect(model.modelId).toBe('claude-sonnet-4-5');
expect(model.provider).toContain('anthropic');
});
it('creates a LanguageModel from OpenAI provider', () => {
const provider = createOpenAI({ apiKey: 'test-key' });
const model = provider('gpt-4.1');
expect(model).toBeDefined();
expect(model.modelId).toBe('gpt-4.1');
expect(model.provider).toContain('openai');
});
it('creates a LanguageModel from Mistral provider', () => {
const provider = createMistral({ apiKey: 'test-key' });
const model = provider('mistral-large-latest');
expect(model).toBeDefined();
expect(model.modelId).toBe('mistral-large-latest');
expect(model.provider).toContain('mistral');
});
});
// ---------------------------------------------------------------------------
// 2. Provider Registry — providerId:modelId routing
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Provider Registry', () => {
it('resolves models via providerId:modelId prefix', () => {
const registry = createProviderRegistry({
anthropic: createAnthropic({ apiKey: 'k1' }),
openai: createOpenAI({ apiKey: 'k2' }),
mistral: createMistral({ apiKey: 'k3' }),
});
const claude = registry.languageModel('anthropic:claude-sonnet-4-5');
expect(claude.modelId).toBe('claude-sonnet-4-5');
expect(claude.provider).toContain('anthropic');
const gpt = registry.languageModel('openai:gpt-4.1');
expect(gpt.modelId).toBe('gpt-4.1');
expect(gpt.provider).toContain('openai');
const mistral = registry.languageModel('mistral:mistral-large-latest');
expect(mistral.modelId).toBe('mistral-large-latest');
expect(mistral.provider).toContain('mistral');
});
it('throws on unknown provider prefix', () => {
const registry = createProviderRegistry({
anthropic: createAnthropic({ apiKey: 'k1' }),
});
expect(() => registry.languageModel('unknown:model-id')).toThrow();
});
});
// ---------------------------------------------------------------------------
// 3. Gateway pattern — OpenCode Zen as custom provider
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Gateway Custom Provider', () => {
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
/**
* Creates an OpenCode gateway provider that routes models to the correct
* SDK type based on a minimal rule:
* - claude* → Anthropic Messages API
* - everything else → OpenAI Chat Completions API
*
* No static model list needed. The rule is: "Anthropic models use the
* Anthropic API, all others use OpenAI-compatible." This matches
* reality — only Anthropic has a separate Messages API.
*
* IMPORTANT: OpenAI SDK v6 defaults to the Responses API (/responses).
* OpenCode Zen (and most third-party gateways) only support Chat
* Completions (/chat/completions). Use provider.chat(modelId) to get
* a Chat Completions model instead of the default Responses model.
*/
function createOpenCodeGateway(apiKey: string, fetchImpl?: typeof fetch) {
const anthropicProvider = createAnthropic({
baseURL: ZEN_BASE_URL,
apiKey,
...(fetchImpl ? { fetch: fetchImpl } : {}),
});
const openaiProvider = createOpenAI({
baseURL: ZEN_BASE_URL,
apiKey,
...(fetchImpl ? { fetch: fetchImpl } : {}),
});
return customProvider({
// No explicit model list — use fallbackProvider with routing logic
languageModels: {},
fallbackProvider: {
languageModel: (modelId: string) => {
// Minimal routing: only Claude uses Anthropic Messages API
if (modelId.startsWith('claude')) {
return anthropicProvider(modelId);
}
// Use .chat() for Chat Completions — Zen doesn't support Responses API
return openaiProvider.chat(modelId);
},
embeddingModel: () => {
throw new Error('Embeddings not supported via OpenCode gateway');
},
imageModel: () => {
throw new Error('Image models not supported via OpenCode gateway');
},
},
});
}
it('routes claude models to Anthropic provider', () => {
const gateway = createOpenCodeGateway('test-key');
const model = gateway.languageModel('claude-sonnet-4-5');
expect(model.provider).toContain('anthropic');
expect(model.modelId).toBe('claude-sonnet-4-5');
});
it('routes gpt models to OpenAI provider', () => {
const gateway = createOpenCodeGateway('test-key');
const model = gateway.languageModel('gpt-4.1');
expect(model.provider).toContain('openai');
expect(model.modelId).toBe('gpt-4.1');
});
it('routes gemini models to OpenAI provider (OpenAI-compatible via Zen)', () => {
const gateway = createOpenCodeGateway('test-key');
const model = gateway.languageModel('gemini-2.5-pro');
expect(model.provider).toContain('openai');
expect(model.modelId).toBe('gemini-2.5-pro');
});
it('routes o3/o4 models to OpenAI provider', () => {
const gateway = createOpenCodeGateway('test-key');
expect(gateway.languageModel('o3-mini').provider).toContain('openai');
expect(gateway.languageModel('o4-mini').provider).toContain('openai');
});
it('integrates with provider registry', () => {
const registry = createProviderRegistry({
opencode: createOpenCodeGateway('oc-key'),
mistral: createMistral({ apiKey: 'mi-key' }),
});
// Claude through OpenCode gateway
const claude = registry.languageModel('opencode:claude-sonnet-4-5');
expect(claude.provider).toContain('anthropic');
// GPT through OpenCode gateway
const gpt = registry.languageModel('opencode:gpt-4.1');
expect(gpt.provider).toContain('openai');
// Mistral direct
const mistral = registry.languageModel('mistral:mistral-large-latest');
expect(mistral.provider).toContain('mistral');
});
it('supports adding direct providers alongside gateway', () => {
const registry = createProviderRegistry({
opencode: createOpenCodeGateway('oc-key'),
anthropic: createAnthropic({ apiKey: 'direct-anthropic-key' }),
openai: createOpenAI({ apiKey: 'direct-openai-key' }),
mistral: createMistral({ apiKey: 'mi-key' }),
});
// Same model, different providers (gateway vs direct)
const viaGateway = registry.languageModel('opencode:claude-sonnet-4-5');
const viaDirect = registry.languageModel('anthropic:claude-sonnet-4-5');
// Both are Anthropic SDK models but with different baseURLs
expect(viaGateway.provider).toContain('anthropic');
expect(viaDirect.provider).toContain('anthropic');
expect(viaGateway.modelId).toBe('claude-sonnet-4-5');
expect(viaDirect.modelId).toBe('claude-sonnet-4-5');
});
});
// ---------------------------------------------------------------------------
// 4. BaseURL request path verification — Zen gateway compatibility
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Zen BaseURL Request Paths', () => {
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
it('Anthropic provider appends /messages to baseURL', async () => {
const { mockFetch, captured } = createCapturingFetch('anthropic');
const provider = createAnthropic({
baseURL: ZEN_BASE_URL,
apiKey: 'test-key',
fetch: mockFetch,
});
await generateText({
model: provider('claude-sonnet-4-5'),
prompt: 'Hello',
maxRetries: 0,
});
expect(captured.length).toBeGreaterThan(0);
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/messages');
});
it('OpenAI provider.chat() appends /chat/completions to baseURL', async () => {
const { mockFetch, captured } = createCapturingFetch('openai');
const provider = createOpenAI({
baseURL: ZEN_BASE_URL,
apiKey: 'test-key',
fetch: mockFetch,
});
// Use .chat() — the default provider() uses Responses API (/responses)
// which Zen and most third-party gateways don't support
await generateText({
model: provider.chat('gpt-4.1'),
prompt: 'Hello',
maxRetries: 0,
});
expect(captured.length).toBeGreaterThan(0);
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/chat/completions');
});
it('Anthropic provider sends x-api-key header', async () => {
const { mockFetch, captured } = createCapturingFetch('anthropic');
const provider = createAnthropic({
baseURL: ZEN_BASE_URL,
apiKey: 'my-zen-key',
fetch: mockFetch,
});
await generateText({
model: provider('claude-sonnet-4-5'),
prompt: 'Hello',
maxRetries: 0,
});
const headers = captured[0].headers;
// Headers could be a Headers object or plain object
const apiKey =
headers instanceof Headers
? headers.get('x-api-key')
: headers?.['x-api-key'];
expect(apiKey).toBe('my-zen-key');
});
it('OpenAI provider sends Authorization Bearer header', async () => {
const { mockFetch, captured } = createCapturingFetch('openai');
const provider = createOpenAI({
baseURL: ZEN_BASE_URL,
apiKey: 'my-zen-key',
fetch: mockFetch,
});
await generateText({
model: provider.chat('gpt-4.1'),
prompt: 'Hello',
maxRetries: 0,
});
const headers = captured[0].headers;
const auth =
headers instanceof Headers
? headers.get('authorization')
: headers?.['Authorization'] ?? headers?.['authorization'];
expect(auth).toContain('Bearer my-zen-key');
});
});
// ---------------------------------------------------------------------------
// 5. Tool definitions work with AI SDK tool() + Zod
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Tool Definitions', () => {
it('defines a tool with Zod schema and execute function', () => {
const checkTerm = tool({
description: 'Check whether a term exists as a category, tag, or both',
inputSchema: z.object({
term: z.string().describe('The term to look up'),
}),
execute: async ({ term }) => {
return { term, found: true, type: 'category' };
},
});
expect(checkTerm).toBeDefined();
expect(checkTerm.description).toBe(
'Check whether a term exists as a category, tag, or both',
);
});
it('defines a tool with toModelOutput for multimodal results', () => {
const viewImage = tool({
description: 'View an image',
inputSchema: z.object({
media_id: z.number(),
size: z.enum(['small', 'medium', 'large']).default('medium'),
}),
execute: async ({ media_id }) => {
return {
base64: 'iVBORw0KGgo...',
mediaType: 'image/png' as const,
caption: `Image #${media_id}`,
};
},
toModelOutput: ({ output }) => ({
type: 'content' as const,
value: [
{
type: 'image' as const,
data: output.base64,
mediaType: output.mediaType,
},
{ type: 'text' as const, text: output.caption },
],
}),
});
expect(viewImage).toBeDefined();
});
it('sends tools in generateText request body', async () => {
const { mockFetch, captured } = createCapturingFetch('anthropic');
const provider = createAnthropic({
apiKey: 'test-key',
fetch: mockFetch,
});
await generateText({
model: provider('claude-sonnet-4-5'),
prompt: 'Check the term "javascript"',
tools: {
check_term: tool({
description: 'Check a term',
inputSchema: z.object({ term: z.string() }),
execute: async ({ term }) => ({ found: true, term }),
}),
},
maxRetries: 0,
});
expect(captured[0].body.tools).toBeDefined();
expect(captured[0].body.tools.length).toBeGreaterThan(0);
expect(captured[0].body.tools[0].name).toBe('check_term');
});
});
// ---------------------------------------------------------------------------
// 6. Anthropic-specific providerOptions (cache control, etc.)
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Anthropic Provider Options', () => {
it('sends cache control on system message', async () => {
const { mockFetch, captured } = createCapturingFetch('anthropic');
const provider = createAnthropic({
apiKey: 'test-key',
fetch: mockFetch,
});
await generateText({
model: provider('claude-sonnet-4-5'),
messages: [
{
role: 'system',
content: 'You are a blog editor.',
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
},
{ role: 'user', content: 'Hello' },
],
maxRetries: 0,
});
// Anthropic SDK sends system as top-level field or within messages
// The key assertion is that the request succeeds with providerOptions
expect(captured.length).toBeGreaterThan(0);
const body = captured[0].body;
// System with cache_control should appear in the request
expect(body.system).toBeDefined();
const systemBlock = Array.isArray(body.system) ? body.system : [body.system];
const hasCacheControl = systemBlock.some(
(s: any) => s.cache_control?.type === 'ephemeral',
);
expect(hasCacheControl).toBe(true);
});
it('sends cache control on tools', async () => {
const { mockFetch, captured } = createCapturingFetch('anthropic');
const provider = createAnthropic({
apiKey: 'test-key',
fetch: mockFetch,
});
await generateText({
model: provider('claude-sonnet-4-5'),
prompt: 'Hello',
tools: {
check_term: tool({
description: 'Check a term',
inputSchema: z.object({ term: z.string() }),
execute: async ({ term }) => ({ found: true, term }),
providerOptions: {
anthropic: { cacheControl: { type: 'ephemeral' } },
},
}),
},
maxRetries: 0,
});
expect(captured[0].body.tools).toBeDefined();
const toolDef = captured[0].body.tools[0];
expect(toolDef.cache_control?.type).toBe('ephemeral');
});
});
// ---------------------------------------------------------------------------
// 7. generateText produces correct model + usage
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — generateText Response', () => {
it('returns text and usage from Anthropic provider', async () => {
const { mockFetch } = createCapturingFetch('anthropic');
const provider = createAnthropic({
apiKey: 'test-key',
fetch: mockFetch,
});
const result = await generateText({
model: provider('claude-sonnet-4-5'),
prompt: 'Hello',
maxRetries: 0,
});
expect(result.text).toBe('Hello from mock');
expect(result.usage.inputTokens).toBe(10);
expect(result.usage.outputTokens).toBe(5);
});
it('returns text and usage from OpenAI provider (chat mode)', async () => {
const { mockFetch } = createCapturingFetch('openai');
const provider = createOpenAI({
apiKey: 'test-key',
fetch: mockFetch,
});
// Use .chat() for Chat Completions format
const result = await generateText({
model: provider.chat('gpt-4.1'),
prompt: 'Hello',
maxRetries: 0,
});
expect(result.text).toBe('Hello from mock');
expect(result.usage.inputTokens).toBe(10);
expect(result.usage.outputTokens).toBe(5);
});
it('returns text via provider registry resolution', async () => {
const { mockFetch } = createCapturingFetch('anthropic');
const registry = createProviderRegistry({
anthropic: createAnthropic({
apiKey: 'test-key',
fetch: mockFetch,
}),
});
const result = await generateText({
model: registry.languageModel('anthropic:claude-sonnet-4-5'),
prompt: 'Hello',
maxRetries: 0,
});
expect(result.text).toBe('Hello from mock');
});
});
// ---------------------------------------------------------------------------
// 8. Gateway end-to-end: registry → gateway → correct provider → correct URL
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Gateway End-to-End', () => {
const ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
function createOpenCodeGateway(apiKey: string, fetchImpl: typeof fetch) {
const anthropicProvider = createAnthropic({
baseURL: ZEN_BASE_URL,
apiKey,
fetch: fetchImpl,
});
const openaiProvider = createOpenAI({
baseURL: ZEN_BASE_URL,
apiKey,
fetch: fetchImpl,
});
return customProvider({
languageModels: {},
fallbackProvider: {
languageModel: (modelId: string) => {
if (modelId.startsWith('claude')) {
return anthropicProvider(modelId);
}
// Must use .chat() for Chat Completions API (Zen gateway compatible)
return openaiProvider.chat(modelId);
},
embeddingModel: () => {
throw new Error('Not supported');
},
imageModel: () => {
throw new Error('Not supported');
},
},
});
}
it('Claude via gateway hits Zen Anthropic endpoint', async () => {
const { mockFetch, captured } = createCapturingFetch('anthropic');
const registry = createProviderRegistry({
opencode: createOpenCodeGateway('oc-key', mockFetch),
});
await generateText({
model: registry.languageModel('opencode:claude-sonnet-4-5'),
prompt: 'Hello',
maxRetries: 0,
});
expect(captured[0].url).toBe('https://opencode.ai/zen/v1/messages');
expect(captured[0].body.model).toBe('claude-sonnet-4-5');
});
it('GPT via gateway hits Zen OpenAI endpoint', async () => {
const { mockFetch, captured } = createCapturingFetch('openai');
const registry = createProviderRegistry({
opencode: createOpenCodeGateway('oc-key', mockFetch),
});
await generateText({
model: registry.languageModel('opencode:gpt-4.1'),
prompt: 'Hello',
maxRetries: 0,
});
expect(captured[0].url).toBe(
'https://opencode.ai/zen/v1/chat/completions',
);
expect(captured[0].body.model).toBe('gpt-4.1');
});
it('Gemini via gateway hits Zen OpenAI endpoint', async () => {
const { mockFetch, captured } = createCapturingFetch('openai');
const registry = createProviderRegistry({
opencode: createOpenCodeGateway('oc-key', mockFetch),
});
await generateText({
model: registry.languageModel('opencode:gemini-2.5-pro'),
prompt: 'Hello',
maxRetries: 0,
});
expect(captured[0].url).toBe(
'https://opencode.ai/zen/v1/chat/completions',
);
expect(captured[0].body.model).toBe('gemini-2.5-pro');
});
it('Mistral via direct provider (not gateway)', async () => {
const { mockFetch: anthropicFetch } = createCapturingFetch('anthropic');
const { mockFetch: mistralFetch, captured: mistralCaptured } =
createCapturingFetch('openai'); // Mistral uses OpenAI-compat format
const registry = createProviderRegistry({
opencode: createOpenCodeGateway('oc-key', anthropicFetch),
mistral: createMistral({
apiKey: 'mi-key',
fetch: mistralFetch,
}),
});
await generateText({
model: registry.languageModel('mistral:mistral-large-latest'),
prompt: 'Hello',
maxRetries: 0,
});
expect(mistralCaptured[0].url).toBe(
'https://api.mistral.ai/v1/chat/completions',
);
expect(mistralCaptured[0].body.model).toBe('mistral-large-latest');
});
});
// ---------------------------------------------------------------------------
// 9. No static model map: the routing rule
// ---------------------------------------------------------------------------
describe('AI SDK Phase 0 — Model Routing Without Static Maps', () => {
it('the only routing rule is: claude* → Anthropic API, else → OpenAI API', () => {
// This test documents the design decision. There is no model registry or
// regex map. The gateway provider uses a single if/else to pick the SDK.
// This is correct because only Anthropic has a separate Messages API;
// every other model family (OpenAI, Google, Cohere, etc.) uses
// OpenAI-compatible Chat Completions endpoints.
const anthropicModels = [
'claude-sonnet-4-5',
'claude-opus-4-1',
'claude-haiku-4-5',
'claude-3-7-sonnet-20250219',
];
const openaiCompatModels = [
'gpt-4.1',
'gpt-4o',
'o3-mini',
'o4-mini',
'gemini-2.5-pro',
'gemini-2.5-flash',
'command-r-plus',
'some-future-model-xyz',
];
function routeModel(modelId: string): 'anthropic' | 'openai-compat' {
return modelId.startsWith('claude') ? 'anthropic' : 'openai-compat';
}
for (const m of anthropicModels) {
expect(routeModel(m)).toBe('anthropic');
}
for (const m of openaiCompatModels) {
expect(routeModel(m)).toBe('openai-compat');
}
});
});

View File

@@ -0,0 +1,494 @@
/**
* Phase 2: Provider registry, ChatService, and OneShotTasks tests.
*
* Tests exercise the real implementation classes with mocked fetch/engines.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ProviderRegistry,
createOpenCodeGateway,
detectProvider,
} from '../../src/main/engine/ai/providers';
import { OneShotTasks } from '../../src/main/engine/ai/tasks';
import { ChatService } from '../../src/main/engine/ai/chat';
import type { BlogToolDeps } from '../../src/main/engine/ai/blog-tools';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMockChatEngine() {
return {
getConversation: vi.fn(),
addMessage: vi.fn(),
getMessages: vi.fn().mockResolvedValue([]),
getSelectedModel: vi.fn().mockResolvedValue('claude-sonnet-4'),
getDefaultSystemPrompt: vi.fn().mockResolvedValue('You are a helpful assistant.'),
getSetting: vi.fn().mockResolvedValue(null),
setSetting: vi.fn(),
updateConversation: vi.fn(),
deleteConversation: vi.fn(),
createConversation: vi.fn(),
clearMessages: vi.fn(),
setDefaultSystemPrompt: vi.fn(),
setSelectedModel: vi.fn(),
getRecentConversations: vi.fn().mockResolvedValue([]),
} as any;
}
function createMockMediaEngine() {
return {
getMedia: vi.fn(),
getAllMedia: vi.fn().mockResolvedValue([]),
getMediaFiltered: vi.fn(),
updateMedia: vi.fn(),
getThumbnailDataUrl: vi.fn(),
} as any;
}
function createMockBlogToolDeps(): BlogToolDeps {
return {
postEngine: {
getPost: vi.fn(),
getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(),
getPostCounts: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
updatePost: vi.fn(),
getBlogStats: vi.fn().mockResolvedValue({
totalPosts: 0, publishedCount: 0, draftCount: 0, archivedCount: 0,
tagCount: 0, categoryCount: 0, postsPerYear: {},
}),
getDashboardStats: vi.fn(),
},
mediaEngine: createMockMediaEngine(),
postMediaEngine: {
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
},
};
}
// =========================================================================
// detectProvider()
// =========================================================================
describe('detectProvider', () => {
it('detects Anthropic models', () => {
expect(detectProvider('claude-sonnet-4')).toBe('anthropic');
expect(detectProvider('claude-haiku-4-5')).toBe('anthropic');
expect(detectProvider('Claude-3-Opus')).toBe('anthropic');
});
it('detects OpenAI models', () => {
expect(detectProvider('gpt-4o')).toBe('openai');
expect(detectProvider('o3-mini')).toBe('openai');
expect(detectProvider('o4-mini')).toBe('openai');
});
it('detects Google models', () => {
expect(detectProvider('gemini-pro')).toBe('google');
expect(detectProvider('gemini-2.5-flash')).toBe('google');
});
it('detects Mistral models', () => {
expect(detectProvider('mistral-large-latest')).toBe('mistral');
expect(detectProvider('mistral-small-latest')).toBe('mistral');
expect(detectProvider('ministral-8b-latest')).toBe('mistral');
expect(detectProvider('codestral-latest')).toBe('mistral');
expect(detectProvider('pixtral-large-latest')).toBe('mistral');
expect(detectProvider('devstral-latest')).toBe('mistral');
});
it('returns other for unknown models', () => {
expect(detectProvider('llama3-70b')).toBe('other');
expect(detectProvider('some-model')).toBe('other');
});
});
// =========================================================================
// ProviderRegistry
// =========================================================================
describe('ProviderRegistry', () => {
let registry: ProviderRegistry;
beforeEach(() => {
registry = new ProviderRegistry();
});
describe('key management', () => {
it('starts with no keys and isReady() false', () => {
expect(registry.isReady()).toBe(false);
expect(registry.getOpencodeKey()).toBe('');
expect(registry.getMistralKey()).toBe('');
});
it('isReady() returns true when OpenCode key is set', () => {
registry.setOpencodeKey('test-key');
expect(registry.isReady()).toBe(true);
});
it('isReady() returns true when only Mistral key is set', () => {
registry.setMistralKey('test-mistral');
expect(registry.isReady()).toBe(true);
});
it('getProviderStatus() reports both providers', () => {
expect(registry.getProviderStatus()).toEqual({ opencode: false, mistral: false });
registry.setOpencodeKey('test');
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: false });
registry.setMistralKey('test2');
expect(registry.getProviderStatus()).toEqual({ opencode: true, mistral: true });
});
it('isProviderKeySet() checks per-provider', () => {
expect(registry.isProviderKeySet('anthropic')).toBe(false);
expect(registry.isProviderKeySet('mistral')).toBe(false);
registry.setOpencodeKey('test');
expect(registry.isProviderKeySet('anthropic')).toBe(true); // routed via OpenCode
expect(registry.isProviderKeySet('openai')).toBe(true); // routed via OpenCode
expect(registry.isProviderKeySet('mistral')).toBe(false);
});
});
describe('resolveModel', () => {
it('throws when OpenCode key is missing for a claude model', () => {
expect(() => registry.resolveModel('claude-sonnet-4')).toThrow('OpenCode API key not configured');
});
it('throws when Mistral key is missing for a mistral model', () => {
expect(() => registry.resolveModel('mistral-large-latest')).toThrow('Mistral API key not configured');
});
it('resolves a claude model when OpenCode key is set', () => {
registry.setOpencodeKey('test-key');
const model = registry.resolveModel('claude-sonnet-4');
expect(model).toBeDefined();
expect(model.modelId).toContain('claude-sonnet-4');
});
it('resolves an OpenAI model when OpenCode key is set', () => {
registry.setOpencodeKey('test-key');
const model = registry.resolveModel('gpt-4o');
expect(model).toBeDefined();
expect(model.modelId).toContain('gpt-4o');
});
it('resolves a Mistral model when Mistral key is set', () => {
registry.setMistralKey('test-key');
const model = registry.resolveModel('mistral-large-latest');
expect(model).toBeDefined();
expect(model.modelId).toContain('mistral-large-latest');
});
});
describe('model cache invalidation', () => {
it('invalidates cache when OpenCode key changes', () => {
registry.setOpencodeKey('key1');
// Access internal cache state via invalidation side effect
registry.invalidateModelCache();
// No error — cache was invalidated
});
});
describe('validateOpencodeKey()', () => {
it('rejects short keys immediately', async () => {
const result = await registry.validateOpencodeKey('ab');
expect(result).toEqual({ isValid: false, models: [] });
});
it('validates against models endpoint', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: 'claude-sonnet-4' },
{ id: 'gpt-4o' },
],
}),
});
try {
const result = await registry.validateOpencodeKey('valid-test-key-1234');
expect(result.isValid).toBe(true);
expect(result.models.length).toBe(2);
expect(result.models[0].id).toBe('claude-sonnet-4');
expect(result.models[0].provider).toBe('anthropic');
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('validateMistralKey()', () => {
it('rejects short keys', async () => {
const result = await registry.validateMistralKey('x');
expect(result).toEqual({ isValid: false, models: [] });
});
});
});
// =========================================================================
// createOpenCodeGateway
// =========================================================================
describe('createOpenCodeGateway', () => {
it('creates a provider that resolves language models', () => {
const gateway = createOpenCodeGateway('test-api-key');
expect(gateway).toBeDefined();
// Try resolving a claude model — should not throw
const model = gateway.languageModel('claude-sonnet-4');
expect(model).toBeDefined();
expect(model.modelId).toContain('claude-sonnet-4');
});
it('routes non-claude models to OpenAI chat provider', () => {
const gateway = createOpenCodeGateway('test-api-key');
const model = gateway.languageModel('gpt-4o');
expect(model).toBeDefined();
expect(model.modelId).toContain('gpt-4o');
});
});
// =========================================================================
// ChatService
// =========================================================================
describe('ChatService', () => {
let chatEngine: any;
let registry: ProviderRegistry;
let deps: BlogToolDeps;
let service: ChatService;
beforeEach(() => {
chatEngine = createMockChatEngine();
registry = new ProviderRegistry();
deps = createMockBlogToolDeps();
service = new ChatService(chatEngine, registry, deps, () => null);
});
it('returns error when no API key is configured', async () => {
const result = await service.sendMessage('conv-1', 'hello');
expect(result.success).toBe(false);
expect(result.error).toContain('API key not configured');
});
it('returns error when conversation not found', async () => {
registry.setOpencodeKey('test-key');
chatEngine.getConversation.mockResolvedValue(null);
const result = await service.sendMessage('conv-1', 'hello');
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('returns error when model provider key is missing', async () => {
registry.setOpencodeKey('test-key');
chatEngine.getConversation.mockResolvedValue({
id: 'conv-1',
model: 'mistral-large-latest', // requires Mistral key
messages: [],
});
const result = await service.sendMessage('conv-1', 'hello');
expect(result.success).toBe(false);
expect(result.error).toContain('Mistral');
});
describe('abortMessage()', () => {
it('returns error for non-existent conversation', async () => {
const result = await service.abortMessage('nonexistent');
expect(result.success).toBe(false);
expect(result.error).toContain('No active request');
});
});
describe('stop()', () => {
it('clears all abort controllers without error', async () => {
await expect(service.stop()).resolves.not.toThrow();
});
});
});
// =========================================================================
// OneShotTasks
// =========================================================================
describe('OneShotTasks', () => {
let chatEngine: any;
let mediaEngine: any;
let registry: ProviderRegistry;
let tasks: OneShotTasks;
beforeEach(() => {
chatEngine = createMockChatEngine();
mediaEngine = createMockMediaEngine();
registry = new ProviderRegistry();
tasks = new OneShotTasks(registry, chatEngine, mediaEngine);
});
describe('analyzeTaxonomy()', () => {
it('returns error if provider key not set', async () => {
const result = await tasks.analyzeTaxonomy(
[{ name: 'Tech', slug: 'tech', existsInProject: false }],
[],
'claude-sonnet-4',
);
expect(result.success).toBe(false);
expect(result.error).toContain('OpenCode');
});
it('returns error for mistral model without mistral key', async () => {
registry.setOpencodeKey('test');
const result = await tasks.analyzeTaxonomy(
[],
[],
'mistral-large-latest',
);
expect(result.success).toBe(false);
expect(result.error).toContain('Mistral');
});
it('validates mappings: rejects new→new mappings', async () => {
registry.setOpencodeKey('test-key');
// Mock the generateText call via fetch
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: JSON.stringify({
categoryMappings: { 'New Cat': 'Other New Cat' },
tagMappings: { 'New Tag': 'Existing Tag' },
})}],
model: 'claude-sonnet-4',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
try {
const result = await tasks.analyzeTaxonomy(
[
{ name: 'New Cat', slug: 'new-cat', existsInProject: false },
{ name: 'Other New Cat', slug: 'other-new-cat', existsInProject: false },
],
[
{ name: 'New Tag', slug: 'new-tag', existsInProject: false },
{ name: 'Existing Tag', slug: 'existing-tag', existsInProject: true },
],
'claude-sonnet-4',
);
expect(result.success).toBe(true);
// new→new mapping filtered out
expect(result.categoryMappings).toEqual({});
// new→existing mapping kept
expect(result.tagMappings).toEqual({ 'New Tag': 'Existing Tag' });
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('analyzeMediaImage()', () => {
it('returns error when no API key is set', async () => {
chatEngine.getSetting.mockResolvedValue(null);
const result = await tasks.analyzeMediaImage('media-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
it('returns error for non-image media', async () => {
registry.setOpencodeKey('test-key');
chatEngine.getSetting.mockResolvedValue('claude-sonnet-4');
mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
mimeType: 'application/pdf',
filename: 'doc.pdf',
});
const result = await tasks.analyzeMediaImage('media-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('Only images');
});
it('returns error when media not found', async () => {
registry.setOpencodeKey('test-key');
chatEngine.getSetting.mockResolvedValue('claude-sonnet-4');
mediaEngine.getMedia.mockResolvedValue(null);
const result = await tasks.analyzeMediaImage('media-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('returns error when thumbnail not available', async () => {
registry.setOpencodeKey('test-key');
chatEngine.getSetting.mockResolvedValue('claude-sonnet-4');
mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
mimeType: 'image/jpeg',
filename: 'photo.jpg',
});
mediaEngine.getThumbnailDataUrl.mockResolvedValue(null);
const result = await tasks.analyzeMediaImage('media-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('thumbnail');
});
it('falls back to claude-sonnet-4-5 when no image analysis model is configured', async () => {
registry.setOpencodeKey('test-key');
chatEngine.getSetting.mockResolvedValue(null);
mediaEngine.getMedia.mockResolvedValue({
id: 'media-1',
mimeType: 'image/jpeg',
filename: 'photo.jpg',
});
mediaEngine.getThumbnailDataUrl.mockResolvedValue('data:image/webp;base64,abc123');
// Verify the method selects the right model by checking it attempts
// to call the resolver (which hits the network). We mock fetch to
// return a minimal Anthropic response.
const originalFetch = globalThis.fetch;
const jsonPayload = '{"title": "Sunset Beach", "alt": "Orange sunset over ocean", "caption": "A stunning sunset at the beach"}';
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: jsonPayload }],
model: 'claude-sonnet-4-5',
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 30, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
);
try {
const result = await tasks.analyzeMediaImage('media-1', 'en');
if (!result.success) {
// Image analysis with real AI SDK may fail on response parsing in tests.
// Verify we at least attempted the right provider call.
const calls = (globalThis.fetch as any).mock.calls;
expect(calls.length).toBeGreaterThan(0);
// Find the API call (not image download calls)
const apiCall = calls.find((c: any[]) =>
typeof c[0] === 'string' && c[0].includes('/messages'),
);
// Should have attempted to call Anthropic Messages API via Zen gateway
expect(apiCall).toBeDefined();
} else {
expect(result.title).toBe('Sunset Beach');
expect(result.alt).toBe('Orange sunset over ocean');
}
} finally {
globalThis.fetch = originalFetch;
}
});
});
});

View File

@@ -0,0 +1,713 @@
/**
* Unit tests for ai/blog-tools.ts — 16 blog data tools.
* Tests exercise the real createBlogTools() with mocked engine dependencies.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createBlogTools, buildAmbiguityHints, type BlogToolDeps } from '../../src/main/engine/ai/blog-tools';
// ---------------------------------------------------------------------------
// Mock factory — creates a BlogToolDeps with all methods stubbed
// ---------------------------------------------------------------------------
function createMockDeps(): BlogToolDeps {
return {
postEngine: {
getPost: vi.fn(),
getAllPosts: vi.fn(),
getPostsFiltered: vi.fn(),
searchPostsFiltered: vi.fn(),
getPostCounts: vi.fn(),
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
getLinkedBy: vi.fn().mockResolvedValue([]),
getLinksTo: vi.fn().mockResolvedValue([]),
updatePost: vi.fn(),
getBlogStats: vi.fn(),
getDashboardStats: vi.fn(),
},
mediaEngine: {
getMedia: vi.fn(),
getAllMedia: vi.fn(),
getMediaFiltered: vi.fn(),
updateMedia: vi.fn(),
getThumbnailDataUrl: vi.fn(),
},
postMediaEngine: {
getLinkedMediaDataForPost: vi.fn().mockResolvedValue([]),
getLinkedPostsForMedia: vi.fn().mockResolvedValue([]),
},
};
}
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
const samplePost = {
id: 'post-1', projectId: 'proj-1', title: 'Hello World', slug: 'hello-world',
excerpt: 'A first post', content: '# Hello\n\nWorld', status: 'published' as const,
author: 'gb', createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
tags: ['intro'], categories: ['article'],
};
const sampleMedia = {
id: 'media-1', filename: 'photo.webp', originalName: 'photo.jpg',
mimeType: 'image/webp', size: 12345, width: 800, height: 600,
title: 'Photo', alt: 'A photo', caption: 'Nice photo',
author: 'gb', createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
tags: ['landscape'],
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('Blog Tools — createBlogTools', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns all 17 tools', () => {
const names = Object.keys(tools);
expect(names).toHaveLength(17);
expect(names).toContain('check_term');
expect(names).toContain('search_posts');
expect(names).toContain('read_post');
expect(names).toContain('list_posts');
expect(names).toContain('get_media');
expect(names).toContain('list_media');
expect(names).toContain('update_post_metadata');
expect(names).toContain('update_media_metadata');
expect(names).toContain('list_tags');
expect(names).toContain('list_categories');
expect(names).toContain('count_posts');
expect(names).toContain('get_blog_stats');
expect(names).toContain('view_image');
expect(names).toContain('get_post_backlinks');
expect(names).toContain('get_post_outlinks');
expect(names).toContain('get_post_media');
expect(names).toContain('get_media_posts');
});
it('each tool has description and inputSchema', () => {
for (const [name, t] of Object.entries(tools)) {
expect(t.description, `${name} missing description`).toBeTruthy();
expect(t.inputSchema, `${name} missing inputSchema`).toBeDefined();
}
});
});
// ---------------------------------------------------------------------------
// check_term
// ---------------------------------------------------------------------------
describe('Blog Tools — check_term', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('finds a term as both category and tag', async () => {
vi.mocked(deps.postEngine.getCategoriesWithCounts).mockResolvedValueOnce([
{ category: 'Travel', count: 5 },
]);
vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'travel', count: 3 },
]);
const result = await tools.check_term.execute!({ term: 'travel' }, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toEqual({
success: true,
term: 'travel',
asCategory: true,
categoryPostCount: 5,
asTag: true,
tagPostCount: 3,
});
});
it('returns false when term not found', async () => {
const result = await tools.check_term.execute!({ term: 'nonexistent' }, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({
success: true,
asCategory: false,
categoryPostCount: 0,
asTag: false,
tagPostCount: 0,
});
});
});
// ---------------------------------------------------------------------------
// search_posts
// ---------------------------------------------------------------------------
describe('Blog Tools — search_posts', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns error when month without year', async () => {
const result = await tools.search_posts.execute!(
{ query: 'test', month: 3 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
it('calls searchPostsFiltered with correct filter', async () => {
vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce({ posts: [samplePost], total: 5 });
const result = await tools.search_posts.execute!(
{ query: 'hello', category: 'article', year: 2025 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.searchPostsFiltered).toHaveBeenCalledWith(
'hello',
{ categories: ['article'], year: 2025 },
{ offset: 0, limit: 10 },
);
expect(result).toMatchObject({ success: true, count: 1, totalMatches: 5, hasMore: false });
});
it('includes ambiguity hints when category also exists as tag', async () => {
vi.mocked(deps.postEngine.searchPostsFiltered).mockResolvedValueOnce({ posts: [], total: 0 });
vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'article', count: 2 },
]);
const result = await tools.search_posts.execute!(
{ query: 'test', category: 'article' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toHaveProperty('hints');
expect((result as Record<string, unknown>).hints).toEqual(
expect.arrayContaining([expect.stringContaining('also exists as a tag')]),
);
});
});
// ---------------------------------------------------------------------------
// read_post
// ---------------------------------------------------------------------------
describe('Blog Tools — read_post', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns post with backlinks and outlinks', async () => {
vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(samplePost);
vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([
{ id: 'post-2', title: 'Related', slug: 'related' },
]);
const result = await tools.read_post.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({
success: true,
post: {
id: 'post-1',
title: 'Hello World',
content: '# Hello\n\nWorld',
backlinks: [{ id: 'post-2', title: 'Related' }],
},
});
});
it('returns error for nonexistent post', async () => {
vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(null);
const result = await tools.read_post.execute!(
{ postId: 'nope' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'Post not found' });
});
});
// ---------------------------------------------------------------------------
// list_posts
// ---------------------------------------------------------------------------
describe('Blog Tools — list_posts', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns error when month without year', async () => {
const result = await tools.list_posts.execute!(
{ month: 6 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
it('uses getAllPosts when no filters', async () => {
vi.mocked(deps.postEngine.getDashboardStats).mockResolvedValueOnce({
totalPosts: 100, draftCount: 10, publishedCount: 85, archivedCount: 5,
});
vi.mocked(deps.postEngine.getAllPosts).mockResolvedValueOnce({
items: [samplePost], total: 100, hasMore: true,
});
const result = await tools.list_posts.execute!(
{},
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getAllPosts).toHaveBeenCalledWith({ limit: 20, offset: 0 });
expect(result).toMatchObject({ success: true, total: 100, filteredTotal: 100 });
});
it('uses getPostsFiltered when filters present', async () => {
vi.mocked(deps.postEngine.getDashboardStats).mockResolvedValueOnce({
totalPosts: 100, draftCount: 10, publishedCount: 85, archivedCount: 5,
});
vi.mocked(deps.postEngine.getPostsFiltered).mockResolvedValueOnce([samplePost]);
const result = await tools.list_posts.execute!(
{ status: 'published', year: 2025 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getPostsFiltered).toHaveBeenCalledWith({
status: 'published', year: 2025,
});
expect(result).toMatchObject({ success: true, filteredTotal: 1 });
});
});
// ---------------------------------------------------------------------------
// get_media / list_media
// ---------------------------------------------------------------------------
describe('Blog Tools — get_media', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns media metadata', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia);
const result = await tools.get_media.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, media: { id: 'media-1', filename: 'photo.webp' } });
});
it('returns error for missing media', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(null);
const result = await tools.get_media.execute!(
{ mediaId: 'nope' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'Media not found' });
});
});
describe('Blog Tools — list_media', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns all media when no filters', async () => {
vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia]);
const result = await tools.list_media.execute!(
{},
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, total: 1 });
});
it('filters by MIME type', async () => {
const pdfMedia = { ...sampleMedia, id: 'media-2', mimeType: 'application/pdf' };
vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia, pdfMedia]);
const result = await tools.list_media.execute!(
{ mimeTypeFilter: 'image/' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, filteredTotal: 1, total: 2 });
});
it('uses getMediaFiltered when year is provided', async () => {
vi.mocked(deps.mediaEngine.getMediaFiltered).mockResolvedValueOnce([sampleMedia]);
await tools.list_media.execute!(
{ year: 2025 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.mediaEngine.getMediaFiltered).toHaveBeenCalledWith({ year: 2025 });
});
it('returns error when month without year', async () => {
const result = await tools.list_media.execute!(
{ month: 3 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
});
// ---------------------------------------------------------------------------
// update_post_metadata / update_media_metadata
// ---------------------------------------------------------------------------
describe('Blog Tools — update_post_metadata', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('calls updatePost with provided fields', async () => {
await tools.update_post_metadata.execute!(
{ postId: 'post-1', title: 'New Title', tags: ['updated'] },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { title: 'New Title', tags: ['updated'] });
});
it('returns error when no updates provided', async () => {
const result = await tools.update_post_metadata.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'No updates provided' });
});
});
describe('Blog Tools — update_media_metadata', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('calls updateMedia with provided fields', async () => {
await tools.update_media_metadata.execute!(
{ mediaId: 'media-1', alt: 'New alt', tags: ['nature'] },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { alt: 'New alt', tags: ['nature'] });
});
it('returns error when no updates provided', async () => {
const result = await tools.update_media_metadata.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: 'No updates provided' });
});
});
// ---------------------------------------------------------------------------
// count_posts
// ---------------------------------------------------------------------------
describe('Blog Tools — count_posts', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('calls getPostCounts with groupBy and filters', async () => {
vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({
groups: [
{ month: 1, tag: 'Politik', count: 12 },
{ month: 2, tag: 'Politik', count: 5 },
],
totalPosts: 200,
});
const result = await tools.count_posts.execute!(
{ groupBy: ['month', 'tag'], year: 2004 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith(
['month', 'tag'],
{ year: 2004 },
);
expect(result).toMatchObject({
success: true,
totalPosts: 200,
groupCount: 2,
});
expect((result as any).groups).toHaveLength(2);
});
it('passes all optional filters', async () => {
vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({
groups: [],
totalPosts: 0,
});
await tools.count_posts.execute!(
{ groupBy: ['status'], year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith(
['status'],
{ year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] },
);
});
it('returns error when month without year', async () => {
const result = await tools.count_posts.execute!(
{ groupBy: ['tag'], month: 3 },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') });
});
});
// ---------------------------------------------------------------------------
// list_tags / list_categories
// ---------------------------------------------------------------------------
describe('Blog Tools — list_tags & list_categories', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('list_tags returns tags with counts', async () => {
vi.mocked(deps.postEngine.getTagsWithCounts).mockResolvedValueOnce([
{ tag: 'travel', count: 10 },
{ tag: 'food', count: 5 },
]);
const result = await tools.list_tags.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({ success: true, count: 2 });
});
it('list_categories returns categories with counts', async () => {
vi.mocked(deps.postEngine.getCategoriesWithCounts).mockResolvedValueOnce([
{ category: 'article', count: 20 },
]);
const result = await tools.list_categories.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({ success: true, count: 1 });
});
});
// ---------------------------------------------------------------------------
// get_blog_stats
// ---------------------------------------------------------------------------
describe('Blog Tools — get_blog_stats', () => {
it('returns comprehensive stats', async () => {
const deps = createMockDeps();
const tools = createBlogTools(deps);
vi.mocked(deps.postEngine.getBlogStats).mockResolvedValueOnce({
totalPosts: 50, draftCount: 5, publishedCount: 40, archivedCount: 5,
oldestPostDate: new Date('2020-01-01'), newestPostDate: new Date('2025-06-01'),
postsPerYear: { 2020: 10, 2021: 10, 2022: 10, 2023: 10, 2024: 10 },
tagCount: 25, categoryCount: 4,
});
vi.mocked(deps.mediaEngine.getAllMedia).mockResolvedValueOnce([sampleMedia, sampleMedia]);
const result = await tools.get_blog_stats.execute!({}, { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal });
expect(result).toMatchObject({
success: true,
totalPosts: 50,
totalMedia: 2,
tagCount: 25,
});
});
});
// ---------------------------------------------------------------------------
// view_image
// ---------------------------------------------------------------------------
describe('Blog Tools — view_image', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('returns base64 image data', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia);
vi.mocked(deps.mediaEngine.getThumbnailDataUrl).mockResolvedValueOnce(
'data:image/webp;base64,iVBORw0KGgo',
);
const result = await tools.view_image.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({
__isImageResult: true,
success: true,
base64: 'iVBORw0KGgo',
mediaType: 'image/webp',
});
});
it('rejects non-image media', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce({
...sampleMedia, mimeType: 'application/pdf',
});
const result = await tools.view_image.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('Only images') });
});
it('returns error when thumbnail unavailable', async () => {
vi.mocked(deps.mediaEngine.getMedia).mockResolvedValueOnce(sampleMedia);
vi.mocked(deps.mediaEngine.getThumbnailDataUrl).mockResolvedValueOnce(null);
const result = await tools.view_image.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: false, error: expect.stringContaining('Thumbnail not available') });
});
});
// ---------------------------------------------------------------------------
// Link tools
// ---------------------------------------------------------------------------
describe('Blog Tools — link tools', () => {
let deps: BlogToolDeps;
let tools: ReturnType<typeof createBlogTools>;
beforeEach(() => {
deps = createMockDeps();
tools = createBlogTools(deps);
});
it('get_post_backlinks returns linked-by posts', async () => {
vi.mocked(deps.postEngine.getLinkedBy).mockResolvedValueOnce([
{ id: 'post-2', title: 'Ref', slug: 'ref' },
]);
const result = await tools.get_post_backlinks.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, linkedBy: [{ id: 'post-2' }] });
});
it('get_post_outlinks returns links-to posts', async () => {
vi.mocked(deps.postEngine.getLinksTo).mockResolvedValueOnce([
{ id: 'post-3', title: 'Target', slug: 'target' },
]);
const result = await tools.get_post_outlinks.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, linksTo: [{ id: 'post-3' }] });
});
it('get_post_media returns linked media', async () => {
vi.mocked(deps.postMediaEngine.getLinkedMediaDataForPost).mockResolvedValueOnce([{
id: 'link-1', projectId: 'proj-1', postId: 'post-1', mediaId: 'media-1',
sortOrder: 0, createdAt: new Date(),
media: sampleMedia,
}]);
const result = await tools.get_post_media.execute!(
{ postId: 'post-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, media: [{ id: 'media-1' }] });
});
it('get_media_posts returns linked posts', async () => {
vi.mocked(deps.postMediaEngine.getLinkedPostsForMedia).mockResolvedValueOnce([{
id: 'link-1', projectId: 'proj-1', postId: 'post-1', mediaId: 'media-1',
sortOrder: 0, createdAt: new Date(),
}]);
vi.mocked(deps.postEngine.getPost).mockResolvedValueOnce(samplePost);
const result = await tools.get_media_posts.execute!(
{ mediaId: 'media-1' },
{ toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal },
);
expect(result).toMatchObject({ success: true, count: 1, posts: [{ id: 'post-1' }] });
});
});
// ---------------------------------------------------------------------------
// buildAmbiguityHints (shared helper)
// ---------------------------------------------------------------------------
describe('buildAmbiguityHints', () => {
it('returns hint when category also exists as tag', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'travel', count: 3 }]),
};
const hints = await buildAmbiguityHints(engine, 'travel', undefined);
expect(hints).toHaveLength(1);
expect(hints[0]).toContain('also exists as a tag');
});
it('returns hint when tag also exists as category', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'photo', count: 5 }]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
};
const hints = await buildAmbiguityHints(engine, undefined, ['photo']);
expect(hints).toHaveLength(1);
expect(hints[0]).toContain('also exists as a category');
});
it('returns empty when no overlaps', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([{ category: 'article', count: 10 }]),
getTagsWithCounts: vi.fn().mockResolvedValue([{ tag: 'travel', count: 3 }]),
};
const hints = await buildAmbiguityHints(engine, 'article', ['travel']);
expect(hints).toHaveLength(0);
});
it('returns empty when no category or tags given', async () => {
const engine = {
getCategoriesWithCounts: vi.fn().mockResolvedValue([]),
getTagsWithCounts: vi.fn().mockResolvedValue([]),
};
const hints = await buildAmbiguityHints(engine, undefined, undefined);
expect(hints).toHaveLength(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,9 @@
/**
* chatHandlers IPC streaming tests
*
* Post-Phase 2: chatHandlers uses ChatService.sendMessage, not OpenCodeManager.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
@@ -10,7 +16,7 @@ const mainWindowMock = {
};
const chatEngineInstances: Array<Record<string, any>> = [];
const openCodeManagerInstances: Array<Record<string, any>> = [];
const chatServiceInstances: Array<Record<string, any>> = [];
const secureKeyStoreInstances: Array<Record<string, any>> = [];
vi.mock('electron', () => ({
@@ -52,42 +58,6 @@ vi.mock('../../src/main/engine/ChatEngine', () => ({
},
}));
vi.mock('../../src/main/engine/OpenCodeManager', () => ({
OpenCodeManager: class {
constructor() {
const instance = {
setApiKey: vi.fn(),
checkReady: vi.fn(async () => ({ ready: true })),
validateApiKey: vi.fn(async () => ({ isValid: true, models: [] })),
getApiKey: vi.fn(() => 'abc12345'),
getAvailableModels: vi.fn(async () => []),
sendMessage: vi.fn(async (_conversationId: string, _message: string, options: any) => {
options?.onDelta?.('stream-delta');
options?.onToolCall?.({ name: 'search_posts', args: { query: 'q' } });
options?.onToolResult?.({ name: 'search_posts', result: { ok: true } });
options?.onTokenUsage?.({
inputTokens: 100, outputTokens: 50,
cacheReadTokens: 80, cacheWriteTokens: 20, totalTokens: 250,
cumulativeInputTokens: 100, cumulativeOutputTokens: 50,
cumulativeCacheReadTokens: 80, cumulativeCacheWriteTokens: 20,
cumulativeTotalTokens: 250,
});
return {
success: true,
message: 'assistant reply',
};
}),
abortMessage: vi.fn(async () => ({ success: true })),
analyzeTaxonomy: vi.fn(async () => ({ success: true })),
analyzeMediaImage: vi.fn(async () => ({ success: true })),
stop: vi.fn(async () => undefined),
};
openCodeManagerInstances.push(instance);
return instance;
}
},
}));
vi.mock('../../src/main/engine/SecureKeyStore', () => ({
SecureKeyStore: class {
constructor() {
@@ -104,12 +74,67 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
},
}));
vi.mock('../../src/main/engine/ai/providers', () => ({
ProviderRegistry: class {
constructor() { /* no-op */ }
setOpencodeKey = vi.fn();
getOpencodeKey = vi.fn(() => 'abc12345');
setMistralKey = vi.fn();
getMistralKey = vi.fn(() => '');
isReady = vi.fn(() => true);
isProviderKeySet = vi.fn(() => true);
getProviderStatus = vi.fn(() => ({ opencode: true, mistral: false }));
resolveModel = vi.fn();
getAvailableModels = vi.fn(async () => []);
validateOpencodeKey = vi.fn(async () => ({ isValid: true, models: [] }));
validateMistralKey = vi.fn(async () => ({ isValid: true, models: [] }));
invalidateModelCache = vi.fn();
getModelCatalogEngine = vi.fn(() => ({ refresh: vi.fn(async () => ({})), getAll: vi.fn(async () => []) }));
},
detectProvider: vi.fn(() => 'anthropic'),
createOpenCodeGateway: vi.fn(),
}));
vi.mock('../../src/main/engine/ai/chat', () => ({
ChatService: class {
constructor() {
const instance = {
sendMessage: vi.fn(async (_conversationId: string, _message: string, callbacks: any) => {
callbacks?.onDelta?.('stream-delta');
callbacks?.onToolCall?.({ name: 'search_posts', args: { query: 'q' } });
callbacks?.onToolResult?.({ name: 'search_posts', result: { ok: true } });
callbacks?.onTokenUsage?.({
inputTokens: 100, outputTokens: 50,
cacheReadTokens: 80, cacheWriteTokens: 20, totalTokens: 250,
cumulativeInputTokens: 100, cumulativeOutputTokens: 50,
cumulativeCacheReadTokens: 80, cumulativeCacheWriteTokens: 20,
cumulativeTotalTokens: 250,
});
return { success: true, message: 'assistant reply' };
}),
abortMessage: vi.fn(async () => ({ success: true })),
stop: vi.fn(async () => undefined),
};
chatServiceInstances.push(instance);
return instance;
}
},
}));
vi.mock('../../src/main/engine/ai/tasks', () => ({
OneShotTasks: class {
constructor() { /* no-op */ }
analyzeTaxonomy = vi.fn(async () => ({ success: true }));
analyzeMediaImage = vi.fn(async () => ({ success: true }));
},
}));
describe('chatHandlers', () => {
beforeEach(() => {
registeredHandlers.clear();
webContentsSend.mockReset();
chatEngineInstances.length = 0;
openCodeManagerInstances.length = 0;
chatServiceInstances.length = 0;
secureKeyStoreInstances.length = 0;
vi.resetModules();
});
@@ -141,13 +166,11 @@ describe('chatHandlers', () => {
expect(result.success).toBe(true);
const manager = openCodeManagerInstances[0];
expect(manager.setApiKey).toHaveBeenCalledWith('stored-key');
expect(manager.sendMessage).toHaveBeenCalledWith(
const service = chatServiceInstances[0];
expect(service.sendMessage).toHaveBeenCalledWith(
'conversation-1',
'hello assistant',
expect.objectContaining({
metadata: { surface: 'sidebar' },
onDelta: expect.any(Function),
onToolCall: expect.any(Function),
onToolResult: expect.any(Function),

View File

@@ -3,6 +3,8 @@
*
* Tests that API keys are stored/retrieved via SecureKeyStore (encrypted)
* and that old plain-text keys are cleaned up on startup.
*
* Post-Phase 2: chatHandlers uses ProviderRegistry + ChatService, not OpenCodeManager.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -17,7 +19,7 @@ const mainWindowMock = {
};
const chatEngineInstances: Array<Record<string, any>> = [];
const openCodeManagerInstances: Array<Record<string, any>> = [];
const providerRegistryInstances: Array<Record<string, any>> = [];
const secureKeyStoreInstances: Array<Record<string, any>> = [];
// Per-test overrides for SecureKeyStore mock behavior
@@ -88,25 +90,47 @@ vi.mock('../../src/main/engine/SecureKeyStore', () => ({
},
}));
vi.mock('../../src/main/engine/OpenCodeManager', () => ({
OpenCodeManager: class {
vi.mock('../../src/main/engine/ai/providers', () => ({
ProviderRegistry: class {
constructor() {
const instance = {
setApiKey: vi.fn(),
checkReady: vi.fn(async () => ({ ready: true })),
validateApiKey: vi.fn(async () => ({ isValid: true, models: [] })),
getApiKey: vi.fn(() => 'abc12345'),
setOpencodeKey: vi.fn(),
getOpencodeKey: vi.fn(() => 'abc12345'),
setMistralKey: vi.fn(),
getMistralKey: vi.fn(() => ''),
isReady: vi.fn(() => true),
isProviderKeySet: vi.fn(() => true),
getProviderStatus: vi.fn(() => ({ opencode: true, mistral: false })),
resolveModel: vi.fn(),
getAvailableModels: vi.fn(async () => []),
sendMessage: vi.fn(async () => ({ success: true, message: 'reply' })),
abortMessage: vi.fn(async () => ({ success: true })),
analyzeTaxonomy: vi.fn(async () => ({ success: true })),
analyzeMediaImage: vi.fn(async () => ({ success: true })),
stop: vi.fn(async () => undefined),
validateOpencodeKey: vi.fn(async () => ({ isValid: true, models: [] })),
validateMistralKey: vi.fn(async () => ({ isValid: true, models: [] })),
invalidateModelCache: vi.fn(),
getModelCatalogEngine: vi.fn(() => ({ refresh: vi.fn(async () => ({})), getAll: vi.fn(async () => []) })),
};
openCodeManagerInstances.push(instance);
providerRegistryInstances.push(instance);
return instance;
}
},
detectProvider: vi.fn(() => 'anthropic'),
createOpenCodeGateway: vi.fn(),
}));
vi.mock('../../src/main/engine/ai/chat', () => ({
ChatService: class {
constructor() { /* no-op */ }
sendMessage = vi.fn(async () => ({ success: true, message: 'reply' }));
abortMessage = vi.fn(async () => ({ success: true }));
stop = vi.fn(async () => undefined);
},
}));
vi.mock('../../src/main/engine/ai/tasks', () => ({
OneShotTasks: class {
constructor() { /* no-op */ }
analyzeTaxonomy = vi.fn(async () => ({ success: true }));
analyzeMediaImage = vi.fn(async () => ({ success: true }));
},
}));
describe('chatHandlers keychain integration', () => {
@@ -114,7 +138,7 @@ describe('chatHandlers keychain integration', () => {
registeredHandlers.clear();
webContentsSend.mockReset();
chatEngineInstances.length = 0;
openCodeManagerInstances.length = 0;
providerRegistryInstances.length = 0;
secureKeyStoreInstances.length = 0;
secureKeyStoreRetrieveResult = 'encrypted-stored-key';
secureKeyStoreStoreError = null;
@@ -141,8 +165,8 @@ describe('chatHandlers keychain integration', () => {
const keyStore = secureKeyStoreInstances[0];
expect(keyStore.retrieve).toHaveBeenCalledWith('opencode_api_key');
const manager = openCodeManagerInstances[0];
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
const registry = providerRegistryInstances[0];
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
});
it('cleans up old plain-text key on init', async () => {
@@ -173,8 +197,8 @@ describe('chatHandlers keychain integration', () => {
const keyStore = secureKeyStoreInstances[0];
expect(keyStore.store).toHaveBeenCalledWith('opencode_api_key', 'sk-new-secret-key');
const manager = openCodeManagerInstances[0];
expect(manager.setApiKey).toHaveBeenCalledWith('sk-new-secret-key');
const registry = providerRegistryInstances[0];
expect(registry.setOpencodeKey).toHaveBeenCalledWith('sk-new-secret-key');
});
it('does not use plain-text getSetting for API key', async () => {
@@ -218,9 +242,9 @@ describe('chatHandlers keychain integration', () => {
const result = await handler!(undefined);
expect(result.ready).toBe(true);
const manager = openCodeManagerInstances[0];
// setApiKey should NOT have been called since there's no stored key
expect(manager.setApiKey).not.toHaveBeenCalled();
const registry = providerRegistryInstances[0];
// setOpencodeKey should NOT have been called since there's no stored key
expect(registry.setOpencodeKey).not.toHaveBeenCalled();
});
it('still initializes when retrieve() throws on init', async () => {
@@ -236,8 +260,8 @@ describe('chatHandlers keychain integration', () => {
// Init should complete even if key retrieval fails
expect(result.ready).toBe(true);
const manager = openCodeManagerInstances[0];
expect(manager.setApiKey).not.toHaveBeenCalled();
const registry = providerRegistryInstances[0];
expect(registry.setOpencodeKey).not.toHaveBeenCalled();
});
it('still initializes and loads key when cleanupPlainTextKey() throws on init', async () => {
@@ -254,8 +278,8 @@ describe('chatHandlers keychain integration', () => {
expect(result.ready).toBe(true);
// The encrypted key should still be loaded despite cleanup failure
const manager = openCodeManagerInstances[0];
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
const registry = providerRegistryInstances[0];
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
});
it('returns error and rolls back in-memory key when store() throws on chat:setApiKey', async () => {
@@ -270,13 +294,13 @@ describe('chatHandlers keychain integration', () => {
const checkHandler = registeredHandlers.get('chat:checkReady');
await checkHandler!(undefined);
const manager = openCodeManagerInstances[0];
// After init, the manager has the key from SecureKeyStore
expect(manager.setApiKey).toHaveBeenCalledWith('encrypted-stored-key');
manager.setApiKey.mockClear();
const registry = providerRegistryInstances[0];
// After init, the registry has the key from SecureKeyStore
expect(registry.setOpencodeKey).toHaveBeenCalledWith('encrypted-stored-key');
registry.setOpencodeKey.mockClear();
// getApiKey returns the current in-memory key (to be restored on rollback)
manager.getApiKey.mockReturnValue('encrypted-stored-key');
// getOpencodeKey returns the current in-memory key (to be restored on rollback)
registry.getOpencodeKey.mockReturnValue('encrypted-stored-key');
const handler = registeredHandlers.get('chat:setApiKey');
const result = await handler!(undefined, 'sk-new-key');
@@ -284,10 +308,10 @@ describe('chatHandlers keychain integration', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('encryption unavailable');
// setApiKey should have been called twice:
// setOpencodeKey should have been called twice:
// 1) with the new key (optimistic), 2) with the old key (rollback)
expect(manager.setApiKey).toHaveBeenCalledTimes(2);
expect(manager.setApiKey).toHaveBeenNthCalledWith(1, 'sk-new-key');
expect(manager.setApiKey).toHaveBeenNthCalledWith(2, 'encrypted-stored-key');
expect(registry.setOpencodeKey).toHaveBeenCalledTimes(2);
expect(registry.setOpencodeKey).toHaveBeenNthCalledWith(1, 'sk-new-key');
expect(registry.setOpencodeKey).toHaveBeenNthCalledWith(2, 'encrypted-stored-key');
});
});

View File

@@ -0,0 +1,53 @@
/**
* Verify that ChatTranscript and A2UIText block external images
* by providing a custom marked-react renderer that converts them to links.
*/
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
const chatTranscriptPath = path.resolve(
__dirname,
'../../../src/renderer/components/ChatSurface/ChatTranscript.tsx',
);
const a2uiTextPath = path.resolve(
__dirname,
'../../../src/renderer/a2ui/components/A2UIText.tsx',
);
describe('External image blocking', () => {
it('ChatTranscript defines a safeRenderer that intercepts image tags', () => {
const source = fs.readFileSync(chatTranscriptPath, 'utf8');
// Must have a renderer that handles images
expect(source).toContain('safeRenderer');
expect(source).toContain('image(src');
expect(source).toContain('https?:');
// Both Markdown usages must pass the renderer
const markdownUsages = source.match(/<Markdown[^>]*>/g) ?? [];
expect(markdownUsages.length).toBeGreaterThanOrEqual(2);
for (const usage of markdownUsages) {
expect(usage).toContain('renderer={safeRenderer}');
}
});
it('A2UIText defines a safeRenderer that intercepts image tags', () => {
const source = fs.readFileSync(a2uiTextPath, 'utf8');
expect(source).toContain('safeRenderer');
expect(source).toContain('image(src');
expect(source).toContain('https?:');
expect(source).toContain('renderer={safeRenderer}');
});
it('safeRenderer converts external URLs to links, not img tags', () => {
const source = fs.readFileSync(chatTranscriptPath, 'utf8');
// The renderer should return <a> for external URLs, not <img>
expect(source).toContain('https?:');
expect(source).toContain('.test(src)');
expect(source).toContain('<a href={src}');
});
});