diff --git a/package.json b/package.json index 4db4d1cc..3a93aaf5 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "start:dev:debug-react": "cross-env NODE_ENV=development DEBUG_REACT=true electron-forge start", "build:plugin": "zx scripts/compilePlugins.mjs", "test": "pnpm run test:unit && pnpm run package && pnpm run test:e2e", - "test:unit": "vitest run", - "test:unit:coverage": "vitest run --coverage", + "test:unit": "chcp 65001 && cross-env LANG=en_US.UTF-8 ELECTRON_RUN_AS_NODE=true ./node_modules/.bin/electron ./node_modules/vitest/vitest.mjs run", + "test:unit:coverage": "pnpm run test:unit -- --coverage", "test:e2e": "cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js", "package": "pnpm run build:plugin && cross-env NODE_ENV=test electron-forge package", "make": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make", @@ -138,6 +138,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/better-sqlite3": "^7.6.13", "@types/bluebird": "3.5.42", "@types/chai": "5.0.1", "@types/circular-dependency-plugin": "5.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e258f84..3545aca7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/bluebird': specifier: 3.5.42 version: 3.5.42 @@ -2185,6 +2188,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/bluebird@3.5.42': resolution: {integrity: sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==} @@ -10266,6 +10272,10 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.13.0 + '@types/bluebird@3.5.42': {} '@types/body-parser@1.19.2': diff --git a/src/services/agentInstance/plugins/__tests__/fullReplacementPlugin.duration.test.ts b/src/services/agentInstance/plugins/__tests__/fullReplacementPlugin.duration.test.ts index fd58b362..414b7e1d 100644 --- a/src/services/agentInstance/plugins/__tests__/fullReplacementPlugin.duration.test.ts +++ b/src/services/agentInstance/plugins/__tests__/fullReplacementPlugin.duration.test.ts @@ -29,7 +29,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { ); expect(historyPlugin).toBeDefined(); expect(historyPlugin!.fullReplacementParam!.targetId).toBe('default-history'); // Real target ID - + // Use real prompts structure from defaultAgents.json const testPrompts = cloneDeep(realHandlerConfig.prompts) as IPrompt[]; @@ -121,7 +121,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { const targetId = historyPlugin!.fullReplacementParam!.targetId; // 'default-history' const historyPrompt = testPrompts.find(p => p.id === 'history'); expect(historyPrompt).toBeDefined(); - + const targetPrompt = historyPrompt!.children?.find(child => child.id === targetId); expect(targetPrompt).toBeDefined(); @@ -149,7 +149,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { const historyPlugin = realHandlerConfig.plugins.find( p => p.pluginId === 'fullReplacement' && p.fullReplacementParam?.sourceType === 'historyOfSession', ); - + const messages: AgentInstanceMessage[] = [ { id: 'user-msg-1', @@ -298,7 +298,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { expect(childrenText).toContain('Message 1 - no duration'); expect(childrenText).toContain('Message 2 - duration 3'); expect(childrenText).toContain('Message 3 - duration 1'); // roundsFromCurrent(0) < duration(1) - + // Last message should be removed by fullReplacement expect(childrenText).not.toContain('Message 4 - latest'); }); diff --git a/src/services/agentInstance/plugins/__tests__/messageManagementPlugin.test.ts b/src/services/agentInstance/plugins/__tests__/messageManagementPlugin.test.ts new file mode 100644 index 00000000..7de00047 --- /dev/null +++ b/src/services/agentInstance/plugins/__tests__/messageManagementPlugin.test.ts @@ -0,0 +1,368 @@ +/** + * Deep integration tests for messageManagementPlugin with real SQLite database + * Tests actual message persistence scenarios using defaultAgents.json configuration + */ +import { + AgentDefinitionEntity, + AgentInstanceEntity, + AgentInstanceMessageEntity, +} from "@services/database/schema/agent"; +import { DataSource } from "typeorm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentInstanceMessage } from "../../interface"; +import defaultAgents from "../../buildInAgentHandlers/defaultAgents.json"; + +// Mock the dependencies BEFORE importing the plugin +vi.mock("@services/container", () => ({ + container: { + get: vi.fn(), + }, +})); + +vi.mock("@services/serviceIdentifier", () => ({ + default: { + AgentInstance: Symbol.for("AgentInstance"), + }, +})); + +// Import plugin after mocks are set up +import { createHandlerHooks } from "../index"; +import { messageManagementPlugin } from "../messageManagementPlugin"; +import type { ToolExecutionContext, UserMessageContext } from "../types"; + +// Use the real agent config from defaultAgents.json +const exampleAgent = defaultAgents[0]; + +describe("Message Management Plugin - Real Database Integration", () => { + let dataSource: DataSource; + let testAgentId: string; + let realAgentInstanceService: { + saveUserMessage: (message: AgentInstanceMessage) => Promise; + debounceUpdateMessage: ( + message: AgentInstanceMessage, + agentId?: string, + ) => void; + updateAgent: ( + agentId: string, + updates: Record, + ) => Promise; + }; + let hooks: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + testAgentId = `test-agent-${Date.now()}`; + + // Create in-memory SQLite database + dataSource = new DataSource({ + type: "better-sqlite3", + database: ":memory:", + entities: [ + AgentDefinitionEntity, + AgentInstanceEntity, + AgentInstanceMessageEntity, + ], + synchronize: true, + logging: false, + }); + + await dataSource.initialize(); + + // Create test data using defaultAgent structure + const agentDefRepo = dataSource.getRepository(AgentDefinitionEntity); + await agentDefRepo.save({ + id: exampleAgent.id, + name: exampleAgent.name, + }); + + const agentRepo = dataSource.getRepository(AgentInstanceEntity); + await agentRepo.save({ + id: testAgentId, + agentDefId: exampleAgent.id, + name: `Instance of ${exampleAgent.name}`, + status: { state: "working", modified: new Date() }, + created: new Date(), + closed: false, + }); + + // Create real service with spy for database operations + realAgentInstanceService = { + saveUserMessage: vi.fn(async (message: AgentInstanceMessage) => { + const messageRepo = dataSource.getRepository( + AgentInstanceMessageEntity, + ); + await messageRepo.save({ + id: message.id, + agentId: message.agentId, + role: message.role, + content: message.content, + contentType: message.contentType || "text/plain", + modified: message.modified || new Date(), + metadata: message.metadata, + duration: message.duration ?? undefined, + }); + }), + debounceUpdateMessage: vi.fn(), + updateAgent: vi.fn(), + }; + + // Configure mock container to return our service + const { container } = await import("@services/container"); + vi.mocked(container.get).mockReturnValue(realAgentInstanceService); + + // Initialize plugin + hooks = createHandlerHooks(); + messageManagementPlugin(hooks); + }); + + afterEach(async () => { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } + }); + + const createHandlerContext = (messages: AgentInstanceMessage[] = []) => ({ + agent: { + id: testAgentId, + agentDefId: exampleAgent.id, + status: { state: "working" as const, modified: new Date() }, + created: new Date(), + messages, + }, + agentDef: { + id: exampleAgent.id, + name: exampleAgent.name, + version: "1.0.0", + capabilities: [], + handlerConfig: exampleAgent.handlerConfig, + }, + isCancelled: () => false, + }); + + describe("Real Wiki Search Scenario - The Missing Tool Result Bug", () => { + it("should persist all messages in wiki search flow: user query → AI tool call → tool result → AI final response", async () => { + const handlerContext = createHandlerContext(); + + // Step 1: User asks to search wiki + const userMessageId = `user-msg-${Date.now()}`; + const userContext: UserMessageContext = { + handlerContext, + content: { text: "搜索 wiki 中的 Index 条目并解释" }, + messageId: userMessageId, + timestamp: new Date(), + }; + + await hooks.userMessageReceived.promise(userContext); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify user message was saved + let messageRepo = dataSource.getRepository(AgentInstanceMessageEntity); + let allMessages = await messageRepo.find({ + where: { agentId: testAgentId }, + }); + expect(allMessages).toHaveLength(1); + expect(allMessages[0].content).toBe("搜索 wiki 中的 Index 条目并解释"); + expect(allMessages[0].role).toBe("user"); + + // Step 2: AI generates tool call (this gets persisted via responseComplete) + const aiToolCallMessage: AgentInstanceMessage = { + id: `ai-tool-call-${Date.now()}`, + agentId: testAgentId, + role: "assistant", + content: + '{ "workspaceName": "wiki", "filter": "[title[Index]]" }', + contentType: "text/plain", + modified: new Date(), + metadata: { isComplete: true }, + duration: undefined, + }; + + await realAgentInstanceService.saveUserMessage(aiToolCallMessage); + handlerContext.agent.messages.push(aiToolCallMessage); + + // Step 3: Tool result message (THIS IS THE MISSING PIECE!) + // This simulates what wikiSearchPlugin does when tool execution completes + const toolResultMessage: AgentInstanceMessage = { + id: `tool-result-${Date.now()}`, + agentId: testAgentId, + role: "user", + content: ` +Tool: wiki-search +Result: 在wiki中找到了名为"Index"的条目。这个条目包含以下内容: + +# Index +这是wiki的索引页面,包含了所有重要条目的链接和分类。主要分为以下几个部分: +- 技术文档 +- 教程指南 +- 常见问题 +- 更新日志 + +该条目创建于2024年,是导航整个wiki内容的重要入口页面。 +`, + contentType: "text/plain", + modified: new Date(), + metadata: { + isToolResult: true, + toolId: "wiki-search", + isPersisted: false, // Key: starts as false, should be marked true after persistence + }, + duration: 10, // Tool results might have expiration + }; + + // Add tool result to agent messages (simulating what wikiSearchPlugin does) + handlerContext.agent.messages.push(toolResultMessage); + + const toolContext: ToolExecutionContext = { + handlerContext, + toolResult: { + success: true, + data: "Wiki search completed successfully", + metadata: { duration: 1500 }, + }, + toolInfo: { + toolId: "wiki-search", + parameters: { workspaceName: "wiki", filter: "[title[Index]]" }, + }, + }; + + // This should trigger the toolExecuted hook that saves tool result messages + await hooks.toolExecuted.promise(toolContext); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify tool result message was persisted + messageRepo = dataSource.getRepository(AgentInstanceMessageEntity); + allMessages = await messageRepo.find({ + where: { agentId: testAgentId }, + order: { modified: "ASC" }, + }); + + expect(allMessages).toHaveLength(3); // user + ai tool call + tool result + + const savedToolResult = allMessages.find((m) => m.metadata?.isToolResult); + expect(savedToolResult).toBeTruthy(); + expect(savedToolResult?.content).toContain(""); + expect(savedToolResult?.content).toContain("Tool: wiki-search"); + expect(savedToolResult?.content).toContain("Index"); + expect(savedToolResult?.metadata?.toolId).toBe("wiki-search"); + expect(savedToolResult?.duration).toBe(10); + + // Verify isPersisted flag was updated + const toolMessageInMemory = handlerContext.agent.messages.find( + (m) => m.metadata?.isToolResult, + ); + expect(toolMessageInMemory?.metadata?.isPersisted).toBe(true); + + // Step 4: AI final response based on tool result + const aiFinalMessage: AgentInstanceMessage = { + id: `ai-final-${Date.now()}`, + agentId: testAgentId, + role: "assistant", + content: + '在wiki中找到了名为"Index"的条目。这个条目包含以下内容:\n\n# Index\n这是wiki的索引页面,包含了所有重要条目的链接和分类。主要分为以下几个部分:\n- 技术文档\n- 教程指南\n- 常见问题\n- 更新日志\n\n该条目创建于2024年,是导航整个wiki内容的重要入口页面。这个Index条目作为整个wiki的导航中心,为用户提供了便捷的内容访问入口。', + contentType: "text/plain", + modified: new Date(), + metadata: { isComplete: true }, + duration: undefined, + }; + + await realAgentInstanceService.saveUserMessage(aiFinalMessage); + + // Final verification: All 4 messages should be in database + messageRepo = dataSource.getRepository(AgentInstanceMessageEntity); + allMessages = await messageRepo.find({ + where: { agentId: testAgentId }, + order: { modified: "ASC" }, + }); + + expect(allMessages).toHaveLength(4); + + // Verify the complete flow + expect(allMessages[0].role).toBe("user"); // User query + expect(allMessages[0].content).toBe("搜索 wiki 中的 Index 条目并解释"); + + expect(allMessages[1].role).toBe("assistant"); // AI tool call + expect(allMessages[1].content).toContain(''); + + expect(allMessages[2].role).toBe("user"); // Tool result (THIS WAS MISSING!) + expect(allMessages[2].content).toContain(""); + expect(allMessages[2].metadata?.isToolResult).toBe(true); + + expect(allMessages[3].role).toBe("assistant"); // AI final response + expect(allMessages[3].content).toContain( + '在wiki中找到了名为"Index"的条目', + ); + }); + + it("should handle multiple tool results in one execution", async () => { + console.log("🧪 Testing multiple tool results persistence..."); + + const handlerContext = createHandlerContext(); + + // Add multiple tool result messages + const toolResult1: AgentInstanceMessage = { + id: `tool-result-1-${Date.now()}`, + agentId: testAgentId, + role: "user", + content: + "Tool: wiki-search\nResult: Found Index page", + contentType: "text/plain", + modified: new Date(), + metadata: { + isToolResult: true, + toolId: "wiki-search", + isPersisted: false, + }, + duration: 5, + }; + + const toolResult2: AgentInstanceMessage = { + id: `tool-result-2-${Date.now()}`, + agentId: testAgentId, + role: "user", + content: + "Tool: wiki-search\nResult: Found related pages", + contentType: "text/plain", + modified: new Date(), + metadata: { + isToolResult: true, + toolId: "wiki-search", + isPersisted: false, + }, + duration: 3, + }; + + handlerContext.agent.messages.push(toolResult1, toolResult2); + + const toolContext: ToolExecutionContext = { + handlerContext, + toolResult: { + success: true, + data: "Multiple tool search completed", + }, + toolInfo: { + toolId: "wiki-search", + parameters: { workspaceName: "wiki" }, + }, + }; + + await hooks.toolExecuted.promise(toolContext); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Verify both tool results were persisted + const messageRepo = dataSource.getRepository(AgentInstanceMessageEntity); + const allMessages = await messageRepo.find({ + where: { agentId: testAgentId }, + }); + + expect(allMessages).toHaveLength(2); + expect(allMessages.every((m) => m.metadata?.isToolResult)).toBe(true); + expect(allMessages.every((m) => m.role === "user")).toBe(true); + + // Verify both messages marked as persisted + expect(toolResult1.metadata?.isPersisted).toBe(true); + expect(toolResult2.metadata?.isPersisted).toBe(true); + + console.log("✅ Multiple tool results test passed"); + }); + }); +}); diff --git a/src/services/agentInstance/plugins/messageManagementPlugin.ts b/src/services/agentInstance/plugins/messageManagementPlugin.ts index 57fb29c4..3df2fcfd 100644 --- a/src/services/agentInstance/plugins/messageManagementPlugin.ts +++ b/src/services/agentInstance/plugins/messageManagementPlugin.ts @@ -201,33 +201,46 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => { }); // Handle tool result messages persistence and UI updates - hooks.toolExecuted.tapAsync('messageManagementPlugin', (context: ToolExecutionContext, callback) => { + hooks.toolExecuted.tapAsync('messageManagementPlugin', async (context: ToolExecutionContext, callback) => { try { const { handlerContext } = context; - // Update UI for any newly added messages with duration settings - const newMessages = handlerContext.agent.messages.filter( - (message) => message.metadata?.isToolResult && !message.metadata.uiUpdated, + // Find newly added tool result messages that need to be persisted + const newToolResultMessages = handlerContext.agent.messages.filter( + (message) => message.metadata?.isToolResult && !message.metadata.isPersisted, ); - for (const message of newMessages) { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + // Save tool result messages to database and update UI + for (const message of newToolResultMessages) { try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + // Save to database using the same method as user messages + await agentInstanceService.saveUserMessage(message); + + // Update UI agentInstanceService.debounceUpdateMessage(message, handlerContext.agent.id); - // Mark as UI updated to avoid duplicate updates - message.metadata = { ...message.metadata, uiUpdated: true }; + + // Mark as persisted to avoid duplicate saves + message.metadata = { ...message.metadata, isPersisted: true, uiUpdated: true }; + + logger.debug('Tool result message persisted to database', { + messageId: message.id, + toolId: message.metadata.toolId, + duration: message.duration, + }); } catch (serviceError) { - logger.warn('Failed to update UI for tool result message', { + logger.error('Failed to persist tool result message', { error: serviceError instanceof Error ? serviceError.message : String(serviceError), messageId: message.id, }); } } - if (newMessages.length > 0) { - logger.debug('Tool result messages UI updated', { - count: newMessages.length, - messageIds: newMessages.map(m => m.id), + if (newToolResultMessages.length > 0) { + logger.debug('Tool result messages processed', { + count: newToolResultMessages.length, + messageIds: newToolResultMessages.map(m => m.id), }); } diff --git a/vitest.config.ts b/vitest.config.ts index 6fbcf283..0170fecc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,13 +44,12 @@ export default defineConfig({ ], }, - // Parallel testing configuration - pool: 'threads', + // Parallel testing configuration - use single fork for database consistency + pool: 'forks', poolOptions: { - threads: { - useAtomics: true, + forks: { + singleFork: true, // Important for in-memory database tests }, - isolate: true, }, // Performance settings @@ -67,4 +66,10 @@ export default defineConfig({ // Handle CSS and static assets assetsInclude: ['**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif', '**/*.svg'], + + // Set environment variables for better-sqlite3 compatibility + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'test'), + 'process.env.ELECTRON_RUN_AS_NODE': JSON.stringify(process.env.ELECTRON_RUN_AS_NODE || 'true'), + }, });