import type { IAgentDefinitionService } from '@services/agentDefinition/interface'; import { container } from '@services/container'; import type { IDatabaseService } from '@services/database/interface'; import { AgentDefinitionEntity, AgentInstanceEntity, AgentInstanceMessageEntity } from '@services/database/schema/agent'; import * as callProvider from '@services/externalAPI/callProviderAPI'; import type { IExternalAPIService } from '@services/externalAPI/interface'; import serviceIdentifier from '@services/serviceIdentifier'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { AgentInstanceMessage, IAgentInstanceService } from '../../interface'; import type { AiAPIConfig } from '../../promptConcat/promptConcatSchema'; import { basicPromptConcatHandler } from '../basicPromptConcatHandler'; import type { AgentHandlerContext } from '../type'; // Use real normalizeRole implementation — do not mock plugins or persistence in these integration tests // Drive ExternalAPIService to emit an error event similar to siliconflow validation failure const mockErrorDetail = { name: 'AIProviderError', code: 'INVALID_PROMPT', provider: 'siliconflow', message: 'Invalid prompt: message must be a ModelMessage or a UI message', }; function makeContext(agentId: string, agentDefId: string, messages: AgentInstanceMessage[]): AgentHandlerContext { return { agent: { id: agentId, agentDefId, status: { state: 'working', modified: new Date() }, created: new Date(), messages, }, agentDef: { id: agentDefId, name: 'Test Agent', handlerConfig: {}, aiApiConfig: { api: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } } as AiAPIConfig, }, isCancelled: () => false, } as unknown as AgentHandlerContext; } describe('basicPromptConcatHandler - failure path persists error message and logs', () => { let dataSource: Awaited>; beforeEach(async () => { vi.clearAllMocks(); const db = container.get(serviceIdentifier.Database); await db.initializeForApp(); dataSource = await db.getDatabase('agent'); // Clean agent tables await dataSource.getRepository(AgentInstanceMessageEntity).clear(); await dataSource.getRepository(AgentInstanceEntity).clear(); await dataSource.getRepository(AgentDefinitionEntity).clear(); // Insert minimal agent def / instance const agentDef = { id: 'def-1', name: 'Def 1' }; await dataSource.getRepository(AgentDefinitionEntity).save(agentDef); await dataSource.getRepository(AgentInstanceEntity).save({ id: 'agent-1', agentDefId: agentDef.id, name: 'Agent 1', status: { state: 'working', modified: new Date() }, created: new Date(), closed: false, }); // Ensure external API debug is on so logs are attempted const pref = container.get(serviceIdentifier.Preference); await pref.set('externalAPIDebug', true); // Ensure AI provider settings present so ExternalAPI can resolve providerConfig const dbService = container.get(serviceIdentifier.Database); const aiSettings = { providers: [{ provider: 'test-provider', models: [{ name: 'test-model' }] }], defaultConfig: { api: { provider: 'test-provider', model: 'test-model' }, modelParameters: { temperature: 0.7, systemPrompt: '', topP: 0.95 } }, }; vi.spyOn(dbService, 'getSetting').mockImplementation((k: string) => (k === 'aiSettings' ? aiSettings : undefined)); // Mock streamFromProvider to throw an error that externalAPI will capture and log vi.spyOn(callProvider, 'streamFromProvider').mockImplementation((_cfg: AiAPIConfig) => { throw new Error(mockErrorDetail.message ?? 'Invalid prompt'); }); // Initialize AgentInstanceService repositories const agentSvc = container.get(serviceIdentifier.AgentInstance); await agentSvc.initialize(); // Stub AgentDefinitionService.getAgentDef to return our saved agent definition const agentDefSvc = container.get(serviceIdentifier.AgentDefinition); if (agentDefSvc && typeof agentDefSvc.getAgentDef === 'function') { vi.spyOn(agentDefSvc, 'getAgentDef').mockResolvedValue({ id: 'def-1', name: 'Def 1', handlerID: 'basicPromptConcatHandler', handlerConfig: { plugins: [ { pluginId: 'wikiOperation', wikiOperationParam: {} }, ], }, }); } // Initialize external API service logging DB const extSvc = container.get(serviceIdentifier.ExternalAPI); await extSvc.initialize(); }); it('should push error message with duration=1, persist it, and write external_api_logs error', async () => { const initialMessages: AgentInstanceMessage[] = [ { id: 'u1', agentId: 'agent-1', role: 'user', content: '触发错误', modified: new Date() }, ]; const ctx = makeContext('agent-1', 'def-1', initialMessages); const statuses: Array> = []; const iter = basicPromptConcatHandler(ctx); for await (const s of iter) statuses.push(s); // 1) Verify error status returned const last = statuses[statuses.length - 1] as unknown as { message?: { content: string } }; expect(last?.message?.content).toContain('Error:'); // 2) Verify message persisted with role=error and duration=1 const msgRepo = dataSource.getRepository(AgentInstanceMessageEntity); const all = await msgRepo.find({ where: { agentId: 'agent-1' }, order: { modified: 'ASC' } }); const errorMsg = all.find((m) => m.role === 'error'); expect(errorMsg).toBeTruthy(); expect(errorMsg?.duration).toBe(1); // 3) Verify external API logs have error with errorDetail // Wait/retry to allow async log write to complete const extSvc = container.get(serviceIdentifier.ExternalAPI); let logs = await extSvc.getAPILogs(); for (let i = 0; i < 10 && !logs.some((l) => l.status === 'error'); i++) { await new Promise((r) => setTimeout(r, 100)); logs = await extSvc.getAPILogs(); } expect(logs.some((l) => l.status === 'error' && (l.errorDetail?.message || '').includes('Invalid prompt'))).toBe(true); // If agentInstanceId is present, it should match our agent id const withAgent = logs.find((l) => l.status === 'error' && l.agentInstanceId); if (withAgent) expect(withAgent.agentInstanceId).toBe('agent-1'); }); it('should cover two-round flow: tool_use then Chat.ConfigError.AIProviderError and print ordering', async () => { // Mock provider: Round 1 DONE with wiki-operation tool_use; Round 2 ERROR with Chat.ConfigError const extSvc = container.get(serviceIdentifier.ExternalAPI); let callIndex = 0; vi.spyOn(extSvc, 'generateFromAI').mockImplementation(async function*(_msgs: Array, _cfg: AiAPIConfig) { callIndex += 1; if (callIndex === 1) { yield { requestId: 'req-1', status: 'start' as const, content: '' }; yield { requestId: 'req-1', status: 'done' as const, content: '{"workspaceName":"__no_such_ws__","operation":"wiki-add-tiddler","title":"T"}', }; return; } // Second round returns provider error yield { requestId: 'req-2', status: 'error' as const, content: '', errorDetail: { name: 'AIProviderError', code: 'Chat.ConfigError.AIProviderError', provider: 'siliconflow', message: 'Config error from provider' }, }; }); // Use the public AgentInstanceService path (simulates front-end send flow) const agentSvc = container.get(serviceIdentifier.AgentInstance); // Spy repository.save to record the exact order DB rows are saved (do NOT mock/replace behaviour) const repo = dataSource.getRepository(AgentInstanceMessageEntity); vi.spyOn(repo, 'save'); // Send message via service which will run the handler and plugins across two rounds await agentSvc.sendMsgToAgent('agent-1', { text: '触发工具并继续' }); // Immediately read DB and refreshed order (simulate immediate UI refresh) const all = await repo.find({ where: { agentId: 'agent-1' }, order: { modified: 'ASC' } }); const assistant = all.find(m => m.role === 'assistant' && m.content.includes(' m.role === 'error'); expect(assistant).toBeTruthy(); expect(errorMsg).toBeTruthy(); // Additional debug: compare repository.save call order, DB rows and service.getAgent ordering const agentSvc2 = container.get(serviceIdentifier.AgentInstance); await agentSvc2.getAgent('agent-1'); // Debug removed: repoSave call order, repoOrder, refreshedOrder, timestamps /* console.log('repoSave call order:', savedIds); console.log('repoOrder (DB):', repoOrder); console.log('refreshedOrder (getAgent):', refreshedOrder); */ // removed debug: assistant vs error (created/modified) // No strict assertion here; we print the sequences so you can inspect exact order in CI/local run. // Now wait beyond debounce window and re-check ordering based on created (not modified) since that's what the service uses await new Promise((r) => setTimeout(r, 400)); const allAfter = await repo.find({ where: { agentId: 'agent-1' }, order: { created: 'ASC' } }); const assistantAfter = allAfter.find(m => m.role === 'assistant' && m.content.includes(' m.role === 'error')!; // Test the actual ordering logic used by the service: created timestamp, not modified expect(assistantAfter.created?.getTime()).toBeLessThanOrEqual(errorAfter.created?.getTime() ?? 0); }); });