feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { createTestAgent, startTestServer, httpRequest, type TestServer } from './test-fixtures.js';
import { DextoAgent } from '@dexto/core';
import { AgentFactory } from '@dexto/agent-management';
import type { CreateDextoAppOptions } from '../index.js';
describe('Hono API Integration Tests - Agent Routes', () => {
let testServer: TestServer | undefined;
let initialAgent: DextoAgent;
let mockAgents: Array<{
id: string;
name: string;
description: string;
author: string;
tags: string[];
type: 'builtin' | 'custom';
}> = [];
beforeAll(async () => {
initialAgent = await createTestAgent();
// Mock AgentFactory.listAgents to return test agents
mockAgents = [
{
id: 'test-agent-1',
name: 'Test Agent 1',
description: 'First test agent',
author: 'Test Author',
tags: ['test'],
type: 'builtin' as const,
},
{
id: 'test-agent-2',
name: 'Test Agent 2',
description: 'Second test agent',
author: 'Test Author',
tags: ['test'],
type: 'builtin' as const,
},
];
vi.spyOn(AgentFactory, 'listAgents').mockResolvedValue({
installed: mockAgents,
available: [],
});
// Create agentsContext with switching functions
let activeAgent = initialAgent;
let activeAgentId = 'test-agent-1';
let isSwitching = false;
const agentsContext: CreateDextoAppOptions['agentsContext'] = {
switchAgentById: async (id: string) => {
if (isSwitching) throw new Error('Agent switch in progress');
isSwitching = true;
try {
// Create a new test agent instance (no need to use AgentFactory.createAgent in tests)
const newAgent = await createTestAgent();
await newAgent.start();
if (activeAgent.isStarted()) {
await activeAgent.stop();
}
activeAgent = newAgent;
activeAgentId = id;
return { id, name: mockAgents.find((a) => a.id === id)?.name ?? id };
} finally {
isSwitching = false;
}
},
switchAgentByPath: async (filePath: string) => {
if (isSwitching) throw new Error('Agent switch in progress');
isSwitching = true;
try {
const newAgent = await createTestAgent();
await newAgent.start();
if (activeAgent.isStarted()) {
await activeAgent.stop();
}
activeAgent = newAgent;
activeAgentId = `agent-from-${filePath}`;
return { id: activeAgentId, name: 'Agent from Path' };
} finally {
isSwitching = false;
}
},
resolveAgentInfo: async (id: string) => {
const agent = mockAgents.find((a) => a.id === id);
return {
id,
name: agent?.name ?? id,
};
},
ensureAgentAvailable: () => {
if (isSwitching) throw new Error('Agent switch in progress');
if (!activeAgent.isStarted()) throw new Error('Agent not started');
},
getActiveAgentId: () => activeAgentId,
};
testServer = await startTestServer(initialAgent, undefined, agentsContext);
});
afterAll(async () => {
vi.restoreAllMocks();
if (testServer) {
await testServer.cleanup();
}
});
describe('Agent Management Routes', () => {
it('GET /api/agents returns list of agents', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agents');
expect(res.status).toBe(200);
expect(Array.isArray((res.body as { installed: unknown[] }).installed)).toBe(true);
expect(
(res.body as { installed: Array<{ id: string }> }).installed.length
).toBeGreaterThan(0);
});
it('GET /api/agents/current returns current agent', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agents/current');
expect(res.status).toBe(200);
expect((res.body as { id: string }).id).toBeDefined();
});
it('POST /api/agents/switch validates input', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/switch', {});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('POST /api/agents/switch switches agent by ID', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Note: Agent switching requires updating getAgent() closure which is complex
// For now, we test the endpoint accepts valid input
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/switch', {
id: 'test-agent-2',
});
// May return 400 if validation fails or 200 if switch succeeds
// The actual switch logic is complex and requires getAgent() to be dynamic
expect([200, 400]).toContain(res.status);
if (res.status === 200) {
const body = res.body as { switched: boolean; id: string; name: string };
expect(body.switched).toBe(true);
expect(body.id).toBe('test-agent-2');
expect(typeof body.name).toBe('string');
}
});
it('POST /api/agents/validate-name validates agent name', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/validate-name', {
id: 'valid-agent-name-that-does-not-exist',
});
expect(res.status).toBe(200);
const body = res.body as { valid: boolean; message?: string };
expect(body.valid).toBe(true);
});
it('POST /api/agents/validate-name rejects invalid names', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/validate-name', {
id: 'test-agent-1', // This conflicts with our mock
});
expect(res.status).toBe(200);
const body = res.body as { valid: boolean; conflict?: string; message?: string };
expect(body.valid).toBe(false);
expect(body.conflict).toBeDefined();
});
});
describe('Agent Config Routes', () => {
// Note: Agent path/config routes require agent to have configPath set
// These are skipped in test environment as we use in-memory agents
it.skip('GET /api/agent/path returns agent path', async () => {
// Requires agent with configPath - test agents don't have this
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agent/path');
expect(res.status).toBe(200);
const body = res.body as {
path: string;
relativePath: string;
name: string;
isDefault: boolean;
};
expect(typeof body.path).toBe('string');
expect(typeof body.relativePath).toBe('string');
expect(typeof body.name).toBe('string');
expect(typeof body.isDefault).toBe('boolean');
});
it.skip('GET /api/agent/config returns agent config', async () => {
// Requires agent with configPath - test agents don't have this
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agent/config');
expect(res.status).toBe(200);
const body = res.body as { config: unknown; path: string; lastModified?: unknown };
expect(body.config).toBeDefined();
expect(typeof body.path).toBe('string');
});
it('GET /api/agent/config/export exports config', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agent/config/export');
expect(res.status).toBe(200);
// Export returns YAML text, not JSON
expect(res.headers['content-type']).toContain('yaml');
expect(typeof res.text).toBe('string');
expect(res.text.length).toBeGreaterThan(0);
});
it('POST /api/agent/validate validates config', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agent/validate', {
yaml: 'systemPrompt: "You are a helpful assistant."\ngreeting: Hello\nllm:\n provider: openai\n model: gpt-5\n apiKey: sk-test-key-for-validation',
});
expect(res.status).toBe(200);
const body = res.body as { valid: boolean; errors?: unknown[]; warnings?: unknown[] };
expect(body.valid).toBe(true);
// errors may be undefined or empty array
expect(
body.errors === undefined ||
(Array.isArray(body.errors) && body.errors.length === 0)
).toBe(true);
});
it('POST /api/agent/validate rejects invalid config', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agent/validate', {
yaml: 'invalid: yaml: content: [',
});
expect(res.status).toBe(200);
const body = res.body as { valid: boolean; errors: unknown[]; warnings: unknown[] };
expect(body.valid).toBe(false);
expect(Array.isArray(body.errors)).toBe(true);
expect(body.errors.length).toBeGreaterThan(0);
const firstError = body.errors[0] as { code: string; message: string };
expect(typeof firstError.code).toBe('string');
expect(typeof firstError.message).toBe('string');
});
});
});

