chore(lint): burn down remaining warnings to zero

This commit is contained in:
William Valentin
2026-02-15 23:14:21 -08:00
parent 49b752e8b0
commit 948d4ac6d8
67 changed files with 235 additions and 256 deletions
+3 -3
View File
@@ -2677,10 +2677,10 @@
"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": { "audit-followup-lint-warning-reduction-pass-2": {
"status": "in_progress", "status": "completed",
"date": "2026-02-16", "date": "2026-02-16",
"updated": "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.", "summary": "Completed warning burn-down by removing non-null assertions and broad `any` usage, tightening adapter/client typing, and cleaning unused imports/params across orchestrator/model/channel/gateway/TUI/setup/skills/tools files and tests. Warning count reduced from 203 to 0 (203 warnings burned down) with lint fully clean.",
"files_modified": [ "files_modified": [
"src/backends/native/agent.ts", "src/backends/native/agent.ts",
"src/backends/native/orchestrator.test.ts", "src/backends/native/orchestrator.test.ts",
@@ -2699,7 +2699,7 @@
"src/models/router.test.ts", "src/models/router.test.ts",
"src/skills/installer.test.ts" "src/skills/installer.test.ts"
], ],
"test_status": "pnpm exec eslint on edited files + pnpm lint passing (0 errors, 115 warnings)" "test_status": "pnpm exec eslint on edited files + pnpm lint passing (0 errors, 0 warnings)"
} }
}, },
"overall_progress": { "overall_progress": {
+8 -2
View File
@@ -52,12 +52,18 @@ describe('AgentConfigRegistry', () => {
}); });
expect(registry.list()).toHaveLength(2); expect(registry.list()).toHaveLength(2);
const assistant = registry.get('assistant')!; const assistant = registry.get('assistant');
if (!assistant) {
throw new Error('Expected assistant config');
}
expect(assistant.systemPrompt).toBe('Be helpful.'); expect(assistant.systemPrompt).toBe('Be helpful.');
expect(assistant.modelTier).toBe('default'); expect(assistant.modelTier).toBe('default');
expect(assistant.toolProfile).toBe('messaging'); expect(assistant.toolProfile).toBe('messaging');
const coder = registry.get('coder')!; const coder = registry.get('coder');
if (!coder) {
throw new Error('Expected coder config');
}
expect(coder.sandbox).toBe(true); expect(coder.sandbox).toBe(true);
}); });
}); });
-1
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { AgentRouter } from './router.js'; import { AgentRouter } from './router.js';
import type { RoutingConfig } from '../config/schema.js';
describe('AgentRouter', () => { describe('AgentRouter', () => {
describe('resolve()', () => { describe('resolve()', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import { createReadStream, promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { dirname, basename } from 'path';
import type { AuditEvent, AuditQuery } from './types.js'; import type { AuditEvent, AuditQuery } from './types.js';
export async function queryAuditLogs(logPath: string, query: AuditQuery): Promise<AuditEvent[]> { export async function queryAuditLogs(logPath: string, query: AuditQuery): Promise<AuditEvent[]> {
+7 -5
View File
@@ -1,4 +1,4 @@
import { createWriteStream, existsSync, mkdirSync, promises as fs } from 'fs'; import { createWriteStream, existsSync, mkdirSync } from 'fs';
import { dirname } from 'path'; import { dirname } from 'path';
import type { import type {
AuditEvent, AuditEvent,
@@ -53,14 +53,15 @@ export class AuditLogger {
} }
private write(event: Omit<AuditEvent, 'timestamp'>): void { private write(event: Omit<AuditEvent, 'timestamp'>): void {
if (!this.config.enabled || !this.writeStream) { const writeStream = this.writeStream;
if (!this.config.enabled || !writeStream) {
return; return;
} }
this.rotator.checkRotation(); this.rotator.checkRotation();
const fullEvent: AuditEvent = { ...event, timestamp: Date.now() }; const fullEvent: AuditEvent = { ...event, timestamp: Date.now() };
this.writeStream!.write(JSON.stringify(fullEvent) + '\n'); writeStream.write(JSON.stringify(fullEvent) + '\n');
} }
private shouldLog(category: 'tools' | 'sessions' | 'automation', level: string): boolean { private shouldLog(category: 'tools' | 'sessions' | 'automation', level: string): boolean {
@@ -294,9 +295,10 @@ export class AuditLogger {
// ── Lifecycle ─────────────────────────────────────────────── // ── Lifecycle ───────────────────────────────────────────────
async close(): Promise<void> { async close(): Promise<void> {
if (this.writeStream) { const writeStream = this.writeStream;
if (writeStream) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
this.writeStream!.end(() => resolve()); writeStream.end(() => resolve());
}); });
this.writeStream = null; this.writeStream = null;
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { statfsSync, accessSync, constants as fsConstants } from 'fs'; import { statfsSync, accessSync, constants as fsConstants } from 'fs';
import { request } from 'http'; import { request } from 'http';
import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js'; import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js'; import type { ChannelAdapter, OutboundMessage } from '../channels/types.js';
import { auditLogger } from '../audit/index.js'; import { auditLogger } from '../audit/index.js';
/** Result of a single health check. */ /** Result of a single health check. */
+4 -4
View File
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { NativeAgent } from './agent.js'; import { NativeAgent } from './agent.js';
import type { ModelClient, ChatResponse } from '../../models/types.js'; import type { ModelClient, ChatRequest, ChatResponse } from '../../models/types.js';
import { ToolRegistry, ToolExecutor } from '../../tools/index.js'; import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
import { HookEngine } from '../../hooks/index.js'; import { HookEngine } from '../../hooks/index.js';
import type { Tool, ToolResult } from '../../tools/index.js'; import type { Tool } from '../../tools/index.js';
describe('NativeAgent', () => { describe('NativeAgent', () => {
const createMockClient = (): ModelClient => ({ const createMockClient = (): ModelClient => ({
@@ -197,7 +197,7 @@ describe('NativeAgent tool loop', () => {
it('nudges model after same tool called too many times with different args', async () => { it('nudges model after same tool called too many times with different args', async () => {
let callCount = 0; let callCount = 0;
const mockClient: ModelClient = { const mockClient: ModelClient = {
chat: vi.fn().mockImplementation((req: any) => { chat: vi.fn().mockImplementation((req: ChatRequest) => {
callCount++; callCount++;
// After nudge message, model should respond with text // After nudge message, model should respond with text
const lastMsg = req.messages[req.messages.length - 1]; const lastMsg = req.messages[req.messages.length - 1];
+4 -1
View File
@@ -15,7 +15,10 @@ function createMockClient() {
user: null as { id: string; tag: string } | null, user: null as { id: string; tag: string } | null,
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
if (!handlers.has(event)) {handlers.set(event, []);} if (!handlers.has(event)) {handlers.set(event, []);}
handlers.get(event)!.push(handler); const eventHandlers = handlers.get(event);
if (eventHandlers) {
eventHandlers.push(handler);
}
}), }),
login: vi.fn(async (_token: string) => { login: vi.fn(async (_token: string) => {
// Set user info after login // Set user info after login
+2 -2
View File
@@ -87,7 +87,7 @@ describe('MatrixAdapter', () => {
it('send delivers a message via PUT', async () => { it('send delivers a message via PUT', async () => {
let syncStarted = false; let syncStarted = false;
mockFetch.mockImplementation(async (url: string, init?: any) => { mockFetch.mockImplementation(async (url: string, init?: RequestInit) => {
if (url.endsWith('/_matrix/client/v3/account/whoami')) { if (url.endsWith('/_matrix/client/v3/account/whoami')) {
return jsonResponse({ user_id: '@flynn:example.org' }); return jsonResponse({ user_id: '@flynn:example.org' });
} }
@@ -99,7 +99,7 @@ describe('MatrixAdapter', () => {
return new Promise<Response>(() => {}); return new Promise<Response>(() => {});
} }
if (init?.method === 'PUT' && url.includes('/send/m.room.message/')) { if (init?.method === 'PUT' && url.includes('/send/m.room.message/')) {
const body = JSON.parse(init.body); const body = JSON.parse(String(init?.body ?? '{}'));
expect(body.msgtype).toBe('m.text'); expect(body.msgtype).toBe('m.text');
expect(body.body).toBe('Hello there'); expect(body.body).toBe('Hello there');
return jsonResponse({ event_id: '$sent1' }); return jsonResponse({ event_id: '$sent1' });
+4 -2
View File
@@ -201,8 +201,10 @@ export class MatrixAdapter implements ChannelAdapter {
return; return;
} }
const err = error as any; const errName = error && typeof error === 'object' && 'name' in error
if (err && typeof err === 'object' && err.name === 'AbortError') { ? String((error as { name?: unknown }).name)
: '';
if (errName === 'AbortError') {
return; return;
} }
+1 -1
View File
@@ -121,7 +121,7 @@ describe('PairingManager', () => {
it('listPendingCodes returns only non-expired codes', () => { it('listPendingCodes returns only non-expired codes', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const code1 = manager.generateCode('first'); manager.generateCode('first');
// Advance time so the first code is almost expired // Advance time so the first code is almost expired
vi.advanceTimersByTime(200_000); vi.advanceTimersByTime(200_000);
+5 -1
View File
@@ -226,7 +226,11 @@ export class SlackAdapter implements ChannelAdapter {
} }
try { try {
const result = await this.app!.client.users.info({ user: userId }); const app = this.app;
if (!app) {
return userId;
}
const result = await app.client.users.info({ user: userId });
const name = result.user?.real_name || result.user?.name || userId; const name = result.user?.real_name || result.user?.name || userId;
this.userNameCache.set(userId, { name, expiresAt: now + this.userNameCacheTtlMs }); this.userNameCache.set(userId, { name, expiresAt: now + this.userNameCacheTtlMs });
if (this.userNameCache.size > this.userNameCacheMaxEntries) { if (this.userNameCache.size > this.userNameCacheMaxEntries) {
+4 -4
View File
@@ -2,7 +2,6 @@ import { Bot, InputFile } from 'grammy';
import type { HookEngine } from '../../hooks/index.js'; import type { HookEngine } from '../../hooks/index.js';
import type { import type {
Attachment,
InboundMessage, InboundMessage,
OutboundMessage, OutboundMessage,
OutboundAttachment, OutboundAttachment,
@@ -438,7 +437,8 @@ export class TelegramAdapter implements ChannelAdapter {
/** Send an outbound message, automatically chunking if it exceeds Telegram's limit. */ /** Send an outbound message, automatically chunking if it exceeds Telegram's limit. */
async send(peerId: string, message: OutboundMessage): Promise<void> { async send(peerId: string, message: OutboundMessage): Promise<void> {
if (!this.bot) {throw new Error('Telegram adapter not connected');} const bot = this.bot;
if (!bot) {throw new Error('Telegram adapter not connected');}
const chatId = Number(peerId); const chatId = Number(peerId);
const text = message.text ?? ''; const text = message.text ?? '';
@@ -459,7 +459,7 @@ export class TelegramAdapter implements ChannelAdapter {
// is strict and can fail on unescaped characters. If Telegram rejects the // is strict and can fail on unescaped characters. If Telegram rejects the
// message, retry once without parse_mode so users still get the content. // message, retry once without parse_mode so users still get the content.
try { try {
await this.bot!.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' }); await bot.api.sendMessage(chatId, chunk, { parse_mode: 'Markdown' });
} catch (error) { } catch (error) {
const description = error && typeof error === 'object' && 'description' in error const description = error && typeof error === 'object' && 'description' in error
? String((error as { description?: unknown }).description) ? String((error as { description?: unknown }).description)
@@ -472,7 +472,7 @@ export class TelegramAdapter implements ChannelAdapter {
throw error; throw error;
} }
await this.bot!.api.sendMessage(chatId, chunk); await bot.api.sendMessage(chatId, chunk);
} }
}; };
+8 -2
View File
@@ -31,10 +31,16 @@ describe('sessions command', () => {
const telegramSession = sessions.find(s => s.id === 'telegram:123'); const telegramSession = sessions.find(s => s.id === 'telegram:123');
expect(telegramSession).toBeDefined(); expect(telegramSession).toBeDefined();
expect(telegramSession!.messageCount).toBe(2); if (!telegramSession) {
throw new Error('Expected telegram session');
}
expect(telegramSession.messageCount).toBe(2);
const tuiSession = sessions.find(s => s.id === 'tui:local'); const tuiSession = sessions.find(s => s.id === 'tui:local');
expect(tuiSession).toBeDefined(); expect(tuiSession).toBeDefined();
expect(tuiSession!.messageCount).toBe(1); if (!tuiSession) {
throw new Error('Expected tui session');
}
expect(tuiSession.messageCount).toBe(1);
}); });
}); });
+2 -2
View File
@@ -110,8 +110,8 @@ export async function setupAutomation(p: Prompter, builder: ConfigBuilder): Prom
* Run OAuth auth flows for enabled Google services. * Run OAuth auth flows for enabled Google services.
* Called after config is saved so the auth commands can read it. * Called after config is saved so the auth commands can read it.
*/ */
export async function runGoogleAuth(p: Prompter, config: Record<string, any>): Promise<void> { export async function runGoogleAuth(p: Prompter, config: Record<string, unknown>): Promise<void> {
const automation = config.automation as Record<string, any> | undefined; const automation = config.automation as Record<string, unknown> | undefined;
if (!automation) {return;} if (!automation) {return;}
const pending: { name: string; authCmd: string }[] = []; const pending: { name: string; authCmd: string }[] = [];
+3 -4
View File
@@ -1,15 +1,14 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'events'; import type { Interface as ReadlineInterface } from 'readline/promises';
import { createPrompter } from './prompts.js'; import { createPrompter } from './prompts.js';
import { ConfigBuilder } from './config.js'; import { ConfigBuilder } from './config.js';
import { setupChannels } from './channels.js'; import { setupChannels } from './channels.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 ?? '';
}, },
@@ -25,7 +24,7 @@ function mockReadline(inputs: string[]) {
async next() { async next() {
return { done: true }; return { done: true };
}, },
} as any; } as unknown as ReadlineInterface;
} }
describe('setupChannels', () => { describe('setupChannels', () => {
+2 -2
View File
@@ -155,8 +155,8 @@ export class ConfigBuilder {
this.config.automation = automation; this.config.automation = automation;
} }
build(): Record<string, any> { build(): Record<string, unknown> {
return structuredClone(this.config) as Record<string, any>; return structuredClone(this.config) as Record<string, unknown>;
} }
toYaml(): string { toYaml(): string {
+1 -1
View File
@@ -14,7 +14,7 @@ function mockReadline(inputs: string[]): ReadlineInterface {
throw new Error('No more inputs'); throw new Error('No more inputs');
}), }),
close: vi.fn(), close: vi.fn(),
} as any as ReadlineInterface; } as unknown as ReadlineInterface;
} }
describe('first-run wizard integration', () => { describe('first-run wizard integration', () => {
+2 -2
View File
@@ -39,8 +39,8 @@ export async function setupMemory(p: Prompter, builder: ConfigBuilder): Promise<
p.println('✓ Vector search enabled'); p.println('✓ Vector search enabled');
} }
function findReusableApiKey(config: Record<string, any>, embeddingProvider: string): string | undefined { function findReusableApiKey(config: Record<string, unknown>, embeddingProvider: string): string | undefined {
const models = config.models ?? {}; const models = (config.models as Record<string, { provider?: string; api_key?: string }>) ?? {};
for (const tier of ['default', 'fast', 'complex', 'local']) { for (const tier of ['default', 'fast', 'complex', 'local']) {
const m = models[tier]; const m = models[tier];
if (m?.provider === embeddingProvider && m?.api_key) {return m.api_key;} if (m?.provider === embeddingProvider && m?.api_key) {return m.api_key;}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it } from 'vitest';
import { createInterface } from 'readline/promises'; import { createInterface } from 'readline/promises';
import { Readable, Writable } from 'stream'; import { Readable, Writable } from 'stream';
import { createPrompter } from './prompts.js'; import { createPrompter } from './prompts.js';
+3 -2
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { Interface as ReadlineInterface } from 'readline/promises';
import { createPrompter } from './prompts.js'; import { createPrompter } from './prompts.js';
import { ConfigBuilder } from './config.js'; import { ConfigBuilder } from './config.js';
import { setupMemory } from './memory.js'; import { setupMemory } from './memory.js';
@@ -9,7 +10,7 @@ function mockReadline(inputs: string[]) {
let questionIdx = 0; let questionIdx = 0;
return { return {
async question(query: string) { async question(_query: string) {
const answer = inputs[questionIdx++]; const answer = inputs[questionIdx++];
return answer ?? ''; return answer ?? '';
}, },
@@ -25,7 +26,7 @@ function mockReadline(inputs: string[]) {
async next() { async next() {
return { done: true }; return { done: true };
}, },
} as any; } as unknown as ReadlineInterface;
} }
describe('setupMemory', () => { describe('setupMemory', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
export function renderSummary(config: Record<string, any>): string { export function renderSummary(config: Record<string, unknown>): string {
const lines: string[] = []; const lines: string[] = [];
const models = config.models ?? {}; const models = config.models ?? {};
+10 -2
View File
@@ -50,7 +50,11 @@ models:
const result = loadConfigSafe(configPath); const result = loadConfigSafe(configPath);
expect(result.config).toBeDefined(); expect(result.config).toBeDefined();
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.config!.telegram?.bot_token).toBe('test-token'); const config = result.config;
if (!config) {
throw new Error('Expected loaded config');
}
expect(config.telegram?.bot_token).toBe('test-token');
}); });
it('loads env vars from FLYNN_ENV_FILE before parsing config', () => { it('loads env vars from FLYNN_ENV_FILE before parsing config', () => {
@@ -78,7 +82,11 @@ models:
const result = loadConfigSafe(configPath); const result = loadConfigSafe(configPath);
expect(result.config).toBeDefined(); expect(result.config).toBeDefined();
expect(result.error).toBeUndefined(); expect(result.error).toBeUndefined();
expect(result.config!.telegram?.bot_token).toBe('test-token'); const config = result.config;
if (!config) {
throw new Error('Expected loaded config');
}
expect(config.telegram?.bot_token).toBe('test-token');
if (prevEnvFile !== undefined) { if (prevEnvFile !== undefined) {
process.env.FLYNN_ENV_FILE = prevEnvFile; process.env.FLYNN_ENV_FILE = prevEnvFile;
+14 -7
View File
@@ -3,21 +3,28 @@ import { readFileSync } from 'fs';
import { parse } from 'yaml'; import { parse } from 'yaml';
describe('config/default.yaml', () => { describe('config/default.yaml', () => {
const asRecord = (value: unknown): Record<string, unknown> => (
value && typeof value === 'object' ? value as Record<string, unknown> : {}
);
it('does not use deprecated server.tailscale_only key', () => { it('does not use deprecated server.tailscale_only key', () => {
const raw = readFileSync('config/default.yaml', 'utf-8'); const raw = readFileSync('config/default.yaml', 'utf-8');
const parsed = parse(raw) as any; const parsed = asRecord(parse(raw));
const server = asRecord(parsed.server);
expect(parsed).toBeTruthy(); expect(parsed).toBeTruthy();
expect(parsed.server).toBeTruthy(); expect(server).toBeTruthy();
expect(parsed.server.tailscale_only).toBeUndefined(); expect(server.tailscale_only).toBeUndefined();
}); });
it('documents server.tailscale.* shape', () => { it('documents server.tailscale.* shape', () => {
const raw = readFileSync('config/default.yaml', 'utf-8'); const raw = readFileSync('config/default.yaml', 'utf-8');
const parsed = parse(raw) as any; const parsed = asRecord(parse(raw));
const server = asRecord(parsed.server);
const tailscale = asRecord(server.tailscale);
expect(parsed.server.tailscale).toBeTruthy(); expect(tailscale).toBeTruthy();
expect(typeof parsed.server.tailscale).toBe('object'); expect(typeof tailscale).toBe('object');
expect(typeof parsed.server.tailscale.serve).toBe('boolean'); expect(typeof tailscale.serve).toBe('boolean');
}); });
}); });
+6 -3
View File
@@ -265,9 +265,12 @@ describe('configSchema — matrix', () => {
}); });
expect(result.matrix).toBeDefined(); expect(result.matrix).toBeDefined();
expect(result.matrix!.homeserver_url).toBe('https://matrix.example.org'); if (!result.matrix) {
expect(result.matrix!.access_token).toBe('syt_test_token'); throw new Error('Expected matrix config');
expect(result.matrix!.sync_timeout_ms).toBe(30000); }
expect(result.matrix.homeserver_url).toBe('https://matrix.example.org');
expect(result.matrix.access_token).toBe('syt_test_token');
expect(result.matrix.sync_timeout_ms).toBe(30000);
}); });
it('matrix config is optional', () => { it('matrix config is optional', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { estimateTokens, estimateAudioTokens, estimateMessageTokens, getContextWindow, shouldCompact, CONTEXT_WINDOWS } from './tokens.js'; import { estimateTokens, estimateAudioTokens, estimateMessageTokens, getContextWindow, shouldCompact } from './tokens.js';
describe('estimateTokens', () => { describe('estimateTokens', () => {
it('returns 0 for empty string', () => { it('returns 0 for empty string', () => {
+1 -1
View File
@@ -39,7 +39,7 @@ export async function initAgents(deps: AgentsDeps): Promise<AgentsResult> {
if (sandboxManager) { if (sandboxManager) {
lifecycle.onShutdown(async () => { lifecycle.onShutdown(async () => {
await sandboxManager!.destroyAll(); await sandboxManager.destroyAll();
console.log('Docker sandboxes destroyed'); console.log('Docker sandboxes destroyed');
}); });
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Lifecycle } from './lifecycle.js'; import { Lifecycle } from './lifecycle.js';
describe('Lifecycle', () => { describe('Lifecycle', () => {
+4 -1
View File
@@ -148,7 +148,10 @@ export function createMessageRouter(deps: {
// Lazy sandbox: create the sandboxed tools with a deferred sandbox reference // Lazy sandbox: create the sandboxed tools with a deferred sandbox reference
// The sandbox is created on first use via SandboxManager.getOrCreate() // The sandbox is created on first use via SandboxManager.getOrCreate()
const sandboxSessionId = sessionId; const sandboxSessionId = sessionId;
const sandboxManager = deps.sandboxManager!; const sandboxManager = deps.sandboxManager;
if (!sandboxManager) {
throw new Error('Sandbox manager unavailable for sandboxed agent execution');
}
// Create a proxy sandbox that lazily initializes // Create a proxy sandbox that lazily initializes
const lazySandboxShell: Tool = { const lazySandboxShell: Tool = {
+4 -3
View File
@@ -68,21 +68,22 @@ export function initTools(deps: ToolsDeps): ToolsResult {
// Initialize browser manager and register browser tools (if enabled) // Initialize browser manager and register browser tools (if enabled)
let browserManager: BrowserManager | undefined; let browserManager: BrowserManager | undefined;
if (config.browser?.enabled) { if (config.browser?.enabled) {
browserManager = new BrowserManager({ const manager = new BrowserManager({
executablePath: config.browser.executable_path, executablePath: config.browser.executable_path,
wsEndpoint: config.browser.ws_endpoint, wsEndpoint: config.browser.ws_endpoint,
headless: config.browser.headless, headless: config.browser.headless,
maxPages: config.browser.max_pages, maxPages: config.browser.max_pages,
defaultTimeout: config.browser.default_timeout, defaultTimeout: config.browser.default_timeout,
}); });
browserManager = manager;
for (const tool of createBrowserTools(browserManager)) { for (const tool of createBrowserTools(manager)) {
toolRegistry.register(tool); toolRegistry.register(tool);
} }
console.log(`Browser tools enabled (headless=${config.browser.headless})`); console.log(`Browser tools enabled (headless=${config.browser.headless})`);
lifecycle.onShutdown(async () => { lifecycle.onShutdown(async () => {
await browserManager!.shutdown(); await manager.shutdown();
console.log('Browser manager stopped'); console.log('Browser manager stopped');
}); });
} }
+3 -2
View File
@@ -372,9 +372,10 @@ export function App({
setStreamingContent(fullContent); setStreamingContent(fullContent);
} }
if (event.type === 'done' && event.usage) { if (event.type === 'done' && event.usage) {
const usage = event.usage;
setTokenUsage(prev => ({ setTokenUsage(prev => ({
inputTokens: prev.inputTokens + event.usage!.inputTokens, inputTokens: prev.inputTokens + usage.inputTokens,
outputTokens: prev.outputTokens + event.usage!.outputTokens, outputTokens: prev.outputTokens + usage.outputTokens,
})); }));
} }
if (event.type === 'error') { if (event.type === 'error') {
+1 -1
View File
@@ -20,7 +20,7 @@ function formatTokens(n: number): string {
} }
export const StatusBar = memo(function StatusBar({ export const StatusBar = memo(function StatusBar({
sessionId, sessionId: _sessionId,
messageCount, messageCount,
model, model,
tokenUsage, tokenUsage,
+3 -2
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { GatewayEvent, GatewayRequest, OutboundMessage } from '../protocol.js'; import type { GatewayEvent, GatewayRequest, OutboundMessage } from '../protocol.js';
import { LaneQueue } from '../lane-queue.js'; import { LaneQueue } from '../lane-queue.js';
import { createAgentHandlers } from './agent.js'; import { createAgentHandlers } from './agent.js';
import type { AgentHandlerDeps } from './agent.js';
import { CommandRegistry, registerBuiltinCommands } from '../../commands/index.js'; import { CommandRegistry, registerBuiltinCommands } from '../../commands/index.js';
describe('createAgentHandlers command fast-path', () => { describe('createAgentHandlers command fast-path', () => {
@@ -35,9 +36,9 @@ describe('createAgentHandlers command fast-path', () => {
registerBuiltinCommands(commandRegistry); registerBuiltinCommands(commandRegistry);
const handlers = createAgentHandlers({ const handlers = createAgentHandlers({
sessionBridge: sessionBridge as any, sessionBridge: sessionBridge as unknown as AgentHandlerDeps['sessionBridge'],
laneQueue: new LaneQueue(), laneQueue: new LaneQueue(),
sessionManager: sessionManager as any, sessionManager: sessionManager as unknown as AgentHandlerDeps['sessionManager'],
commandRegistry, commandRegistry,
}); });
+3 -2
View File
@@ -59,7 +59,8 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
}, },
'system.restart': async (request: GatewayRequest): Promise<OutboundMessage> => { 'system.restart': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.restart) { const restart = deps.restart;
if (!restart) {
return makeError(request.id, ErrorCode.InternalError, 'Restart not available in this environment'); return makeError(request.id, ErrorCode.InternalError, 'Restart not available in this environment');
} }
@@ -68,7 +69,7 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
// Schedule restart after response is sent (next tick) // Schedule restart after response is sent (next tick)
queueMicrotask(() => { queueMicrotask(() => {
deps.restart!().catch((err) => { restart().catch((err) => {
console.error('Restart failed:', err); console.error('Restart failed:', err);
}); });
}); });
+1 -1
View File
@@ -49,7 +49,7 @@ export class LaneQueue {
// Otherwise, queue the work and return a deferred promise // Otherwise, queue the work and return a deferred promise
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
lane!.queue.push({ lane.queue.push({
work: work as () => Promise<unknown>, work: work as () => Promise<unknown>,
resolve: resolve as (value: unknown) => void, resolve: resolve as (value: unknown) => void,
reject, reject,
+3 -3
View File
@@ -199,7 +199,7 @@ describe('GatewayServer integration', () => {
const doneEvent = messages[0] as GatewayEvent; const doneEvent = messages[0] as GatewayEvent;
expect(doneEvent.id).toBe(4); expect(doneEvent.id).toBe(4);
expect(doneEvent.event).toBe('done'); expect(doneEvent.event).toBe('done');
expect((doneEvent.data as any).content).toBe('Hello from Flynn!'); expect((doneEvent.data as { content?: string }).content).toBe('Hello from Flynn!');
} finally { } finally {
ws.close(); ws.close();
} }
@@ -319,7 +319,7 @@ describe('GatewayServer lock mode', () => {
try { try {
const result = await sendAndReceive(ws, { id: 1, method: 'system.health' }); const result = await sendAndReceive(ws, { id: 1, method: 'system.health' });
const response = result as GatewayResponse; const response = result as GatewayResponse;
expect((response.result as any).status).toBe('ok'); expect((response.result as { status?: string }).status).toBe('ok');
} finally { } finally {
ws.close(); ws.close();
// Wait for the close to propagate so connectionMap is empty // Wait for the close to propagate so connectionMap is empty
@@ -363,7 +363,7 @@ describe('GatewayServer lock mode', () => {
try { try {
const result = await sendAndReceive(ws2, { id: 2, method: 'system.health' }); const result = await sendAndReceive(ws2, { id: 2, method: 'system.health' });
const response = result as GatewayResponse; const response = result as GatewayResponse;
expect((response.result as any).status).toBe('ok'); expect((response.result as { status?: string }).status).toBe('ok');
} finally { } finally {
ws2.close(); ws2.close();
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
+1 -1
View File
@@ -71,7 +71,7 @@ export class FlynnClient {
} }
this._setStatus('disconnected'); this._setStatus('disconnected');
// Reject all pending requests // Reject all pending requests
for (const [id, pending] of this._pending) { for (const [_id, pending] of this._pending) {
pending.reject(new Error('WebSocket closed')); pending.reject(new Error('WebSocket closed'));
} }
this._pending.clear(); this._pending.clear();
+1 -1
View File
@@ -289,7 +289,7 @@ function hideSlashPopup() {
_slashPopupIndex = -1; _slashPopupIndex = -1;
} }
function updatePopupSelection(filtered) { function updatePopupSelection(_filtered) {
const popup = _elements.slashPopup; const popup = _elements.slashPopup;
if (!popup) {return;} if (!popup) {return;}
const items = popup.querySelectorAll('.slash-popup-item'); const items = popup.querySelectorAll('.slash-popup-item');
+1 -1
View File
@@ -217,7 +217,7 @@ function updateActiveRequests(requestsData) {
`; `;
} }
function updateChannels(channelsData) { function _updateChannels(channelsData) {
const el = document.getElementById('ops-channels'); const el = document.getElementById('ops-channels');
if (!el) {return;} if (!el) {return;}
+1 -1
View File
@@ -17,7 +17,7 @@ let _el = null;
async function loadSettings() { async function loadSettings() {
if (!_client || !_el) {return;} if (!_client || !_el) {return;}
let config, tools, channels; let config, tools;
let services; let services;
try { try {
+8 -3
View File
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { chunkText } from './chunker.js'; import { chunkText } from './chunker.js';
import type { Chunk } from './chunker.js';
describe('chunkText', () => { describe('chunkText', () => {
it('returns empty array for empty content', () => { it('returns empty array for empty content', () => {
@@ -50,12 +49,18 @@ describe('chunkText', () => {
// Line three is on actual line 3 // Line three is on actual line 3
const lineThreeChunk = chunks.find((c) => c.text.includes('Line three')); const lineThreeChunk = chunks.find((c) => c.text.includes('Line three'));
expect(lineThreeChunk).toBeDefined(); expect(lineThreeChunk).toBeDefined();
expect(lineThreeChunk!.startLine).toBe(3); if (!lineThreeChunk) {
throw new Error('Expected chunk containing Line three');
}
expect(lineThreeChunk.startLine).toBe(3);
// Line five is on actual line 5 // Line five is on actual line 5
const lineFiveChunk = chunks.find((c) => c.text.includes('Line five')); const lineFiveChunk = chunks.find((c) => c.text.includes('Line five'));
expect(lineFiveChunk).toBeDefined(); expect(lineFiveChunk).toBeDefined();
expect(lineFiveChunk!.startLine).toBe(5); if (!lineFiveChunk) {
throw new Error('Expected chunk containing Line five');
}
expect(lineFiveChunk.startLine).toBe(5);
}); });
it('includes overlap between consecutive chunks', () => { it('includes overlap between consecutive chunks', () => {
+3 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { AnthropicClient } from './anthropic.js'; import { AnthropicClient } from './anthropic.js';
// Shared mock function so we can override per-test // Shared mock function so we can override per-test
@@ -99,7 +99,8 @@ describe('AnthropicClient tool use', () => {
expect(response.stopReason).toBe('tool_use'); expect(response.stopReason).toBe('tool_use');
expect(response.toolCalls).toHaveLength(1); expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls![0]).toEqual({ const firstToolCall = response.toolCalls?.[0];
expect(firstToolCall).toEqual({
id: 'toolu_01', id: 'toolu_01',
name: 'shell.exec', name: 'shell.exec',
args: { command: 'ls' }, args: { command: 'ls' },
+3 -2
View File
@@ -83,8 +83,9 @@ describe('BedrockClient', () => {
expect(response.stopReason).toBe('tool_use'); expect(response.stopReason).toBe('tool_use');
expect(response.toolCalls).toHaveLength(1); expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls![0].name).toBe('shell.exec'); const firstToolCall = response.toolCalls?.[0];
expect(response.toolCalls![0].args).toEqual({ command: 'ls' }); expect(firstToolCall?.name).toBe('shell.exec');
expect(firstToolCall?.args).toEqual({ command: 'ls' });
}); });
it('uses default region when none provided', async () => { it('uses default region when none provided', async () => {
+3 -2
View File
@@ -247,8 +247,9 @@ describe('GeminiClient tool use', () => {
expect(response.stopReason).toBe('tool_use'); expect(response.stopReason).toBe('tool_use');
expect(response.toolCalls).toHaveLength(1); expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls![0].name).toBe('shell.exec'); const firstToolCall = response.toolCalls?.[0];
expect(response.toolCalls![0].args).toEqual({ command: 'ls' }); expect(firstToolCall?.name).toBe('shell.exec');
expect(firstToolCall?.args).toEqual({ command: 'ls' });
// Verify tools were passed to getGenerativeModel // Verify tools were passed to getGenerativeModel
expect(mockGetGenerativeModel).toHaveBeenCalledWith( expect(mockGetGenerativeModel).toHaveBeenCalledWith(
+1 -1
View File
@@ -1,6 +1,6 @@
import { GoogleGenerativeAI } from '@google/generative-ai'; import { GoogleGenerativeAI } from '@google/generative-ai';
import type { GenerativeModel, Content, Part, FunctionDeclaration, FunctionDeclarationSchema } from '@google/generative-ai'; import type { GenerativeModel, Content, Part, FunctionDeclaration, FunctionDeclarationSchema } from '@google/generative-ai';
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, ModelToolCall, ToolDefinition, Message, MessageContentPart } from './types.js'; import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, ModelToolCall, ToolDefinition, Message } from './types.js';
export interface GeminiClientConfig { export interface GeminiClientConfig {
apiKey?: string; apiKey?: string;
+9 -3
View File
@@ -31,9 +31,15 @@ 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]' };
}
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') {
@@ -154,7 +160,7 @@ export class GitHubModelsClient implements ModelClient {
// Extended thinking/reasoning mode // Extended thinking/reasoning mode
if (request.thinking) { if (request.thinking) {
(params as any).reasoning_effort = 'medium'; (params as OpenAI.ChatCompletionCreateParamsNonStreaming & { reasoning_effort?: 'low' | 'medium' | 'high' }).reasoning_effort = 'medium';
} }
const response = await this.client.chat.completions.create(params); const response = await this.client.chat.completions.create(params);
+4 -3
View File
@@ -152,7 +152,7 @@ export function normalizeMessagesForLlamaCpp(
} catch { } catch {
argsStr = '{}'; argsStr = '{}';
} }
toolCalls!.push({ toolCalls.push({
id, id,
type: 'function', type: 'function',
function: { function: {
@@ -167,7 +167,7 @@ export function normalizeMessagesForLlamaCpp(
role: 'assistant', role: 'assistant',
content: textParts.join('\n'), content: textParts.join('\n'),
}; };
if (toolCalls!.length > 0) { if (toolCalls.length > 0) {
chatMsg.tool_calls = toolCalls; chatMsg.tool_calls = toolCalls;
} }
result.push(chatMsg); result.push(chatMsg);
@@ -381,7 +381,8 @@ export class LlamaCppClient implements ModelClient {
arguments: '', arguments: '',
}); });
} }
const acc = toolCallAccumulators.get(tc.index)!; const acc = toolCallAccumulators.get(tc.index);
if (!acc) {continue;}
if (tc.function?.name) {acc.name = tc.function.name;} if (tc.function?.name) {acc.name = tc.function.name;}
if (tc.function?.arguments) {acc.arguments += tc.function.arguments;} if (tc.function?.arguments) {acc.arguments += tc.function.arguments;}
} }
+6 -3
View File
@@ -57,13 +57,13 @@ const mp3AudioAttachment: Attachment = makeAttachment({
filename: 'audio.mp3', filename: 'audio.mp3',
}); });
const wavAudioAttachment: Attachment = makeAttachment({ const _wavAudioAttachment: Attachment = makeAttachment({
mimeType: 'audio/wav', mimeType: 'audio/wav',
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=', // Base64 of a short WAV data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=', // Base64 of a short WAV
filename: 'audio.wav', filename: 'audio.wav',
}); });
const m4aAudioAttachment: Attachment = makeAttachment({ const _m4aAudioAttachment: Attachment = makeAttachment({
mimeType: 'audio/x-m4a', mimeType: 'audio/x-m4a',
data: 'AAAAUGV0Zi4xLjAgc291cmNlIGZvciBzdGFydHBvaW50', // Base64 of M4A data: 'AAAAUGV0Zi4xLjAgc291cmNlIGZvciBzdGFydHBvaW50', // Base64 of M4A
filename: 'audio.m4a', filename: 'audio.m4a',
@@ -545,8 +545,11 @@ describe('buildUserMessageWithAudio', () => {
const content = Array.isArray(result.content) ? result.content : [{ type: 'text' as const, text: result.content }]; const content = Array.isArray(result.content) ? result.content : [{ type: 'text' as const, text: result.content }];
const textPart = content.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined; const textPart = content.find((p) => p.type === 'text') as { type: 'text'; text: string } | undefined;
expect(textPart).toBeDefined(); expect(textPart).toBeDefined();
if (!textPart) {
throw new Error('Expected text content part');
}
const textContent = textPart!.text || ''; const textContent = textPart.text || '';
const firstVoiceIndex = textContent.indexOf('[Voice message]:'); const firstVoiceIndex = textContent.indexOf('[Voice message]:');
const textIndex = textContent.indexOf(textMessage); const textIndex = textContent.indexOf(textMessage);
+4 -1
View File
@@ -241,9 +241,12 @@ export async function transcribeAudio(
if (!config?.endpoint) { if (!config?.endpoint) {
return '[Audio message received but no transcription service is configured]'; return '[Audio message received but no transcription service is configured]';
} }
if (!attachment.data) {
return '[Audio message transcription failed]';
}
try { try {
const audioBuffer = Buffer.from(attachment.data!, 'base64'); const audioBuffer = Buffer.from(attachment.data, 'base64');
const ext = mimeToExtension(attachment.mimeType); const ext = mimeToExtension(attachment.mimeType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', new Blob([audioBuffer], { type: attachment.mimeType }), `audio.${ext}`); formData.append('file', new Blob([audioBuffer], { type: attachment.mimeType }), `audio.${ext}`);
+2 -2
View File
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
let capturedOptions: any; let capturedOptions: Record<string, unknown> | undefined;
vi.mock('openai', () => { vi.mock('openai', () => {
class OpenAI { class OpenAI {
constructor(options: any) { constructor(options: Record<string, unknown>) {
capturedOptions = options; capturedOptions = options;
} }
} }
+2 -1
View File
@@ -83,7 +83,8 @@ describe('OpenAIClient tool use', () => {
expect(response.stopReason).toBe('tool_use'); expect(response.stopReason).toBe('tool_use');
expect(response.toolCalls).toHaveLength(1); expect(response.toolCalls).toHaveLength(1);
expect(response.toolCalls![0]).toEqual({ const firstToolCall = response.toolCalls?.[0];
expect(firstToolCall).toEqual({
id: 'call_1', id: 'call_1',
name: 'shell.exec', name: 'shell.exec',
args: { command: 'ls' }, args: { command: 'ls' },
+8 -3
View File
@@ -39,7 +39,10 @@ describe('SyntheticClient', () => {
it('streams content + done', async () => { it('streams content + done', async () => {
const client = new SyntheticClient({ model: 'fixed:stream-me' }); const client = new SyntheticClient({ model: 'fixed:stream-me' });
const events: string[] = []; const events: string[] = [];
for await (const ev of client.chatStream!(makeRequest('x'))) { if (!client.chatStream) {
throw new Error('Expected streaming support');
}
for await (const ev of client.chatStream(makeRequest('x'))) {
events.push(ev.type); events.push(ev.type);
} }
expect(events).toEqual(['content', 'done']); expect(events).toEqual(['content', 'done']);
@@ -49,7 +52,10 @@ describe('SyntheticClient', () => {
const client = new SyntheticClient({ model: 'echo' }); const client = new SyntheticClient({ model: 'echo' });
const types: string[] = []; const types: string[] = [];
const toolNames: string[] = []; const toolNames: string[] = [];
for await (const ev of client.chatStream!(makeRequest('@tool file.read {"path":"README.md"}'))) { if (!client.chatStream) {
throw new Error('Expected streaming support');
}
for await (const ev of client.chatStream(makeRequest('@tool file.read {"path":"README.md"}'))) {
types.push(ev.type); types.push(ev.type);
if (ev.type === 'tool_use' && ev.toolCall) { if (ev.type === 'tool_use' && ev.toolCall) {
toolNames.push(ev.toolCall.name); toolNames.push(ev.toolCall.name);
@@ -59,4 +65,3 @@ describe('SyntheticClient', () => {
expect(toolNames).toEqual(['file.read']); expect(toolNames).toEqual(['file.read']);
}); });
}); });
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { loadPreferences, savePreference } from './preferences.js'; import { loadPreferences, savePreference } from './preferences.js';
+6 -2
View File
@@ -34,8 +34,12 @@ describe('SessionStore', () => {
expect(messages[0].role).toBe('user'); expect(messages[0].role).toBe('user');
expect(messages[0].content).toBe('Hello'); expect(messages[0].content).toBe('Hello');
expect(messages[0].timestamp).toBeTypeOf('number'); expect(messages[0].timestamp).toBeTypeOf('number');
expect(messages[0].timestamp!).toBeGreaterThanOrEqual(before - 1000); const timestamp = messages[0].timestamp;
expect(messages[0].timestamp!).toBeLessThanOrEqual(after + 1000); if (typeof timestamp !== 'number') {
throw new Error('Expected numeric timestamp');
}
expect(timestamp).toBeGreaterThanOrEqual(before - 1000);
expect(timestamp).toBeLessThanOrEqual(after + 1000);
expect(messages[1].role).toBe('assistant'); expect(messages[1].role).toBe('assistant');
expect(messages[1].content).toBe('Hi there!'); expect(messages[1].content).toBe('Hi there!');
expect(messages[1].timestamp).toBeTypeOf('number'); expect(messages[1].timestamp).toBeTypeOf('number');
+1 -121
View File
@@ -13,126 +13,6 @@ import type { Skill, SkillManifest, SkillRequirements, SkillTier } from './types
import { scanSkillDirectory } from './scanner.js'; import { scanSkillDirectory } from './scanner.js';
import { auditLogger } from '../audit/index.js'; import { auditLogger } from '../audit/index.js';
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function hasValidInstallers(manifest: unknown): boolean {
if (!manifest || typeof manifest !== 'object') {
return false;
}
const candidate = manifest as { installers?: unknown };
if (candidate.installers === undefined) {
return true;
}
if (!Array.isArray(candidate.installers)) {
return false;
}
return candidate.installers.every((installer) => {
if (!installer || typeof installer !== 'object') {
return false;
}
const typedInstaller = installer as { type?: unknown; packages?: unknown; url?: unknown; destination?: unknown };
if (typedInstaller.type === 'brew' || typedInstaller.type === 'node' || typedInstaller.type === 'go') {
return isStringArray(typedInstaller.packages);
}
if (typedInstaller.type === 'download') {
if (typeof typedInstaller.url !== 'string') {
return false;
}
if (typedInstaller.destination !== undefined && typeof typedInstaller.destination !== 'string') {
return false;
}
return true;
}
return false;
});
}
function isNumberArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every((item) => typeof item === 'number' && Number.isFinite(item));
}
function hasValidPermissions(manifest: unknown): boolean {
if (!manifest || typeof manifest !== 'object') {
return false;
}
const candidate = manifest as { permissions?: unknown };
if (candidate.permissions === undefined) {
return true;
}
if (!candidate.permissions || typeof candidate.permissions !== 'object') {
return false;
}
const perms = candidate.permissions as {
tool_groups?: unknown;
tools?: unknown;
fs?: unknown;
net?: unknown;
secrets?: unknown;
execution_environment?: unknown;
};
if (perms.tool_groups !== undefined && !isStringArray(perms.tool_groups)) {
return false;
}
if (perms.tools !== undefined && !isStringArray(perms.tools)) {
return false;
}
if (perms.fs !== undefined) {
if (!perms.fs || typeof perms.fs !== 'object') {
return false;
}
const fsPerms = perms.fs as { read?: unknown; write?: unknown };
if (fsPerms.read !== undefined && !isStringArray(fsPerms.read)) {
return false;
}
if (fsPerms.write !== undefined && !isStringArray(fsPerms.write)) {
return false;
}
}
if (perms.net !== undefined) {
if (!Array.isArray(perms.net)) {
return false;
}
for (const entry of perms.net) {
if (!entry || typeof entry !== 'object') {
return false;
}
const e = entry as { host?: unknown; ports?: unknown };
if (typeof e.host !== 'string' || e.host.trim().length === 0) {
return false;
}
if (e.ports !== undefined && !isNumberArray(e.ports)) {
return false;
}
}
}
if (perms.secrets !== undefined && !isStringArray(perms.secrets)) {
return false;
}
if (perms.execution_environment !== undefined) {
if (perms.execution_environment !== 'sandbox' && perms.execution_environment !== 'host') {
return false;
}
}
return true;
}
/** /**
* Check whether a skill's system requirements are met. * Check whether a skill's system requirements are met.
* *
@@ -237,7 +117,7 @@ export function loadSkill(directory: string, tier: SkillTier): Skill | null {
tier, // Override tier from argument tier, // Override tier from argument
}; };
} }
} catch (error) { } catch {
// Scanner will capture this and mark the skill unavailable. // Scanner will capture this and mark the skill unavailable.
} }
} else { } else {
+5 -1
View File
@@ -83,7 +83,11 @@ describe('SkillRegistry', () => {
registry.register(replacement); registry.register(replacement);
expect(registry.list()).toHaveLength(1); expect(registry.list()).toHaveLength(1);
expect(registry.get('web')!.instructions).toBe('# Replacement'); const webSkill = registry.get('web');
if (!webSkill) {
throw new Error('Expected web skill');
}
expect(webSkill.instructions).toBe('# Replacement');
}); });
it('unregisters a skill by name', () => { it('unregisters a skill by name', () => {
+1 -1
View File
@@ -248,7 +248,7 @@ export function scanSkillDirectory(directory: string, opts: SkillScanOptions = {
issues.push({ severity: 'error', code: 'content.binary', message: 'manifest.json appears to be binary', path: manifestPath }); issues.push({ severity: 'error', code: 'content.binary', message: 'manifest.json appears to be binary', path: manifestPath });
} else { } else {
const text = buf.toString('utf-8'); const text = buf.toString('utf-8');
const raw = safeJsonParse(text) as any; const raw = safeJsonParse(text) as Record<string, unknown> | null;
if (!raw || typeof raw !== 'object') { if (!raw || typeof raw !== 'object') {
issues.push({ severity: 'error', code: 'manifest.missing_required_fields', message: 'manifest.json must be an object with required fields', path: manifestPath }); issues.push({ severity: 'error', code: 'manifest.missing_required_fields', message: 'manifest.json must be an object with required fields', path: manifestPath });
} else { } else {
+6 -2
View File
@@ -71,7 +71,11 @@ function validateInput(args: AudioTranscribeArgs): { valid: boolean; error?: str
} }
if (hasUrl) { if (hasUrl) {
const urlValidation = validateUrl(args.url!); const url = args.url;
if (!url) {
return { valid: false, error: 'URL is required when using url mode' };
}
const urlValidation = validateUrl(url);
if (!urlValidation.valid) { if (!urlValidation.valid) {
return urlValidation; return urlValidation;
} }
@@ -153,7 +157,7 @@ export function createAudioTranscribeTool(audioConfig?: AudioTranscriptionConfig
'audio/mp4': 'm4a', 'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a', 'audio/x-m4a': 'm4a',
}; };
const ext = extMap[args.mime_type!] || 'bin'; const ext = extMap[args.mime_type ?? ''] || 'bin';
filename = `audio.${ext}`; filename = `audio.${ext}`;
const mimeType = args.mime_type ?? 'audio/wav'; const mimeType = args.mime_type ?? 'audio/wav';
+1 -2
View File
@@ -3,8 +3,7 @@ import { BrowserManager } from './manager.js';
// Use vi.hoisted() so these are available inside the hoisted vi.mock() call // Use vi.hoisted() so these are available inside the hoisted vi.mock() call
const { const {
mockGoto, mockTitle, mockUrl, mockClose, mockIsClosed, mockClose, mockIsClosed, mockPage, mockNewPage, mockPages,
mockSetDefaultTimeout, mockPage, mockNewPage, mockPages,
mockBrowserClose, mockBrowserClose,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const mockGoto = vi.fn().mockResolvedValue(undefined); const mockGoto = vi.fn().mockResolvedValue(undefined);
+1 -1
View File
@@ -27,7 +27,7 @@ export const fileListTool: Tool = {
try { try {
let entries = readdirSync(args.path, { withFileTypes: true }); let entries = readdirSync(args.path, { withFileTypes: true });
if (args.pattern) { if (args.pattern) {
entries = entries.filter(e => matchGlob(e.name, args.pattern!)); entries = entries.filter(e => matchGlob(e.name, args.pattern));
} }
const output = entries const output = entries
.map(e => e.isDirectory() ? `${e.name}/` : e.name) .map(e => e.isDirectory() ? `${e.name}/` : e.name)
+1 -1
View File
@@ -86,7 +86,7 @@ export function createImageAnalyzeTool(modelClient: ModelClient): Tool {
} }
: { : {
type: 'base64' as const, type: 'base64' as const,
media_type: args.media_type!, media_type: args.media_type ?? 'image/jpeg',
data: args.data, data: args.data,
}; };
-2
View File
@@ -40,8 +40,6 @@ import { filePatchTool } from './file-patch.js';
import { fileListTool } from './file-list.js'; import { fileListTool } from './file-list.js';
import { systemInfoTool } from './system-info.js'; import { systemInfoTool } from './system-info.js';
import { webFetchTool } from './web-fetch.js'; import { webFetchTool } from './web-fetch.js';
import { createMediaSendTool } from './media-send.js';
import { createImageAnalyzeTool } from './image-analyze.js';
import { createMemoryReadTool } from './memory-read.js'; import { createMemoryReadTool } from './memory-read.js';
import { createMemoryWriteTool } from './memory-write.js'; import { createMemoryWriteTool } from './memory-write.js';
import { createMemorySearchTool } from './memory-search.js'; import { createMemorySearchTool } from './memory-search.js';
+5 -1
View File
@@ -151,12 +151,16 @@ export class ProcessManager {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: false, detached: false,
}); });
const pid = child.pid;
if (pid === undefined) {
throw new Error('Failed to start process: missing pid');
}
const proc: ManagedProcess = { const proc: ManagedProcess = {
id, id,
command, command,
cwd, cwd,
pid: child.pid!, pid,
status: 'running', status: 'running',
outputBuffer, outputBuffer,
startedAt: Date.now(), startedAt: Date.now(),
-4
View File
@@ -1,10 +1,6 @@
import type { Tool, ToolResult } from '../types.js'; import type { Tool, ToolResult } from '../types.js';
import type { SessionManager } from '../../session/manager.js'; import type { SessionManager } from '../../session/manager.js';
interface SessionsListArgs {
// no args
}
interface SessionsHistoryArgs { interface SessionsHistoryArgs {
sessionId: string; sessionId: string;
limit?: number; limit?: number;
+2 -2
View File
@@ -396,10 +396,10 @@ describe('ToolExecutor', () => {
const fakeSandbox = { const fakeSandbox = {
exec: async () => ({ stdout: 'sandbox-out', stderr: '' }), exec: async () => ({ stdout: 'sandbox-out', stderr: '' }),
} as any; } as { exec: (command: string, opts?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> };
const fakeManager = { const fakeManager = {
getOrCreate: async () => fakeSandbox, getOrCreate: async () => fakeSandbox,
} as any; } as { getOrCreate: (sessionId: string) => Promise<typeof fakeSandbox> };
executor.setSandboxManager(fakeManager); executor.setSandboxManager(fakeManager);
const result = await executor.execute('shell.exec', { command: 'echo hi' }, { const result = await executor.execute('shell.exec', { command: 'echo hi' }, {
+6 -4
View File
@@ -288,11 +288,13 @@ export class ToolPolicy {
*/ */
getEffectiveProfile(context?: ToolPolicyContext): ToolProfile { getEffectiveProfile(context?: ToolPolicyContext): ToolProfile {
// Check agent override first, then provider, then global // Check agent override first, then provider, then global
if (context?.agent && this.config.agents[context.agent]?.profile) { const agentProfile = context?.agent ? this.config.agents[context.agent]?.profile : undefined;
return this.config.agents[context.agent].profile!; if (agentProfile) {
return agentProfile;
} }
if (context?.provider && this.config.providers[context.provider]?.profile) { const providerProfile = context?.provider ? this.config.providers[context.provider]?.profile : undefined;
return this.config.providers[context.provider].profile!; if (providerProfile) {
return providerProfile;
} }
return this.config.profile; return this.config.profile;
} }
+20 -5
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { ToolRegistry } from './registry.js'; import { ToolRegistry } from './registry.js';
import type { Tool } from './types.js'; import type { Tool } from './types.js';
import type { ToolPolicy } from './policy.js';
const echoTool: Tool = { const echoTool: Tool = {
name: 'test.echo', name: 'test.echo',
@@ -114,8 +115,13 @@ describe('ToolRegistry', () => {
it('inherits the policy from original', () => { it('inherits the policy from original', () => {
const reg = new ToolRegistry(); const reg = new ToolRegistry();
const mockPolicy = { filterTools: vi.fn(), isAllowed: vi.fn(), resolveAllowedNames: vi.fn(), getEffectiveProfile: vi.fn() }; const mockPolicy: ToolPolicy = {
reg.setPolicy(mockPolicy as any); filterTools: vi.fn((tools) => tools),
isAllowed: vi.fn(() => true),
resolveAllowedNames: vi.fn(() => new Set()),
getEffectiveProfile: vi.fn(() => ({ profile: 'full', source: 'explicit' })),
};
reg.setPolicy(mockPolicy);
const cloned = reg.clone(); const cloned = reg.clone();
expect(cloned.getPolicy()).toBe(mockPolicy); expect(cloned.getPolicy()).toBe(mockPolicy);
@@ -131,8 +137,13 @@ describe('ToolRegistry', () => {
replacementTool.description = 'Sandboxed version'; replacementTool.description = 'Sandboxed version';
cloned.replace(replacementTool); cloned.replace(replacementTool);
expect(cloned.get('shell.exec')!.description).toBe('Sandboxed version'); const clonedTool = cloned.get('shell.exec');
expect(reg.get('shell.exec')!.description).toBe('Mock shell.exec'); const originalToolFromReg = reg.get('shell.exec');
if (!clonedTool || !originalToolFromReg) {
throw new Error('Expected shell.exec to exist in both registries');
}
expect(clonedTool.description).toBe('Sandboxed version');
expect(originalToolFromReg.description).toBe('Mock shell.exec');
}); });
}); });
@@ -153,7 +164,11 @@ describe('ToolRegistry', () => {
replacement.description = 'New description'; replacement.description = 'New description';
reg.replace(replacement); reg.replace(replacement);
expect(reg.get('tool.a')!.description).toBe('New description'); const replaced = reg.get('tool.a');
if (!replaced) {
throw new Error('Expected tool.a to be present');
}
expect(replaced.description).toBe('New description');
}); });
it('throws if tool does not exist', () => { it('throws if tool does not exist', () => {