feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* Hook 系统测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
HookManager,
|
||||
initHookManager,
|
||||
getHookManager,
|
||||
resetHookManager,
|
||||
loadHookConfig,
|
||||
loadProjectConfig,
|
||||
type Hooks,
|
||||
type HookConfig,
|
||||
} from '../../src/hooks/index.js';
|
||||
|
||||
describe('HookManager', () => {
|
||||
let tempDir: string;
|
||||
let manager: HookManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), `hooks-test-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
manager = new HookManager(tempDir, 'test-session');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetHookManager();
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('Plugin Registration', () => {
|
||||
it('should register hooks from plugin', async () => {
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.before': async (input, output) => {
|
||||
output.args = { ...output.args, injected: true };
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
expect(manager.getHookCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should register multiple plugins', async () => {
|
||||
const hooks1: Hooks = {
|
||||
'tool.execute.before': async () => {},
|
||||
};
|
||||
const hooks2: Hooks = {
|
||||
'tool.execute.after': async () => {},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks1);
|
||||
manager.registerHooks(hooks2);
|
||||
expect(manager.getHookCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Execute Before Hook', () => {
|
||||
it('should trigger tool.execute.before hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.before': async (input, output) => {
|
||||
triggered = true;
|
||||
expect(input.tool).toBe('test_tool');
|
||||
expect(input.sessionId).toBe('test-session');
|
||||
expect(input.args).toEqual({ foo: 'bar' });
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerToolExecuteBefore({
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: { foo: 'bar' },
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow hook to modify args', async () => {
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.before': async (input, output) => {
|
||||
output.args = { ...output.args, modified: true };
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
const result = await manager.triggerToolExecuteBefore({
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: { original: true },
|
||||
});
|
||||
|
||||
expect(result.args).toEqual({ original: true, modified: true });
|
||||
});
|
||||
|
||||
it('should allow hook to skip execution', async () => {
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.before': async (input, output) => {
|
||||
output.skip = true;
|
||||
output.skipResult = {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Blocked by hook',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
const result = await manager.triggerToolExecuteBefore({
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(result.skip).toBe(true);
|
||||
expect(result.skipResult?.error).toBe('Blocked by hook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Execute After Hook', () => {
|
||||
it('should trigger tool.execute.after hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.after': async (input, output) => {
|
||||
triggered = true;
|
||||
expect(input.tool).toBe('test_tool');
|
||||
expect(input.duration).toBeGreaterThanOrEqual(0);
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerToolExecuteAfter(
|
||||
{
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
duration: 100,
|
||||
},
|
||||
{ success: true, output: 'test output' }
|
||||
);
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow hook to modify result', async () => {
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.after': async (input, output) => {
|
||||
output.result = {
|
||||
...output.result,
|
||||
output: output.result.output + ' (modified)',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
const result = await manager.triggerToolExecuteAfter(
|
||||
{
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
duration: 100,
|
||||
},
|
||||
{ success: true, output: 'original' }
|
||||
);
|
||||
|
||||
expect(result.result.output).toBe('original (modified)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Hooks', () => {
|
||||
it('should trigger session.start hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'session.start': async (input) => {
|
||||
triggered = true;
|
||||
expect(input.sessionId).toBe('session-123');
|
||||
expect(input.workdir).toBe('/test/dir');
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerSessionStart({
|
||||
sessionId: 'session-123',
|
||||
workdir: '/test/dir',
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger session.end hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'session.end': async (input) => {
|
||||
triggered = true;
|
||||
expect(input.messageCount).toBe(10);
|
||||
expect(input.duration).toBe(5000);
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerSessionEnd({
|
||||
sessionId: 'session-123',
|
||||
messageCount: 10,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Hooks', () => {
|
||||
it('should trigger message.before hook', async () => {
|
||||
const hooks: Hooks = {
|
||||
'message.before': async (input, output) => {
|
||||
output.content = input.content.toUpperCase();
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
const result = await manager.triggerMessageBefore({
|
||||
sessionId: 'test-session',
|
||||
content: 'hello world',
|
||||
});
|
||||
|
||||
expect(result.content).toBe('HELLO WORLD');
|
||||
});
|
||||
|
||||
it('should allow message.before to skip', async () => {
|
||||
const hooks: Hooks = {
|
||||
'message.before': async (input, output) => {
|
||||
if (input.content.includes('forbidden')) {
|
||||
output.skip = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
const result = await manager.triggerMessageBefore({
|
||||
sessionId: 'test-session',
|
||||
content: 'this is forbidden',
|
||||
});
|
||||
|
||||
expect(result.skip).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger message.after hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'message.after': async (input) => {
|
||||
triggered = true;
|
||||
expect(input.toolCalls).toBe(3);
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerMessageAfter({
|
||||
sessionId: 'test-session',
|
||||
content: 'response',
|
||||
toolCalls: 3,
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Change Hooks', () => {
|
||||
it('should trigger file.edited hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'file.edited': async (input, output) => {
|
||||
triggered = true;
|
||||
expect(input.path).toBe('/test/file.ts');
|
||||
expect(input.tool).toBe('edit_file');
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerFileEdited({
|
||||
path: '/test/file.ts',
|
||||
tool: 'edit_file',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger file.created hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'file.created': async (input, output) => {
|
||||
triggered = true;
|
||||
expect(input.path).toBe('/test/new-file.ts');
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerFileCreated({
|
||||
path: '/test/new-file.ts',
|
||||
tool: 'write_file',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger file.deleted hook', async () => {
|
||||
let triggered = false;
|
||||
const hooks: Hooks = {
|
||||
'file.deleted': async (input, output) => {
|
||||
triggered = true;
|
||||
expect(input.path).toBe('/test/old-file.ts');
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
await manager.triggerFileDeleted({
|
||||
path: '/test/old-file.ts',
|
||||
tool: 'delete_file',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Hooks', () => {
|
||||
it('should execute file hooks matching glob pattern', async () => {
|
||||
const config: HookConfig = {
|
||||
file_edited: {
|
||||
'*.ts': [
|
||||
{
|
||||
command: ['echo', 'TypeScript file edited'],
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
manager.setConfigHooks(config);
|
||||
|
||||
const result = await manager.triggerFileEdited({
|
||||
path: 'test.ts',
|
||||
tool: 'edit_file',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(result.commandResults).toBeDefined();
|
||||
expect(result.commandResults?.length).toBe(1);
|
||||
expect(result.commandResults?.[0].success).toBe(true);
|
||||
expect(result.commandResults?.[0].output).toContain('TypeScript file edited');
|
||||
});
|
||||
|
||||
it('should not execute hooks for non-matching patterns', async () => {
|
||||
const config: HookConfig = {
|
||||
file_edited: {
|
||||
'*.ts': [
|
||||
{
|
||||
command: ['echo', 'TypeScript'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
manager.setConfigHooks(config);
|
||||
|
||||
const result = await manager.triggerFileEdited({
|
||||
path: 'test.js',
|
||||
tool: 'edit_file',
|
||||
sessionId: 'test-session',
|
||||
});
|
||||
|
||||
expect(result.commandResults).toBeDefined();
|
||||
expect(result.commandResults?.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Listeners', () => {
|
||||
it('should emit events to listeners', async () => {
|
||||
const events: any[] = [];
|
||||
manager.addEventListener((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
await manager.triggerToolExecuteBefore({
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].type).toBe('tool.execute.before');
|
||||
expect(events[0].timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should remove event listener', async () => {
|
||||
const events: any[] = [];
|
||||
const listener = (event: any) => events.push(event);
|
||||
|
||||
manager.addEventListener(listener);
|
||||
manager.removeEventListener(listener);
|
||||
|
||||
await manager.triggerToolExecuteBefore({
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should continue execution when hook throws error', async () => {
|
||||
const hooks: Hooks = {
|
||||
'tool.execute.before': async () => {
|
||||
throw new Error('Hook error');
|
||||
},
|
||||
};
|
||||
|
||||
manager.registerHooks(hooks);
|
||||
|
||||
// Should not throw
|
||||
const result = await manager.triggerToolExecuteBefore({
|
||||
tool: 'test_tool',
|
||||
sessionId: 'test-session',
|
||||
callId: 'call-1',
|
||||
args: { foo: 'bar' },
|
||||
});
|
||||
|
||||
// Args should remain unchanged
|
||||
expect(result.args).toEqual({ foo: 'bar' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Global Hook Manager', () => {
|
||||
afterEach(() => {
|
||||
resetHookManager();
|
||||
});
|
||||
|
||||
it('should initialize global hook manager', () => {
|
||||
const manager = initHookManager('/test/dir', 'session-1');
|
||||
expect(manager).toBeInstanceOf(HookManager);
|
||||
expect(getHookManager()).toBe(manager);
|
||||
});
|
||||
|
||||
it('should return null before initialization', () => {
|
||||
expect(getHookManager()).toBeNull();
|
||||
});
|
||||
|
||||
it('should reset global hook manager', () => {
|
||||
initHookManager('/test/dir');
|
||||
resetHookManager();
|
||||
expect(getHookManager()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Loader', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should load project config from .ai-assistant.json', async () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
file_edited: {
|
||||
'*.ts': [{ command: ['echo', 'test'] }],
|
||||
},
|
||||
},
|
||||
plugins: ['plugin-a', 'plugin-b'],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.ai-assistant.json'),
|
||||
JSON.stringify(config)
|
||||
);
|
||||
|
||||
const loaded = await loadProjectConfig(tempDir);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.hooks?.file_edited).toBeDefined();
|
||||
expect(loaded?.plugins).toEqual(['plugin-a', 'plugin-b']);
|
||||
});
|
||||
|
||||
it('should load hook config', async () => {
|
||||
const config = {
|
||||
hooks: {
|
||||
file_edited: {
|
||||
'*.ts': [{ command: ['npm', 'run', 'lint'] }],
|
||||
},
|
||||
session_completed: [{ command: ['echo', 'done'] }],
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.ai-assistant.json'),
|
||||
JSON.stringify(config)
|
||||
);
|
||||
|
||||
const hookConfig = await loadHookConfig(tempDir);
|
||||
expect(hookConfig).not.toBeNull();
|
||||
expect(hookConfig?.file_edited?.['*.ts']).toHaveLength(1);
|
||||
expect(hookConfig?.session_completed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return null for missing config', async () => {
|
||||
const config = await loadProjectConfig(tempDir);
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('should support JSONC format with comments', async () => {
|
||||
const configContent = `{
|
||||
// This is a comment
|
||||
"hooks": {
|
||||
/* Multi-line
|
||||
comment */
|
||||
"file_edited": {
|
||||
"*.ts": [{ "command": ["echo", "test"] }]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.ai-assistant.jsonc'),
|
||||
configContent
|
||||
);
|
||||
|
||||
const loaded = await loadProjectConfig(tempDir);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.hooks?.file_edited?.['*.ts']).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user