feat: implement Tier 3 features — lane queue, credential redaction, token dashboard, xAI, Voyage AI
- Lane Queue: per-session FIFO queue in gateway replacing reject-when-busy (9 tests) - Credential Redaction: redactConfig() expanded to cover 18+ secret fields (16 tests) - Web UI Token Dashboard: system.tokenUsage endpoint + Usage page with summary cards - xAI (Grok) Provider: OpenAI-compatible client with model pricing - Voyage AI Embeddings: new embedding provider with configurable dimensions (5 tests) - Update gap analysis: 90→95 match (70%→74%), Tier 3 section marked DONE - Update state.json: test count 1001→1034, add tier3_completion entry Total: 1034 tests passing across 85 files, typecheck clean
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createSystemHandlers } from './system.js';
|
||||
import type { TokenUsageEntry } from './system.js';
|
||||
import { createSessionHandlers } from './sessions.js';
|
||||
import { createToolHandlers } from './tools.js';
|
||||
import { createAgentHandlers } from './agent.js';
|
||||
import { createConfigHandlers } from './config.js';
|
||||
import { createConfigHandlers, redactConfig } from './config.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { ErrorCode } from '../protocol.js';
|
||||
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
|
||||
|
||||
@@ -33,6 +35,64 @@ describe('system handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('system.tokenUsage handler', () => {
|
||||
it('returns empty sessions when no getTokenUsage provided', async () => {
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 0,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 0,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 1, method: 'system.tokenUsage' };
|
||||
const result = await handlers['system.tokenUsage'](req) as GatewayResponse;
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
const r = result.result as { sessions: unknown[] };
|
||||
expect(r.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns session usage data from getTokenUsage callback', async () => {
|
||||
const mockUsage: TokenUsageEntry[] = [
|
||||
{
|
||||
sessionId: 'telegram:user1',
|
||||
primary: { inputTokens: 1000, outputTokens: 500, calls: 3 },
|
||||
delegation: { fast: { inputTokens: 200, outputTokens: 100, calls: 1 } },
|
||||
total: { inputTokens: 1200, outputTokens: 600, calls: 4, estimatedCost: 0.0234 },
|
||||
},
|
||||
{
|
||||
sessionId: 'ws:abc-123',
|
||||
primary: { inputTokens: 50, outputTokens: 25, calls: 1 },
|
||||
delegation: {},
|
||||
total: { inputTokens: 50, outputTokens: 25, calls: 1, estimatedCost: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const handlers = createSystemHandlers({
|
||||
startTime: Date.now(),
|
||||
version: '0.1.0',
|
||||
getSessionCount: () => 2,
|
||||
getToolCount: () => 0,
|
||||
getConnectionCount: () => 1,
|
||||
getTokenUsage: () => mockUsage,
|
||||
});
|
||||
|
||||
const req: GatewayRequest = { id: 2, method: 'system.tokenUsage' };
|
||||
const result = await handlers['system.tokenUsage'](req) as GatewayResponse;
|
||||
|
||||
expect(result.id).toBe(2);
|
||||
const r = result.result as { sessions: typeof mockUsage };
|
||||
expect(r.sessions).toHaveLength(2);
|
||||
expect(r.sessions[0].sessionId).toBe('telegram:user1');
|
||||
expect(r.sessions[0].total.inputTokens).toBe(1200);
|
||||
expect(r.sessions[0].total.estimatedCost).toBe(0.0234);
|
||||
expect(r.sessions[0].delegation.fast.inputTokens).toBe(200);
|
||||
expect(r.sessions[1].sessionId).toBe('ws:abc-123');
|
||||
expect(r.sessions[1].total.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session handlers', () => {
|
||||
const mockHistory = [
|
||||
{ role: 'user' as const, content: 'hello' },
|
||||
@@ -188,8 +248,11 @@ describe('agent handlers', () => {
|
||||
setOnToolUse: vi.fn(),
|
||||
};
|
||||
|
||||
const laneQueue = new LaneQueue();
|
||||
|
||||
const handlers = createAgentHandlers({
|
||||
sessionBridge: mockBridge as any,
|
||||
laneQueue,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -260,13 +323,38 @@ describe('agent handlers', () => {
|
||||
expect(result.error.message).toContain('message');
|
||||
});
|
||||
|
||||
it('agent.send rejects when busy', async () => {
|
||||
mockBridge.isBusy.mockReturnValue(true);
|
||||
const req: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } };
|
||||
const send = vi.fn();
|
||||
const result = await handlers['agent.send'](req, send) as GatewayError;
|
||||
it('agent.send queues concurrent requests instead of rejecting', async () => {
|
||||
// Simulate the first request blocking
|
||||
let resolveFirst!: () => void;
|
||||
const firstBlocks = new Promise<void>((r) => { resolveFirst = r; });
|
||||
let callCount = 0;
|
||||
mockAgent.process.mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
await firstBlocks;
|
||||
return 'first response';
|
||||
}
|
||||
return 'second response';
|
||||
});
|
||||
|
||||
expect(result.error.code).toBe(ErrorCode.AgentBusy);
|
||||
const req1: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'first', connectionId: 'conn-1' } };
|
||||
const req2: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'second', connectionId: 'conn-1' } };
|
||||
const sent1: OutboundMessage[] = [];
|
||||
const sent2: OutboundMessage[] = [];
|
||||
|
||||
const p1 = handlers['agent.send'](req1, vi.fn((msg: OutboundMessage) => sent1.push(msg)));
|
||||
const p2 = handlers['agent.send'](req2, vi.fn((msg: OutboundMessage) => sent2.push(msg)));
|
||||
|
||||
// Release the first request
|
||||
resolveFirst();
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
// Both should have completed — no AgentBusy error
|
||||
expect(sent1).toHaveLength(1);
|
||||
expect((sent1[0] as GatewayEvent).event).toBe('done');
|
||||
expect(sent2).toHaveLength(1);
|
||||
expect((sent2[0] as GatewayEvent).event).toBe('done');
|
||||
expect(mockAgent.process).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('agent.send handles errors gracefully', async () => {
|
||||
@@ -452,3 +540,210 @@ describe('config handlers', () => {
|
||||
expect(result.error.code).toBe(ErrorCode.InvalidRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactConfig – comprehensive credential redaction', () => {
|
||||
/**
|
||||
* Build a full config object with secrets in every possible location.
|
||||
* Optional sections (discord, slack, etc.) are included to test redaction.
|
||||
*/
|
||||
function makeFullConfig() {
|
||||
return {
|
||||
telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true },
|
||||
discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true },
|
||||
slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false },
|
||||
server: { tailscale_only: true, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true },
|
||||
models: {
|
||||
default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def',
|
||||
fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' },
|
||||
},
|
||||
fast: { provider: 'openai' as const, model: 'gpt-4o-mini', api_key: 'sk-fast',
|
||||
fallback: { provider: 'gemini' as const, model: 'gemini-flash', api_key: 'sk-fast-fb' },
|
||||
},
|
||||
complex: { provider: 'anthropic' as const, model: 'claude-opus', auth_token: 'at-complex' },
|
||||
local: { provider: 'ollama' as const, model: 'llama3' },
|
||||
fallback_chain: ['anthropic'],
|
||||
local_providers: {
|
||||
ollama: { provider: 'ollama' as const, model: 'llama3', api_key: 'lp-key', auth_token: 'lp-token',
|
||||
fallback: { provider: 'llamacpp' as const, model: 'llama', api_key: 'lp-fb-key' },
|
||||
},
|
||||
},
|
||||
thinking: { anthropic: { budgetTokens: 4096 }, openai: { reasoningEffort: 'medium' as const }, gemini: { budgetTokens: 4096 } },
|
||||
},
|
||||
web_search: { provider: 'brave' as const, api_key: 'brave-key', endpoint: 'https://api.brave.com', max_results: 5 },
|
||||
audio: { transcription_endpoint: 'https://api.openai.com', transcription_api_key: 'audio-key', transcription_model: 'whisper-1' },
|
||||
memory: {
|
||||
enabled: true, auto_extract: true, max_context_tokens: 2000,
|
||||
embedding: { enabled: true, provider: 'openai' as const, model: 'text-embedding-3-small', api_key: 'embed-key', dimensions: 1536, chunk_size: 512, chunk_overlap: 50, top_k: 5, hybrid_weight: 0.7 },
|
||||
},
|
||||
automation: {
|
||||
cron: [],
|
||||
webhooks: [
|
||||
{ name: 'github', secret: 'wh-secret-1', message: '{{body}}', output: { channel: 'telegram', peer: '123' }, enabled: true },
|
||||
{ name: 'gitlab', secret: 'wh-secret-2', message: '{{body}}', output: { channel: 'telegram', peer: '456' }, enabled: true },
|
||||
{ name: 'no-secret', message: '{{body}}', output: { channel: 'telegram', peer: '789' }, enabled: true },
|
||||
],
|
||||
gmail: { enabled: true, credentials_file: '/path/to/creds.json', token_file: '/path/to/token.json', watch_labels: ['INBOX'], poll_interval: '60s', output: { channel: 'telegram', peer: '123' }, message: 'new email' },
|
||||
heartbeat: { enabled: false, interval: '5m', checks: ['gateway'], failure_threshold: 2, disk_threshold_mb: 100 },
|
||||
},
|
||||
mcp: {
|
||||
servers: [
|
||||
{ name: 'my-server', command: 'node', args: ['server.js'], env: { API_KEY: 'mcp-api-key', DATABASE_URL: 'postgres://secret@host/db' } },
|
||||
{ name: 'no-env', command: 'python', args: ['app.py'] },
|
||||
],
|
||||
},
|
||||
hooks: { confirm: ['shell.exec'], log: [], silent: [] },
|
||||
backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } },
|
||||
};
|
||||
}
|
||||
|
||||
it('redacts telegram.bot_token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.telegram as any).bot_token).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts discord.bot_token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.discord as any).bot_token).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts slack.bot_token, app_token, and signing_secret', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const slack = result.slack as any;
|
||||
expect(slack.bot_token).toBe('***');
|
||||
expect(slack.app_token).toBe('***');
|
||||
expect(slack.signing_secret).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts server.token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.server as any).token).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts model api_key and auth_token for all tiers', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const models = result.models as any;
|
||||
|
||||
expect(models.default.api_key).toBe('***');
|
||||
expect(models.default.auth_token).toBe('***');
|
||||
expect(models.fast.api_key).toBe('***');
|
||||
expect(models.complex.auth_token).toBe('***');
|
||||
// local has no keys — should remain unchanged
|
||||
expect(models.local.api_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts model fallback api_key and auth_token', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const models = result.models as any;
|
||||
|
||||
expect(models.default.fallback.api_key).toBe('***');
|
||||
expect(models.default.fallback.auth_token).toBe('***');
|
||||
expect(models.fast.fallback.api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts local_providers api_key, auth_token, and their fallbacks', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const ollama = (result.models as any).local_providers.ollama;
|
||||
|
||||
expect(ollama.api_key).toBe('***');
|
||||
expect(ollama.auth_token).toBe('***');
|
||||
expect(ollama.fallback.api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts web_search.api_key', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.web_search as any).api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts audio.transcription_api_key', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.audio as any).transcription_api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts memory.embedding.api_key', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
expect((result.memory as any).embedding.api_key).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts automation webhook secrets', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const webhooks = (result.automation as any).webhooks;
|
||||
|
||||
expect(webhooks[0].secret).toBe('***');
|
||||
expect(webhooks[1].secret).toBe('***');
|
||||
// Webhook without a secret should remain unaffected
|
||||
expect(webhooks[2].secret).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts automation gmail credentials_file and token_file', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const gmail = (result.automation as any).gmail;
|
||||
|
||||
expect(gmail.credentials_file).toBe('***');
|
||||
expect(gmail.token_file).toBe('***');
|
||||
});
|
||||
|
||||
it('redacts all MCP server env vars', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
const servers = (result.mcp as any).servers;
|
||||
|
||||
expect(servers[0].env.API_KEY).toBe('***');
|
||||
expect(servers[0].env.DATABASE_URL).toBe('***');
|
||||
// Server without env should be unaffected
|
||||
expect(servers[1].env).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves non-secret fields', () => {
|
||||
const result = redactConfig(makeFullConfig() as any);
|
||||
|
||||
// telegram
|
||||
expect((result.telegram as any).allowed_chat_ids).toEqual([1]);
|
||||
expect((result.telegram as any).require_mention).toBe(true);
|
||||
// discord
|
||||
expect((result.discord as any).allowed_guild_ids).toEqual(['g1']);
|
||||
// slack
|
||||
expect((result.slack as any).allowed_channel_ids).toEqual([]);
|
||||
// server
|
||||
expect((result.server as any).port).toBe(18800);
|
||||
expect((result.server as any).tailscale_only).toBe(true);
|
||||
// models
|
||||
expect((result.models as any).default.provider).toBe('anthropic');
|
||||
expect((result.models as any).default.model).toBe('claude');
|
||||
expect((result.models as any).fallback_chain).toEqual(['anthropic']);
|
||||
// web_search
|
||||
expect((result.web_search as any).provider).toBe('brave');
|
||||
expect((result.web_search as any).max_results).toBe(5);
|
||||
// audio
|
||||
expect((result.audio as any).transcription_model).toBe('whisper-1');
|
||||
// memory
|
||||
expect((result.memory as any).embedding.model).toBe('text-embedding-3-small');
|
||||
// hooks
|
||||
expect((result.hooks as any).confirm).toEqual(['shell.exec']);
|
||||
// mcp
|
||||
expect((result.mcp as any).servers[0].name).toBe('my-server');
|
||||
expect((result.mcp as any).servers[0].command).toBe('node');
|
||||
});
|
||||
|
||||
it('handles missing optional sections gracefully', () => {
|
||||
const minimal = {
|
||||
telegram: { bot_token: 'tok', allowed_chat_ids: [1] },
|
||||
models: { default: { provider: 'anthropic' as const, model: 'claude' }, fallback_chain: [] },
|
||||
server: { port: 18800 },
|
||||
hooks: { confirm: [], log: [], silent: [] },
|
||||
};
|
||||
// Should not throw even when discord, slack, automation, mcp, etc. are absent
|
||||
const result = redactConfig(minimal as any);
|
||||
expect((result.telegram as any).bot_token).toBe('***');
|
||||
expect(result.discord).toBeUndefined();
|
||||
expect(result.slack).toBeUndefined();
|
||||
expect(result.automation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mutate the original config object', () => {
|
||||
const config = makeFullConfig();
|
||||
redactConfig(config as any);
|
||||
// Original secrets should still be intact
|
||||
expect(config.telegram.bot_token).toBe('tg-secret');
|
||||
expect(config.models.default.api_key).toBe('sk-def');
|
||||
expect(config.server.token).toBe('bearer-secret');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user