diff --git a/packages/core/src/core/agent.ts b/packages/core/src/core/agent.ts index 8dacc55..d622bcc 100644 --- a/packages/core/src/core/agent.ts +++ b/packages/core/src/core/agent.ts @@ -6,7 +6,7 @@ import { type Tool as AITool, type LanguageModel, } from 'ai'; -import type { Tool, ToolResult, Message, AgentConfig, UserInput, ContentBlock } from '../types/index.js'; +import type { Tool, ToolResult, Message, AgentConfig, UserInput, ContentBlock, ChatResult } from '../types/index.js'; import { buildZodSchema } from '../types/index.js'; import { ToolRegistry } from '../tools/registry.js'; import { SessionManager } from '../session/index.js'; @@ -311,8 +311,9 @@ export class Agent { * 发送消息并处理响应(流式) * @param userMessage 用户消息文本或包含图片的 UserInput * @param onStream 流式输出回调 + * @returns ChatResult 包含最终文本和完整的响应消息链 */ - async chat(userMessage: string | UserInput, onStream?: (text: string) => void): Promise { + async chat(userMessage: string | UserInput, onStream?: (text: string) => void): Promise { // 处理带图片的消息 let processedMessage = userMessage; @@ -331,7 +332,8 @@ export class Agent { processedMessage = visionResult; } else { // 失败,返回错误信息 - return '无法处理图片:当前模型不支持图片理解,且 Vision 服务未配置或调用失败。'; + const errorText = '无法处理图片:当前模型不支持图片理解,且 Vision 服务未配置或调用失败。'; + return { text: errorText, messages: [] }; } } } @@ -465,7 +467,10 @@ export class Agent { // 持久化会话 await this.persistSession(); - return fullResponse; + return { + text: fullResponse, + messages: responseMessages, + }; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc81ec4..e1e8f5b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,7 +19,7 @@ export { SessionManager } from './session/index.js'; export type { SessionData, SessionSummary } from './session/types.js'; // Types -export type { UserInput } from './types/index.js'; +export type { UserInput, ChatResult } from './types/index.js'; // Permission export { getPermissionManager } from './permission/index.js'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index ee059e7..d55e9ec 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -88,6 +88,14 @@ export interface ConversationContext { workingDirectory: string; } +// Chat 返回结果(包含完整的消息链) +export interface ChatResult { + /** 最终文本响应 */ + text: string; + /** 完整的响应消息链(包含 tool-call 和 tool-result) */ + messages: unknown[]; +} + // 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema export function buildZodSchema(parameters: Record): z.ZodObject> { const schemaObj: Record = {}; diff --git a/packages/core/tests/unit/core/agent.test.ts b/packages/core/tests/unit/core/agent.test.ts index 77a3444..0eb2ccc 100644 --- a/packages/core/tests/unit/core/agent.test.ts +++ b/packages/core/tests/unit/core/agent.test.ts @@ -391,6 +391,7 @@ describe('Agent - chat with images', () => { }); expect(result).toBeDefined(); + expect(result.text).toBeDefined(); }); it('不支持 vision 时返回错误消息(Vision 未配置)', async () => { @@ -408,6 +409,6 @@ describe('Agent - chat with images', () => { ], }); - expect(result).toContain('无法处理图片'); + expect(result.text).toContain('无法处理图片'); }); }); diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index a4ca713..bf2f090 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -39,12 +39,20 @@ export interface CompressionResult { summaryTokens?: number; } +/** + * Chat 返回结果 + */ +interface ChatResult { + text: string; + messages: unknown[]; +} + /** * Agent 实例接口 */ interface AgentInstance { setRegistry(registry: unknown): void; - chat(message: string, onStream?: (chunk: string) => void): Promise; + chat(message: string, onStream?: (chunk: string) => void): Promise; getToolCount(): { core: number; discovered: number; total: number }; getContextUsageFormatted(): string; getContextUsage(): TokenUsage; @@ -52,6 +60,7 @@ interface AgentInstance { getCompressionManager(): { shouldCompress(messages: unknown[]): boolean; }; + getHistory(): unknown[]; } /** @@ -256,23 +265,17 @@ export async function processMessage(sessionId: string, content: string): Promis } // Core 模块不可用,返回占位响应 + const errorContent = 'Agent core module not available. Please build @ai-assistant/core first.'; broadcastToSession(sessionId, { type: 'chunk', sessionId, - payload: { - content: 'Agent core module not available. Please build @ai-assistant/core first.', - }, - }); - - const assistantMessage = await sessionManager.addMessage(sessionId, { - role: 'assistant', - content: 'Agent core module not available. Please build @ai-assistant/core first.', + payload: { content: errorContent }, }); broadcastToSession(sessionId, { type: 'done', sessionId, - payload: assistantMessage, + payload: { text: errorContent, hasToolCalls: false, messageCount: 0 }, }); sessionManager.updateStatus(sessionId, 'idle' as SessionStatus); @@ -282,7 +285,7 @@ export async function processMessage(sessionId: string, content: string): Promis try { // 调用 Agent 的 chat 方法,使用流式回调 - const response = await agent.chat(content, (chunk: string) => { + const result = await agent.chat(content, (chunk: string) => { // 推送流式内容 broadcastToSession(sessionId, { type: 'chunk', @@ -299,31 +302,41 @@ export async function processMessage(sessionId: string, content: string): Promis } }); - // 保存助手消息 - const assistantMessage = await sessionManager.addMessage(sessionId, { - role: 'assistant', - content: response, + // 消息已由 Core Agent 自动持久化,这里只更新 Server 端的会话计数 + const session = sessionManager.get(sessionId); + if (session) { + // 从 Agent 获取实际消息数 + const history = agent.getHistory(); + session.messageCount = history.length; + session.updatedAt = new Date().toISOString(); + } + + // 检查是否有工具调用 + const hasToolCalls = result.messages.some((m: unknown) => { + const msg = m as { content?: unknown }; + return Array.isArray(msg.content) && msg.content.some((c: unknown) => { + const block = c as { type?: string }; + return block.type === 'tool-call'; + }); }); // 发送完成消息 broadcastToSession(sessionId, { type: 'done', sessionId, - payload: assistantMessage, + payload: { + text: result.text, + hasToolCalls, + messageCount: result.messages.length, + }, }); // 检查是否需要生成会话标题(首次对话完成后) - const session = sessionManager.get(sessionId); - const messages = sessionManager.getMessages(sessionId); - if (session && !session.name && messages.length === 2) { - // 首条用户消息 + 首条 AI 回复 = 2 条消息 - const userMessage = messages.find(m => m.role === 'user'); - if (userMessage) { - // 异步生成标题,不阻塞响应 - generateSessionTitle(sessionId, userMessage.content, response).catch(err => { - console.error('[Agent] Failed to generate session title:', err); - }); - } + if (session && !session.name) { + // 异步生成标题,不阻塞响应 + generateSessionTitle(sessionId, content, result.text).catch(err => { + console.error('[Agent] Failed to generate session title:', err); + }); } emitStatusEvent(sessionId, 'idle'); diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 8619882..f86ee03 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -6,7 +6,7 @@ import { Hono } from 'hono'; import { getSessionManager } from '../session/manager.js'; -import { CreateSessionInputSchema, SendMessageInputSchema } from '../types.js'; +import { CreateSessionInputSchema } from '../types.js'; export const sessionsRouter = new Hono(); @@ -99,8 +99,10 @@ sessionsRouter.delete('/:id', async (c) => { /** * GET /sessions/:id/messages - 获取会话消息 + * + * 从 Core 存储读取完整的消息历史(包含 tool-call 和 tool-result) */ -sessionsRouter.get('/:id/messages', (c) => { +sessionsRouter.get('/:id/messages', async (c) => { const id = c.req.param('id'); if (!sessionManager.exists(id)) { @@ -113,66 +115,27 @@ sessionsRouter.get('/:id/messages', (c) => { ); } - const messages = sessionManager.getMessages(id); + // 从 Core Storage 读取消息 + const storage = sessionManager.getStorage(); + if (!storage) { + return c.json({ + success: true, + data: [], + }); + } + + const projectId = sessionManager.getProjectId(id); + const sessionData = await storage.loadSession(projectId, id); + + if (!sessionData) { + return c.json({ + success: true, + data: [], + }); + } return c.json({ success: true, - data: messages, + data: sessionData.messages, }); }); - -/** - * POST /sessions/:id/messages - 发送消息 - * - * 注意: 这个端点仅用于添加消息记录。 - * 实际的 AI 对话应该通过 WebSocket 进行。 - */ -sessionsRouter.post('/:id/messages', async (c) => { - const id = c.req.param('id'); - - if (!sessionManager.exists(id)) { - return c.json( - { - success: false, - error: 'Session not found', - }, - 404 - ); - } - - try { - const body = await c.req.json(); - const input = SendMessageInputSchema.parse(body); - - const message = await sessionManager.addMessage(id, { - role: input.role, - content: input.content, - }); - - if (!message) { - return c.json( - { - success: false, - error: 'Failed to add message', - }, - 500 - ); - } - - return c.json( - { - success: true, - data: message, - }, - 201 - ); - } catch (error) { - return c.json( - { - success: false, - error: error instanceof Error ? error.message : 'Invalid input', - }, - 400 - ); - } -}); diff --git a/packages/server/src/session/manager.ts b/packages/server/src/session/manager.ts index c08ed1e..c2e8f0d 100644 --- a/packages/server/src/session/manager.ts +++ b/packages/server/src/session/manager.ts @@ -1,34 +1,16 @@ /** * Session Manager * - * 管理所有活跃的会话,支持文件持久化 + * 管理所有活跃的会话元数据(不存储消息,消息由 Core 负责) */ import { v4 as uuidv4 } from 'uuid'; -import type { Session, CreateSessionInput, Message, SessionStatus } from '../types.js'; +import type { Session, CreateSessionInput, SessionStatus } from '../types.js'; // ============================================================================ // Core 模块接口定义(避免构建时依赖) // ============================================================================ -interface SessionMetadata { - id: string; - projectId: string; - parentId?: string; - agentName?: string; - createdAt: string; - updatedAt: string; - workdir: string; - title?: string; - messageCount: number; - discoveredTools: string[]; - todos: unknown[]; -} - -interface SessionData extends Omit { - messages: Array<{ role: string; content: unknown }>; -} - interface SessionSummary { id: string; title: string; @@ -45,6 +27,20 @@ interface ProjectMetadata { isGitRepo: boolean; } +interface SessionData { + id: string; + projectId: string; + parentId?: string; + agentName?: string; + createdAt: string; + updatedAt: string; + workdir: string; + title?: string; + messages: Array<{ role: string; content: unknown }>; + discoveredTools: string[]; + todos: unknown[]; +} + interface SessionStorageInterface { ensureDir(): Promise; generateSessionId(): string; @@ -56,46 +52,31 @@ interface SessionStorageInterface { deleteSession(projectId: string, sessionId: string): Promise; } -// ============================================================================ -// 消息格式转换 -// ============================================================================ - -/** - * 将 Server Message 转换为 Core ModelMessage 格式 - */ -function toModelMessage(msg: Message): { role: string; content: string } { - return { role: msg.role, content: msg.content }; -} - -/** - * 将 Core ModelMessage 转换为 Server Message 格式 - */ -function fromModelMessage( - msg: { role: string; content: unknown }, - sessionId: string, - _index: number -): Message { - return { - id: uuidv4(), - sessionId, - role: msg.role as 'user' | 'assistant' | 'system' | 'tool', - content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), - createdAt: new Date().toISOString(), - }; -} - // ============================================================================ // SessionManager 类 // ============================================================================ export class SessionManager { private sessions: Map = new Map(); - private messages: Map = new Map(); private sessionProjects: Map = new Map(); // sessionId -> projectId private storage: SessionStorageInterface | null = null; private currentProject: ProjectMetadata | null = null; private initialized = false; + /** + * 获取 storage 实例(供外部使用) + */ + getStorage(): SessionStorageInterface | null { + return this.storage; + } + + /** + * 获取 session 所属的 projectId + */ + getProjectId(sessionId: string): string { + return this.sessionProjects.get(sessionId) || this.currentProject?.id || 'default'; + } + /** * 初始化:加载 Core 模块的 SessionStorage 并恢复已有 sessions */ @@ -127,7 +108,7 @@ export class SessionManager { // 记录 session -> project 映射 this.sessionProjects.set(sessionData.id, sessionData.projectId); - // 转换为 Server Session 格式 + // 转换为 Server Session 格式(只保存元数据,不存储消息) const session: Session = { id: sessionData.id, name: sessionData.title, @@ -139,10 +120,6 @@ export class SessionManager { }; this.sessions.set(session.id, session); - - // 转换消息格式 - const messages = sessionData.messages.map((msg, i) => fromModelMessage(msg, session.id, i)); - this.messages.set(session.id, messages); } console.log(`[SessionManager] Loaded ${this.sessions.size} sessions from storage`); @@ -154,18 +131,20 @@ export class SessionManager { } /** - * 持久化单个 session + * 持久化单个 session 的元数据 + * 注意:消息存储由 Core Agent 负责,这里只更新会话元数据 */ - private async persist(sessionId: string): Promise { + private async persistMetadata(sessionId: string): Promise { if (!this.storage) return; const session = this.sessions.get(sessionId); - const messages = this.messages.get(sessionId) || []; - if (!session) return; const projectId = this.sessionProjects.get(sessionId) || this.currentProject?.id || 'default'; + // 先加载现有的 session 数据(保留消息) + const existingData = await this.storage.loadSession(projectId, sessionId); + const sessionData: SessionData = { id: session.id, projectId, @@ -173,9 +152,9 @@ export class SessionManager { updatedAt: session.updatedAt, workdir: session.workdir, title: session.name, - messages: messages.map(toModelMessage), - discoveredTools: [], - todos: [], + messages: existingData?.messages || [], + discoveredTools: existingData?.discoveredTools || [], + todos: existingData?.todos || [], }; await this.storage.saveSession(sessionData); @@ -206,11 +185,10 @@ export class SessionManager { }; this.sessions.set(session.id, session); - this.messages.set(session.id, []); this.sessionProjects.set(session.id, projectId); - // 持久化 - await this.persist(session.id); + // 持久化空会话 + await this.persistMetadata(session.id); return session; } @@ -235,7 +213,6 @@ export class SessionManager { * 删除会话 */ async delete(id: string): Promise { - this.messages.delete(id); const deleted = this.sessions.delete(id); // 从存储中删除 @@ -261,41 +238,15 @@ export class SessionManager { } /** - * 获取会话消息 + * 更新会话消息计数(由 Agent 调用) */ - getMessages(sessionId: string): Message[] { - return this.messages.get(sessionId) || []; - } - - /** - * 添加消息 - */ - async addMessage( - sessionId: string, - message: Omit - ): Promise { - const session = this.sessions.get(sessionId); + updateMessageCount(id: string, count: number): Session | undefined { + const session = this.sessions.get(id); if (!session) return undefined; - const fullMessage: Message = { - ...message, - id: uuidv4(), - sessionId, - createdAt: new Date().toISOString(), - }; - - const messages = this.messages.get(sessionId) || []; - messages.push(fullMessage); - this.messages.set(sessionId, messages); - - // 更新会话 - session.messageCount = messages.length; + session.messageCount = count; session.updatedAt = new Date().toISOString(); - - // 持久化 - await this.persist(sessionId); - - return fullMessage; + return session; } /** @@ -308,8 +259,8 @@ export class SessionManager { session.name = name; session.updatedAt = new Date().toISOString(); - // 持久化 - await this.persist(sessionId); + // 持久化元数据 + await this.persistMetadata(sessionId); return session; } diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index f3af87e..fb6f385 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -106,24 +106,19 @@ export async function handleWebSocketMessage( case 'message': { // 用户发送消息 const content = message.payload?.content || ''; - const userMessage = sessionManager.addMessage(sessionId, { - role: 'user', - content, + + // 广播确认收到消息 + broadcastToSession(sessionId, { + type: 'message_received', + sessionId, + payload: { content }, }); - if (userMessage) { - // 广播用户消息 - broadcastToSession(sessionId, { - type: 'message_received', - sessionId, - payload: userMessage, - }); - - // 调用 Agent 处理消息(异步,不阻塞) - processMessage(sessionId, content).catch((error) => { - console.error('[WS] Agent processing error:', error); - }); - } + // 调用 Agent 处理消息(异步,不阻塞) + // 消息存储由 Core Agent 负责 + processMessage(sessionId, content).catch((error) => { + console.error('[WS] Agent processing error:', error); + }); break; } diff --git a/packages/server/tests/mocks/core.mock.ts b/packages/server/tests/mocks/core.mock.ts index 99fbbf4..0c34e83 100644 --- a/packages/server/tests/mocks/core.mock.ts +++ b/packages/server/tests/mocks/core.mock.ts @@ -12,7 +12,13 @@ import { vi } from 'vitest'; export function createMockAgent() { return { setRegistry: vi.fn(), - chat: vi.fn().mockResolvedValue('mock response'), + chat: vi.fn().mockResolvedValue({ + text: 'mock response', + messages: [ + { role: 'user', content: 'test' }, + { role: 'assistant', content: 'mock response' }, + ], + }), getToolCount: vi.fn().mockReturnValue({ core: 5, discovered: 0, total: 5 }), getContextUsageFormatted: vi.fn().mockReturnValue('10k / 200k'), getContextUsage: vi.fn().mockReturnValue({ @@ -25,6 +31,10 @@ export function createMockAgent() { getCompressionManager: vi.fn().mockReturnValue({ shouldCompress: vi.fn().mockReturnValue(false), }), + getHistory: vi.fn().mockReturnValue([ + { role: 'user', content: 'test' }, + { role: 'assistant', content: 'mock response' }, + ]), }; } diff --git a/packages/server/tests/mocks/session.mock.ts b/packages/server/tests/mocks/session.mock.ts index eec3448..54ebdfa 100644 --- a/packages/server/tests/mocks/session.mock.ts +++ b/packages/server/tests/mocks/session.mock.ts @@ -2,6 +2,8 @@ * SessionManager Mock 工厂 * * 提供会话管理器的 mock 实现 + * + * 注意:消息存储已移至 Core 层,SessionManager 只负责会话元数据管理 */ import { vi } from 'vitest'; @@ -13,6 +15,7 @@ export interface MockSession { createdAt: number; updatedAt: number; workdir?: string; + messageCount: number; } export interface MockMessage { @@ -27,8 +30,6 @@ export interface MockMessage { */ export function createMockSessionManager() { const sessions = new Map(); - const messages = new Map(); - let messageIdCounter = 1; const manager = { // 初始化 @@ -48,44 +49,40 @@ export function createMockSessionManager() { createdAt: Date.now(), updatedAt: Date.now(), workdir: data?.workdir ?? process.cwd(), + messageCount: 0, }; sessions.set(id, session); - messages.set(id, []); return session; }), delete: vi.fn(async (id: string) => { const existed = sessions.has(id); sessions.delete(id); - messages.delete(id); return existed; }), list: vi.fn(() => Array.from(sessions.values())), - // 消息管理 - addMessage: vi.fn(async (sessionId: string, msg: { role: 'user' | 'assistant'; content: string }) => { - const msgList = messages.get(sessionId) || []; - const newMsg: MockMessage = { - id: `msg-${messageIdCounter++}`, - role: msg.role, - content: msg.content, - timestamp: Date.now(), - }; - msgList.push(newMsg); - messages.set(sessionId, msgList); - return newMsg; - }), - - getMessages: vi.fn((id: string) => messages.get(id) || []), - // 状态更新 updateStatus: vi.fn((id: string, status: 'idle' | 'busy') => { const session = sessions.get(id); if (session) { session.status = status; session.updatedAt = Date.now(); + return session; } + return undefined; + }), + + // 更新消息计数 + updateMessageCount: vi.fn((id: string, count: number) => { + const session = sessions.get(id); + if (session) { + session.messageCount = count; + session.updatedAt = Date.now(); + return session; + } + return undefined; }), updateSessionName: vi.fn(async (id: string, name: string) => { @@ -98,26 +95,20 @@ export function createMockSessionManager() { return null; }), + // Storage 访问 + getStorage: vi.fn(() => null), + getProjectId: vi.fn((_sessionId: string) => 'default-project'), + // 测试辅助方法 _addSession: (session: MockSession) => { sessions.set(session.id, session); - messages.set(session.id, []); - }, - - _addMessage: (sessionId: string, message: MockMessage) => { - const msgList = messages.get(sessionId) || []; - msgList.push(message); - messages.set(sessionId, msgList); }, _clear: () => { sessions.clear(); - messages.clear(); - messageIdCounter = 1; }, _getSessions: () => sessions, - _getMessages: () => messages, }; return manager; @@ -134,6 +125,7 @@ export function createTestSession(overrides: Partial = {}): MockSes createdAt: Date.now(), updatedAt: Date.now(), workdir: '/test/workdir', + messageCount: 0, ...overrides, }; } diff --git a/packages/server/tests/unit/agent/adapter.test.ts b/packages/server/tests/unit/agent/adapter.test.ts index 47c0992..d05ee89 100644 --- a/packages/server/tests/unit/agent/adapter.test.ts +++ b/packages/server/tests/unit/agent/adapter.test.ts @@ -487,7 +487,6 @@ describe('Agent Adapter', () => { const mockSessionManager = { exists: vi.fn().mockReturnValue(true), updateStatus: vi.fn(), - addMessage: vi.fn().mockResolvedValue({ id: 'msg-1', role: 'assistant', content: 'placeholder' }), }; vi.doMock('../../../src/session/manager.js', () => ({ @@ -523,8 +522,7 @@ describe('Agent Adapter', () => { exists: vi.fn().mockReturnValue(true), get: vi.fn().mockReturnValue({ id: 'session-1', name: 'Test' }), updateStatus: vi.fn(), - addMessage: vi.fn().mockResolvedValue({ id: 'msg-1', role: 'assistant', content: 'response' }), - getMessages: vi.fn().mockReturnValue([]), + updateMessageCount: vi.fn(), }; vi.doMock('../../../src/session/manager.js', () => ({ diff --git a/packages/server/tests/unit/routes/sessions.test.ts b/packages/server/tests/unit/routes/sessions.test.ts index 1c88a79..64cb484 100644 --- a/packages/server/tests/unit/routes/sessions.test.ts +++ b/packages/server/tests/unit/routes/sessions.test.ts @@ -2,20 +2,25 @@ * Sessions Route 测试 * * 测试会话管理 REST API 端点 + * + * 注意:消息存储已移至 Core 层,GET /sessions/:id/messages 从 Core Storage 读取 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Hono } from 'hono'; +// Mock storage interface +const mockLoadSession = vi.fn(); + // Use vi.hoisted to create mocks before vi.mock is hoisted -const { mockList, mockCreate, mockGet, mockExists, mockDelete, mockGetMessages, mockAddMessage } = vi.hoisted(() => ({ +const { mockList, mockCreate, mockGet, mockExists, mockDelete, mockGetStorage, mockGetProjectId } = vi.hoisted(() => ({ mockList: vi.fn(), mockCreate: vi.fn(), mockGet: vi.fn(), mockExists: vi.fn(), mockDelete: vi.fn(), - mockGetMessages: vi.fn(), - mockAddMessage: vi.fn(), + mockGetStorage: vi.fn(), + mockGetProjectId: vi.fn(), })); vi.mock('../../../src/session/manager.js', () => ({ @@ -25,8 +30,8 @@ vi.mock('../../../src/session/manager.js', () => ({ get: mockGet, exists: mockExists, delete: mockDelete, - getMessages: mockGetMessages, - addMessage: mockAddMessage, + getStorage: mockGetStorage, + getProjectId: mockGetProjectId, })), })); @@ -39,6 +44,10 @@ app.route('/sessions', sessionsRouter); describe('Sessions Route', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetStorage.mockReturnValue({ + loadSession: mockLoadSession, + }); + mockGetProjectId.mockReturnValue('default-project'); }); describe('GET /sessions - 列出会话', () => { @@ -160,13 +169,18 @@ describe('Sessions Route', () => { }); describe('GET /sessions/:id/messages - 获取消息', () => { - it('返回会话消息', async () => { + it('返回会话消息(从 Core Storage 读取)', async () => { const messages = [ - { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, - { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: 2000 }, + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: [{ type: 'tool-call', toolName: 'read_file' }] }, + { role: 'user', content: [{ type: 'tool-result', toolCallId: 'call-1' }] }, + { role: 'assistant', content: 'Hi!' }, ]; mockExists.mockReturnValue(true); - mockGetMessages.mockReturnValue(messages); + mockLoadSession.mockResolvedValue({ + id: 'session-1', + messages, + }); const res = await app.request('/sessions/session-1/messages'); const json = await res.json(); @@ -174,6 +188,8 @@ describe('Sessions Route', () => { expect(res.status).toBe(200); expect(json.success).toBe(true); expect(json.data).toEqual(messages); + expect(mockGetStorage).toHaveBeenCalled(); + expect(mockGetProjectId).toHaveBeenCalledWith('session-1'); }); it('不存在的会话返回 404', async () => { @@ -189,7 +205,32 @@ describe('Sessions Route', () => { it('空消息返回空数组', async () => { mockExists.mockReturnValue(true); - mockGetMessages.mockReturnValue([]); + mockLoadSession.mockResolvedValue({ + id: 'session-1', + messages: [], + }); + + const res = await app.request('/sessions/session-1/messages'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data).toEqual([]); + }); + + it('Storage 不可用时返回空数组', async () => { + mockExists.mockReturnValue(true); + mockGetStorage.mockReturnValue(null); + + const res = await app.request('/sessions/session-1/messages'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data).toEqual([]); + }); + + it('Session 数据不存在时返回空数组', async () => { + mockExists.mockReturnValue(true); + mockLoadSession.mockResolvedValue(null); const res = await app.request('/sessions/session-1/messages'); const json = await res.json(); @@ -198,83 +239,4 @@ describe('Sessions Route', () => { expect(json.data).toEqual([]); }); }); - - describe('POST /sessions/:id/messages - 发送消息', () => { - it('添加用户消息', async () => { - const message = { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now() }; - mockExists.mockReturnValue(true); - mockAddMessage.mockResolvedValue(message); - - const res = await app.request('/sessions/session-1/messages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'user', content: 'Hello' }), - }); - const json = await res.json(); - - expect(res.status).toBe(201); - expect(json.success).toBe(true); - expect(json.data).toEqual(message); - }); - - it('添加助手消息', async () => { - const message = { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: Date.now() }; - mockExists.mockReturnValue(true); - mockAddMessage.mockResolvedValue(message); - - const res = await app.request('/sessions/session-1/messages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'assistant', content: 'Hi!' }), - }); - const json = await res.json(); - - expect(res.status).toBe(201); - expect(json.success).toBe(true); - }); - - it('不存在的会话返回 404', async () => { - mockExists.mockReturnValue(false); - - const res = await app.request('/sessions/non-existent/messages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'user', content: 'Hello' }), - }); - const json = await res.json(); - - expect(res.status).toBe(404); - expect(json.success).toBe(false); - }); - - it('无效输入返回 400', async () => { - mockExists.mockReturnValue(true); - - const res = await app.request('/sessions/session-1/messages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'invalid', content: 'Hello' }), - }); - const json = await res.json(); - - expect(res.status).toBe(400); - expect(json.success).toBe(false); - }); - - it('添加消息失败返回 500', async () => { - mockExists.mockReturnValue(true); - mockAddMessage.mockResolvedValue(null); - - const res = await app.request('/sessions/session-1/messages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'user', content: 'Hello' }), - }); - const json = await res.json(); - - expect(res.status).toBe(500); - expect(json.success).toBe(false); - expect(json.error).toBe('Failed to add message'); - }); - }); }); diff --git a/packages/server/tests/unit/session/manager.test.ts b/packages/server/tests/unit/session/manager.test.ts index 88a79fe..63a1d9d 100644 --- a/packages/server/tests/unit/session/manager.test.ts +++ b/packages/server/tests/unit/session/manager.test.ts @@ -5,6 +5,8 @@ * * 注意:SessionManager 使用动态 import 加载 @ai-assistant/core。 * 测试中会创建新实例并记录初始会话数量,以确保测试独立性。 + * + * 消息存储已移至 Core 层,Server 的 SessionManager 只负责会话元数据管理。 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -172,16 +174,6 @@ describe('SessionManager', () => { expect(result).toBe(false); }); - it('删除会话时同时删除消息', async () => { - // 注意:这个测试会自己删除 session,不需要追踪 - const session = await manager.create({ name: 'With Messages' }); - await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); - - await manager.delete(session.id); - - expect(manager.getMessages(session.id)).toEqual([]); - }); - it('删除后 count 减少', async () => { // 注意:这个测试会自己删除 session,不需要追踪 const session = await manager.create({ name: 'Test' }); @@ -232,112 +224,28 @@ describe('SessionManager', () => { }); }); - describe('getMessages - 获取消息', () => { - it('返回会话消息', async () => { - const session = await createTrackedSession({ name: 'Test' }); - await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); - await manager.addMessage(session.id, { role: 'assistant', content: 'Hi!' }); - - const messages = manager.getMessages(session.id); - - expect(messages.length).toBe(2); - expect(messages[0].content).toBe('Hello'); - expect(messages[1].content).toBe('Hi!'); - }); - - it('新会话消息为空', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const messages = manager.getMessages(session.id); - expect(messages).toEqual([]); - }); - - it('不存在的会话返回空数组', () => { - const messages = manager.getMessages('non-existent-id-12345'); - expect(messages).toEqual([]); - }); - }); - - describe('addMessage - 添加消息', () => { - it('添加用户消息', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const message = await manager.addMessage(session.id, { - role: 'user', - content: 'Hello', - }); - - expect(message).toBeDefined(); - expect(message?.role).toBe('user'); - expect(message?.content).toBe('Hello'); - expect(message?.id).toBeDefined(); - expect(message?.sessionId).toBe(session.id); - }); - - it('添加助手消息', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const message = await manager.addMessage(session.id, { - role: 'assistant', - content: 'Hello!', - }); - - expect(message?.role).toBe('assistant'); - }); - - it('添加系统消息', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const message = await manager.addMessage(session.id, { - role: 'system', - content: 'System prompt', - }); - - expect(message?.role).toBe('system'); - }); - - it('消息有唯一 ID', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const msg1 = await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); - const msg2 = await manager.addMessage(session.id, { role: 'assistant', content: 'Hi!' }); - - expect(msg1?.id).not.toBe(msg2?.id); - }); - - it('消息有正确的时间戳', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const before = new Date().toISOString(); - const message = await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); - const after = new Date().toISOString(); - - expect(message?.createdAt).toBeDefined(); - expect(message?.createdAt! >= before).toBe(true); - expect(message?.createdAt! <= after).toBe(true); - }); - - it('更新会话的 messageCount', async () => { + describe('updateMessageCount - 更新消息计数', () => { + it('更新消息计数', async () => { const session = await createTrackedSession({ name: 'Test' }); expect(session.messageCount).toBe(0); - await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); - - const updated = manager.get(session.id); - expect(updated?.messageCount).toBe(1); + const updated = manager.updateMessageCount(session.id, 5); + expect(updated?.messageCount).toBe(5); }); - it('更新会话的 updatedAt', async () => { + it('更新时更新 updatedAt', async () => { const session = await createTrackedSession({ name: 'Test' }); const originalUpdatedAt = session.updatedAt; await new Promise((resolve) => setTimeout(resolve, 10)); - await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + manager.updateMessageCount(session.id, 3); const updated = manager.get(session.id); expect(updated?.updatedAt).not.toBe(originalUpdatedAt); }); - it('不存在的会话返回 undefined', async () => { - const result = await manager.addMessage('non-existent-id-12345', { - role: 'user', - content: 'Hello', - }); - + it('不存在的会话返回 undefined', () => { + const result = manager.updateMessageCount('non-existent-id-12345', 10); expect(result).toBeUndefined(); }); }); @@ -410,48 +318,30 @@ describe('SessionManager', () => { }); }); + describe('getStorage 和 getProjectId - Storage 访问', () => { + it('getStorage 返回 Storage 实例或 null', () => { + const storage = manager.getStorage(); + // 可能为 null(如果 Core 未加载)或者是 SessionStorage 实例 + expect(storage === null || typeof storage === 'object').toBe(true); + }); + + it('getProjectId 返回字符串', async () => { + const session = await createTrackedSession({ name: 'Test' }); + const projectId = manager.getProjectId(session.id); + expect(typeof projectId).toBe('string'); + }); + + it('不存在的会话返回默认 projectId', () => { + const projectId = manager.getProjectId('non-existent-id'); + expect(typeof projectId).toBe('string'); + }); + }); + describe('边界情况', () => { it('处理特殊字符的会话名称', async () => { const session = await createTrackedSession({ name: '测试会话 <>&"\'`' }); expect(session.name).toBe('测试会话 <>&"\'`'); }); - - it('处理长消息内容', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const longContent = 'x'.repeat(10000); - const message = await manager.addMessage(session.id, { - role: 'user', - content: longContent, - }); - - expect(message?.content).toBe(longContent); - }); - - it('处理空字符串消息', async () => { - const session = await createTrackedSession({ name: 'Test' }); - const message = await manager.addMessage(session.id, { - role: 'user', - content: '', - }); - - expect(message?.content).toBe(''); - }); - - it('多个会话独立的消息', async () => { - const session1 = await createTrackedSession({ name: 'Session 1' }); - const session2 = await createTrackedSession({ name: 'Session 2' }); - - await manager.addMessage(session1.id, { role: 'user', content: 'Message for session 1' }); - await manager.addMessage(session2.id, { role: 'user', content: 'Message for session 2' }); - - const messages1 = manager.getMessages(session1.id); - const messages2 = manager.getMessages(session2.id); - - expect(messages1.length).toBe(1); - expect(messages2.length).toBe(1); - expect(messages1[0].content).toBe('Message for session 1'); - expect(messages2[0].content).toBe('Message for session 2'); - }); }); }); diff --git a/packages/server/tests/unit/ws.test.ts b/packages/server/tests/unit/ws.test.ts index 16a3fba..d2df627 100644 --- a/packages/server/tests/unit/ws.test.ts +++ b/packages/server/tests/unit/ws.test.ts @@ -2,6 +2,8 @@ * WebSocket Handler 测试 * * 测试 WebSocket 连接处理、消息路由、广播功能等 + * + * 注意:消息存储已移至 Core 层,Server 的 WebSocket 只负责消息广播和路由 */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -9,7 +11,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; // Create mock functions const mockExists = vi.fn(); const mockUpdateStatus = vi.fn(); -const mockAddMessage = vi.fn(); const mockGet = vi.fn(); // Mock dependencies before imports @@ -17,7 +18,6 @@ vi.mock('../../src/session/manager.js', () => ({ getSessionManager: vi.fn(() => ({ exists: mockExists, updateStatus: mockUpdateStatus, - addMessage: mockAddMessage, get: mockGet, })), })); @@ -60,7 +60,6 @@ describe('WebSocket Handler', () => { beforeEach(() => { vi.clearAllMocks(); mockExists.mockReturnValue(false); - mockAddMessage.mockReturnValue({ id: 'msg-1', role: 'user', content: '', timestamp: Date.now() }); }); describe('handleWebSocket - 连接处理', () => { @@ -110,6 +109,8 @@ describe('WebSocket Handler', () => { it('处理 message 类型消息', async () => { const ws = createMockWSContext(); + handleWebSocket(ws as any, 'session-1'); + const message = JSON.stringify({ type: 'message', payload: { content: 'Hello AI' }, @@ -117,10 +118,11 @@ describe('WebSocket Handler', () => { await handleWebSocketMessage(ws as any, 'session-1', message); - expect(mockAddMessage).toHaveBeenCalledWith('session-1', { - role: 'user', - content: 'Hello AI', - }); + // 应该广播 message_received 确认 + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"message_received"') + ); + // 应该调用 processMessage expect(processMessage).toHaveBeenCalledWith('session-1', 'Hello AI'); }); @@ -175,6 +177,7 @@ describe('WebSocket Handler', () => { it('处理 ArrayBuffer 数据', async () => { const ws = createMockWSContext(); + handleWebSocket(ws as any, 'session-1'); const message = { type: 'message', payload: { content: 'ArrayBuffer test' } }; const encoder = new TextEncoder(); @@ -182,14 +185,13 @@ describe('WebSocket Handler', () => { await handleWebSocketMessage(ws as any, 'session-1', buffer); - expect(mockAddMessage).toHaveBeenCalledWith('session-1', { - role: 'user', - content: 'ArrayBuffer test', - }); + expect(processMessage).toHaveBeenCalledWith('session-1', 'ArrayBuffer test'); }); it('空 content 处理正确', async () => { const ws = createMockWSContext(); + handleWebSocket(ws as any, 'session-1'); + const message = JSON.stringify({ type: 'message', payload: {}, @@ -197,24 +199,19 @@ describe('WebSocket Handler', () => { await handleWebSocketMessage(ws as any, 'session-1', message); - expect(mockAddMessage).toHaveBeenCalledWith('session-1', { - role: 'user', - content: '', - }); + expect(processMessage).toHaveBeenCalledWith('session-1', ''); }); it('处理 Blob 数据', async () => { const ws = createMockWSContext(); + handleWebSocket(ws as any, 'session-1'); const message = { type: 'message', payload: { content: 'Blob test' } }; const blob = new Blob([JSON.stringify(message)]); await handleWebSocketMessage(ws as any, 'session-1', blob); - expect(mockAddMessage).toHaveBeenCalledWith('session-1', { - role: 'user', - content: 'Blob test', - }); + expect(processMessage).toHaveBeenCalledWith('session-1', 'Blob test'); }); it('处理非标准数据类型(转为字符串)', async () => { @@ -229,21 +226,6 @@ describe('WebSocket Handler', () => { expect(cancelProcessing).toHaveBeenCalledWith('session-1'); }); - it('addMessage 返回 null 时不调用 processMessage', async () => { - const ws = createMockWSContext(); - mockAddMessage.mockReturnValue(null); - - const message = JSON.stringify({ - type: 'message', - payload: { content: 'Test' }, - }); - - await handleWebSocketMessage(ws as any, 'session-1', message); - - expect(mockAddMessage).toHaveBeenCalled(); - expect(processMessage).not.toHaveBeenCalled(); - }); - it('处理 tool_response 类型消息(TODO 场景)', async () => { const ws = createMockWSContext(); const message = JSON.stringify({