Phase 3: delete OpenCodeManager + streaming, dedup MCPServer tools (-6,359 lines)
This commit is contained in:
@@ -391,11 +391,11 @@ Domain logic only — no AI protocol code survives.
|
|||||||
13. ~~Update IPC handlers: generic provider management, wire to new modules~~ ✅
|
13. ~~Update IPC handlers: generic provider management, wire to new modules~~ ✅
|
||||||
14. ~~Integration tests~~ ✅ 34 tests
|
14. ~~Integration tests~~ ✅ 34 tests
|
||||||
|
|
||||||
### Phase 3: Delete + ship (1 session)
|
### Phase 3: Delete + ship (1 session) ✅ DONE
|
||||||
15. Delete `OpenCodeManager.ts` (2,745 lines)
|
15. ~~Delete `OpenCodeManager.ts` (2,745 lines)~~ ✅
|
||||||
16. Delete `streaming.ts` (621 lines)
|
16. ~~Delete `streaming.ts` (621 lines)~~ ✅
|
||||||
17. Delete old MCPServer duplication
|
17. ~~Delete old MCPServer duplication~~ ✅ (shared `enrichWithLinks`, `executeCheckTerm`, `buildAmbiguityHints`)
|
||||||
18. Update all tests, full build pass
|
18. ~~Update all tests, full build pass~~ ✅ 2599 tests, 0 failures
|
||||||
19. Smoke test: chat conversation end-to-end, taxonomy analysis, image analysis
|
19. Smoke test: chat conversation end-to-end, taxonomy analysis, image analysis
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@modelcontextprotocol/ext-apps/server';
|
} from '@modelcontextprotocol/ext-apps/server';
|
||||||
import { createServer as createHttpServer, type Server } from 'http';
|
import { createServer as createHttpServer, type Server } from 'http';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { buildAmbiguityHints } from './ai/blog-tools';
|
import { buildAmbiguityHints, enrichWithLinks, executeCheckTerm } from './ai/blog-tools';
|
||||||
import { ProposalStore, type ProposalType } from './ProposalStore';
|
import { ProposalStore, type ProposalType } from './ProposalStore';
|
||||||
import {
|
import {
|
||||||
reviewPostHtml,
|
reviewPostHtml,
|
||||||
@@ -498,25 +498,8 @@ export class MCPServer {
|
|||||||
},
|
},
|
||||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||||
}, async (args) => {
|
}, async (args) => {
|
||||||
const [categories, tags] = await Promise.all([
|
const result = await executeCheckTerm(args.term, this.deps.postEngine);
|
||||||
this.deps.postEngine.getCategoriesWithCounts(),
|
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||||
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,
|
|
||||||
}),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── search_posts ──
|
// ── search_posts ──
|
||||||
@@ -535,7 +518,6 @@ export class MCPServer {
|
|||||||
},
|
},
|
||||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||||
}, async (args) => {
|
}, async (args) => {
|
||||||
// Validate: month requires year
|
|
||||||
if (args.month && !args.year) {
|
if (args.month && !args.year) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }],
|
content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }],
|
||||||
@@ -547,30 +529,13 @@ export class MCPServer {
|
|||||||
const offset = args.offset ?? 0;
|
const offset = args.offset ?? 0;
|
||||||
const limit = args.limit ?? 50;
|
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 })),
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (args.query && !hasFilters) {
|
if (args.query && !hasFilters) {
|
||||||
// Pure text search — use FTS
|
|
||||||
const results = await this.deps.postEngine.searchPosts(args.query);
|
const results = await this.deps.postEngine.searchPosts(args.query);
|
||||||
const paginated = results.slice(offset, offset + limit);
|
const paginated = results.slice(offset, offset + limit);
|
||||||
const enriched = await enrichWithLinks(paginated);
|
const enriched = await enrichWithLinks(paginated, this.deps.postEngine);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(enriched) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build structural filter
|
|
||||||
const filter: PostFilter = {};
|
const filter: PostFilter = {};
|
||||||
if (args.category) filter.categories = [args.category];
|
if (args.category) filter.categories = [args.category];
|
||||||
if (args.tags) filter.tags = args.tags;
|
if (args.tags) filter.tags = args.tags;
|
||||||
@@ -578,37 +543,23 @@ export class MCPServer {
|
|||||||
if (args.month) filter.month = args.month;
|
if (args.month) filter.month = args.month;
|
||||||
if (args.status) filter.status = args.status;
|
if (args.status) filter.status = args.status;
|
||||||
|
|
||||||
|
let enriched;
|
||||||
if (args.query && hasFilters) {
|
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 results = await this.deps.postEngine.searchPostsFiltered(
|
enriched = await enrichWithLinks(results, this.deps.postEngine);
|
||||||
args.query, filter, { offset, limit },
|
} else {
|
||||||
);
|
const results = await this.deps.postEngine.getPostsFiltered(filter);
|
||||||
const enriched = await enrichWithLinks(results);
|
const paginated = results.slice(offset, offset + limit);
|
||||||
const content: Array<{ type: 'text'; text: string }> = [
|
enriched = await enrichWithLinks(paginated, this.deps.postEngine);
|
||||||
{ type: 'text' as const, text: JSON.stringify(enriched) },
|
|
||||||
];
|
|
||||||
const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags);
|
|
||||||
if (hintsList.length > 0) {
|
|
||||||
content.push({ type: 'text' as const, text: hintsList.join(' ') });
|
|
||||||
}
|
|
||||||
return { content };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter-only query (no text search)
|
|
||||||
const results = await this.deps.postEngine.getPostsFiltered(filter);
|
|
||||||
const paginated = results.slice(offset, offset + limit);
|
|
||||||
const enriched = await enrichWithLinks(paginated);
|
|
||||||
|
|
||||||
const content: Array<{ type: 'text'; text: string }> = [
|
const content: Array<{ type: 'text'; text: string }> = [
|
||||||
{ type: 'text' as const, text: JSON.stringify(enriched) },
|
{ type: 'text' as const, text: JSON.stringify(enriched) },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Ambiguity hints: check if category/tag terms exist in the other namespace
|
|
||||||
const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags);
|
const hintsList = await buildAmbiguityHints(this.deps.postEngine, args.category, args.tags);
|
||||||
if (hintsList.length > 0) {
|
if (hintsList.length > 0) {
|
||||||
content.push({ type: 'text' as const, text: hintsList.join(' ') });
|
content.push({ type: 'text' as const, text: hintsList.join(' ') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content };
|
return { content };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog.
|
||||||
*/
|
*/
|
||||||
async getMaxOutputTokens(modelId: string, provider?: string): Promise<number> {
|
async getMaxOutputTokens(modelId: string, provider?: string): Promise<number> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -57,10 +57,16 @@ export interface BlogToolDeps {
|
|||||||
// Shared helpers
|
// 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. */
|
/** Enrich posts with backlinks and outlinks. */
|
||||||
async function enrichWithLinks<T extends { id: string }>(
|
export async function enrichWithLinks<T extends { id: string }>(
|
||||||
posts: T[],
|
posts: T[],
|
||||||
postEngine: BlogToolDeps['postEngine'],
|
postEngine: LinkEnrichmentDeps,
|
||||||
): Promise<Array<T & { backlinks: Array<{ id: string; title: string; slug: string }>; linksTo: Array<{ id: string; title: string; slug: string }> }>> {
|
): 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) => {
|
return Promise.all(posts.map(async (p) => {
|
||||||
const [backlinks, linksTo] = await Promise.all([
|
const [backlinks, linksTo] = await Promise.all([
|
||||||
@@ -113,6 +119,27 @@ export async function buildAmbiguityHints(
|
|||||||
return hints;
|
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
|
// Tool factory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -127,21 +154,8 @@ export function createBlogTools(deps: BlogToolDeps) {
|
|||||||
term: z.string().describe('The term to look up'),
|
term: z.string().describe('The term to look up'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ term }) => {
|
execute: async ({ term }) => {
|
||||||
const [categories, tags] = await Promise.all([
|
const result = await executeCheckTerm(term, postEngine);
|
||||||
postEngine.getCategoriesWithCounts(),
|
return { success: true, ...result };
|
||||||
postEngine.getTagsWithCounts(),
|
|
||||||
]);
|
|
||||||
const termLower = term.toLowerCase();
|
|
||||||
const catMatch = categories.find(c => c.category.toLowerCase() === termLower);
|
|
||||||
const tagMatch = tags.find(t => t.tag.toLowerCase() === termLower);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
term,
|
|
||||||
asCategory: !!catMatch,
|
|
||||||
categoryPostCount: catMatch?.count ?? 0,
|
|
||||||
asTag: !!tagMatch,
|
|
||||||
tagPostCount: tagMatch?.count ?? 0,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ChatService — streaming chat using AI SDK's streamText().
|
* ChatService — streaming chat using AI SDK's streamText().
|
||||||
*
|
*
|
||||||
* Replaces OpenCodeManager's sendAnthropicMessage/sendOpenAIMessage/
|
* Streaming chat service using AI SDK v6 streamText().
|
||||||
* streaming.ts with a single, provider-agnostic code path.
|
|
||||||
*
|
*
|
||||||
* AI SDK handles:
|
* AI SDK handles:
|
||||||
* - SSE parsing, reconnection, abort
|
* - SSE parsing, reconnection, abort
|
||||||
@@ -78,7 +77,7 @@ function dbMessagesToAIMessages(
|
|||||||
messages.push({ role: 'user', content: msg.content || '' });
|
messages.push({ role: 'user', content: msg.content || '' });
|
||||||
} else if (msg.role === 'assistant') {
|
} else if (msg.role === 'assistant') {
|
||||||
let content = msg.content || '';
|
let content = msg.content || '';
|
||||||
// Append tool-call annotation from previous turns (same as OpenCodeManager)
|
// Append tool-call annotation from previous turns
|
||||||
if (msg.toolCalls) {
|
if (msg.toolCalls) {
|
||||||
try {
|
try {
|
||||||
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>;
|
const calls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>;
|
||||||
@@ -216,7 +215,7 @@ export class ChatService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a user message, stream the AI response with tool use.
|
* Send a user message, stream the AI response with tool use.
|
||||||
* This is the main entry point — replaces OpenCodeManager.sendMessage().
|
* Send a message in a conversation, streaming the response.
|
||||||
*/
|
*/
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* OneShotTasks — non-streaming AI tasks using generateText().
|
* OneShotTasks — non-streaming AI tasks using generateText().
|
||||||
*
|
*
|
||||||
* Replaces OpenCodeManager.analyzeTaxonomy() and analyzeMediaImage()
|
* One-shot AI tasks: taxonomy analysis and image analysis.
|
||||||
* with provider-agnostic AI SDK calls.
|
* with provider-agnostic AI SDK calls.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ export {
|
|||||||
type ChatMessageData,
|
type ChatMessageData,
|
||||||
type CreateConversationInput,
|
type CreateConversationInput,
|
||||||
} from './ChatEngine';
|
} from './ChatEngine';
|
||||||
export {
|
|
||||||
OpenCodeManager,
|
|
||||||
type SendMessageOptions,
|
|
||||||
type SendMessageResult,
|
|
||||||
} from './OpenCodeManager';
|
|
||||||
export {
|
export {
|
||||||
WxrParser,
|
WxrParser,
|
||||||
type WxrData,
|
type WxrData,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Chat IPC handlers — AI chat via AI SDK v6.
|
* Chat IPC handlers — AI chat via AI SDK v6.
|
||||||
*
|
*
|
||||||
* Uses ProviderRegistry, ChatService, and OneShotTasks instead of OpenCodeManager.
|
* Uses ProviderRegistry, ChatService, and OneShotTasks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ipcMain, BrowserWindow } from 'electron';
|
import { ipcMain, BrowserWindow } from 'electron';
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user