View File

@@ -0,0 +1,706 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { TextDecoder } from 'node:util';
import type { StreamingEvent } from '@dexto/core';
import {
createTestAgent,
startTestServer,
httpRequest,
type TestServer,
expectResponseStructure,
validators,
} from './test-fixtures.js';
describe('Hono API Integration Tests', () => {
let testServer: TestServer | undefined;
beforeAll(async () => {
const agent = await createTestAgent();
testServer = await startTestServer(agent);
}, 30000); // 30 second timeout for server startup
afterAll(async () => {
if (testServer) {
await testServer.cleanup();
}
});
describe('Health', () => {
it('GET /health returns OK', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/health');
expect(res.status).toBe(200);
expect(res.text).toBe('OK');
});
});
describe('LLM Routes', () => {
it('GET /api/llm/current returns current LLM config', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/llm/current');
expect(res.status).toBe(200);
expectResponseStructure(res.body, {
config: validators.object,
});
const config = (
res.body as {
config: {
provider: string;
model: string;
displayName?: string;
};
}
).config;
expect(config.provider).toBe('openai');
expect(config.model).toBe('gpt-5-nano');
expect(typeof config.displayName === 'string' || config.displayName === undefined).toBe(
true
);
});
it('GET /api/llm/current with sessionId returns session-specific config', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create a session first
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-llm',
});
expect(createRes.status).toBe(201);
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/llm/current?sessionId=test-session-llm'
);
expect(res.status).toBe(200);
expect((res.body as { config: unknown }).config).toBeDefined();
});
it('GET /api/llm/catalog returns LLM catalog', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/llm/catalog');
expect(res.status).toBe(200);
expectResponseStructure(res.body, {
providers: validators.object,
});
const providers = (res.body as { providers: Record<string, unknown> }).providers;
expect(Object.keys(providers).length).toBeGreaterThan(0);
// Validate provider structure
const firstProvider = Object.values(providers)[0] as {
models: unknown;
};
expect(firstProvider).toBeDefined();
expect(typeof firstProvider === 'object').toBe(true);
});
it('POST /api/llm/switch validates input', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/llm/switch', {});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('POST /api/llm/switch with model update succeeds', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/llm/switch', {
model: 'gpt-5',
});
expect(res.status).toBe(200);
});
});
describe('Sessions Routes', () => {
it('GET /api/sessions returns empty list initially', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/sessions');
expect(res.status).toBe(200);
expectResponseStructure(res.body, {
sessions: validators.array,
});
const sessions = (res.body as { sessions: unknown[] }).sessions;
// May have sessions from previous tests in integration suite
expect(sessions.length).toBeGreaterThanOrEqual(0);
});
it('POST /api/sessions creates a new session', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-1',
});
expect(res.status).toBe(201);
expectResponseStructure(res.body, {
session: validators.object,
});
const session = (
res.body as {
session: {
id: string;
createdAt: number | null;
lastActivity: number | null;
messageCount: number;
title: string | null;
};
}
).session;
expect(session.id).toBe('test-session-1');
expect(typeof session.messageCount).toBe('number');
expect(session.createdAt === null || typeof session.createdAt === 'number').toBe(true);
});
it('GET /api/sessions/:id returns session details', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-details',
});
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/sessions/test-session-details'
);
expect(res.status).toBe(200);
expect((res.body as { session: { id: string } }).session.id).toBe(
'test-session-details'
);
});
it('GET /api/sessions/:id returns 404 for non-existent session', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/sessions/non-existent-session'
);
expect(res.status).toBe(404);
});
it('GET /api/sessions/:id/load validates and returns session info', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-load',
});
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/sessions/test-session-load/load'
);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('session');
expect((res.body as { session: { id: string } }).session.id).toBe('test-session-load');
});
it('GET /api/sessions/:id/history returns session history', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-history',
});
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/sessions/test-session-history/history'
);
expect(res.status).toBe(200);
expect(Array.isArray((res.body as { history: unknown[] }).history)).toBe(true);
});
it('DELETE /api/sessions/:id deletes session', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-delete',
});
const res = await httpRequest(
testServer.baseUrl,
'DELETE',
'/api/sessions/test-session-delete'
);
expect(res.status).toBe(200);
// Verify deletion
const getRes = await httpRequest(
testServer.baseUrl,
'GET',
'/api/sessions/test-session-delete'
);
expect(getRes.status).toBe(404);
});
});
describe('Search Routes', () => {
it('GET /api/search/messages requires query parameter', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/messages');
expect(res.status).toBe(400);
});
it('GET /api/search/messages with query returns results', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/messages?q=test');
expect(res.status).toBe(200);
expect((res.body as { results: unknown[] }).results).toBeDefined();
});
it('GET /api/search/sessions requires query parameter', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/sessions');
expect(res.status).toBe(400);
});
it('GET /api/search/sessions with query returns results', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/sessions?q=test');
expect(res.status).toBe(200);
expect((res.body as { results: unknown[] }).results).toBeDefined();
});
});
describe('Memory Routes', () => {
it('GET /api/memory returns empty list initially', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/memory');
expect(res.status).toBe(200);
expect(Array.isArray((res.body as { memories: unknown[] }).memories)).toBe(true);
});
it('POST /api/memory creates a memory', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
content: 'Test memory content',
tags: ['test'],
});
expect(res.status).toBe(201);
expect((res.body as { memory: { id: string } }).memory.id).toBeDefined();
expect((res.body as { memory: { content: string } }).memory.content).toBe(
'Test memory content'
);
});
it('POST /api/memory validates required fields', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('GET /api/memory/:id returns memory details', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create memory first
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
content: 'Memory to retrieve',
tags: ['test'],
});
const memoryId = (createRes.body as { memory: { id: string } }).memory.id;
const res = await httpRequest(testServer.baseUrl, 'GET', `/api/memory/${memoryId}`);
expect(res.status).toBe(200);
expect((res.body as { memory: { id: string } }).memory.id).toBe(memoryId);
});
it('PUT /api/memory/:id updates memory', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create memory first
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
content: 'Original content',
tags: ['test'],
});
const memoryId = (createRes.body as { memory: { id: string } }).memory.id;
const res = await httpRequest(testServer.baseUrl, 'PUT', `/api/memory/${memoryId}`, {
content: 'Updated content',
});
expect(res.status).toBe(200);
expect((res.body as { memory: { content: string } }).memory.content).toBe(
'Updated content'
);
});
it('DELETE /api/memory/:id deletes memory', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create memory first
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
content: 'Memory to delete',
tags: ['test'],
});
const memoryId = (createRes.body as { memory: { id: string } }).memory.id;
const res = await httpRequest(testServer.baseUrl, 'DELETE', `/api/memory/${memoryId}`);
expect(res.status).toBe(200);
// Verify deletion
const getRes = await httpRequest(testServer.baseUrl, 'GET', `/api/memory/${memoryId}`);
expect(getRes.status).toBeGreaterThanOrEqual(400);
});
});
describe('MCP Routes', () => {
it('GET /api/mcp/servers returns server list', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/mcp/servers');
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
it('POST /api/mcp/servers validates input', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/mcp/servers', {});
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('Prompts Routes', () => {
it('GET /api/prompts returns prompt list', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/prompts');
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
it('GET /api/prompts/:name returns prompt details', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/prompts/non-existent-prompt'
);
// May return 404 or empty result depending on implementation
expect([200, 404]).toContain(res.status);
});
});
describe('Resources Routes', () => {
it('GET /api/resources returns resource list', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/resources');
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
});
describe('Webhooks Routes', () => {
it('GET /api/webhooks returns webhook list', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/webhooks');
expect(res.status).toBe(200);
expect(Array.isArray((res.body as { webhooks: unknown[] }).webhooks)).toBe(true);
});
it('POST /api/webhooks validates URL', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/webhooks', {
url: 'not-a-url',
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('POST /api/webhooks creates webhook', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/webhooks', {
url: 'https://example.com/webhook',
});
expect(res.status).toBe(201);
});
});
describe('Greeting Route', () => {
it('GET /api/greeting returns greeting', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/greeting');
expect(res.status).toBe(200);
// greeting might be undefined if not set in config, which is valid
expect(res.body).toBeDefined();
expect(
typeof (res.body as { greeting?: unknown }).greeting === 'string' ||
(res.body as { greeting?: unknown }).greeting === undefined
).toBe(true);
});
it('GET /api/greeting with sessionId returns session-specific greeting', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-greeting',
});
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/greeting?sessionId=test-session-greeting'
);
expect(res.status).toBe(200);
// greeting might be undefined if not set in config, which is valid
expect(res.body).toBeDefined();
expect(
typeof (res.body as { greeting?: unknown }).greeting === 'string' ||
(res.body as { greeting?: unknown }).greeting === undefined
).toBe(true);
});
});
describe('A2A Routes', () => {
it('GET /.well-known/agent-card.json returns agent card', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/.well-known/agent-card.json'
);
expect(res.status).toBe(200);
expect((res.body as { name: unknown }).name).toBeDefined();
});
});
describe('Message Routes', () => {
it('POST /api/message validates input', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/message', {});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('POST /api/message-sync validates input', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/message-sync', {});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('POST /api/reset resets conversation', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-session-reset',
});
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/reset', {
sessionId: 'test-session-reset',
});
expect(res.status).toBe(200);
});
it('POST /api/message-stream returns SSE stream directly', async () => {
if (!testServer) throw new Error('Test server not initialized');
const sessionId = 'stream-session';
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', { sessionId });
const agent = testServer.agent;
const originalStream = agent.stream;
const fakeEvents: StreamingEvent[] = [
{
name: 'llm:thinking',
sessionId,
},
{
name: 'llm:chunk',
content: 'hello',
chunkType: 'text',
isComplete: false,
sessionId,
},
{
name: 'llm:response',
content: 'hello',
tokenUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
sessionId,
provider: 'openai',
model: 'test-model',
},
];
agent.stream = async function (
_message: string,
_options
): Promise<AsyncIterableIterator<StreamingEvent>> {
async function* generator() {
for (const event of fakeEvents) {
yield event;
}
}
return generator();
} as typeof agent.stream;
try {
// POST to /api/message-stream - response IS the SSE stream
const response = await fetch(`${testServer.baseUrl}/api/message-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
content: 'Say hello',
}),
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
const reader = response.body?.getReader();
if (!reader) throw new Error('Response does not contain a readable body');
const decoder = new TextDecoder();
let received = '';
let chunks = 0;
while (chunks < 50) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks++;
received += decoder.decode(value, { stream: true });
if (received.includes('event: llm:response')) {
break;
}
}
await reader.cancel();
expect(received).toContain('event: llm:thinking');
expect(received).toContain('event: llm:response');
} finally {
agent.stream = originalStream;
}
});
});
describe('Queue Routes', () => {
it('GET /api/queue/:sessionId returns empty queue initially', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-queue-session',
});
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/queue/test-queue-session'
);
expect(res.status).toBe(200);
expect((res.body as { messages: unknown[]; count: number }).messages).toEqual([]);
expect((res.body as { count: number }).count).toBe(0);
});
it('GET /api/queue/:sessionId returns 404 for non-existent session', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(
testServer.baseUrl,
'GET',
'/api/queue/non-existent-queue-session'
);
expect(res.status).toBe(404);
});
it('POST /api/queue/:sessionId queues a message', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-queue-post-session',
});
const res = await httpRequest(
testServer.baseUrl,
'POST',
'/api/queue/test-queue-post-session',
{ content: 'Hello from queue' }
);
expect(res.status).toBe(201);
expect((res.body as { queued: boolean }).queued).toBe(true);
expect((res.body as { id: string }).id).toBeDefined();
expect((res.body as { position: number }).position).toBe(1);
// Verify message is in queue
const getRes = await httpRequest(
testServer.baseUrl,
'GET',
'/api/queue/test-queue-post-session'
);
expect((getRes.body as { count: number }).count).toBe(1);
});
it('POST /api/queue/:sessionId validates input', async () => {
if (!testServer) throw new Error('Test server not initialized');
// Create session first
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId: 'test-queue-validate-session',
});
const res = await httpRequest(
testServer.baseUrl,
'POST',
'/api/queue/test-queue-validate-session',
{} // Empty body should fail validation
);
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('DELETE /api/queue/:sessionId/:messageId removes a queued message', async () => {
if (!testServer) throw new Error('Test server not initialized');
const sessionId = `queue-delete-msg-${Date.now()}`;
// Create session and queue a message
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId,
});
expect(createRes.status).toBe(201);
const queueRes = await httpRequest(
testServer.baseUrl,
'POST',
`/api/queue/${sessionId}`,
{ content: 'Message to delete' }
);
expect(queueRes.status).toBe(201);
const messageId = (queueRes.body as { id: string }).id;
// Delete the message
const res = await httpRequest(
testServer.baseUrl,
'DELETE',
`/api/queue/${sessionId}/${messageId}`
);
expect(res.status).toBe(200);
expect((res.body as { removed: boolean }).removed).toBe(true);
// Verify queue is empty
const getRes = await httpRequest(testServer.baseUrl, 'GET', `/api/queue/${sessionId}`);
expect((getRes.body as { count: number }).count).toBe(0);
});
it('DELETE /api/queue/:sessionId clears all queued messages', async () => {
if (!testServer) throw new Error('Test server not initialized');
const sessionId = `queue-clear-${Date.now()}`;
// Create session and queue multiple messages
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
sessionId,
});
expect(createRes.status).toBe(201);
const q1 = await httpRequest(testServer.baseUrl, 'POST', `/api/queue/${sessionId}`, {
content: 'Message 1',
});
expect(q1.status).toBe(201);
const q2 = await httpRequest(testServer.baseUrl, 'POST', `/api/queue/${sessionId}`, {
content: 'Message 2',
});
expect(q2.status).toBe(201);
// Clear the queue
const res = await httpRequest(testServer.baseUrl, 'DELETE', `/api/queue/${sessionId}`);
expect(res.status).toBe(200);
expect((res.body as { cleared: boolean }).cleared).toBe(true);
expect((res.body as { count: number }).count).toBe(2);
// Verify queue is empty
const getRes = await httpRequest(testServer.baseUrl, 'GET', `/api/queue/${sessionId}`);
expect((getRes.body as { count: number }).count).toBe(0);
});
});
describe('OpenAPI Schema', () => {
it('GET /openapi.json returns OpenAPI schema', async () => {
if (!testServer) throw new Error('Test server not initialized');
const res = await httpRequest(testServer.baseUrl, 'GET', '/openapi.json');
expect(res.status).toBe(200);
expect((res.body as { openapi: string }).openapi).toBe('3.0.0');
});
});
});

View File

@@ -0,0 +1,294 @@
import { DextoAgent, createAgentCard } from '@dexto/core';
import type { AgentConfig, AgentCard } from '@dexto/core';
import type { Server as HttpServer } from 'node:http';
import type { Context } from 'hono';
import { createDextoApp } from '../index.js';
import type { DextoApp } from '../types.js';
import { createNodeServer, type NodeBridgeResult } from '../node/index.js';
import type { CreateDextoAppOptions } from '../index.js';
/**
* Test configuration for integration tests
* Uses in-memory storage to avoid side effects
*/
export function createTestAgentConfig(): AgentConfig {
return {
systemPrompt: 'You are a test assistant.',
llm: {
provider: 'openai',
model: 'gpt-5-nano',
apiKey: 'test-key-123', // Mock key for testing
maxIterations: 10,
},
mcpServers: {},
storage: {
cache: { type: 'in-memory' },
database: { type: 'in-memory' },
blob: { type: 'local', storePath: '/tmp/test-blobs' },
},
sessions: {
maxSessions: 50, // Increased to accommodate all integration tests
sessionTTL: 3600,
},
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: false,
timeout: 120000,
},
};
}
/**
* Creates a real DextoAgent instance with in-memory storage
* No mocks - uses real implementations
*/
export async function createTestAgent(config?: AgentConfig): Promise<DextoAgent> {
const agentConfig = config ?? createTestAgentConfig();
const agent = new DextoAgent(agentConfig);
await agent.start();
return agent;
}
/**
* Test server setup result
*/
export interface TestServer {
server: HttpServer;
app: DextoApp;
bridge: NodeBridgeResult;
agent: DextoAgent;
agentCard: AgentCard;
baseUrl: string;
port: number;
cleanup: () => Promise<void>;
}
/**
* Starts a real HTTP server for testing
* Uses createDextoApp and createNodeServer directly
* @param agent - The agent instance to use
* @param port - Optional port (auto-selected if not provided)
* @param agentsContext - Optional agent switching context (enables /api/agents routes)
*/
export async function startTestServer(
agent: DextoAgent,
port?: number,
agentsContext?: CreateDextoAppOptions['agentsContext']
): Promise<TestServer> {
// Use provided port or find an available port
const serverPort = port ?? (await findAvailablePort());
// Create agent card
const agentCard = createAgentCard({
defaultName: 'test-agent',
defaultVersion: '1.0.0',
defaultBaseUrl: `http://localhost:${serverPort}`,
});
// Create getter functions
// Note: For agent switching tests, getAgent needs to reference activeAgent from agentsContext
// This is handled by the agentsContext implementation itself
const getAgent = (_ctx: Context) => agent;
const getAgentCard = () => agentCard;
// Create event subscribers and approval coordinator for test
const { WebhookEventSubscriber } = await import('../../events/webhook-subscriber.js');
const { A2ASseEventSubscriber } = await import('../../events/a2a-sse-subscriber.js');
const { ApprovalCoordinator } = await import('../../approval/approval-coordinator.js');
const webhookSubscriber = new WebhookEventSubscriber();
const sseSubscriber = new A2ASseEventSubscriber();
const approvalCoordinator = new ApprovalCoordinator();
// Subscribe to agent's event bus
webhookSubscriber.subscribe(agent.agentEventBus);
sseSubscriber.subscribe(agent.agentEventBus);
// Create Hono app
const app = createDextoApp({
getAgent,
getAgentCard,
approvalCoordinator,
webhookSubscriber,
sseSubscriber,
...(agentsContext ? { agentsContext } : {}), // Include agentsContext only if provided
});
// Create Node server bridge
const bridge = createNodeServer(app, {
getAgent: () => agent,
port: serverPort,
});
// Agent card (no updates needed after bridge creation in SSE migration)
const updatedAgentCard = createAgentCard({
defaultName: 'test-agent',
defaultVersion: '1.0.0',
defaultBaseUrl: `http://localhost:${serverPort}`,
});
// Start the server
await new Promise<void>((resolve, reject) => {
bridge.server.listen(serverPort, '0.0.0.0', () => {
resolve();
});
bridge.server.on('error', reject);
});
const baseUrl = `http://localhost:${serverPort}`;
return {
server: bridge.server,
app,
bridge,
agent,
agentCard: updatedAgentCard,
baseUrl,
port: serverPort,
cleanup: async () => {
// Cleanup subscribers to prevent memory leaks
webhookSubscriber.cleanup();
sseSubscriber.cleanup();
approvalCoordinator.removeAllListeners();
await new Promise<void>((resolve, reject) => {
bridge.server.close((err) => {
if (err) reject(err);
else resolve();
});
});
if (agent.isStarted()) {
await agent.stop();
}
},
};
}
/**
* Finds an available port starting from a random port in the ephemeral range
* Uses ports 49152-65535 (IANA ephemeral port range)
*/
async function findAvailablePort(): Promise<number> {
const { createServer } = await import('node:http');
// Start from a random port in the ephemeral range to avoid conflicts
const startPort = 49152 + Math.floor(Math.random() * 1000);
for (let port = startPort; port < 65535; port++) {
try {
await new Promise<void>((resolve, reject) => {
const server = createServer();
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${port} is in use`));
} else {
reject(err);
}
});
server.listen(port, () => {
server.close(() => resolve());
});
});
return port;
} catch {
// Port is in use, try next
continue;
}
}
throw new Error(`Could not find an available port starting from ${startPort}`);
}
/**
* Helper to make HTTP requests to the test server
*/
export async function httpRequest(
baseUrl: string,
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
): Promise<{
status: number;
headers: Record<string, string>;
body: unknown;
text: string;
}> {
const url = `${baseUrl}${path}`;
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
};
if (body !== undefined) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
const text = await response.text();
let parsedBody: unknown;
try {
parsedBody = JSON.parse(text);
} catch {
parsedBody = text;
}
// Convert Headers to plain object for serialization
const headersObject: Record<string, string> = {};
response.headers.forEach((value, key) => {
headersObject[key] = value;
});
return {
status: response.status,
headers: headersObject,
body: parsedBody,
text,
};
}
/**
* Validates that a response has the expected structure
*/
export function expectResponseStructure(
body: unknown,
schema: Record<string, (value: unknown) => boolean>
): void {
if (typeof body !== 'object' || body === null) {
throw new Error(`Expected object response, got ${typeof body}`);
}
const bodyObj = body as Record<string, unknown>;
for (const [key, validator] of Object.entries(schema)) {
if (!(key in bodyObj)) {
throw new Error(`Missing required field: ${key}`);
}
if (!validator(bodyObj[key])) {
throw new Error(
`Invalid type for field '${key}': expected validator to return true, got false`
);
}
}
}
/**
* Common response validators
*/
export const validators = {
string: (value: unknown): boolean => typeof value === 'string',
number: (value: unknown): boolean => typeof value === 'number',
boolean: (value: unknown): boolean => typeof value === 'boolean',
array: (value: unknown): boolean => Array.isArray(value),
object: (value: unknown): boolean =>
typeof value === 'object' && value !== null && !Array.isArray(value),
optionalString: (value: unknown): boolean => value === undefined || typeof value === 'string',
optionalNumber: (value: unknown): boolean => value === undefined || typeof value === 'number',
optionalArray: (value: unknown): boolean => value === undefined || Array.isArray(value),
optionalObject: (value: unknown): boolean =>
value === undefined ||
(typeof value === 'object' && value !== null && !Array.isArray(value)),
};