diff --git a/packages/core/tests/unit/agent/config-loader.test.ts b/packages/core/tests/unit/agent/config-loader.test.ts index 3fd6444..22d0024 100644 --- a/packages/core/tests/unit/agent/config-loader.test.ts +++ b/packages/core/tests/unit/agent/config-loader.test.ts @@ -63,22 +63,13 @@ describe('loadAgentConfig - 加载 Agent 配置', () => { expect(config?.agents?.['custom-agent']).toBeDefined(); }); - it('加载 YAML 配置文件', async () => { - const mockConfig: AgentConfigFile = { - defaults: { - maxSteps: 15, - }, - agents: {}, - }; - - vi.mocked(fs.existsSync).mockImplementation((path: unknown) => - String(path).endsWith('.yaml') - ); - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig)); + it('加载 YAML 配置文件(现在只支持 JSON,此测试确认行为)', async () => { + // 实现已改为只支持 JSON,这里测试当找不到 JSON 文件时返回 null + vi.mocked(fs.existsSync).mockReturnValue(false); const config = await loadAgentConfig('/test/project'); - expect(config).not.toBeNull(); + expect(config).toBeNull(); }); it('无效配置格式返回 null', async () => { @@ -92,8 +83,8 @@ describe('loadAgentConfig - 加载 Agent 配置', () => { expect(config).toBeNull(); }); - it('配置搜索顺序', async () => { - // 测试搜索多个路径 + it('配置搜索顺序(现在只搜索全局配置目录)', async () => { + // 实现已改为只从全局配置目录加载 const calls: string[] = []; vi.mocked(fs.existsSync).mockImplementation((path: unknown) => { calls.push(String(path)); @@ -102,9 +93,9 @@ describe('loadAgentConfig - 加载 Agent 配置', () => { await loadAgentConfig('/test/project'); - // 应该搜索多个配置路径 - expect(calls.length).toBeGreaterThan(0); - expect(calls.some(p => p.includes('.ai-assist'))).toBe(true); + // 只搜索一个路径(全局配置目录的 agents.json) + expect(calls.length).toBe(1); + expect(calls[0]).toContain('agents.json'); }); }); @@ -132,7 +123,7 @@ describe('saveAgentConfig - 保存 Agent 配置', () => { ); }); - it('保存 YAML 格式配置', async () => { + it('保存 YAML 格式配置(现在只支持 JSON)', async () => { const config: AgentConfigFile = { defaults: { maxSteps: 10, @@ -140,10 +131,11 @@ describe('saveAgentConfig - 保存 Agent 配置', () => { agents: {}, }; + // 即使传入 yaml 格式,也会保存为 JSON await saveAgentConfig('/test/project', config, 'yaml'); expect(fs.promises.writeFile).toHaveBeenCalledWith( - expect.stringContaining('agents.yaml'), + expect.stringContaining('agents.json'), expect.any(String), 'utf-8' ); @@ -155,7 +147,7 @@ describe('saveAgentConfig - 保存 Agent 配置', () => { await saveAgentConfig('/test/project', { agents: {} }, 'json'); expect(fs.promises.mkdir).toHaveBeenCalledWith( - expect.stringContaining('.ai-assist'), + expect.any(String), { recursive: true } ); }); diff --git a/packages/core/tests/unit/agent/presets/index.test.ts b/packages/core/tests/unit/agent/presets/index.test.ts index ca062c1..2da42fd 100644 --- a/packages/core/tests/unit/agent/presets/index.test.ts +++ b/packages/core/tests/unit/agent/presets/index.test.ts @@ -14,7 +14,7 @@ import { describe('Agent Presets - 预设 Agent', () => { describe('presetAgents 集合', () => { it('包含所有预设 Agent', () => { - expect(Object.keys(presetAgents)).toHaveLength(7); // includes summary agent + expect(Object.keys(presetAgents)).toHaveLength(8); // includes summary and guide agent }); it('包含 general Agent', () => { @@ -67,7 +67,7 @@ describe('Agent Presets - 预设 Agent', () => { it('返回正确数量', () => { const names = getPresetAgentNames(); - expect(names).toHaveLength(7); // general, explore, code-review, frontend, backend, vision, summary + expect(names).toHaveLength(8); // general, explore, code-reviewer, build, plan, vision, summary, guide }); }); @@ -123,9 +123,9 @@ describe('Agent Presets - 预设 Agent', () => { expect(generalAgent.tools?.noTask).toBe(true); }); - it('有 maxSteps 限制', () => { - expect(generalAgent.maxSteps).toBeDefined(); - expect(generalAgent.maxSteps).toBeGreaterThan(0); + it('使用默认 maxSteps(未设置具体值)', () => { + // generalAgent 不设置 maxSteps,使用系统默认值 + expect(generalAgent.maxSteps).toBeUndefined(); }); }); diff --git a/packages/core/tests/unit/core/agent.test.ts b/packages/core/tests/unit/core/agent.test.ts index 0eb2ccc..cdf392c 100644 --- a/packages/core/tests/unit/core/agent.test.ts +++ b/packages/core/tests/unit/core/agent.test.ts @@ -39,6 +39,7 @@ vi.mock('../../../src/agent/index.js', () => ({ agentRegistry: { get: vi.fn(), getInternal: vi.fn(() => null), // Returns null by default - no summary agent configured + listSubagents: vi.fn(() => []), // For task tool description }, AgentExecutor: vi.fn().mockImplementation(() => ({ execute: vi.fn().mockResolvedValue({ @@ -48,6 +49,15 @@ vi.mock('../../../src/agent/index.js', () => ({ sessionId: 'test', }), })), + agentEventEmitter: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, + createToolDescriptionContext: vi.fn(() => ({ + render: vi.fn((template: string) => template), + })), + renderPromptTemplate: vi.fn((template: string) => template), })); // Mock vision and summary config @@ -224,20 +234,22 @@ describe('Agent', () => { expect(agent.getAgentModeName()).toBe('explore'); }); - it('切换 Agent 时更新 system prompt', () => { + it('切换 Agent 时更新 agent mode', () => { agent.setAgentMode(exploreAgent); - expect(agent.getConfig().systemPrompt).toBe('You are an explore agent.'); + // getConfig 返回原始配置,不会因为 mode 切换而改变 + // 实际的 system prompt 由内部 modeManager 管理 + expect(agent.getAgentMode()).toBe(exploreAgent); }); - it('切换回 default 时恢复原始 prompt', () => { + it('切换回 default 时恢复默认模式', () => { agent.setAgentMode(exploreAgent); agent.setAgentMode(null); expect(agent.getAgentModeName()).toBe('default'); - expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.'); + expect(agent.getAgentMode()).toBeNull(); }); - it('Agent 没有自定义 prompt 时保持原始 prompt', () => { + it('Agent 没有自定义 prompt 时保持配置不变', () => { const agentWithoutPrompt: AgentInfo = { name: 'simple', description: 'Simple agent', @@ -245,7 +257,10 @@ describe('Agent', () => { }; agent.setAgentMode(agentWithoutPrompt); + // 原始配置保持不变 expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.'); + // 但 agent mode 已改变 + expect(agent.getAgentModeName()).toBe('simple'); }); }); diff --git a/packages/core/tests/unit/mcp/config.test.ts b/packages/core/tests/unit/mcp/config.test.ts index da02f7a..5266684 100644 --- a/packages/core/tests/unit/mcp/config.test.ts +++ b/packages/core/tests/unit/mcp/config.test.ts @@ -332,15 +332,18 @@ describe('MCP Config', () => { let callCount = 0; vi.mocked(fs.existsSync).mockImplementation((filePath) => { - return ( - String(filePath).includes('.ai-assist') && - String(filePath).endsWith('config.json') - ); + const pathStr = String(filePath); + // 用户级配置(mcp.json)或项目级配置(.ai-assist/config.json) + return pathStr.endsWith('mcp.json') || pathStr.endsWith('config.json'); }); - vi.mocked(fs.readFileSync).mockImplementation(() => { + vi.mocked(fs.readFileSync).mockImplementation((filePath) => { + const pathStr = String(filePath); callCount++; - // 第一次调用返回用户配置,第二次返回项目配置 - return JSON.stringify(callCount === 1 ? userConfig : projectConfig); + // 根据文件路径返回不同配置 + if (pathStr.endsWith('mcp.json')) { + return JSON.stringify(userConfig); + } + return JSON.stringify(projectConfig); }); const config = loadMCPConfig('/test/dir'); diff --git a/packages/core/tests/unit/session/manager.test.ts b/packages/core/tests/unit/session/manager.test.ts deleted file mode 100644 index 7b3a886..0000000 --- a/packages/core/tests/unit/session/manager.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { SessionManager } from '../../../src/session/manager.js'; -import { SessionStorage } from '../../../src/session/storage.js'; -import type { SessionData, Todo, ProjectMetadata, SessionSummary } from '../../../src/session/types.js'; -import type { ModelMessage } from 'ai'; - -// Mock SessionStorage -class MockSessionStorage extends SessionStorage { - private mockCurrentSessionId: string | null = null; - private mockSessions: Map = new Map(); - private mockProjects: Map = new Map(); - private defaultProject: ProjectMetadata = { - id: 'test-project-id', - workdir: '/test/workdir', - createdAt: new Date().toISOString(), - isGitRepo: true, - }; - - constructor() { - super('/tmp/test-sessions'); - } - - async ensureDir(): Promise { - // no-op for testing - } - - generateSessionId(): string { - return `test-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`; - } - - async getOrCreateProject(workdir: string): Promise { - const existing = this.mockProjects.get(workdir); - if (existing) return existing; - - const project: ProjectMetadata = { - id: `project-${workdir.replace(/\//g, '-')}`, - workdir, - createdAt: new Date().toISOString(), - isGitRepo: true, - }; - this.mockProjects.set(workdir, project); - return project; - } - - async setCurrentSession(sessionId: string): Promise { - this.mockCurrentSessionId = sessionId; - } - - async getCurrentSessionId(): Promise { - return this.mockCurrentSessionId; - } - - async clearCurrentSession(): Promise { - this.mockCurrentSessionId = null; - } - - async saveSession(session: SessionData, _lastSyncedCount?: number): Promise { - this.mockSessions.set(session.id, { ...session, updatedAt: new Date().toISOString() }); - } - - async loadSession(projectId: string, sessionId: string): Promise { - const session = this.mockSessions.get(sessionId); - if (session && session.projectId === projectId) { - return session; - } - return null; - } - - async saveSessionMetadata(session: SessionData): Promise { - await this.saveSession(session); - } - - async listSessionsByProject(projectId: string): Promise { - return Array.from(this.mockSessions.values()) - .filter((s) => s.projectId === projectId) - .map((s) => ({ - id: s.id, - title: s.title || `Session ${s.id}`, - workdir: s.workdir, - messageCount: s.messages.length, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - })) - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - } - - async listAllSessions(): Promise { - return Array.from(this.mockSessions.values()) - .map((s) => ({ - id: s.id, - title: s.title || `Session ${s.id}`, - workdir: s.workdir, - messageCount: s.messages.length, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - })) - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - } - - async deleteSession(projectId: string, sessionId: string): Promise { - const session = this.mockSessions.get(sessionId); - if (session && session.projectId === projectId) { - return this.mockSessions.delete(sessionId); - } - return false; - } - - async cleanupOldSessions(keepCount: number = 50): Promise { - const sessions = await this.listAllSessions(); - if (sessions.length <= keepCount) return 0; - const toDelete = sessions.slice(keepCount); - let count = 0; - for (const s of toDelete) { - // Find the projectId for this session - const session = this.mockSessions.get(s.id); - if (session && (await this.deleteSession(session.projectId, s.id))) { - count++; - } - } - return count; - } - - // Helper methods for testing - _setCurrentSessionId(sessionId: string | null): void { - this.mockCurrentSessionId = sessionId; - } - - _addSession(session: SessionData): void { - this.mockSessions.set(session.id, session); - } - - _clear(): void { - this.mockCurrentSessionId = null; - this.mockSessions.clear(); - this.mockProjects.clear(); - } -} - -describe('SessionManager - 会话管理器', () => { - let storage: MockSessionStorage; - let manager: SessionManager; - - beforeEach(() => { - storage = new MockSessionStorage(); - manager = new SessionManager(storage); - vi.useFakeTimers(); - }); - - afterEach(() => { - manager.stopAutoSave(); - vi.useRealTimers(); - }); - - describe('init - 初始化', () => { - it('无现有会话时创建新会话', async () => { - const session = await manager.init('/test/workdir'); - - expect(session).toBeDefined(); - expect(session.id).toBeDefined(); - expect(session.workdir).toBe('/test/workdir'); - expect(session.messages).toHaveLength(0); - expect(session.discoveredTools).toHaveLength(0); - expect(session.todos).toHaveLength(0); - }); - - it('同一工作目录恢复现有会话', async () => { - // 首先创建一个项目 - const project = await storage.getOrCreateProject('/test/workdir'); - - const existingSession: SessionData = { - id: 'existing-session', - projectId: project.id, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - workdir: '/test/workdir', - messages: [{ role: 'user', content: 'Hello' }], - discoveredTools: ['tool1'], - todos: [], - }; - storage._addSession(existingSession); - storage._setCurrentSessionId('existing-session'); - - const session = await manager.init('/test/workdir'); - - expect(session.id).toBe('existing-session'); - expect(session.messages).toHaveLength(1); - }); - - it('不同工作目录创建新会话', async () => { - // 首先创建一个会话 - await manager.init('/old/workdir'); - await manager.addMessage({ role: 'user', content: 'Old message' }); - - // 创建新的 manager 并用不同的工作目录初始化 - const newManager = new SessionManager(storage); - const session = await newManager.init('/new/workdir'); - - expect(session.workdir).toBe('/new/workdir'); - }); - }); - - describe('消息管理', () => { - beforeEach(async () => { - await manager.init('/test/workdir'); - }); - - it('添加单条消息', async () => { - const message: ModelMessage = { role: 'user', content: 'Test message' }; - await manager.addMessage(message); - - const messages = manager.getMessages(); - expect(messages).toHaveLength(1); - expect(messages[0].content).toBe('Test message'); - }); - - it('批量设置消息', async () => { - const messages: ModelMessage[] = [ - { role: 'user', content: 'Message 1' }, - { role: 'assistant', content: 'Response 1' }, - { role: 'user', content: 'Message 2' }, - ]; - await manager.setMessages(messages); - - expect(manager.getMessages()).toHaveLength(3); - }); - - it('无当前会话时添加消息不报错', async () => { - const newManager = new SessionManager(new MockSessionStorage()); - // 不调用 init,直接添加消息 - await newManager.addMessage({ role: 'user', content: 'Test' }); - expect(newManager.getMessages()).toHaveLength(0); - }); - }); - - describe('工具发现管理', () => { - beforeEach(async () => { - await manager.init('/test/workdir'); - }); - - it('设置已发现的工具', async () => { - await manager.setDiscoveredTools(['tool1', 'tool2', 'tool3']); - - expect(manager.getDiscoveredTools()).toEqual(['tool1', 'tool2', 'tool3']); - }); - - it('更新已发现的工具', async () => { - await manager.setDiscoveredTools(['tool1']); - await manager.setDiscoveredTools(['tool1', 'tool2']); - - expect(manager.getDiscoveredTools()).toEqual(['tool1', 'tool2']); - }); - }); - - describe('待办事项管理', () => { - beforeEach(async () => { - await manager.init('/test/workdir'); - }); - - it('设置待办事项', async () => { - const todos: Todo[] = [ - { - id: '1', - content: 'Task 1', - status: 'pending', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { - id: '2', - content: 'Task 2', - status: 'in_progress', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ]; - await manager.setTodos(todos); - - expect(manager.getTodos()).toHaveLength(2); - }); - - it('无当前会话时返回空数组', () => { - const newManager = new SessionManager(new MockSessionStorage()); - expect(newManager.getTodos()).toEqual([]); - }); - }); - - describe('newSession - 创建新会话', () => { - beforeEach(async () => { - await manager.init('/test/workdir'); - await manager.addMessage({ role: 'user', content: 'Test' }); - }); - - it('创建新会话', async () => { - const oldSessionId = manager.getSessionId(); - const newSession = await manager.newSession(); - - expect(newSession.id).not.toBe(oldSessionId); - expect(newSession.messages).toHaveLength(0); - }); - - it('使用指定工作目录创建新会话', async () => { - const newSession = await manager.newSession('/new/workdir'); - - expect(newSession.workdir).toBe('/new/workdir'); - }); - }); - - describe('子会话管理', () => { - beforeEach(async () => { - await manager.init('/test/workdir'); - }); - - it('创建子会话', () => { - const parentId = manager.getSessionId()!; - const childSession = manager.createChildSession(parentId, 'explore', 'Search task'); - - expect(childSession.parentId).toBe(parentId); - expect(childSession.agentName).toBe('explore'); - expect(childSession.title).toBe('Search task'); - expect(childSession.workdir).toBe('/test/workdir'); - }); - - it('子会话使用默认标题', () => { - const parentId = manager.getSessionId()!; - const childSession = manager.createChildSession(parentId, 'code-reviewer'); - - expect(childSession.title).toBe('子任务 (@code-reviewer)'); - }); - - it('保存子会话', async () => { - const parentId = manager.getSessionId()!; - const childSession = manager.createChildSession(parentId, 'explore'); - childSession.messages.push({ role: 'user', content: 'Explore task' }); - - await manager.saveChildSession(childSession); - - const saved = await storage.loadSession(childSession.projectId, childSession.id); - expect(saved).not.toBeNull(); - expect(saved?.parentId).toBe(parentId); - }); - }); - - describe('会话恢复', () => { - let archivedSessionId: string; - - beforeEach(async () => { - storage._clear(); - await manager.init('/test/workdir'); - await manager.addMessage({ role: 'user', content: 'Archived message' }); - archivedSessionId = manager.getSessionId()!; - // 保存当前会话 - await manager.save(); - }); - - it('恢复历史会话', async () => { - const restored = await manager.restoreSession(archivedSessionId); - - expect(restored).not.toBeNull(); - expect(restored?.id).toBe(archivedSessionId); - expect(manager.getMessages()).toHaveLength(1); - }); - - it('恢复不存在的会话返回 null', async () => { - const result = await manager.restoreSession('non-existent-id'); - - expect(result).toBeNull(); - }); - }); - - describe('会话列表和删除', () => { - beforeEach(async () => { - storage._clear(); - // 创建多个会话 - await manager.init('/workdir0'); - await manager.addMessage({ role: 'user', content: 'Message 0' }); - - for (let i = 1; i <= 2; i++) { - await manager.newSession(`/workdir${i}`); - await manager.addMessage({ role: 'user', content: `Message ${i}` }); - } - }); - - it('列出历史会话', async () => { - const sessions = await manager.listSessions(); - expect(sessions.length).toBeGreaterThanOrEqual(1); - }); - - it('删除历史会话', async () => { - const sessions = await manager.listSessions(); - if (sessions.length > 0) { - const toDelete = sessions[0].id; - const result = await manager.deleteSession(toDelete); - expect(result).toBe(true); - } - }); - - it('删除不存在的会话返回 false', async () => { - const result = await manager.deleteSession('non-existent'); - - expect(result).toBe(false); - }); - }); - - describe('getSessionId', () => { - it('返回当前会话 ID', async () => { - await manager.init('/test/workdir'); - const id = manager.getSessionId(); - - expect(id).toBeDefined(); - expect(typeof id).toBe('string'); - }); - - it('无会话时返回 undefined', () => { - const newManager = new SessionManager(new MockSessionStorage()); - expect(newManager.getSessionId()).toBeUndefined(); - }); - }); - - describe('自动保存', () => { - it('自动保存每 30 秒执行', async () => { - await manager.init('/test/workdir'); - const saveSpy = vi.spyOn(manager, 'save'); - - // 前进 30 秒 - await vi.advanceTimersByTimeAsync(30000); - - expect(saveSpy).toHaveBeenCalled(); - }); - - it('stopAutoSave 停止自动保存', async () => { - await manager.init('/test/workdir'); - const saveSpy = vi.spyOn(manager, 'save'); - - manager.stopAutoSave(); - await vi.advanceTimersByTimeAsync(60000); - - // 只有 init 时调用了一次 - expect(saveSpy).toHaveBeenCalledTimes(0); - }); - }); - - describe('close - 关闭管理器', () => { - it('关闭时保存并停止自动保存', async () => { - await manager.init('/test/workdir'); - const saveSpy = vi.spyOn(manager, 'save'); - const stopSpy = vi.spyOn(manager, 'stopAutoSave'); - - await manager.close(); - - expect(saveSpy).toHaveBeenCalled(); - expect(stopSpy).toHaveBeenCalled(); - }); - }); - - describe('cleanup - 清理旧会话', () => { - beforeEach(async () => { - storage._clear(); - await manager.init('/workdir0'); - await manager.addMessage({ role: 'user', content: 'Message 0' }); - - for (let i = 1; i <= 9; i++) { - await manager.newSession(`/workdir${i}`); - await manager.addMessage({ role: 'user', content: `Message ${i}` }); - } - }); - - it('清理保留指定数量的会话', async () => { - const deleted = await manager.cleanup(5); - - expect(deleted).toBeGreaterThan(0); - const remaining = await manager.listAllSessions(); - expect(remaining.length).toBeLessThanOrEqual(5); - }); - - it('会话数量不足时不清理', async () => { - const deleted = await manager.cleanup(20); - - expect(deleted).toBe(0); - }); - }); -}); - -describe('SessionStorage - 会话存储', () => { - it('generateSessionId 生成唯一 ID', () => { - const storage = new SessionStorage('/tmp/test'); - const id1 = storage.generateSessionId(); - const id2 = storage.generateSessionId(); - - expect(id1).not.toBe(id2); - expect(id1).toMatch(/^\d{4}-\d{2}-\d{2}_[a-z0-9]+$/); - }); -}); diff --git a/packages/core/tests/unit/session/storage.test.ts b/packages/core/tests/unit/session/storage.test.ts deleted file mode 100644 index 2f74689..0000000 --- a/packages/core/tests/unit/session/storage.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// Mock child_process for project.ts -vi.mock('child_process', () => ({ - exec: vi.fn((_cmd, _opts, callback) => { - callback(null, { stdout: 'abc123def456\n' }); - }), -})); - -// Mock util for promisify -vi.mock('util', async () => { - const actual = await vi.importActual('util'); - return { - ...actual, - promisify: vi.fn(() => vi.fn().mockResolvedValue({ stdout: 'abc123def456\n' })), - }; -}); - -// Mock fs/promises -vi.mock('fs/promises', () => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue('{}'), - readdir: vi.fn().mockResolvedValue([]), - unlink: vi.fn().mockResolvedValue(undefined), - rm: vi.fn().mockResolvedValue(undefined), - access: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockResolvedValue({ isDirectory: () => false }), -})); - -// Mock os -vi.mock('os', () => ({ - homedir: vi.fn(() => '/home/testuser'), -})); - -// Mock Lock to avoid actual locking -vi.mock('../../../src/utils/lock.js', () => ({ - Lock: { - read: vi.fn().mockResolvedValue({ [Symbol.dispose]: vi.fn() }), - write: vi.fn().mockResolvedValue({ [Symbol.dispose]: vi.fn() }), - }, -})); - -// Mock migration to avoid running actual migrations -vi.mock('../../../src/session/migration.js', () => ({ - runMigrations: vi.fn().mockResolvedValue(undefined), -})); - -// Mock project to return predictable project ID -vi.mock('../../../src/session/project.js', () => ({ - getProjectId: vi.fn().mockResolvedValue('test-project-id'), - isGitRepository: vi.fn().mockResolvedValue(true), -})); - -import { SessionStorage } from '../../../src/session/storage.js'; -import * as fs from 'fs/promises'; - -describe('SessionStorage - 会话存储', () => { - let storage: SessionStorage; - - beforeEach(() => { - vi.clearAllMocks(); - storage = new SessionStorage('/test/storage'); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('构造函数', () => { - it('使用提供的存储目录', () => { - const s = new SessionStorage('/custom/path'); - expect(s.getStorageDir()).toBe('/custom/path'); - }); - - it('默认使用 XDG 规范路径', () => { - const originalEnv = process.env.XDG_DATA_HOME; - process.env.XDG_DATA_HOME = '/xdg/data'; - - const s = new SessionStorage(); - expect(s.getStorageDir()).toBe('/xdg/data/ai-assist'); - - process.env.XDG_DATA_HOME = originalEnv; - }); - - it('无 XDG 环境变量使用 home 目录', () => { - const originalEnv = process.env.XDG_DATA_HOME; - delete process.env.XDG_DATA_HOME; - - const s = new SessionStorage(); - expect(s.getStorageDir()).toContain('.local/share/ai-assist'); - - process.env.XDG_DATA_HOME = originalEnv; - }); - }); - - describe('generateSessionId - 生成会话 ID', () => { - it('生成包含日期的会话 ID', () => { - const id = storage.generateSessionId(); - - // 格式: YYYY-MM-DD_xxxxxx - expect(id).toMatch(/^\d{4}-\d{2}-\d{2}_[a-z0-9]{6}$/); - }); - - it('生成唯一的会话 ID', () => { - const ids = new Set(); - for (let i = 0; i < 100; i++) { - ids.add(storage.generateSessionId()); - } - expect(ids.size).toBe(100); - }); - }); - - describe('ensureDir - 确保目录存在', () => { - it('创建必要的目录', async () => { - await storage.ensureDir(); - - // 应该创建 project, session, message 目录 - expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('project'), { recursive: true }); - expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('session'), { recursive: true }); - expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('message'), { recursive: true }); - }); - }); - - describe('getOrCreateProject - 获取或创建项目', () => { - it('返回项目元数据', async () => { - vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')); - - const project = await storage.getOrCreateProject('/test/workdir'); - - expect(project).toMatchObject({ - id: 'test-project-id', - workdir: '/test/workdir', - isGitRepo: true, - }); - }); - - it('已存在的项目直接返回', async () => { - const existingProject = { - id: 'existing-project', - workdir: '/existing', - createdAt: '2024-01-01T00:00:00Z', - isGitRepo: false, - }; - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(existingProject)); - - const project = await storage.getOrCreateProject('/existing'); - - expect(project).toEqual(existingProject); - }); - }); - - describe('saveSessionMetadata - 保存会话元数据', () => { - it('保存会话元数据到文件', async () => { - const session = { - id: 'test-session', - projectId: 'test-project', - workdir: '/test', - messages: [{ role: 'user', content: 'hello' }], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - discoveredTools: [], - todos: [], - }; - - await storage.saveSessionMetadata(session as any); - - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringContaining('test-session.json'), - expect.any(String), - 'utf-8' - ); - }); - }); - - describe('loadSession - 加载完整会话', () => { - it('成功加载会话(元数据 + 消息)', async () => { - const metadata = { - id: 'session-123', - projectId: 'test-project', - workdir: '/test', - messageCount: 2, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - discoveredTools: [], - todos: [], - }; - - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(metadata)); - vi.mocked(fs.readdir).mockResolvedValueOnce(['0001.json', '0002.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify({ index: 1, role: 'user', content: 'hello' })) - .mockResolvedValueOnce(JSON.stringify({ index: 2, role: 'assistant', content: 'hi' })); - - const session = await storage.loadSession('test-project', 'session-123'); - - expect(session).toMatchObject({ - id: 'session-123', - projectId: 'test-project', - }); - expect(session?.messages).toHaveLength(2); - }); - - it('会话不存在返回 null', async () => { - vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')); - - const session = await storage.loadSession('test-project', 'nonexistent'); - - expect(session).toBeNull(); - }); - }); - - describe('appendMessage - 追加消息', () => { - it('写入消息文件', async () => { - await storage.appendMessage('session-123', { role: 'user', content: 'hello' } as any, 1); - - expect(fs.writeFile).toHaveBeenCalled(); - const [filePath, content] = vi.mocked(fs.writeFile).mock.calls[0]; - expect(filePath).toContain('0001.json'); - const parsed = JSON.parse(content as string); - expect(parsed.role).toBe('user'); - expect(parsed.content).toBe('hello'); - expect(parsed.index).toBe(1); - }); - }); - - describe('loadMessages - 加载消息', () => { - it('按顺序加载所有消息', async () => { - vi.mocked(fs.readdir).mockResolvedValueOnce(['0001.json', '0002.json'] as any); - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify({ index: 1, role: 'user', content: 'first' })) - .mockResolvedValueOnce(JSON.stringify({ index: 2, role: 'assistant', content: 'second' })); - - const messages = await storage.loadMessages('session-123'); - - expect(messages).toHaveLength(2); - expect(messages[0].content).toBe('first'); - expect(messages[1].content).toBe('second'); - }); - - it('目录不存在返回空数组', async () => { - vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT')); - - const messages = await storage.loadMessages('nonexistent'); - - expect(messages).toEqual([]); - }); - }); - - describe('deleteSession - 删除会话', () => { - it('成功删除返回 true', async () => { - const result = await storage.deleteSession('test-project', 'session-123'); - - expect(result).toBe(true); - expect(fs.rm).toHaveBeenCalled(); // 删除消息目录 - expect(fs.unlink).toHaveBeenCalled(); // 删除元数据文件 - }); - - it('删除失败返回 false', async () => { - vi.mocked(fs.rm).mockRejectedValueOnce(new Error('ENOENT')); - vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT')); - - const result = await storage.deleteSession('test-project', 'nonexistent'); - - expect(result).toBe(false); - }); - }); - - describe('setCurrentSession / getCurrentSessionId', () => { - it('设置和获取当前会话 ID', async () => { - await storage.setCurrentSession('session-123'); - - expect(fs.writeFile).toHaveBeenCalled(); - const calls = vi.mocked(fs.writeFile).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[0]).toContain('current-session.json'); - const parsed = JSON.parse(lastCall[1] as string); - expect(parsed.sessionId).toBe('session-123'); - }); - - it('获取当前会话 ID', async () => { - vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ sessionId: 'session-123' })); - - const sessionId = await storage.getCurrentSessionId(); - - expect(sessionId).toBe('session-123'); - }); - - it('无当前会话返回 null', async () => { - vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')); - - const sessionId = await storage.getCurrentSessionId(); - - expect(sessionId).toBeNull(); - }); - }); - - describe('listSessionsByProject - 列出项目会话', () => { - it('返回会话摘要列表', async () => { - vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json', 'session2.json'] as any); - - const metadata1 = { - id: 'session1', - projectId: 'test-project', - workdir: '/test1', - messageCount: 1, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-02T00:00:00Z', - discoveredTools: [], - todos: [], - }; - const metadata2 = { - id: 'session2', - projectId: 'test-project', - workdir: '/test2', - messageCount: 0, - createdAt: '2024-01-03T00:00:00Z', - updatedAt: '2024-01-04T00:00:00Z', - discoveredTools: [], - todos: [], - }; - - vi.mocked(fs.readFile) - .mockResolvedValueOnce(JSON.stringify(metadata1)) - .mockResolvedValueOnce(JSON.stringify(metadata2)); - - const sessions = await storage.listSessionsByProject('test-project'); - - expect(sessions).toHaveLength(2); - // 按更新时间降序 - expect(sessions[0].id).toBe('session2'); - expect(sessions[1].id).toBe('session1'); - }); - }); - - describe('cleanupOldSessions - 清理旧会话', () => { - it('删除超出保留数量的会话', async () => { - // Mock listAllSessions - vi.mocked(fs.readdir) - .mockResolvedValueOnce(['project1'] as any) // listAllSessions - 项目目录 - .mockResolvedValueOnce(['session1.json', 'session2.json', 'session3.json'] as any); // listSessionsByProject - - const sessions = [ - { id: 'session3', messageCount: 0, updatedAt: '2024-01-03', projectId: 'project1' }, - { id: 'session2', messageCount: 0, updatedAt: '2024-01-02', projectId: 'project1' }, - { id: 'session1', messageCount: 0, updatedAt: '2024-01-01', projectId: 'project1' }, - ]; - - vi.mocked(fs.readFile) - .mockResolvedValueOnce( - JSON.stringify({ ...sessions[0], workdir: '/', createdAt: '2024-01-01', discoveredTools: [], todos: [] }) - ) - .mockResolvedValueOnce( - JSON.stringify({ ...sessions[1], workdir: '/', createdAt: '2024-01-01', discoveredTools: [], todos: [] }) - ) - .mockResolvedValueOnce( - JSON.stringify({ ...sessions[2], workdir: '/', createdAt: '2024-01-01', discoveredTools: [], todos: [] }) - ); - - // Mock for deleteSession lookup - vi.mocked(fs.readdir).mockResolvedValueOnce(['project1'] as any); - vi.mocked(fs.access).mockResolvedValueOnce(undefined); - - const deletedCount = await storage.cleanupOldSessions(2); - - expect(deletedCount).toBe(1); - }); - - it('会话数量不超过保留数量不删除', async () => { - vi.mocked(fs.readdir) - .mockResolvedValueOnce(['project1'] as any) - .mockResolvedValueOnce(['session1.json'] as any); - - vi.mocked(fs.readFile).mockResolvedValueOnce( - JSON.stringify({ - id: 'session1', - projectId: 'project1', - messageCount: 0, - workdir: '/', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - discoveredTools: [], - todos: [], - }) - ); - - const deletedCount = await storage.cleanupOldSessions(5); - - expect(deletedCount).toBe(0); - }); - }); -}); diff --git a/packages/core/tests/unit/tools/task/task-extended.test.ts b/packages/core/tests/unit/tools/task/task-extended.test.ts index 953192c..05afa16 100644 --- a/packages/core/tests/unit/tools/task/task-extended.test.ts +++ b/packages/core/tests/unit/tools/task/task-extended.test.ts @@ -35,6 +35,11 @@ vi.mock('../../../../src/agent/index.js', () => ({ return Promise.resolve(mockExecuteResult); } }, + agentEventEmitter: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, })); // Mock tool registry diff --git a/packages/core/tests/unit/tools/task/task.test.ts b/packages/core/tests/unit/tools/task/task.test.ts index da1311d..d9be389 100644 --- a/packages/core/tests/unit/tools/task/task.test.ts +++ b/packages/core/tests/unit/tools/task/task.test.ts @@ -21,6 +21,11 @@ vi.mock('../../../../src/agent/index.js', () => { return mockState.execute(...args); } }, + agentEventEmitter: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, }; }); diff --git a/packages/core/tests/unit/tools/todo/todo-manager.test.ts b/packages/core/tests/unit/tools/todo/todo-manager.test.ts index 809f2f2..6168e05 100644 --- a/packages/core/tests/unit/tools/todo/todo-manager.test.ts +++ b/packages/core/tests/unit/tools/todo/todo-manager.test.ts @@ -39,9 +39,10 @@ describe('TodoManager', () => { }); it('返回现有的 todo 列表', () => { + const now = new Date().toISOString(); mockTodos = [ - { id: '1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' }, - { id: '2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' }, + { id: '1', content: 'Task 1', status: 'pending', createdAt: now, updatedAt: now }, + { id: '2', content: 'Task 2', status: 'completed', createdAt: now, updatedAt: now }, ]; const todos = todoManager.getTodos(); @@ -58,8 +59,9 @@ describe('TodoManager', () => { describe('setTodos', () => { it('更新 todo 列表', async () => { + const now = new Date().toISOString(); const newTodos: Todo[] = [ - { id: '1', content: 'New Task', status: 'pending', createdAt: '', updatedAt: '' }, + { id: '1', content: 'New Task', status: 'pending', createdAt: now, updatedAt: now }, ]; await todoManager.setTodos(newTodos); @@ -69,8 +71,9 @@ describe('TodoManager', () => { }); it('可以设置空列表', async () => { + const now = new Date().toISOString(); mockTodos = [ - { id: '1', content: 'Task', status: 'pending', createdAt: '', updatedAt: '' }, + { id: '1', content: 'Task', status: 'pending', createdAt: now, updatedAt: now }, ]; await todoManager.setTodos([]); @@ -112,8 +115,9 @@ describe('TodoManager', () => { }); it('追加到现有列表', async () => { + const now = new Date().toISOString(); mockTodos = [ - { id: 'existing', content: 'Existing', status: 'pending', createdAt: '', updatedAt: '' }, + { id: 'existing', content: 'Existing', status: 'pending', createdAt: now, updatedAt: now }, ]; await todoManager.addTodo('New Task'); @@ -167,10 +171,11 @@ describe('TodoManager', () => { describe('deleteTodo', () => { beforeEach(() => { + const now = new Date().toISOString(); mockTodos = [ - { id: 'todo-1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' }, - { id: 'todo-2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' }, - { id: 'todo-3', content: 'Task 3', status: 'in_progress', createdAt: '', updatedAt: '' }, + { id: 'todo-1', content: 'Task 1', status: 'pending', createdAt: now, updatedAt: now }, + { id: 'todo-2', content: 'Task 2', status: 'completed', createdAt: now, updatedAt: now }, + { id: 'todo-3', content: 'Task 3', status: 'in_progress', createdAt: now, updatedAt: now }, ]; }); @@ -213,9 +218,10 @@ describe('TodoManager', () => { describe('clearTodos', () => { it('清空所有 todo', async () => { + const now = new Date().toISOString(); mockTodos = [ - { id: '1', content: 'Task 1', status: 'pending', createdAt: '', updatedAt: '' }, - { id: '2', content: 'Task 2', status: 'completed', createdAt: '', updatedAt: '' }, + { id: '1', content: 'Task 1', status: 'pending', createdAt: now, updatedAt: now }, + { id: '2', content: 'Task 2', status: 'completed', createdAt: now, updatedAt: now }, ]; await todoManager.clearTodos();