refactor(storage): 统一消息存储到 Core 层

问题:Server 端只存储最终文本响应,工具调用的中间消息丢失。

解决方案:
- Agent.chat() 返回 ChatResult,包含完整消息链
- Server SessionManager 简化为只管理会话元数据
- 消息 API 改为从 Core Storage 读取
- 移除 Server 端的消息存储和 addMessage 方法

影响范围:
- core: Agent.chat() 返回类型变更
- server: SessionManager 接口变更,移除消息存储
- server: GET /sessions/:id/messages 从 Core 读取
- server: 移除 POST /sessions/:id/messages 端点
This commit is contained in:
2025-12-15 10:04:22 +08:00
parent a657af9bb7
commit 6342a46e59
14 changed files with 273 additions and 503 deletions
+9 -4
View File
@@ -6,7 +6,7 @@ import {
type Tool as AITool, type Tool as AITool,
type LanguageModel, type LanguageModel,
} from 'ai'; } 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 { buildZodSchema } from '../types/index.js';
import { ToolRegistry } from '../tools/registry.js'; import { ToolRegistry } from '../tools/registry.js';
import { SessionManager } from '../session/index.js'; import { SessionManager } from '../session/index.js';
@@ -311,8 +311,9 @@ export class Agent {
* 发送消息并处理响应(流式) * 发送消息并处理响应(流式)
* @param userMessage 用户消息文本或包含图片的 UserInput * @param userMessage 用户消息文本或包含图片的 UserInput
* @param onStream 流式输出回调 * @param onStream 流式输出回调
* @returns ChatResult 包含最终文本和完整的响应消息链
*/ */
async chat(userMessage: string | UserInput, onStream?: (text: string) => void): Promise<string> { async chat(userMessage: string | UserInput, onStream?: (text: string) => void): Promise<ChatResult> {
// 处理带图片的消息 // 处理带图片的消息
let processedMessage = userMessage; let processedMessage = userMessage;
@@ -331,7 +332,8 @@ export class Agent {
processedMessage = visionResult; processedMessage = visionResult;
} else { } else {
// 失败,返回错误信息 // 失败,返回错误信息
return '无法处理图片:当前模型不支持图片理解,且 Vision 服务未配置或调用失败。'; const errorText = '无法处理图片:当前模型不支持图片理解,且 Vision 服务未配置或调用失败。';
return { text: errorText, messages: [] };
} }
} }
} }
@@ -465,7 +467,10 @@ export class Agent {
// 持久化会话 // 持久化会话
await this.persistSession(); await this.persistSession();
return fullResponse; return {
text: fullResponse,
messages: responseMessages,
};
} }
/** /**
+1 -1
View File
@@ -19,7 +19,7 @@ export { SessionManager } from './session/index.js';
export type { SessionData, SessionSummary } from './session/types.js'; export type { SessionData, SessionSummary } from './session/types.js';
// Types // Types
export type { UserInput } from './types/index.js'; export type { UserInput, ChatResult } from './types/index.js';
// Permission // Permission
export { getPermissionManager } from './permission/index.js'; export { getPermissionManager } from './permission/index.js';
+8
View File
@@ -88,6 +88,14 @@ export interface ConversationContext {
workingDirectory: string; workingDirectory: string;
} }
// Chat 返回结果(包含完整的消息链)
export interface ChatResult {
/** 最终文本响应 */
text: string;
/** 完整的响应消息链(包含 tool-call 和 tool-result */
messages: unknown[];
}
// 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema // 将自定义 Tool 转换为 Vercel AI SDK 的 zod schema
export function buildZodSchema(parameters: Record<string, ToolParameter>): z.ZodObject<Record<string, z.ZodTypeAny>> { export function buildZodSchema(parameters: Record<string, ToolParameter>): z.ZodObject<Record<string, z.ZodTypeAny>> {
const schemaObj: Record<string, z.ZodTypeAny> = {}; const schemaObj: Record<string, z.ZodTypeAny> = {};
+2 -1
View File
@@ -391,6 +391,7 @@ describe('Agent - chat with images', () => {
}); });
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result.text).toBeDefined();
}); });
it('不支持 vision 时返回错误消息(Vision 未配置)', async () => { it('不支持 vision 时返回错误消息(Vision 未配置)', async () => {
@@ -408,6 +409,6 @@ describe('Agent - chat with images', () => {
], ],
}); });
expect(result).toContain('无法处理图片'); expect(result.text).toContain('无法处理图片');
}); });
}); });
+37 -24
View File
@@ -39,12 +39,20 @@ export interface CompressionResult {
summaryTokens?: number; summaryTokens?: number;
} }
/**
* Chat 返回结果
*/
interface ChatResult {
text: string;
messages: unknown[];
}
/** /**
* Agent 实例接口 * Agent 实例接口
*/ */
interface AgentInstance { interface AgentInstance {
setRegistry(registry: unknown): void; setRegistry(registry: unknown): void;
chat(message: string, onStream?: (chunk: string) => void): Promise<string>; chat(message: string, onStream?: (chunk: string) => void): Promise<ChatResult>;
getToolCount(): { core: number; discovered: number; total: number }; getToolCount(): { core: number; discovered: number; total: number };
getContextUsageFormatted(): string; getContextUsageFormatted(): string;
getContextUsage(): TokenUsage; getContextUsage(): TokenUsage;
@@ -52,6 +60,7 @@ interface AgentInstance {
getCompressionManager(): { getCompressionManager(): {
shouldCompress(messages: unknown[]): boolean; shouldCompress(messages: unknown[]): boolean;
}; };
getHistory(): unknown[];
} }
/** /**
@@ -256,23 +265,17 @@ export async function processMessage(sessionId: string, content: string): Promis
} }
// Core 模块不可用,返回占位响应 // Core 模块不可用,返回占位响应
const errorContent = 'Agent core module not available. Please build @ai-assistant/core first.';
broadcastToSession(sessionId, { broadcastToSession(sessionId, {
type: 'chunk', type: 'chunk',
sessionId, sessionId,
payload: { payload: { content: errorContent },
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.',
}); });
broadcastToSession(sessionId, { broadcastToSession(sessionId, {
type: 'done', type: 'done',
sessionId, sessionId,
payload: assistantMessage, payload: { text: errorContent, hasToolCalls: false, messageCount: 0 },
}); });
sessionManager.updateStatus(sessionId, 'idle' as SessionStatus); sessionManager.updateStatus(sessionId, 'idle' as SessionStatus);
@@ -282,7 +285,7 @@ export async function processMessage(sessionId: string, content: string): Promis
try { try {
// 调用 Agent 的 chat 方法,使用流式回调 // 调用 Agent 的 chat 方法,使用流式回调
const response = await agent.chat(content, (chunk: string) => { const result = await agent.chat(content, (chunk: string) => {
// 推送流式内容 // 推送流式内容
broadcastToSession(sessionId, { broadcastToSession(sessionId, {
type: 'chunk', type: 'chunk',
@@ -299,32 +302,42 @@ export async function processMessage(sessionId: string, content: string): Promis
} }
}); });
// 保存助手消息 // 消息已由 Core Agent 自动持久化,这里只更新 Server 端的会话计数
const assistantMessage = await sessionManager.addMessage(sessionId, { const session = sessionManager.get(sessionId);
role: 'assistant', if (session) {
content: response, // 从 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, { broadcastToSession(sessionId, {
type: 'done', type: 'done',
sessionId, sessionId,
payload: assistantMessage, payload: {
text: result.text,
hasToolCalls,
messageCount: result.messages.length,
},
}); });
// 检查是否需要生成会话标题(首次对话完成后) // 检查是否需要生成会话标题(首次对话完成后)
const session = sessionManager.get(sessionId); if (session && !session.name) {
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 => { generateSessionTitle(sessionId, content, result.text).catch(err => {
console.error('[Agent] Failed to generate session title:', err); console.error('[Agent] Failed to generate session title:', err);
}); });
} }
}
emitStatusEvent(sessionId, 'idle'); emitStatusEvent(sessionId, 'idle');
} catch (error) { } catch (error) {
+23 -60
View File
@@ -6,7 +6,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { getSessionManager } from '../session/manager.js'; import { getSessionManager } from '../session/manager.js';
import { CreateSessionInputSchema, SendMessageInputSchema } from '../types.js'; import { CreateSessionInputSchema } from '../types.js';
export const sessionsRouter = new Hono(); export const sessionsRouter = new Hono();
@@ -99,8 +99,10 @@ sessionsRouter.delete('/:id', async (c) => {
/** /**
* GET /sessions/:id/messages - 获取会话消息 * 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'); const id = c.req.param('id');
if (!sessionManager.exists(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({ return c.json({
success: true, 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
);
}
});
+49 -98
View File
@@ -1,34 +1,16 @@
/** /**
* Session Manager * Session Manager
* *
* 管理所有活跃的会话,支持文件持久化 * 管理所有活跃的会话元数据(不存储消息,消息由 Core 负责)
*/ */
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import type { Session, CreateSessionInput, Message, SessionStatus } from '../types.js'; import type { Session, CreateSessionInput, SessionStatus } from '../types.js';
// ============================================================================ // ============================================================================
// Core 模块接口定义(避免构建时依赖) // 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<SessionMetadata, 'messageCount'> {
messages: Array<{ role: string; content: unknown }>;
}
interface SessionSummary { interface SessionSummary {
id: string; id: string;
title: string; title: string;
@@ -45,6 +27,20 @@ interface ProjectMetadata {
isGitRepo: boolean; 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 { interface SessionStorageInterface {
ensureDir(): Promise<void>; ensureDir(): Promise<void>;
generateSessionId(): string; generateSessionId(): string;
@@ -56,46 +52,31 @@ interface SessionStorageInterface {
deleteSession(projectId: string, sessionId: string): Promise<boolean>; deleteSession(projectId: string, sessionId: string): Promise<boolean>;
} }
// ============================================================================
// 消息格式转换
// ============================================================================
/**
* 将 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 类 // SessionManager 类
// ============================================================================ // ============================================================================
export class SessionManager { export class SessionManager {
private sessions: Map<string, Session> = new Map(); private sessions: Map<string, Session> = new Map();
private messages: Map<string, Message[]> = new Map();
private sessionProjects: Map<string, string> = new Map(); // sessionId -> projectId private sessionProjects: Map<string, string> = new Map(); // sessionId -> projectId
private storage: SessionStorageInterface | null = null; private storage: SessionStorageInterface | null = null;
private currentProject: ProjectMetadata | null = null; private currentProject: ProjectMetadata | null = null;
private initialized = false; 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 * 初始化:加载 Core 模块的 SessionStorage 并恢复已有 sessions
*/ */
@@ -127,7 +108,7 @@ export class SessionManager {
// 记录 session -> project 映射 // 记录 session -> project 映射
this.sessionProjects.set(sessionData.id, sessionData.projectId); this.sessionProjects.set(sessionData.id, sessionData.projectId);
// 转换为 Server Session 格式 // 转换为 Server Session 格式(只保存元数据,不存储消息)
const session: Session = { const session: Session = {
id: sessionData.id, id: sessionData.id,
name: sessionData.title, name: sessionData.title,
@@ -139,10 +120,6 @@ export class SessionManager {
}; };
this.sessions.set(session.id, session); 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`); 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<void> { private async persistMetadata(sessionId: string): Promise<void> {
if (!this.storage) return; if (!this.storage) return;
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
const messages = this.messages.get(sessionId) || [];
if (!session) return; if (!session) return;
const projectId = this.sessionProjects.get(sessionId) || this.currentProject?.id || 'default'; const projectId = this.sessionProjects.get(sessionId) || this.currentProject?.id || 'default';
// 先加载现有的 session 数据(保留消息)
const existingData = await this.storage.loadSession(projectId, sessionId);
const sessionData: SessionData = { const sessionData: SessionData = {
id: session.id, id: session.id,
projectId, projectId,
@@ -173,9 +152,9 @@ export class SessionManager {
updatedAt: session.updatedAt, updatedAt: session.updatedAt,
workdir: session.workdir, workdir: session.workdir,
title: session.name, title: session.name,
messages: messages.map(toModelMessage), messages: existingData?.messages || [],
discoveredTools: [], discoveredTools: existingData?.discoveredTools || [],
todos: [], todos: existingData?.todos || [],
}; };
await this.storage.saveSession(sessionData); await this.storage.saveSession(sessionData);
@@ -206,11 +185,10 @@ export class SessionManager {
}; };
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
this.messages.set(session.id, []);
this.sessionProjects.set(session.id, projectId); this.sessionProjects.set(session.id, projectId);
// 持久化 // 持久化空会话
await this.persist(session.id); await this.persistMetadata(session.id);
return session; return session;
} }
@@ -235,7 +213,6 @@ export class SessionManager {
* 删除会话 * 删除会话
*/ */
async delete(id: string): Promise<boolean> { async delete(id: string): Promise<boolean> {
this.messages.delete(id);
const deleted = this.sessions.delete(id); const deleted = this.sessions.delete(id);
// 从存储中删除 // 从存储中删除
@@ -261,41 +238,15 @@ export class SessionManager {
} }
/** /**
* 获取会话消息 * 更新会话消息计数(由 Agent 调用)
*/ */
getMessages(sessionId: string): Message[] { updateMessageCount(id: string, count: number): Session | undefined {
return this.messages.get(sessionId) || []; const session = this.sessions.get(id);
}
/**
* 添加消息
*/
async addMessage(
sessionId: string,
message: Omit<Message, 'id' | 'sessionId' | 'createdAt'>
): Promise<Message | undefined> {
const session = this.sessions.get(sessionId);
if (!session) return undefined; if (!session) return undefined;
const fullMessage: Message = { session.messageCount = count;
...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.updatedAt = new Date().toISOString(); session.updatedAt = new Date().toISOString();
return session;
// 持久化
await this.persist(sessionId);
return fullMessage;
} }
/** /**
@@ -308,8 +259,8 @@ export class SessionManager {
session.name = name; session.name = name;
session.updatedAt = new Date().toISOString(); session.updatedAt = new Date().toISOString();
// 持久化 // 持久化元数据
await this.persist(sessionId); await this.persistMetadata(sessionId);
return session; return session;
} }
+3 -8
View File
@@ -106,24 +106,19 @@ export async function handleWebSocketMessage(
case 'message': { case 'message': {
// 用户发送消息 // 用户发送消息
const content = message.payload?.content || ''; const content = message.payload?.content || '';
const userMessage = sessionManager.addMessage(sessionId, {
role: 'user',
content,
});
if (userMessage) { // 广播确认收到消息
// 广播用户消息
broadcastToSession(sessionId, { broadcastToSession(sessionId, {
type: 'message_received', type: 'message_received',
sessionId, sessionId,
payload: userMessage, payload: { content },
}); });
// 调用 Agent 处理消息(异步,不阻塞) // 调用 Agent 处理消息(异步,不阻塞)
// 消息存储由 Core Agent 负责
processMessage(sessionId, content).catch((error) => { processMessage(sessionId, content).catch((error) => {
console.error('[WS] Agent processing error:', error); console.error('[WS] Agent processing error:', error);
}); });
}
break; break;
} }
+11 -1
View File
@@ -12,7 +12,13 @@ import { vi } from 'vitest';
export function createMockAgent() { export function createMockAgent() {
return { return {
setRegistry: vi.fn(), 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 }), getToolCount: vi.fn().mockReturnValue({ core: 5, discovered: 0, total: 5 }),
getContextUsageFormatted: vi.fn().mockReturnValue('10k / 200k'), getContextUsageFormatted: vi.fn().mockReturnValue('10k / 200k'),
getContextUsage: vi.fn().mockReturnValue({ getContextUsage: vi.fn().mockReturnValue({
@@ -25,6 +31,10 @@ export function createMockAgent() {
getCompressionManager: vi.fn().mockReturnValue({ getCompressionManager: vi.fn().mockReturnValue({
shouldCompress: vi.fn().mockReturnValue(false), shouldCompress: vi.fn().mockReturnValue(false),
}), }),
getHistory: vi.fn().mockReturnValue([
{ role: 'user', content: 'test' },
{ role: 'assistant', content: 'mock response' },
]),
}; };
} }
+22 -30
View File
@@ -2,6 +2,8 @@
* SessionManager Mock 工厂 * SessionManager Mock 工厂
* *
* 提供会话管理器的 mock 实现 * 提供会话管理器的 mock 实现
*
* 注意:消息存储已移至 Core 层,SessionManager 只负责会话元数据管理
*/ */
import { vi } from 'vitest'; import { vi } from 'vitest';
@@ -13,6 +15,7 @@ export interface MockSession {
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
workdir?: string; workdir?: string;
messageCount: number;
} }
export interface MockMessage { export interface MockMessage {
@@ -27,8 +30,6 @@ export interface MockMessage {
*/ */
export function createMockSessionManager() { export function createMockSessionManager() {
const sessions = new Map<string, MockSession>(); const sessions = new Map<string, MockSession>();
const messages = new Map<string, MockMessage[]>();
let messageIdCounter = 1;
const manager = { const manager = {
// 初始化 // 初始化
@@ -48,44 +49,40 @@ export function createMockSessionManager() {
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
workdir: data?.workdir ?? process.cwd(), workdir: data?.workdir ?? process.cwd(),
messageCount: 0,
}; };
sessions.set(id, session); sessions.set(id, session);
messages.set(id, []);
return session; return session;
}), }),
delete: vi.fn(async (id: string) => { delete: vi.fn(async (id: string) => {
const existed = sessions.has(id); const existed = sessions.has(id);
sessions.delete(id); sessions.delete(id);
messages.delete(id);
return existed; return existed;
}), }),
list: vi.fn(() => Array.from(sessions.values())), 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') => { updateStatus: vi.fn((id: string, status: 'idle' | 'busy') => {
const session = sessions.get(id); const session = sessions.get(id);
if (session) { if (session) {
session.status = status; session.status = status;
session.updatedAt = Date.now(); 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) => { updateSessionName: vi.fn(async (id: string, name: string) => {
@@ -98,26 +95,20 @@ export function createMockSessionManager() {
return null; return null;
}), }),
// Storage 访问
getStorage: vi.fn(() => null),
getProjectId: vi.fn((_sessionId: string) => 'default-project'),
// 测试辅助方法 // 测试辅助方法
_addSession: (session: MockSession) => { _addSession: (session: MockSession) => {
sessions.set(session.id, session); 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: () => { _clear: () => {
sessions.clear(); sessions.clear();
messages.clear();
messageIdCounter = 1;
}, },
_getSessions: () => sessions, _getSessions: () => sessions,
_getMessages: () => messages,
}; };
return manager; return manager;
@@ -134,6 +125,7 @@ export function createTestSession(overrides: Partial<MockSession> = {}): MockSes
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
workdir: '/test/workdir', workdir: '/test/workdir',
messageCount: 0,
...overrides, ...overrides,
}; };
} }
@@ -487,7 +487,6 @@ describe('Agent Adapter', () => {
const mockSessionManager = { const mockSessionManager = {
exists: vi.fn().mockReturnValue(true), exists: vi.fn().mockReturnValue(true),
updateStatus: vi.fn(), updateStatus: vi.fn(),
addMessage: vi.fn().mockResolvedValue({ id: 'msg-1', role: 'assistant', content: 'placeholder' }),
}; };
vi.doMock('../../../src/session/manager.js', () => ({ vi.doMock('../../../src/session/manager.js', () => ({
@@ -523,8 +522,7 @@ describe('Agent Adapter', () => {
exists: vi.fn().mockReturnValue(true), exists: vi.fn().mockReturnValue(true),
get: vi.fn().mockReturnValue({ id: 'session-1', name: 'Test' }), get: vi.fn().mockReturnValue({ id: 'session-1', name: 'Test' }),
updateStatus: vi.fn(), updateStatus: vi.fn(),
addMessage: vi.fn().mockResolvedValue({ id: 'msg-1', role: 'assistant', content: 'response' }), updateMessageCount: vi.fn(),
getMessages: vi.fn().mockReturnValue([]),
}; };
vi.doMock('../../../src/session/manager.js', () => ({ vi.doMock('../../../src/session/manager.js', () => ({
@@ -2,20 +2,25 @@
* Sessions Route 测试 * Sessions Route 测试
* *
* 测试会话管理 REST API 端点 * 测试会话管理 REST API 端点
*
* 注意:消息存储已移至 Core 层,GET /sessions/:id/messages 从 Core Storage 读取
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Hono } from 'hono'; import { Hono } from 'hono';
// Mock storage interface
const mockLoadSession = vi.fn();
// Use vi.hoisted to create mocks before vi.mock is hoisted // 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(), mockList: vi.fn(),
mockCreate: vi.fn(), mockCreate: vi.fn(),
mockGet: vi.fn(), mockGet: vi.fn(),
mockExists: vi.fn(), mockExists: vi.fn(),
mockDelete: vi.fn(), mockDelete: vi.fn(),
mockGetMessages: vi.fn(), mockGetStorage: vi.fn(),
mockAddMessage: vi.fn(), mockGetProjectId: vi.fn(),
})); }));
vi.mock('../../../src/session/manager.js', () => ({ vi.mock('../../../src/session/manager.js', () => ({
@@ -25,8 +30,8 @@ vi.mock('../../../src/session/manager.js', () => ({
get: mockGet, get: mockGet,
exists: mockExists, exists: mockExists,
delete: mockDelete, delete: mockDelete,
getMessages: mockGetMessages, getStorage: mockGetStorage,
addMessage: mockAddMessage, getProjectId: mockGetProjectId,
})), })),
})); }));
@@ -39,6 +44,10 @@ app.route('/sessions', sessionsRouter);
describe('Sessions Route', () => { describe('Sessions Route', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetStorage.mockReturnValue({
loadSession: mockLoadSession,
});
mockGetProjectId.mockReturnValue('default-project');
}); });
describe('GET /sessions - 列出会话', () => { describe('GET /sessions - 列出会话', () => {
@@ -160,13 +169,18 @@ describe('Sessions Route', () => {
}); });
describe('GET /sessions/:id/messages - 获取消息', () => { describe('GET /sessions/:id/messages - 获取消息', () => {
it('返回会话消息', async () => { it('返回会话消息(从 Core Storage 读取)', async () => {
const messages = [ const messages = [
{ id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, { role: 'user', content: 'Hello' },
{ id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: 2000 }, { 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); mockExists.mockReturnValue(true);
mockGetMessages.mockReturnValue(messages); mockLoadSession.mockResolvedValue({
id: 'session-1',
messages,
});
const res = await app.request('/sessions/session-1/messages'); const res = await app.request('/sessions/session-1/messages');
const json = await res.json(); const json = await res.json();
@@ -174,6 +188,8 @@ describe('Sessions Route', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(json.success).toBe(true); expect(json.success).toBe(true);
expect(json.data).toEqual(messages); expect(json.data).toEqual(messages);
expect(mockGetStorage).toHaveBeenCalled();
expect(mockGetProjectId).toHaveBeenCalledWith('session-1');
}); });
it('不存在的会话返回 404', async () => { it('不存在的会话返回 404', async () => {
@@ -189,7 +205,32 @@ describe('Sessions Route', () => {
it('空消息返回空数组', async () => { it('空消息返回空数组', async () => {
mockExists.mockReturnValue(true); 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 res = await app.request('/sessions/session-1/messages');
const json = await res.json(); const json = await res.json();
@@ -198,83 +239,4 @@ describe('Sessions Route', () => {
expect(json.data).toEqual([]); 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');
});
});
}); });
@@ -5,6 +5,8 @@
* *
* 注意:SessionManager 使用动态 import 加载 @ai-assistant/core。 * 注意:SessionManager 使用动态 import 加载 @ai-assistant/core。
* 测试中会创建新实例并记录初始会话数量,以确保测试独立性。 * 测试中会创建新实例并记录初始会话数量,以确保测试独立性。
*
* 消息存储已移至 Core 层,Server 的 SessionManager 只负责会话元数据管理。
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -172,16 +174,6 @@ describe('SessionManager', () => {
expect(result).toBe(false); 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 () => { it('删除后 count 减少', async () => {
// 注意:这个测试会自己删除 session,不需要追踪 // 注意:这个测试会自己删除 session,不需要追踪
const session = await manager.create({ name: 'Test' }); const session = await manager.create({ name: 'Test' });
@@ -232,112 +224,28 @@ describe('SessionManager', () => {
}); });
}); });
describe('getMessages - 获取消息', () => { describe('updateMessageCount - 更新消息计数', () => {
it('返回会话消息', async () => { 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 () => {
const session = await createTrackedSession({ name: 'Test' }); const session = await createTrackedSession({ name: 'Test' });
expect(session.messageCount).toBe(0); expect(session.messageCount).toBe(0);
await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); const updated = manager.updateMessageCount(session.id, 5);
expect(updated?.messageCount).toBe(5);
const updated = manager.get(session.id);
expect(updated?.messageCount).toBe(1);
}); });
it('更新会话的 updatedAt', async () => { it('更新时更新 updatedAt', async () => {
const session = await createTrackedSession({ name: 'Test' }); const session = await createTrackedSession({ name: 'Test' });
const originalUpdatedAt = session.updatedAt; const originalUpdatedAt = session.updatedAt;
await new Promise((resolve) => setTimeout(resolve, 10)); 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); const updated = manager.get(session.id);
expect(updated?.updatedAt).not.toBe(originalUpdatedAt); expect(updated?.updatedAt).not.toBe(originalUpdatedAt);
}); });
it('不存在的会话返回 undefined', async () => { it('不存在的会话返回 undefined', () => {
const result = await manager.addMessage('non-existent-id-12345', { const result = manager.updateMessageCount('non-existent-id-12345', 10);
role: 'user',
content: 'Hello',
});
expect(result).toBeUndefined(); 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('边界情况', () => { describe('边界情况', () => {
it('处理特殊字符的会话名称', async () => { it('处理特殊字符的会话名称', async () => {
const session = await createTrackedSession({ name: '测试会话 <>&"\'`' }); const session = await createTrackedSession({ name: '测试会话 <>&"\'`' });
expect(session.name).toBe('测试会话 <>&"\'`'); 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');
});
}); });
}); });
+16 -34
View File
@@ -2,6 +2,8 @@
* WebSocket Handler 测试 * WebSocket Handler 测试
* *
* 测试 WebSocket 连接处理、消息路由、广播功能等 * 测试 WebSocket 连接处理、消息路由、广播功能等
*
* 注意:消息存储已移至 Core 层,Server 的 WebSocket 只负责消息广播和路由
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -9,7 +11,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
// Create mock functions // Create mock functions
const mockExists = vi.fn(); const mockExists = vi.fn();
const mockUpdateStatus = vi.fn(); const mockUpdateStatus = vi.fn();
const mockAddMessage = vi.fn();
const mockGet = vi.fn(); const mockGet = vi.fn();
// Mock dependencies before imports // Mock dependencies before imports
@@ -17,7 +18,6 @@ vi.mock('../../src/session/manager.js', () => ({
getSessionManager: vi.fn(() => ({ getSessionManager: vi.fn(() => ({
exists: mockExists, exists: mockExists,
updateStatus: mockUpdateStatus, updateStatus: mockUpdateStatus,
addMessage: mockAddMessage,
get: mockGet, get: mockGet,
})), })),
})); }));
@@ -60,7 +60,6 @@ describe('WebSocket Handler', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockExists.mockReturnValue(false); mockExists.mockReturnValue(false);
mockAddMessage.mockReturnValue({ id: 'msg-1', role: 'user', content: '', timestamp: Date.now() });
}); });
describe('handleWebSocket - 连接处理', () => { describe('handleWebSocket - 连接处理', () => {
@@ -110,6 +109,8 @@ describe('WebSocket Handler', () => {
it('处理 message 类型消息', async () => { it('处理 message 类型消息', async () => {
const ws = createMockWSContext(); const ws = createMockWSContext();
handleWebSocket(ws as any, 'session-1');
const message = JSON.stringify({ const message = JSON.stringify({
type: 'message', type: 'message',
payload: { content: 'Hello AI' }, payload: { content: 'Hello AI' },
@@ -117,10 +118,11 @@ describe('WebSocket Handler', () => {
await handleWebSocketMessage(ws as any, 'session-1', message); await handleWebSocketMessage(ws as any, 'session-1', message);
expect(mockAddMessage).toHaveBeenCalledWith('session-1', { // 应该广播 message_received 确认
role: 'user', expect(ws.send).toHaveBeenCalledWith(
content: 'Hello AI', expect.stringContaining('"type":"message_received"')
}); );
// 应该调用 processMessage
expect(processMessage).toHaveBeenCalledWith('session-1', 'Hello AI'); expect(processMessage).toHaveBeenCalledWith('session-1', 'Hello AI');
}); });
@@ -175,6 +177,7 @@ describe('WebSocket Handler', () => {
it('处理 ArrayBuffer 数据', async () => { it('处理 ArrayBuffer 数据', async () => {
const ws = createMockWSContext(); const ws = createMockWSContext();
handleWebSocket(ws as any, 'session-1');
const message = { type: 'message', payload: { content: 'ArrayBuffer test' } }; const message = { type: 'message', payload: { content: 'ArrayBuffer test' } };
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -182,14 +185,13 @@ describe('WebSocket Handler', () => {
await handleWebSocketMessage(ws as any, 'session-1', buffer); await handleWebSocketMessage(ws as any, 'session-1', buffer);
expect(mockAddMessage).toHaveBeenCalledWith('session-1', { expect(processMessage).toHaveBeenCalledWith('session-1', 'ArrayBuffer test');
role: 'user',
content: 'ArrayBuffer test',
});
}); });
it('空 content 处理正确', async () => { it('空 content 处理正确', async () => {
const ws = createMockWSContext(); const ws = createMockWSContext();
handleWebSocket(ws as any, 'session-1');
const message = JSON.stringify({ const message = JSON.stringify({
type: 'message', type: 'message',
payload: {}, payload: {},
@@ -197,24 +199,19 @@ describe('WebSocket Handler', () => {
await handleWebSocketMessage(ws as any, 'session-1', message); await handleWebSocketMessage(ws as any, 'session-1', message);
expect(mockAddMessage).toHaveBeenCalledWith('session-1', { expect(processMessage).toHaveBeenCalledWith('session-1', '');
role: 'user',
content: '',
});
}); });
it('处理 Blob 数据', async () => { it('处理 Blob 数据', async () => {
const ws = createMockWSContext(); const ws = createMockWSContext();
handleWebSocket(ws as any, 'session-1');
const message = { type: 'message', payload: { content: 'Blob test' } }; const message = { type: 'message', payload: { content: 'Blob test' } };
const blob = new Blob([JSON.stringify(message)]); const blob = new Blob([JSON.stringify(message)]);
await handleWebSocketMessage(ws as any, 'session-1', blob); await handleWebSocketMessage(ws as any, 'session-1', blob);
expect(mockAddMessage).toHaveBeenCalledWith('session-1', { expect(processMessage).toHaveBeenCalledWith('session-1', 'Blob test');
role: 'user',
content: 'Blob test',
});
}); });
it('处理非标准数据类型(转为字符串)', async () => { it('处理非标准数据类型(转为字符串)', async () => {
@@ -229,21 +226,6 @@ describe('WebSocket Handler', () => {
expect(cancelProcessing).toHaveBeenCalledWith('session-1'); 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 () => { it('处理 tool_response 类型消息(TODO 场景)', async () => {
const ws = createMockWSContext(); const ws = createMockWSContext();
const message = JSON.stringify({ const message = JSON.stringify({