chore(lint): reduce warning debt across core adapters and model clients

This commit is contained in:
William Valentin
2026-02-15 23:03:42 -08:00
parent 92da407e22
commit 49b752e8b0
17 changed files with 239 additions and 117 deletions
+26 -1
View File
@@ -2649,7 +2649,7 @@
"test_status": "pnpm test:run src/gateway/server.test.ts src/tools/executor.test.ts src/backends/native/orchestrator.test.ts src/daemon/routing.test.ts + pnpm typecheck + pnpm lint passing (0 errors, warnings remain)" "test_status": "pnpm test:run src/gateway/server.test.ts src/tools/executor.test.ts src/backends/native/orchestrator.test.ts src/daemon/routing.test.ts + pnpm typecheck + pnpm lint passing (0 errors, warnings remain)"
}, },
"audit-followup-lint-warning-reduction-pass-1": { "audit-followup-lint-warning-reduction-pass-1": {
"status": "in_progress", "status": "completed",
"date": "2026-02-16", "date": "2026-02-16",
"updated": "2026-02-16", "updated": "2026-02-16",
"summary": "Continued stage-2 lint warning reduction with hotspot-focused cleanup in `gateway/handlers/handlers.test.ts`, `daemon/routing.test.ts`, `frontends/tui/minimal.test.ts`, `gateway/tailscale.test.ts`, `automation/webhooks.test.ts`, `automation/cron.test.ts`, `automation/heartbeat.test.ts`, `frontends/tui/minimal.login.test.ts`, `daemon/clientFactory.test.ts`, `gateway/handlers/services.test.ts`, `models/local/llamacpp.test.ts`, `models/local/ollama.test.ts`, and `tools/builtin/image-analyze.test.ts`. Replaced broad `any` casts with typed helper casts/unknown-path accessors and removed non-null assertions in high-warning tests. Warning count reduced from 466 to 203 (263 warnings burned down) with lint/test suites still green.", "summary": "Continued stage-2 lint warning reduction with hotspot-focused cleanup in `gateway/handlers/handlers.test.ts`, `daemon/routing.test.ts`, `frontends/tui/minimal.test.ts`, `gateway/tailscale.test.ts`, `automation/webhooks.test.ts`, `automation/cron.test.ts`, `automation/heartbeat.test.ts`, `frontends/tui/minimal.login.test.ts`, `daemon/clientFactory.test.ts`, `gateway/handlers/services.test.ts`, `models/local/llamacpp.test.ts`, `models/local/ollama.test.ts`, and `tools/builtin/image-analyze.test.ts`. Replaced broad `any` casts with typed helper casts/unknown-path accessors and removed non-null assertions in high-warning tests. Warning count reduced from 466 to 203 (263 warnings burned down) with lint/test suites still green.",
@@ -2675,6 +2675,31 @@
"docs/plans/analysis/2026-02-16-codebase-audit-report.md" "docs/plans/analysis/2026-02-16-codebase-audit-report.md"
], ],
"test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts src/daemon/routing.test.ts src/gateway/handlers/handlers.test.ts src/frontends/tui/minimal.test.ts src/gateway/tailscale.test.ts src/automation/webhooks.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/frontends/tui/minimal.login.test.ts src/daemon/clientFactory.test.ts src/gateway/handlers/services.test.ts src/models/local/llamacpp.test.ts src/models/local/ollama.test.ts src/tools/builtin/image-analyze.test.ts + pnpm lint passing (0 errors, 203 warnings)" "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts src/daemon/routing.test.ts src/gateway/handlers/handlers.test.ts src/frontends/tui/minimal.test.ts src/gateway/tailscale.test.ts src/automation/webhooks.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/frontends/tui/minimal.login.test.ts src/daemon/clientFactory.test.ts src/gateway/handlers/services.test.ts src/models/local/llamacpp.test.ts src/models/local/ollama.test.ts src/tools/builtin/image-analyze.test.ts + pnpm lint passing (0 errors, 203 warnings)"
},
"audit-followup-lint-warning-reduction-pass-2": {
"status": "in_progress",
"date": "2026-02-16",
"updated": "2026-02-16",
"summary": "Continued warning burn-down on current hotspots by removing non-null assertions and broad `any` usage, tightening adapter/client typing, and cleaning unused imports/params across orchestrator/model/channel/gateway/TUI/doctor files and selected tests. Warning count reduced from 203 to 115 (88 warnings burned down) while keeping lint at 0 errors.",
"files_modified": [
"src/backends/native/agent.ts",
"src/backends/native/orchestrator.test.ts",
"src/channels/discord/adapter.ts",
"src/channels/whatsapp/adapter.ts",
"src/cli/doctor.ts",
"src/cli/setup/providers.test.ts",
"src/frontends/tui/minimal.ts",
"src/gateway/server.ts",
"src/gateway/session-bridge.test.ts",
"src/memory/hybrid-search.test.ts",
"src/models/anthropic.ts",
"src/models/local/ollama.ts",
"src/models/openai.oauth.test.ts",
"src/models/openai.ts",
"src/models/router.test.ts",
"src/skills/installer.test.ts"
],
"test_status": "pnpm exec eslint on edited files + pnpm lint passing (0 errors, 115 warnings)"
} }
}, },
"overall_progress": { "overall_progress": {
+14 -6
View File
@@ -1,4 +1,4 @@
import type { ModelClient, Message, ChatRequest, ChatResponse, ModelToolCall, TokenUsage } from '../../models/types.js'; import type { ModelClient, Message, ChatRequest, ChatResponse, TokenUsage } from '../../models/types.js';
import type { ModelRouter, ModelTier } from '../../models/router.js'; import type { ModelRouter, ModelTier } from '../../models/router.js';
import type { Session } from '../../session/index.js'; import type { Session } from '../../session/index.js';
import type { ToolRegistry } from '../../tools/registry.js'; import type { ToolRegistry } from '../../tools/registry.js';
@@ -8,7 +8,7 @@ import type { ToolPolicyContext } from '../../tools/policy.js';
import { auditLogger } from '../../audit/index.js'; import { auditLogger } from '../../audit/index.js';
import type { Attachment } from '../../channels/types.js'; import type { Attachment } from '../../channels/types.js';
import type { OutboundAttachmentCollector } from './attachments.js'; import type { OutboundAttachmentCollector } from './attachments.js';
import { buildUserMessage, getMessageText } from '../../models/media.js'; import { buildUserMessage } from '../../models/media.js';
export interface ToolUseEvent { export interface ToolUseEvent {
type: 'start' | 'end'; type: 'start' | 'end';
@@ -142,7 +142,12 @@ export class NativeAgent {
} }
private async toolLoop(): Promise<string> { private async toolLoop(): Promise<string> {
const tools = this.toolRegistry!.filteredToAnthropicFormat(this._toolPolicyContext); const toolRegistry = this.toolRegistry;
const toolExecutor = this.toolExecutor;
if (!toolRegistry || !toolExecutor) {
throw new Error('Tool loop requires tool registry and executor');
}
const tools = toolRegistry.filteredToAnthropicFormat(this._toolPolicyContext);
// Track whether untrusted content (web/fetched/tool output) has been introduced // Track whether untrusted content (web/fetched/tool output) has been introduced
// during this run. Used to harden against prompt injection. // during this run. Used to harden against prompt injection.
@@ -218,7 +223,10 @@ export class NativeAgent {
} }
// Safe to assert non-null — wantsToolUse guarantees toolCalls exists and is non-empty // Safe to assert non-null — wantsToolUse guarantees toolCalls exists and is non-empty
const toolCalls = response.toolCalls!; const toolCalls = response.toolCalls;
if (!toolCalls || toolCalls.length === 0) {
continue;
}
// Check for repeated tool calls — build a fingerprint from tool names + args // Check for repeated tool calls — build a fingerprint from tool names + args
const fingerprint = toolCalls const fingerprint = toolCalls
@@ -264,7 +272,7 @@ export class NativeAgent {
for (const tc of toolCalls) { for (const tc of toolCalls) {
this.throwIfCancelled(); this.throwIfCancelled();
const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name; const internalName = toolRegistry.getByApiName(tc.name)?.name ?? tc.name;
this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args }); this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args });
let elevationUntilMs: number | undefined; let elevationUntilMs: number | undefined;
@@ -311,7 +319,7 @@ export class NativeAgent {
} }
: undefined; : undefined;
const result = await this.toolExecutor!.execute(internalName, tc.args, perCallContext); const result = await toolExecutor.execute(internalName, tc.args, perCallContext);
this.onToolUse?.({ type: 'end', tool: internalName, result }); this.onToolUse?.({ type: 'end', tool: internalName, result });
+13 -11
View File
@@ -5,11 +5,13 @@ import type { ChatResponse, ModelClient } from '../../models/types.js';
import { ToolRegistry, ToolExecutor } from '../../tools/index.js'; import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
import { HookEngine } from '../../hooks/engine.js'; import { HookEngine } from '../../hooks/engine.js';
import { MemoryStore } from '../../memory/store.js'; import { MemoryStore } from '../../memory/store.js';
import type { Session } from '../../session/index.js';
import { mkdtempSync, rmSync } from 'fs'; import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { auditLogger, initAuditLogger } from '../../audit/index.js'; import { auditLogger, initAuditLogger } from '../../audit/index.js';
import type { AuditLogger } from '../../audit/index.js'; import type { AuditLogger } from '../../audit/index.js';
import type { Message } from '../../models/types.js';
describe('AgentOrchestrator', () => { describe('AgentOrchestrator', () => {
let mockDefaultClient: ModelClient; let mockDefaultClient: ModelClient;
@@ -468,7 +470,7 @@ describe('AgentOrchestrator', () => {
fallbackChain: [], fallbackChain: [],
}); });
const history: any[] = [ const history: Message[] = [
{ role: 'user', content: 'u1' }, { role: 'user', content: 'u1' },
{ role: 'assistant', content: 'a1' }, { role: 'assistant', content: 'a1' },
{ role: 'user', content: 'u2' }, { role: 'user', content: 'u2' },
@@ -476,19 +478,19 @@ describe('AgentOrchestrator', () => {
{ role: 'user', content: 'u3' }, { role: 'user', content: 'u3' },
{ role: 'assistant', content: 'a3' }, { role: 'assistant', content: 'a3' },
]; ];
const session = { const session: Session = {
id: 'session-compact-audit', id: 'session-compact-audit',
addMessage: vi.fn((m: any) => { history.push(m); }), addMessage: vi.fn((m: Message) => { history.push(m); }),
getHistory: vi.fn(() => [...history]), getHistory: vi.fn(() => [...history]),
clear: vi.fn(() => { history.length = 0; }), clear: vi.fn(() => { history.length = 0; }),
replaceHistory: vi.fn((msgs: any[]) => { replaceHistory: vi.fn((msgs: Message[]) => {
history.length = 0; history.length = 0;
history.push(...msgs); history.push(...msgs);
}), }),
getConfig: vi.fn(() => undefined), getConfig: vi.fn(() => undefined),
setConfig: vi.fn(), setConfig: vi.fn(),
deleteConfig: vi.fn(), deleteConfig: vi.fn(),
} as any; };
const sessionCompact = vi.fn(); const sessionCompact = vi.fn();
const previousAuditLogger = auditLogger; const previousAuditLogger = auditLogger;
@@ -717,27 +719,27 @@ describe('AgentOrchestrator', () => {
}); });
// Minimal Session stub that supports rollback via replaceHistory(). // Minimal Session stub that supports rollback via replaceHistory().
const history: any[] = []; const history: Message[] = [];
const session = { const session: Session = {
id: 'test', id: 'test',
addMessage: vi.fn((m: any) => { history.push(m); }), addMessage: vi.fn((m: Message) => { history.push(m); }),
getHistory: vi.fn(() => [...history]), getHistory: vi.fn(() => [...history]),
clear: vi.fn(() => { history.length = 0; }), clear: vi.fn(() => { history.length = 0; }),
replaceHistory: vi.fn((msgs: any[]) => { replaceHistory: vi.fn((msgs: Message[]) => {
history.length = 0; history.length = 0;
history.push(...msgs); history.push(...msgs);
}), }),
getConfig: vi.fn(() => undefined), getConfig: vi.fn(() => undefined),
setConfig: vi.fn(), setConfig: vi.fn(),
deleteConfig: vi.fn(), deleteConfig: vi.fn(),
} as any; };
const registry = new ToolRegistry(); const registry = new ToolRegistry();
registry.register({ registry.register({
name: 'test.echo', name: 'test.echo',
description: 'echo', description: 'echo',
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
execute: async (args: any) => ({ success: true, output: String(args.text ?? '') }), execute: async (args: { text?: string }) => ({ success: true, output: String(args.text ?? '') }),
}); });
const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
+13 -10
View File
@@ -85,7 +85,7 @@ export class DiscordAdapter implements ChannelAdapter {
async connect(): Promise<void> { async connect(): Promise<void> {
this._status = 'connecting'; this._status = 'connecting';
this.client = new Client({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
@@ -93,23 +93,24 @@ export class DiscordAdapter implements ChannelAdapter {
GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessages,
], ],
}); });
this.client = client;
// ── Ready handler — resolve connect() when the bot is online ── // ── Ready handler — resolve connect() when the bot is online ──
const readyPromise = new Promise<void>((resolve) => { const readyPromise = new Promise<void>((resolve) => {
this.client!.on(Events.ClientReady, () => { client.on(Events.ClientReady, () => {
console.log(`Discord bot ready as ${this.client!.user?.tag}`); console.log(`Discord bot ready as ${client.user?.tag}`);
this._status = 'connected'; this._status = 'connected';
resolve(); resolve();
}); });
}); });
// ── Message handler — route inbound messages ── // ── Message handler — route inbound messages ──
this.client.on(Events.MessageCreate, (message: DiscordMessage) => { client.on(Events.MessageCreate, (message: DiscordMessage) => {
void this.handleMessage(message); void this.handleMessage(message);
}); });
// Log in and wait for the ready event // Log in and wait for the ready event
await this.client.login(this.config.botToken); await client.login(this.config.botToken);
await readyPromise; await readyPromise;
} }
@@ -162,8 +163,11 @@ export class DiscordAdapter implements ChannelAdapter {
name: attachment.filename ?? 'attachment', name: attachment.filename ?? 'attachment',
}); });
} }
if (!attachment.url) {
throw new Error('Attachment must include data or url');
}
// URL-based attachment // URL-based attachment
return new AttachmentBuilder(attachment.url!, { return new AttachmentBuilder(attachment.url, {
name: attachment.filename ?? 'attachment', name: attachment.filename ?? 'attachment',
}); });
} }
@@ -180,9 +184,8 @@ export class DiscordAdapter implements ChannelAdapter {
// ── Guild/channel filtering ── // ── Guild/channel filtering ──
if (!isDM) { if (!isDM) {
// Check allowed guild IDs // Check allowed guild IDs
if ( const guildId = message.guild?.id;
!isAllowedByAllowlist(message.guild!.id, this.config.allowedGuildIds) if (!guildId || !isAllowedByAllowlist(guildId, this.config.allowedGuildIds)) {
) {
return; return;
} }
@@ -223,7 +226,7 @@ export class DiscordAdapter implements ChannelAdapter {
// Send typing indicator (lasts 10 seconds, no need for interval) // Send typing indicator (lasts 10 seconds, no need for interval)
try { try {
if ('sendTyping' in message.channel) { if ('sendTyping' in message.channel) {
(message.channel as any).sendTyping(); await (message.channel as { sendTyping: () => Promise<unknown> }).sendTyping();
} }
} catch { /* ignore typing errors */ } } catch { /* ignore typing errors */ }
+21 -10
View File
@@ -57,6 +57,10 @@ interface WhatsAppMessage {
type?: string; type?: string;
/** Download the media attached to this message. */ /** Download the media attached to this message. */
downloadMedia?: () => Promise<{ mimetype: string; data: string; filename?: string } | null>; downloadMedia?: () => Promise<{ mimetype: string; data: string; filename?: string } | null>;
/** Chat handle for typing indicator. */
getChat?: () => Promise<{ sendStateTyping: () => Promise<void> }>;
/** Mentioned user IDs in message metadata. */
mentionedIds?: string[];
} }
/** /**
@@ -110,34 +114,41 @@ export class WhatsAppAdapter implements ChannelAdapter {
args: puppeteerArgs, args: puppeteerArgs,
}, },
}); });
const client = this.client;
if (!client) {
throw new Error('WhatsApp client initialization failed');
}
// Promise that resolves on 'ready' or rejects on 'auth_failure' // Promise that resolves on 'ready' or rejects on 'auth_failure'
const readyPromise = new Promise<void>((resolve, reject) => { const readyPromise = new Promise<void>((resolve, reject) => {
this.client!.on('ready', () => { client.on('ready', () => {
console.log('WhatsApp bot connected'); console.log('WhatsApp bot connected');
this._status = 'connected'; this._status = 'connected';
// Capture bot's own JID for mention detection // Capture bot's own JID for mention detection
this.botId = (this.client as any)?.info?.wid?._serialized; const clientInfo = client as InstanceType<typeof Client> & {
info?: { wid?: { _serialized?: string } };
};
this.botId = clientInfo.info?.wid?._serialized;
resolve(); resolve();
}); });
this.client!.on('auth_failure', (msg: string) => { client.on('auth_failure', (msg: string) => {
this._status = 'error'; this._status = 'error';
reject(new Error(`WhatsApp auth failure: ${msg}`)); reject(new Error(`WhatsApp auth failure: ${msg}`));
}); });
this.client!.on('qr', (qr: string) => { client.on('qr', (qr: string) => {
console.log('WhatsApp QR code received. Scan with your phone:'); console.log('WhatsApp QR code received. Scan with your phone:');
console.log(qr); console.log(qr);
}); });
}); });
// Register message event handler // Register message event handler
this.client.on('message', (message: unknown) => { client.on('message', (message: unknown) => {
this.handleMessage(message as WhatsAppMessage); this.handleMessage(message as WhatsAppMessage);
}); });
await this.client.initialize(); await client.initialize();
await readyPromise; await readyPromise;
} catch (error) { } catch (error) {
this._status = 'error'; this._status = 'error';
@@ -234,7 +245,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
requireMention: this.config.requireMention, requireMention: this.config.requireMention,
defaultRequireMention: true, defaultRequireMention: true,
mentionsBot: message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) || mentionsBot: message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) ||
(message as unknown as { mentionedIds?: string[] }).mentionedIds?.some((id) => id === this.botId) === true, message.mentionedIds?.some((id) => id === this.botId) === true,
})) { })) {
// WhatsApp mentions use @phone_number format in body // WhatsApp mentions use @phone_number format in body
// Also check for mentions in the message mentionedIds // Also check for mentions in the message mentionedIds
@@ -269,8 +280,8 @@ export class WhatsAppAdapter implements ChannelAdapter {
// Send typing indicator // Send typing indicator
try { try {
const chat = await (message as any).getChat(); const chat = await message.getChat?.();
await chat.sendStateTyping(); await chat?.sendStateTyping();
} catch { /* ignore typing errors */ } } catch { /* ignore typing errors */ }
// Strip bot mention from message body for group messages // Strip bot mention from message body for group messages
@@ -286,7 +297,7 @@ export class WhatsAppAdapter implements ChannelAdapter {
const attachments: Attachment[] = []; const attachments: Attachment[] = [];
if (message.hasMedia) { if (message.hasMedia) {
try { try {
const media = await (message as any).downloadMedia(); const media = await message.downloadMedia?.();
if (media && typeof media.mimetype === 'string') { if (media && typeof media.mimetype === 'string') {
const mimeType = media.mimetype; const mimeType = media.mimetype;
const isAudio = mimeType.startsWith('audio/'); const isAudio = mimeType.startsWith('audio/');
+20 -15
View File
@@ -20,6 +20,11 @@ export interface DoctorContext {
} }
type Check = (ctx: DoctorContext) => Promise<CheckResult>; type Check = (ctx: DoctorContext) => Promise<CheckResult>;
type UnknownRecord = Record<string, unknown>;
const asRecord = (value: unknown): UnknownRecord | undefined => (
value && typeof value === 'object' ? value as UnknownRecord : undefined
);
const checkConfigExists: Check = async (ctx) => { const checkConfigExists: Check = async (ctx) => {
if (existsSync(ctx.configPath)) { if (existsSync(ctx.configPath)) {
@@ -78,8 +83,9 @@ const checkDeprecatedConfigKeys: Check = async (ctx) => {
try { try {
const raw = readFileSync(ctx.configPath, 'utf-8'); const raw = readFileSync(ctx.configPath, 'utf-8');
const parsed = parse(raw) as any; const parsed = asRecord(parse(raw));
const tailscaleOnly = Boolean(parsed?.server && typeof parsed.server === 'object' && 'tailscale_only' in parsed.server); const server = asRecord(parsed?.server);
const tailscaleOnly = Boolean(server && 'tailscale_only' in server);
if (tailscaleOnly) { if (tailscaleOnly) {
return { return {
@@ -198,11 +204,11 @@ const checkModelConnectivity: Check = async (ctx) => {
return true; return true;
} }
const oauth = o.oauth; const oauth = o.oauth;
const oauthRecord = asRecord(oauth);
return Boolean( return Boolean(
oauth oauthRecord
&& typeof oauth === 'object' && typeof oauthRecord.access_token === 'string'
&& typeof (oauth as any).access_token === 'string' && typeof oauthRecord.refresh_token === 'string',
&& typeof (oauth as any).refresh_token === 'string',
); );
}; };
@@ -213,22 +219,21 @@ const checkModelConnectivity: Check = async (ctx) => {
} }
const o = openai as Record<string, unknown>; const o = openai as Record<string, unknown>;
const apiKey = o.api_key; const apiKey = o.api_key;
const apiKeyRecord = asRecord(apiKey);
return Boolean( return Boolean(
apiKey typeof apiKeyRecord?.api_key === 'string'
&& typeof apiKey === 'object' && apiKeyRecord.api_key.length > 0,
&& typeof (apiKey as any).api_key === 'string'
&& (apiKey as any).api_key.length > 0,
); );
}; };
const storeAnthropicApiKeyPresent = (): boolean => { const storeAnthropicApiKeyPresent = (): boolean => {
const anthropic = store.anthropic as any; const anthropic = asRecord(store.anthropic);
return Boolean(anthropic && typeof anthropic.api_key === 'string' && anthropic.api_key.length > 0); return Boolean(typeof anthropic?.api_key === 'string' && anthropic.api_key.length > 0);
}; };
const storeAnthropicAuthTokenPresent = (): boolean => { const storeAnthropicAuthTokenPresent = (): boolean => {
const anthropic = store.anthropic as any; const anthropic = asRecord(store.anthropic);
return Boolean(anthropic && typeof anthropic.auth_token === 'string' && anthropic.auth_token.length > 0); return Boolean(typeof anthropic?.auth_token === 'string' && anthropic.auth_token.length > 0);
}; };
const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => { const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => {
@@ -318,7 +323,7 @@ const checkModelConnectivity: Check = async (ctx) => {
const envVar = envVarMap[provider]; const envVar = envVarMap[provider];
const sources = { const sources = {
config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0,
env: Boolean(envVar && process.env[envVar] && process.env[envVar]!.length > 0), env: Boolean(envVar && typeof process.env[envVar] === 'string' && process.env[envVar].length > 0),
store: false, store: false,
}; };
const ok = sources.config || sources.env; const ok = sources.config || sources.env;
+3 -5
View File
@@ -1,16 +1,14 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { createInterface } from 'readline/promises'; import type { Interface as ReadlineInterface } from 'readline/promises';
import { EventEmitter } from 'events';
import { createPrompter } from './prompts.js'; import { createPrompter } from './prompts.js';
import { ConfigBuilder } from './config.js'; import { ConfigBuilder } from './config.js';
import { setupProviders } from './providers.js'; import { setupProviders } from './providers.js';
function mockReadline(inputs: string[]) { function mockReadline(inputs: string[]) {
let questionIdx = 0; let questionIdx = 0;
const emitter = new EventEmitter();
return { return {
async question(query: string) { async question(_query: string) {
const answer = inputs[questionIdx++]; const answer = inputs[questionIdx++];
return answer ?? ''; return answer ?? '';
}, },
@@ -26,7 +24,7 @@ function mockReadline(inputs: string[]) {
async next() { async next() {
return { done: true }; return { done: true };
}, },
} as any; } as unknown as ReadlineInterface;
} }
describe('setupProviders', () => { describe('setupProviders', () => {
+7 -4
View File
@@ -1,7 +1,7 @@
import * as readline from 'node:readline'; import * as readline from 'node:readline';
import type { ManagedSession } from '../../session/index.js'; import type { ManagedSession } from '../../session/index.js';
import type { ModelClient, TokenUsage } from '../../models/types.js'; import type { ModelClient, TokenUsage } from '../../models/types.js';
import type { ModelRouter, ModelTier } from '../../models/router.js'; import type { ModelRouter } from '../../models/router.js';
import type { NativeAgent } from '../../backends/native/agent.js'; import type { NativeAgent } from '../../backends/native/agent.js';
import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js'; import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js';
import { renderMarkdown } from './markdown.js'; import { renderMarkdown } from './markdown.js';
@@ -406,13 +406,13 @@ export class MinimalTui {
} }
}); });
}); });
} catch (error) { } catch {
// Service might not exist or already stopped, ignore // Service might not exist or already stopped, ignore
console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`); console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`);
} }
} }
private async startBackend(provider: string, config: ModelConfig): Promise<void> { private async startBackend(provider: string, _config: ModelConfig): Promise<void> {
try { try {
const { exec } = await import('child_process'); const { exec } = await import('child_process');
let serviceName: string; let serviceName: string;
@@ -454,7 +454,10 @@ export class MinimalTui {
const promptHidden = async (question: string): Promise<string> => { const promptHidden = async (question: string): Promise<string> => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const rlAny = rl as any; const rlAny = rl as readline.Interface & {
stdoutMuted?: boolean;
_writeToOutput?: (s: string) => void;
};
rlAny.stdoutMuted = true; rlAny.stdoutMuted = true;
rlAny._writeToOutput = (s: string) => { rlAny._writeToOutput = (s: string) => {
if (!rlAny.stdoutMuted) { if (!rlAny.stdoutMuted) {
+9 -7
View File
@@ -135,6 +135,8 @@ export class GatewayServer {
} }
private registerHandlers(): void { private registerHandlers(): void {
const channelRegistry = this.config.channelRegistry;
const runtimeConfig = this.config.config;
const systemHandlers = createSystemHandlers({ const systemHandlers = createSystemHandlers({
startTime: this.startTime, startTime: this.startTime,
version: this.config.version ?? '0.1.0', version: this.config.version ?? '0.1.0',
@@ -142,14 +144,14 @@ export class GatewayServer {
getToolCount: () => this.config.toolRegistry.list().length, getToolCount: () => this.config.toolRegistry.list().length,
getConnectionCount: () => this.sessionBridge.connectionCount, getConnectionCount: () => this.sessionBridge.connectionCount,
restart: this.config.restart, restart: this.config.restart,
getChannels: this.config.channelRegistry getChannels: channelRegistry
? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status })) ? () => channelRegistry.list().map(a => ({ name: a.name, status: a.status }))
: undefined, : undefined,
getServices: this.config.config && this.config.channelRegistry getServices: runtimeConfig && channelRegistry
? () => discoverServices(this.config.config!, this.config.channelRegistry!) ? () => discoverServices(runtimeConfig, channelRegistry)
: undefined, : undefined,
getPresence: this.config.channelRegistry getPresence: channelRegistry
? (opts) => this.config.channelRegistry!.getPresence(opts) ? (opts) => channelRegistry.getPresence(opts)
: undefined, : undefined,
getUsage: () => ({ getUsage: () => ({
totalSessions: this.config.sessionManager.listSessions().length, totalSessions: this.config.sessionManager.listSessions().length,
@@ -307,7 +309,7 @@ export class GatewayServer {
}); });
} }
private handleConnection(ws: WebSocket, identity?: string): void { private handleConnection(ws: WebSocket, _identity?: string): void {
// Gateway lock — reject if another client is already connected // Gateway lock — reject if another client is already connected
if (this.config.lock && this.connectionMap.size > 0) { if (this.config.lock && this.connectionMap.size > 0) {
ws.close(4003, 'Gateway locked — another client is already connected'); ws.close(4003, 'Gateway locked — another client is already connected');
+7 -4
View File
@@ -124,7 +124,10 @@ describe('SessionBridge', () => {
const bridge = createBridge(); const bridge = createBridge();
bridge.connect('conn-1'); bridge.connect('conn-1');
const agent = bridge.getAgent('conn-1'); const agent = bridge.getAgent('conn-1');
const cancelSpy = vi.spyOn(agent!, 'cancel'); if (!agent) {
throw new Error('Expected agent for conn-1');
}
const cancelSpy = vi.spyOn(agent, 'cancel');
bridge.setBusy('conn-1', true); bridge.setBusy('conn-1', true);
expect(bridge.cancel('conn-1')).toBe(true); expect(bridge.cancel('conn-1')).toBe(true);
@@ -193,7 +196,7 @@ describe('SessionBridge', () => {
}, },
compaction: { enabled: false }, compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } },
} as any); } as unknown as SessionBridgeConfig['config']);
bridge.connect('conn-tier'); bridge.connect('conn-tier');
const agent = bridge.getAgent('conn-tier'); const agent = bridge.getAgent('conn-tier');
@@ -201,7 +204,7 @@ describe('SessionBridge', () => {
}); });
it('keeps different sessions isolated by persisted model tier', () => { it('keeps different sessions isolated by persisted model tier', () => {
const sessionById: Record<string, any> = {}; const sessionById: Record<string, typeof mockSession> = {};
const localSessionManager = { const localSessionManager = {
...mockSessionManager, ...mockSessionManager,
getSession: vi.fn((frontend: string, sessionId: string) => { getSession: vi.fn((frontend: string, sessionId: string) => {
@@ -238,7 +241,7 @@ describe('SessionBridge', () => {
}, },
compaction: { enabled: false }, compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } },
} as any, } as unknown as SessionBridgeConfig['config'],
}); });
bridge.connect('conn-a'); bridge.connect('conn-a');
+5 -3
View File
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { HybridSearch } from './hybrid-search.js'; import { HybridSearch } from './hybrid-search.js';
import type { HybridSearchResult } from './hybrid-search.js';
import type { MemoryStore, SearchResult } from './store.js'; import type { MemoryStore, SearchResult } from './store.js';
import type { VectorStore, VectorSearchResult } from './vector-store.js'; import type { VectorStore, VectorSearchResult } from './vector-store.js';
import type { EmbeddingProvider } from './embeddings.js'; import type { EmbeddingProvider } from './embeddings.js';
@@ -153,7 +152,10 @@ describe('HybridSearch', () => {
const keywordResult = results.find((r) => r.source === 'keyword'); const keywordResult = results.find((r) => r.source === 'keyword');
expect(vectorResult).toBeDefined(); expect(vectorResult).toBeDefined();
expect(keywordResult).toBeDefined(); expect(keywordResult).toBeDefined();
expect(vectorResult!.score).toBeGreaterThan(keywordResult!.score); if (!vectorResult || !keywordResult) {
throw new Error('Expected both vector and keyword results');
}
expect(vectorResult.score).toBeGreaterThan(keywordResult.score);
}); });
it('falls back to keyword search when vector search fails', async () => { it('falls back to keyword search when vector search fails', async () => {
+23 -9
View File
@@ -1,6 +1,6 @@
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
import type { Message as AnthropicMessage } from '@anthropic-ai/sdk/resources/messages/messages.js'; import type { Message as AnthropicMessage } from '@anthropic-ai/sdk/resources/messages/messages.js';
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, Message, MessageContentPart } from './types.js'; import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, MessageContentPart } from './types.js';
export interface AnthropicClientConfig { export interface AnthropicClientConfig {
apiKey?: string; // Falls back to ANTHROPIC_API_KEY env var apiKey?: string; // Falls back to ANTHROPIC_API_KEY env var
@@ -23,21 +23,27 @@ function toAnthropicContent(content: string | MessageContentPart[]): string | un
} }
if (part.type === 'image') { if (part.type === 'image') {
if (part.source.type === 'base64') { if (part.source.type === 'base64') {
if (!part.source.data) {
return { type: 'text', text: '[Image omitted: missing base64 data]' };
}
return { return {
type: 'image', type: 'image',
source: { source: {
type: 'base64', type: 'base64',
media_type: part.source.media_type, media_type: part.source.media_type,
data: part.source.data!, data: part.source.data,
}, },
}; };
} }
if (!part.source.url) {
return { type: 'text', text: '[Image omitted: missing URL]' };
}
// URL-based image // URL-based image
return { return {
type: 'image', type: 'image',
source: { source: {
type: 'url', type: 'url',
url: part.source.url!, url: part.source.url,
}, },
}; };
} }
@@ -52,6 +58,10 @@ function toAnthropicContent(content: string | MessageContentPart[]): string | un
}); });
} }
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' ? value as Record<string, unknown> : undefined;
}
export class AnthropicClient implements ModelClient { export class AnthropicClient implements ModelClient {
private client: Anthropic; private client: Anthropic;
private model: string; private model: string;
@@ -67,14 +77,17 @@ export class AnthropicClient implements ModelClient {
} }
async chat(request: ChatRequest): Promise<ChatResponse> { async chat(request: ChatRequest): Promise<ChatResponse> {
const params: Record<string, unknown> = { type CreateParams = Parameters<typeof this.client.messages.create>[0] & {
thinking?: { type: 'enabled'; budget_tokens: number };
};
const params: CreateParams = {
model: this.model, model: this.model,
max_tokens: request.maxTokens ?? this.defaultMaxTokens, max_tokens: request.maxTokens ?? this.defaultMaxTokens,
system: request.system, system: request.system,
messages: request.messages.map((m) => ({ messages: request.messages.map((m) => ({
role: m.role, role: m.role,
content: toAnthropicContent(m.content), content: toAnthropicContent(m.content),
})), })) as CreateParams['messages'],
}; };
if (request.tools && request.tools.length > 0) { if (request.tools && request.tools.length > 0) {
@@ -83,18 +96,19 @@ export class AnthropicClient implements ModelClient {
// Extended thinking mode — enable thinking with a budget // Extended thinking mode — enable thinking with a budget
if (request.thinking) { if (request.thinking) {
params.max_tokens = Math.max(params.max_tokens as number, 16384); params.max_tokens = Math.max(params.max_tokens, 16384);
(params as any).thinking = { type: 'enabled', budget_tokens: 4096 }; params.thinking = { type: 'enabled', budget_tokens: 4096 };
} }
const response = await this.client.messages.create(params as unknown as Parameters<typeof this.client.messages.create>[0]) as AnthropicMessage; const response = await this.client.messages.create(params) as AnthropicMessage;
const textContent = response.content.find((c) => c.type === 'text'); const textContent = response.content.find((c) => c.type === 'text');
const content = textContent?.type === 'text' ? textContent.text : ''; const content = textContent?.type === 'text' ? textContent.text : '';
// Extract thinking content if present // Extract thinking content if present
const thinkingBlock = response.content.find((c) => c.type === 'thinking'); const thinkingBlock = response.content.find((c) => c.type === 'thinking');
const thinkingContent = thinkingBlock && 'thinking' in thinkingBlock ? (thinkingBlock as any).text : undefined; const thinkingText = asRecord(thinkingBlock)?.text;
const thinkingContent = typeof thinkingText === 'string' ? thinkingText : undefined;
const toolCalls = response.content const toolCalls = response.content
.filter((c): c is { type: 'tool_use'; id: string; name: string; input: unknown } => c.type === 'tool_use') .filter((c): c is { type: 'tool_use'; id: string; name: string; input: unknown } => c.type === 'tool_use')
+12 -6
View File
@@ -137,8 +137,8 @@ export class OllamaClient implements ModelClient {
private async checkToolSupport(): Promise<boolean> { private async checkToolSupport(): Promise<boolean> {
if (this._supportsTools !== null) {return this._supportsTools;} if (this._supportsTools !== null) {return this._supportsTools;}
try { try {
const info = await this.client.show({ model: this.model }); const info = await this.client.show({ model: this.model }) as { capabilities?: string[] };
const caps: string[] = (info as any).capabilities ?? []; const caps = info.capabilities ?? [];
this._supportsTools = caps.includes('tools'); this._supportsTools = caps.includes('tools');
} catch { } catch {
// Old Ollama or network issue — assume tools are supported // Old Ollama or network issue — assume tools are supported
@@ -151,6 +151,12 @@ export class OllamaClient implements ModelClient {
* Convert Flynn ToolDefinition[] to Ollama Tool[] format. * Convert Flynn ToolDefinition[] to Ollama Tool[] format.
*/ */
private convertTools(tools: ToolDefinition[]): Tool[] { private convertTools(tools: ToolDefinition[]): Tool[] {
type OllamaParameter = {
type?: string | string[];
items?: unknown;
description?: string;
enum?: unknown[];
};
return tools.map(t => ({ return tools.map(t => ({
type: 'function', type: 'function',
function: { function: {
@@ -159,7 +165,7 @@ export class OllamaClient implements ModelClient {
parameters: { parameters: {
type: t.input_schema.type, type: t.input_schema.type,
required: t.input_schema.required, required: t.input_schema.required,
properties: t.input_schema.properties as Record<string, any>, properties: t.input_schema.properties as Record<string, OllamaParameter>,
}, },
}, },
})); }));
@@ -186,7 +192,7 @@ export class OllamaClient implements ModelClient {
// Extract content, checking for thinking field from reasoning models // Extract content, checking for thinking field from reasoning models
let content = response.message.content; let content = response.message.content;
let thinkingContent: string | undefined; let thinkingContent: string | undefined;
const thinking = (response.message as any).thinking; const thinking = (response.message as unknown as { thinking?: unknown }).thinking;
if (thinking && typeof thinking === 'string') { if (thinking && typeof thinking === 'string') {
if (!content) { if (!content) {
// If no regular content, use thinking as content // If no regular content, use thinking as content
@@ -245,7 +251,7 @@ export class OllamaClient implements ModelClient {
} }
// Handle thinking field from reasoning models (e.g., deepseek-r1) // Handle thinking field from reasoning models (e.g., deepseek-r1)
const thinking = (chunk.message as any)?.thinking; const thinking = (chunk.message as unknown as { thinking?: unknown } | undefined)?.thinking;
if (thinking && typeof thinking === 'string') { if (thinking && typeof thinking === 'string') {
yield { type: 'content', content: thinking }; yield { type: 'content', content: thinking };
} }
@@ -259,7 +265,7 @@ export class OllamaClient implements ModelClient {
if (chunk.done) { if (chunk.done) {
// Handle tool_calls in the final chunk // Handle tool_calls in the final chunk
const toolCalls = (chunk.message as any)?.tool_calls; const toolCalls = (chunk.message as unknown as { tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }> } | undefined)?.tool_calls;
if (toolCalls && Array.isArray(toolCalls)) { if (toolCalls && Array.isArray(toolCalls)) {
for (let i = 0; i < toolCalls.length; i++) { for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i]; const tc = toolCalls[i];
+8 -4
View File
@@ -12,7 +12,7 @@ vi.mock('../auth/openai.js', () => ({
})), })),
})); }));
function makeSse(events: Array<{ event: string; data: any }>): string { function makeSse(events: Array<{ event: string; data: unknown }>): string {
return events return events
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`) .map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
.join(''); .join('');
@@ -39,8 +39,12 @@ describe('OpenAIClient OAuth (Codex)', () => {
{ event: 'response.completed', data: { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } } }, { event: 'response.completed', data: { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } } },
]); ]);
globalThis.fetch = vi.fn(async (_url: any, init?: any) => { globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const parsed = JSON.parse(init.body); const body = typeof init?.body === 'string' ? init.body : '';
if (!body) {
throw new Error('Expected JSON body');
}
const parsed = JSON.parse(body) as Record<string, unknown>;
expect(parsed.store).toBe(false); expect(parsed.store).toBe(false);
expect(parsed.stream).toBe(true); expect(parsed.stream).toBe(true);
expect(typeof parsed.instructions).toBe('string'); expect(typeof parsed.instructions).toBe('string');
@@ -54,7 +58,7 @@ describe('OpenAIClient OAuth (Codex)', () => {
}); });
return new Response(stream, { status: 200 }); return new Response(stream, { status: 200 });
}) as any; }) as typeof fetch;
const client = new OpenAIClient({ model: 'gpt-5.3-codex', useOAuth: true }); const client = new OpenAIClient({ model: 'gpt-5.3-codex', useOAuth: true });
const resp = await client.chat({ const resp = await client.chat({
+23 -8
View File
@@ -27,13 +27,22 @@ function toOpenAIContent(content: string | MessageContentPart[]): string | OpenA
return { type: 'text', text: part.text }; return { type: 'text', text: part.text };
} }
if (part.type === 'image') { if (part.type === 'image') {
if (part.source.type === 'base64' && !part.source.data) {
return { type: 'text', text: '[Image omitted: missing base64 data]' };
}
if (part.source.type !== 'base64' && !part.source.url) {
return { type: 'text', text: '[Image omitted: missing URL]' };
}
// OpenAI accepts data URIs or regular URLs // OpenAI accepts data URIs or regular URLs
const url = part.source.type === 'base64' const url = part.source.type === 'base64'
? `data:${part.source.media_type};base64,${part.source.data!}` ? `data:${part.source.media_type};base64,${part.source.data}`
: part.source.url!; : part.source.url;
return { type: 'image_url', image_url: { url } }; return { type: 'image_url', image_url: { url } };
} }
if (part.type === 'audio') { if (part.type === 'audio') {
if (!part.source.data) {
return { type: 'text', text: '[Audio omitted: missing data]' };
}
// OpenAI native audio input via input_audio content part // OpenAI native audio input via input_audio content part
// Determine format from MIME type (OpenAI supports: wav, mp3, flac, opus, ogg, webm) // Determine format from MIME type (OpenAI supports: wav, mp3, flac, opus, ogg, webm)
const formatMap: Record<string, string> = { const formatMap: Record<string, string> = {
@@ -157,9 +166,13 @@ export class OpenAIClient implements ModelClient {
} }
} }
if (!data) {return;} if (!data) {return;}
let obj: any; let obj: Record<string, unknown>;
try { try {
obj = JSON.parse(data); const parsed = JSON.parse(data) as unknown;
if (!parsed || typeof parsed !== 'object') {
return;
}
obj = parsed as Record<string, unknown>;
} catch { } catch {
return; return;
} }
@@ -169,8 +182,9 @@ export class OpenAIClient implements ModelClient {
} }
if (obj.type === 'response.completed') { if (obj.type === 'response.completed') {
const u = obj.response?.usage; const response = obj.response as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined;
if (u) { const u = response?.usage;
if (u && typeof u === 'object') {
usage = { usage = {
inputTokens: u.input_tokens ?? 0, inputTokens: u.input_tokens ?? 0,
outputTokens: u.output_tokens ?? 0, outputTokens: u.output_tokens ?? 0,
@@ -179,7 +193,8 @@ export class OpenAIClient implements ModelClient {
} }
if (obj.type === 'response.failed') { if (obj.type === 'response.failed') {
const detail = obj.response?.error?.message ?? 'OpenAI OAuth response failed'; const response = obj.response as { error?: { message?: string } } | undefined;
const detail = response?.error?.message ?? 'OpenAI OAuth response failed';
throw new Error(detail); throw new Error(detail);
} }
}; };
@@ -247,7 +262,7 @@ export class OpenAIClient implements ModelClient {
// Extended thinking/reasoning mode for o1/o3 models // Extended thinking/reasoning mode for o1/o3 models
if (request.thinking) { if (request.thinking) {
(params as any).reasoning_effort = 'medium'; (params as OpenAI.ChatCompletionCreateParamsNonStreaming & { reasoning_effort?: 'low' | 'medium' | 'high' }).reasoning_effort = 'medium';
} }
let response: OpenAI.ChatCompletion; let response: OpenAI.ChatCompletion;
+21 -9
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { ModelRouter } from './router.js'; import { ModelRouter } from './router.js';
import type { ModelClient, ChatResponse, ChatStreamEvent } from './types.js'; import type { ModelClient, ChatResponse, ChatStreamEvent } from './types.js';
@@ -314,10 +314,13 @@ describe('setClient and labels', () => {
const newFastClient = router.getClient('fast'); const newFastClient = router.getClient('fast');
expect(newFastClient).toBeDefined(); expect(newFastClient).toBeDefined();
if (!newFastClient) {
throw new Error('Expected fast client to be set');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast'); await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast');
expect(newFastClient!.chat).toHaveBeenCalled(); expect(newFastClient.chat).toHaveBeenCalled();
expect(newFastClient!.chat).toHaveBeenCalledTimes(1); expect(newFastClient.chat).toHaveBeenCalledTimes(1);
expect(mockClient1.chat).toHaveBeenCalledTimes(1); expect(mockClient1.chat).toHaveBeenCalledTimes(1);
}); });
@@ -336,10 +339,13 @@ describe('setClient and labels', () => {
const newClient = router.getClient('complex'); const newClient = router.getClient('complex');
expect(newClient).toBe(mockClient2); expect(newClient).toBe(mockClient2);
if (!newClient) {
throw new Error('Expected complex client to be set');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'complex'); await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'complex');
expect(newClient!.chat).toHaveBeenCalled(); expect(newClient.chat).toHaveBeenCalled();
}); });
it('getLabel returns the label set by setClient', () => { it('getLabel returns the label set by setClient', () => {
@@ -424,19 +430,25 @@ describe('setClient and labels', () => {
const initialFastClient = router.getClient('fast'); const initialFastClient = router.getClient('fast');
expect(initialFastClient).toBeDefined(); expect(initialFastClient).toBeDefined();
if (!initialFastClient) {
throw new Error('Expected initial fast client to exist');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast'); await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast');
expect(initialFastClient!.chat).toHaveBeenCalled(); expect(initialFastClient.chat).toHaveBeenCalled();
expect(initialFastClient!.chat).toHaveBeenCalledTimes(1); expect(initialFastClient.chat).toHaveBeenCalledTimes(1);
router.setClient('fast', mockClient2, 'fast-replaced'); router.setClient('fast', mockClient2, 'fast-replaced');
const newFastClient = router.getClient('fast'); const newFastClient = router.getClient('fast');
if (!newFastClient) {
throw new Error('Expected replaced fast client to exist');
}
await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast'); await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast');
expect(newFastClient!.chat).toHaveBeenCalled(); expect(newFastClient.chat).toHaveBeenCalled();
expect(newFastClient!.chat).toHaveBeenCalledTimes(1); expect(newFastClient.chat).toHaveBeenCalledTimes(1);
expect(initialFastClient!.chat).toHaveBeenCalledTimes(1); expect(initialFastClient.chat).toHaveBeenCalledTimes(1);
}); });
it('strict tier mode disables fallback chain for that tier', async () => { it('strict tier mode disables fallback chain for that tier', async () => {
+14 -5
View File
@@ -62,8 +62,11 @@ describe('SkillInstaller', () => {
const skill = installer.install(sourceDir); const skill = installer.install(sourceDir);
expect(skill).not.toBeNull(); expect(skill).not.toBeNull();
expect(skill!.manifest.name).toBe('my-skill'); if (!skill) {
expect(skill!.instructions).toBe('# My Skill\nDo the thing.'); throw new Error('Expected installed skill');
}
expect(skill.manifest.name).toBe('my-skill');
expect(skill.instructions).toBe('# My Skill\nDo the thing.');
expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true); expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true);
}); });
@@ -79,7 +82,10 @@ describe('SkillInstaller', () => {
const skill = installer.install(sourceDir); const skill = installer.install(sourceDir);
expect(skill).not.toBeNull(); expect(skill).not.toBeNull();
expect(skill!.manifest.tier).toBe('managed'); if (!skill) {
throw new Error('Expected installed skill');
}
expect(skill.manifest.tier).toBe('managed');
}); });
it('uses manifest.json name field for the installed directory name', () => { it('uses manifest.json name field for the installed directory name', () => {
@@ -131,8 +137,11 @@ describe('SkillInstaller', () => {
const skill = installer.install(sourceV2); const skill = installer.install(sourceV2);
expect(skill).not.toBeNull(); expect(skill).not.toBeNull();
expect(skill!.manifest.version).toBe('2.0.0'); if (!skill) {
expect(skill!.instructions).toBe('# V2'); throw new Error('Expected installed skill');
}
expect(skill.manifest.version).toBe('2.0.0');
expect(skill.instructions).toBe('# V2');
}); });
it('throws when source directory does not exist', () => { it('throws when source directory does not exist', () => {