mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-05 18:20:39 -08:00
* 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.
949 lines
36 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|