TidGi-Desktop/src/services/agentInstance/plugins/__tests__/wikiSearchPlugin.test.ts
lin onetwo b76fc17794
Chore/upgrade (#646)
* docs: deps

* Update dependencies and type usage for AI features

Upgraded multiple dependencies in package.json and pnpm-lock.yaml, including @ai-sdk, @mui, react, and others for improved compatibility and performance. Changed type usage from CoreMessage to ModelMessage in mockOpenAI.test.ts to align with updated ai package. No functional changes to application logic.

* feat: i18n

* feat: test oauth login and use PKCE

* fix: use ollama-ai-provider-v2

* test: github and mock oauth2 login

* test: gitea login

* Refactor context menu cleanup and error message

Moved context menu cleanup for OAuth window to a single closed event handler in Authentication service. Simplified error message formatting in ContextService for missing keys.

* lint: AI fix

* Add tsx as a dev dependency and update scripts

Replaced usage of 'pnpm dlx tsx' with direct 'tsx' command in development and test scripts for improved reliability. Added 'tsx' to devDependencies in package.json.
2025-10-23 23:42:06 +08:00

949 lines
36 KiB
TypeScript

/**
* Comprehensive tests for Wiki Search plugin
* Covers tool list injection, tool execution, duration mechanism, message persistence, and integration scenarios
*/
import type { AgentDefinition } from '@services/agentDefinition/interface';
import type { IWikiService } from '@services/wiki/interface';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentInstance } from '../../interface';
import type { AgentInstanceMessage } from '../../interface';
import type { IAgentInstanceService } from '../../interface';
import type { AIResponseContext, YieldNextRoundTarget } from '../types';
import { WikiChannel } from '@/constants/channels';
import serviceIdentifier from '@services/serviceIdentifier';
import type { AgentHandlerContext } from '@services/agentInstance/buildInAgentHandlers/type';
import { AgentPromptDescription } from '@services/agentInstance/promptConcat/promptConcatSchema';
import type { IPrompt } from '@services/agentInstance/promptConcat/promptConcatSchema';
import type { IPromptConcatPlugin } from '@services/agentInstance/promptConcat/promptConcatSchema';
import { cloneDeep } from 'lodash';
import defaultAgents from '../../buildInAgentHandlers/defaultAgents.json';
import { createHandlerHooks, PromptConcatHookContext } from '../index';
import { messageManagementPlugin } from '../messageManagementPlugin';
import { wikiSearchPlugin } from '../wikiSearchPlugin';
// Mock i18n
vi.mock('@services/libs/i18n', () => ({
i18n: {
t: vi.fn((key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'Tool.WikiSearch.Success.NoResults': '在Wiki工作空间"{{workspaceName}}"中未找到过滤器"{{filter}}"的结果',
'Tool.WikiSearch.Success.Completed': 'Wiki搜索完成。找到{{totalResults}}个总结果,显示{{shownResults}}个:\n\n',
'Tool.WikiSearch.Error.WorkspaceNotFound': '工作空间名称或ID"{{workspaceName}}"不存在。可用工作空间:{{availableWorkspaces}}',
'Tool.WikiSearch.Error.WorkspaceNotExist': '工作空间{{workspaceID}}不存在',
'Tool.WikiSearch.Error.ExecutionFailed': '工具执行失败:{{error}}',
};
let translation = translations[key] || key;
// Handle interpolation
if (options && typeof options === 'object') {
Object.keys(options).forEach(optionKey => {
if (optionKey !== 'ns' && optionKey !== 'defaultValue') {
translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), String(options[optionKey]));
}
});
}
return translation;
}),
},
}));
// Use the real agent config
const exampleAgent = defaultAgents[0];
const handlerConfig = exampleAgent.handlerConfig as AgentPromptDescription['handlerConfig'];
// Services will be retrieved from container on demand inside each test/describe
type ActionBag = { yieldNextRoundTo?: YieldNextRoundTarget; newUserMessage?: string | undefined };
describe('Wiki Search Plugin - Comprehensive Tests', () => {
beforeEach(async () => {
vi.clearAllMocks();
});
describe('Tool List Injection', () => {
beforeEach(async () => {
const { container } = await import('@services/container');
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
// Replace agent instance methods with spies
vi.spyOn(agentInstanceService, 'saveUserMessage').mockResolvedValue(undefined);
vi.spyOn(agentInstanceService, 'debounceUpdateMessage').mockImplementation(() => undefined);
// updateAgent returns Promise<AgentInstance> - return a minimal stub
vi.spyOn(agentInstanceService, 'updateAgent').mockResolvedValue({} as AgentInstance);
});
it('should inject wiki tools into prompts when configured', async () => {
// Find the wiki search plugin config, make sure our default config
const wikiPlugin = handlerConfig.plugins.find((p: unknown): p is IPromptConcatPlugin => (p as IPromptConcatPlugin).pluginId === 'wikiSearch');
expect(wikiPlugin).toBeDefined();
if (!wikiPlugin) {
// throw error to keep ts believe the plugin exists
throw new Error('Wiki plugin not found');
}
// Verify the plugin has the correct parameter structure
expect(wikiPlugin.wikiSearchParam).toBeDefined();
expect(wikiPlugin.wikiSearchParam?.toolListPosition).toBeDefined();
// Create a copy of prompts to test modification
const prompts = cloneDeep(handlerConfig.prompts);
const messages = [
{
id: 'user-1',
role: 'user' as const,
content: 'Help me search for information in my wiki',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
},
];
const context: PromptConcatHookContext = {
handlerContext: {
agent: { id: 'test', messages: [], agentDefId: 'test', status: { state: 'working' as const, modified: new Date() }, created: new Date() },
agentDef: { id: 'test', name: 'test', handlerConfig: {} },
isCancelled: () => false,
},
pluginConfig: wikiPlugin,
prompts: prompts,
messages,
};
// Use real hooks from the plugin system
const promptHooks = createHandlerHooks();
wikiSearchPlugin(promptHooks);
// Execute the processPrompts hook
await promptHooks.processPrompts.promise(context);
// Verify that tool information was injected into the prompts
const promptTexts = JSON.stringify(prompts);
expect(promptTexts).toContain('wiki-search');
expect(promptTexts).toContain('workspaceName');
expect(promptTexts).toContain('filter');
});
it('should skip injection when trigger condition is not met', async () => {
// Create a plugin config with trigger that won't match
const wikiPlugin = {
id: 'test-wiki-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
retrievalAugmentedGenerationParam: {
sourceType: 'wiki' as const,
trigger: {
search: 'specific-search-term-not-in-message',
},
toolListPosition: {
position: 'after' as const,
targetId: 'default-before-tool',
},
},
};
const prompts = cloneDeep(defaultAgents[0].handlerConfig.prompts);
const originalPromptsText = JSON.stringify(prompts);
const context = {
pluginConfig: wikiPlugin,
prompts,
messages: [
{
id: 'user-1',
role: 'user' as const,
content: 'Hello, how are you?',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
},
],
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
// build a minimal PromptConcatHookContext to run the plugin's processPrompts
const handlerCtx: AgentHandlerContext = {
agent: {
id: 'test',
agentDefId: 'test',
messages: [],
status: { state: 'working' as const, modified: new Date() },
created: new Date(),
} as AgentInstance,
agentDef: { id: 'test', name: 'test', handlerConfig: {} } as AgentDefinition,
isCancelled: () => false,
};
const hookContext: PromptConcatHookContext = {
handlerContext: handlerCtx,
pluginConfig: wikiPlugin as IPromptConcatPlugin,
prompts: prompts as IPrompt[],
messages: context.messages as AgentInstanceMessage[],
};
await hooks.processPrompts.promise(hookContext);
// Prompts should not be modified since trigger condition wasn't met
const modifiedPromptsText = JSON.stringify(prompts);
expect(modifiedPromptsText).toBe(originalPromptsText);
});
});
describe('Tool Execution & Duration Mechanism', () => {
beforeEach(async () => {
// Mock wiki search results
const { container } = await import('@services/container');
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
if (!wikiService.wikiOperationInServer) {
// ensure method exists for spying (exception allowed)
(wikiService as unknown as { wikiOperationInServer: (...p: unknown[]) => Promise<unknown> }).wikiOperationInServer = async () => [];
}
vi.spyOn(wikiService, 'wikiOperationInServer').mockImplementation(
((...args: unknown[]) => {
const channel = args[0] as WikiChannel;
const opArgs = args[2] as string[] | undefined;
if (channel === WikiChannel.runFilter) {
return Promise.resolve(['Important Note 1', 'Important Note 2']);
}
if (channel === WikiChannel.getTiddlersAsJson && opArgs && opArgs.length > 0) {
const title = opArgs[0];
return Promise.resolve([
{
title,
text: `Content of ${title}: This contains important information.`,
tags: ['important'],
},
]);
}
return Promise.resolve([]);
}) as unknown as IWikiService['wikiOperationInServer'],
);
});
it('should execute wiki search with correct duration=1 and trigger next round', async () => {
// Find the real wikiSearch plugin config from defaultAgents.json
const wikiPlugin = handlerConfig.plugins.find((p: unknown): p is IPromptConcatPlugin => (p as IPromptConcatPlugin).pluginId === 'wikiSearch');
expect(wikiPlugin).toBeDefined();
expect(wikiPlugin!.wikiSearchParam).toBeDefined();
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
// Previous user message
{
id: 'user-msg-1',
role: 'user' as const,
content: 'Search for information',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined, // Should stay visible
},
// AI tool call message (this is the message we're testing)
{
id: 'ai-tool-call-msg',
role: 'assistant' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "filter": "[tag[important]]"}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined, // Should be set to 1 after tool execution
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
// Create a response that contains a valid tool call
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "filter": "[tag[important]]"}</tool_use>',
requestId: 'test-request-123',
};
const context = {
handlerContext,
response,
requestId: 'test-request-123',
isFinal: true,
pluginConfig: wikiPlugin!,
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {} as ActionBag,
};
// Use real handler hooks
const hooks = createHandlerHooks();
// Register the plugin
wikiSearchPlugin(hooks);
// Execute the response complete hook
await hooks.responseComplete.promise(context);
// Verify that the search was executed and results were set up for next round
expect(context.actions.yieldNextRoundTo).toBe('self');
// Verify that debounceUpdateMessage was called to notify frontend immediately (no delay)
const { container } = await import('@services/container');
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
expect(agentInstanceService.debounceUpdateMessage).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ai-tool-call-msg',
duration: 1,
}),
'test-agent',
0, // No delay for immediate update
);
// Check that AI tool call message now has duration=1 (should gray out immediately)
const aiToolCallMessage = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(aiToolCallMessage.id).toBe('ai-tool-call-msg');
expect(aiToolCallMessage.duration).toBe(1); // Should be 1 to gray out immediately
expect(aiToolCallMessage.metadata?.containsToolCall).toBe(true);
expect(aiToolCallMessage.metadata?.toolId).toBe('wiki-search');
// Verify tool result message was added to agent history with correct settings
expect(handlerContext.agent.messages.length).toBe(3); // user + ai + tool_result
const toolResultMessage = handlerContext.agent.messages[2] as AgentInstanceMessage;
expect(toolResultMessage.role).toBe('tool'); // Tool result message
expect(toolResultMessage.content).toContain('<functions_result>');
expect(toolResultMessage.content).toContain('Tool: wiki-search');
expect(toolResultMessage.content).toContain('Important Note 1');
expect(toolResultMessage.metadata?.isToolResult).toBe(true);
expect(toolResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially
expect(toolResultMessage.duration).toBe(1); // Tool result uses configurable toolResultDuration (default 1)
// Check that previous user message is unchanged
const userMessage = handlerContext.agent.messages[0] as AgentInstanceMessage;
expect(userMessage.id).toBe('user-msg-1');
expect(userMessage.duration).toBeUndefined(); // Should stay visible
});
it('should handle wiki search errors gracefully and set duration=1 for both messages', async () => {
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
{
id: 'ai-error-tool-call',
role: 'assistant' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Nonexistent Wiki", "filter": "[tag[test]]"}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined, // Should be set to 1 after error handling
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
// Tool call with nonexistent workspace
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Nonexistent Wiki", "filter": "[tag[test]]"}</tool_use>',
requestId: 'test-request-error',
};
const context = {
handlerContext,
response,
requestId: 'test-request-error',
isFinal: true,
pluginConfig: {
id: 'test-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
},
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {
yieldNextRoundTo: undefined,
newUserMessage: undefined,
},
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
await hooks.responseComplete.promise(context);
// Should still set up next round with error message
expect(context.actions.yieldNextRoundTo).toBe('self');
// Check that AI tool call message has duration=1 even after error (should gray out immediately)
const aiToolCallMessage = handlerContext.agent.messages[0] as AgentInstanceMessage;
expect(aiToolCallMessage.id).toBe('ai-error-tool-call');
expect(aiToolCallMessage.duration).toBe(1); // Should be 1 to gray out immediately
expect(aiToolCallMessage.metadata?.containsToolCall).toBe(true);
// Verify error message was added to agent history
expect(handlerContext.agent.messages.length).toBe(2); // tool_call + error_result
const errorResultMessage = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(errorResultMessage.role).toBe('tool'); // Tool error message
expect(errorResultMessage.content).toContain('<functions_result>');
expect(errorResultMessage.content).toContain('Error:');
expect(errorResultMessage.content).toContain('工作空间名称或ID');
expect(errorResultMessage.metadata?.isToolResult).toBe(true);
expect(errorResultMessage.metadata?.isError).toBe(true);
expect(errorResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially
expect(errorResultMessage.duration).toBe(1); // Now uses configurable toolResultDuration (default 1)
});
it('should not modify duration of unrelated messages', async () => {
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
{
id: 'unrelated-user-msg',
role: 'user' as const,
content: 'This is an unrelated message',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: 5, // Should remain unchanged
},
{
id: 'unrelated-ai-msg',
role: 'assistant' as const,
content: 'This is a regular AI response without tool calls',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined, // Should remain unchanged
},
{
id: 'ai-tool-call-msg',
role: 'assistant' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "filter": "[tag[test]]"}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined, // This should be modified to 1
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "filter": "[tag[test]]"}</tool_use>',
requestId: 'test-request-selective',
};
const context = {
handlerContext,
response,
requestId: 'test-request-selective',
isFinal: true,
pluginConfig: {
id: 'test-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
},
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {
yieldNextRoundTo: undefined,
newUserMessage: undefined,
},
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
await hooks.responseComplete.promise(context);
// Check that unrelated messages were not modified
const unrelatedUserMsg = handlerContext.agent.messages[0] as AgentInstanceMessage;
expect(unrelatedUserMsg.duration).toBe(5); // Should remain unchanged
const unrelatedAiMsg = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(unrelatedAiMsg.duration).toBeUndefined(); // Should remain unchanged
// Check that only the tool call message was modified
const toolCallMsg = handlerContext.agent.messages[2] as AgentInstanceMessage;
expect(toolCallMsg.duration).toBe(1); // Should be set to 1
expect(toolCallMsg.metadata?.containsToolCall).toBe(true);
});
it('should skip execution when no tool call is detected', async () => {
const handlerCtx = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
messages: [],
status: { state: 'working' as const, modified: new Date() },
created: new Date(),
} as AgentInstance,
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} } as AgentDefinition,
isCancelled: () => false,
};
const context: AIResponseContext = {
handlerContext: handlerCtx,
pluginConfig: { id: 'test-plugin', pluginId: 'wikiSearch' } as IPromptConcatPlugin,
response: { requestId: 'test-request-345', content: 'Just a regular response without any tool calls', status: 'done' },
requestId: 'test-request',
isFinal: true,
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
await hooks.responseComplete.promise(context);
// Context should not be modified
expect(context.actions).toBeUndefined();
});
});
describe('Vector Search Functionality', () => {
beforeEach(async () => {
const { container } = await import('@services/container');
// Mock WikiEmbeddingService
const mockWikiEmbeddingService = {
searchSimilar: vi.fn(),
};
// Replace the service in container
if (container.isBound(serviceIdentifier.WikiEmbedding)) {
await container.unbind(serviceIdentifier.WikiEmbedding);
}
container.bind(serviceIdentifier.WikiEmbedding).toConstantValue(mockWikiEmbeddingService);
});
it('should execute vector search when searchType=vector', async () => {
const { container } = await import('@services/container');
const wikiEmbeddingService = container.get(serviceIdentifier.WikiEmbedding);
// Mock vector search results
(wikiEmbeddingService as { searchSimilar: ReturnType<typeof vi.fn> }).searchSimilar.mockResolvedValue([
{
record: {
id: 1,
workspaceId: 'test-workspace',
tiddlerTitle: 'Vector Result 1',
model: 'test-model',
provider: 'test-provider',
dimensions: 384,
created: new Date(),
modified: new Date(),
},
similarity: 0.95,
},
{
record: {
id: 2,
workspaceId: 'test-workspace',
tiddlerTitle: 'Vector Result 2',
model: 'test-model',
provider: 'test-provider',
dimensions: 384,
created: new Date(),
modified: new Date(),
},
similarity: 0.85,
},
]);
// Mock wiki service to return full tiddler content
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
vi.spyOn(wikiService, 'wikiOperationInServer').mockImplementation(
((...args: unknown[]) => {
const channel = args[0] as WikiChannel;
const opArgs = args[2] as string[] | undefined;
if (channel === WikiChannel.getTiddlersAsJson && opArgs && opArgs.length > 0) {
const title = opArgs[0];
return Promise.resolve([
{
title,
text: `Full content of ${title}`,
tags: ['test'],
},
]);
}
return Promise.resolve([]);
}) as unknown as IWikiService['wikiOperationInServer'],
);
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
aiApiConfig: {
api: {
provider: 'openai',
model: 'gpt-4',
embeddingModel: 'text-embedding-ada-002',
},
modelParameters: {},
},
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
{
id: 'ai-vector-tool-call',
role: 'assistant' as const,
content:
'<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "searchType": "vector", "query": "How to use AI agents", "limit": 10, "threshold": 0.7}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined,
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "searchType": "vector", "query": "How to use AI agents", "limit": 10, "threshold": 0.7}</tool_use>',
requestId: 'test-request-vector',
};
const context = {
handlerContext,
response,
requestId: 'test-request-vector',
isFinal: true,
pluginConfig: {
id: 'test-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
},
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {} as ActionBag,
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
await hooks.responseComplete.promise(context);
// Verify vector search was called
const mockService = wikiEmbeddingService as { searchSimilar: ReturnType<typeof vi.fn> };
expect(mockService.searchSimilar).toHaveBeenCalledWith(
expect.any(String), // workspaceID
'How to use AI agents',
expect.objectContaining({
api: expect.objectContaining({
provider: 'openai',
model: 'gpt-4',
}),
}),
10,
0.7,
);
// Verify results were processed
expect(context.actions.yieldNextRoundTo).toBe('self');
expect(handlerContext.agent.messages.length).toBe(2);
const toolResultMessage = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(toolResultMessage.content).toContain('<functions_result>');
expect(toolResultMessage.content).toContain('Vector Result 1');
expect(toolResultMessage.content).toContain('Vector Result 2');
expect(toolResultMessage.content).toContain('Similarity:');
expect(toolResultMessage.content).toContain('95.0%');
expect(toolResultMessage.content).toContain('85.0%');
});
it('should handle vector search errors gracefully', async () => {
const { container } = await import('@services/container');
const wikiEmbeddingService = container.get(serviceIdentifier.WikiEmbedding);
// Mock vector search to throw error
(wikiEmbeddingService as { searchSimilar: ReturnType<typeof vi.fn> }).searchSimilar.mockRejectedValue(
new Error('Vector database not initialized'),
);
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
aiApiConfig: {
api: {
provider: 'openai',
model: 'gpt-4',
},
modelParameters: {},
},
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
{
id: 'ai-vector-error-call',
role: 'assistant' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "searchType": "vector", "query": "test query", "limit": 10, "threshold": 0.7}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined,
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "searchType": "vector", "query": "test query", "limit": 10, "threshold": 0.7}</tool_use>',
requestId: 'test-request-vector-error',
};
const context = {
handlerContext,
response,
requestId: 'test-request-vector-error',
isFinal: true,
pluginConfig: {
id: 'test-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
},
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {} as ActionBag,
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
await hooks.responseComplete.promise(context);
// Should still set up next round with error message
expect(context.actions.yieldNextRoundTo).toBe('self');
const errorResultMessage = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(errorResultMessage.content).toContain('Error:');
// Error message contains i18n key or actual error
expect(errorResultMessage.content).toMatch(/Vector database not initialized|Tool\.WikiSearch\.Error\.VectorSearchFailed/);
expect(errorResultMessage.metadata?.isError).toBe(true);
});
it('should require query parameter for vector search', async () => {
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
aiApiConfig: {
api: {
provider: 'openai',
model: 'gpt-4',
},
modelParameters: {},
},
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
{
id: 'ai-no-query',
role: 'assistant' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "searchType": "vector", "limit": 10, "threshold": 0.7}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined,
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "searchType": "vector", "limit": 10, "threshold": 0.7}</tool_use>',
requestId: 'test-request-no-query',
};
const context = {
handlerContext,
response,
requestId: 'test-request-no-query',
isFinal: true,
pluginConfig: {
id: 'test-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
},
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {} as ActionBag,
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
await hooks.responseComplete.promise(context);
// Should return error about missing query
const errorMessage = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(errorMessage.content).toContain('Error:');
// Error message contains i18n key or translated text
expect(errorMessage.content).toMatch(/query|Tool\.WikiSearch\.Error\.VectorSearchRequiresQuery/);
});
});
describe('Message Persistence Integration', () => {
it('should work with messageManagementPlugin for complete persistence flow', async () => {
// This test ensures wikiSearchPlugin works well with messageManagementPlugin
const handlerContext = {
agent: {
id: 'test-agent',
agentDefId: 'test-agent-def',
status: {
state: 'working' as const,
modified: new Date(),
},
created: new Date(),
messages: [
{
id: 'ai-tool-msg',
role: 'assistant' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "filter": "[tag[test]]"}</tool_use>',
agentId: 'test-agent',
contentType: 'text/plain',
modified: new Date(),
duration: undefined,
},
],
},
agentDef: { id: 'test-agent-def', name: 'test', handlerConfig: {} },
isCancelled: () => false,
};
const response = {
status: 'done' as const,
content: '<tool_use name="wiki-search">{"workspaceName": "Test Wiki 1", "filter": "[tag[test]]"}</tool_use>',
requestId: 'test-request-integration',
};
const context = {
handlerContext,
response,
requestId: 'test-request-integration',
isFinal: true,
pluginConfig: {
id: 'test-plugin',
pluginId: 'wikiSearch' as const,
forbidOverrides: false,
},
prompts: [],
messages: [],
llmResponse: response.content,
responses: [],
actions: {
yieldNextRoundTo: undefined,
newUserMessage: undefined,
},
};
const hooks = createHandlerHooks();
wikiSearchPlugin(hooks);
messageManagementPlugin(hooks);
await hooks.responseComplete.promise(context);
// Verify integration works
expect(context.actions.yieldNextRoundTo).toBe('self');
expect(handlerContext.agent.messages.length).toBe(2); // original + tool result
const toolResultMessage = handlerContext.agent.messages[1] as AgentInstanceMessage;
expect(toolResultMessage.metadata?.isToolResult).toBe(true);
expect(toolResultMessage.metadata?.isPersisted).toBe(true); // Should be true after messageManagementPlugin processing
});
it('should prevent regression: tool result not filtered in second round', async () => {
// Root cause test to prevent regression of the original bug
// Bug: Tool result messages were filtered out in second round due to duration=1
const messages: AgentInstanceMessage[] = [
{
id: 'user-1',
agentId: 'test',
role: 'user',
content: '搜索 wiki 中的 Index 条目并解释',
modified: new Date(),
duration: undefined,
},
{
id: 'ai-tool-1',
agentId: 'test',
role: 'assistant',
content: '<tool_use name="wiki-search">{workspaceName:"wiki", filter:"[title[Index]]"}</tool_use>',
modified: new Date(),
duration: 1,
metadata: { containsToolCall: true, toolId: 'wiki-search' },
},
{
id: 'tool-result-1',
agentId: 'test',
role: 'assistant',
content: '<functions_result>\nTool: wiki-search\nResult: Found Index page...\n</functions_result>',
modified: new Date(),
duration: 1,
metadata: { isToolResult: true, toolId: 'wiki-search' },
},
];
// Test duration filtering - this is where the bug was
const { filterMessagesByDuration } = await import('../../utilities/messageDurationFilter');
const filtered = filterMessagesByDuration(messages);
// Root cause: Both tool call and tool result should be included for proper AI context
expect(filtered.length).toBe(3); // user + tool call + tool result
expect(filtered.some((m: AgentInstanceMessage) => m.metadata?.containsToolCall)).toBe(true);
expect(filtered.some((m: AgentInstanceMessage) => m.metadata?.isToolResult)).toBe(true);
});
});
});