/** * IPC Agent Handlers — sendMessage 编排链路测试(v0.4.1 测试补齐) * * 覆盖 sendMessage 的主编排逻辑: * 1. 参数校验(无效 sessionId / userMessage → ERROR+DONE 流事件,防止前端 isStreaming 卡死) * 2. Adapter 加载失败中止 * 3. Prompt 注入阻断(riskScore >= 7) * 4. 成功路径(消息持久化 / 审计 / 记忆固化 / 摘要评估 / Token 统计) * 5. 引擎异常路径(审计错误 + ERROR 流事件) */ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { EventEmitter } from 'events'; // ===== Mock electron(ipcMain) ===== const ipcMainHandleMock = vi.fn(); const ipcMainOnMock = vi.fn(); vi.mock('electron', () => ({ ipcMain: { handle: (...args: unknown[]) => ipcMainHandleMock(...args), on: (...args: unknown[]) => ipcMainOnMock(...args), }, })); // ===== Mock broadcast(ipc/context) ===== const broadcastMock = vi.fn(); vi.mock('../context', () => ({ broadcast: (...args: unknown[]) => broadcastMock(...args), })); import { registerAgentHandlers } from '../agent'; import type { IPCContext } from '../context'; import type { MetonaMessage } from '../../harness/types'; import { DeepSeekAdapter } from '../../harness/adapters/deepseek.adapter'; import { OllamaAdapter } from '../../harness/adapters/ollama.adapter'; // ===== Mock 依赖工厂 ===== function makeEngineMock(overrides: Record = {}) { return { runStream: vi.fn().mockResolvedValue({ // v0.7.3 P1-5: 固化门控要求回答 >= minChars(默认 200)—— mock 回答扩到阈值之上 finalAnswer: '这是最终回答' + '补充细节。'.repeat(60), terminationReason: 'completed', iterations: [ { iteration: 1, state: 'OBSERVING', startedAt: 1, completedAt: 2, thought: { id: 'thought-1', content: '本轮思考文本', reasoningContent: '推理过程', timestamp: 1, iteration: 1, }, toolCalls: [], toolResults: [], }, ], totalTokenUsage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, durationMs: 1234, metadata: {}, }), ...overrides, }; } function makeCtx(overrides: Record = {}) { const engine = makeEngineMock(); const engineManager = new EventEmitter() as EventEmitter & { getEngine: Mock; abort: Mock; waitForAbort: Mock; // v0.7.4 P1-3/P3-5: 同会话防重入 + 会话删除联动淘汰引擎 isRunning: Mock; disposeEngine: Mock; getAdapter: Mock; }; (engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine); (engineManager as unknown as { abort: Mock }).abort = vi.fn(); (engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi .fn() .mockResolvedValue(true); (engineManager as unknown as { isRunning: Mock }).isRunning = vi.fn(() => false); (engineManager as unknown as { disposeEngine: Mock }).disposeEngine = vi.fn(); (engineManager as unknown as { getAdapter: Mock }).getAdapter = vi.fn(() => null); const ctx = { agentEngineManager: engineManager, sessionRecorder: { startRecording: vi.fn(), stopRecording: vi.fn(), recordContextBuilt: vi.fn(), recordToolCall: vi.fn(), recordToolResult: vi.fn(), recordLLMResponse: vi.fn(), recordIterationStart: vi.fn(), recordIterationEnd: vi.fn(), recordLLMRequest: vi.fn(), }, configService: { get: vi.fn(() => '') }, sessionService: { saveMessage: vi.fn(), getMessages: vi.fn(() => []), updateTokenUsage: vi.fn(), // v0.7.4 P1-4: 会话存在性预检(getSession 返回非 null 视为会话存在) getSession: vi.fn(() => ({ id: 'sess_1' })), }, workspaceService: { getFiles: vi.fn(() => ({ soul: '# Metona', memory: '# Memory' })), getPath: vi.fn(() => '/workspace'), updateMemoryTimestamp: vi.fn(), }, contextBuilder: { buildSystemPrompt: vi.fn(() => ({ roleDefinition: 'role', outputConstraints: 'constraints', safetyGuidelines: 'safety', dynamicReminders: 'reminders', })), isUsingFallbackRole: vi.fn(() => false), }, auditService: { logSessionStart: vi.fn(), logSessionEnd: vi.fn(), log: vi.fn(), }, memoryManager: { search: vi.fn(() => []), clearWorkingMemory: vi.fn() }, promptInjectionDefender: { detect: vi.fn(() => ({ isInjection: false, riskScore: 0, findings: [], recommendation: 'PASS: ok', })), }, outputValidator: { validate: vi.fn().mockResolvedValue({ valid: true, issues: [], score: 1 }), }, memoryConsolidator: { consolidate: vi.fn().mockResolvedValue({ appended: 0, entries: [], skipped: 0 }), isRunning: vi.fn(() => false), }, sessionSummaryService: { buildHistoryMessages: vi.fn(() => []), maybeSummarize: vi.fn().mockResolvedValue(undefined), }, // v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制), // mock 改用 EventEmitter 提供真实 on/emit 行为 // v0.5.1: abortByParent 返回 taskId[](abortSession 据此清理 SubAgent pending 确认) orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }), confirmationHook: { clearPending: vi.fn(), forgetSession: vi.fn() }, // v0.7.3 P4-1: 会话标题生成器接线(sendMessage 完成路径消费) titleGenerator: { maybeGenerateTitle: vi.fn().mockResolvedValue(null) }, reloadAdapter: vi.fn(() => true), ...overrides, }; return { ctx: ctx as unknown as IPCContext, engine, engineManager, ctxRaw: ctx }; } function getHandler(channel: string): (...args: unknown[]) => Promise { const call = ipcMainHandleMock.mock.calls.find(([ch]) => ch === channel); if (!call) throw new Error(`IPC handler not registered: ${channel}`); return call[1] as (...args: unknown[]) => Promise; } const VALID_MESSAGE: MetonaMessage = { role: 'user', content: '你好,请帮我分析这个项目', timestamp: Date.now(), }; beforeEach(() => { ipcMainHandleMock.mockClear(); ipcMainOnMock.mockClear(); broadcastMock.mockClear(); }); describe('agent:sendMessage — 参数校验', () => { it('无效 sessionId 拒绝并发送 ERROR + DONE 流事件(防止前端 isStreaming 卡死)', async () => { const { ctx } = makeCtx(); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, ''); expect(result).toEqual({ success: false, error: 'Invalid sessionId' }); // ERROR + DONE 两个流事件都应广播 const eventTypes = broadcastMock.mock.calls.map(([, ev]) => (ev as { type: string }).type); expect(eventTypes).toContain('error'); expect(eventTypes).toContain('done'); }); it('无效 userMessage(非对象 / content 非字符串)拒绝', async () => { const { ctx } = makeCtx(); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, { content: 123 }, 'sess_1'); expect(result).toEqual({ success: false, error: 'Invalid message format' }); expect(broadcastMock).toHaveBeenCalled(); }); }); describe('agent:sendMessage — 前置检查', () => { it('Adapter 加载失败时中止并停止录制', async () => { const { ctx, ctxRaw } = makeCtx({ reloadAdapter: vi.fn(() => false) }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect((result as { success: boolean }).success).toBe(false); expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); // 不应调用引擎 expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); }); it('注入风险 riskScore >= 7 时阻断消息', async () => { const { ctx, ctxRaw } = makeCtx({ promptInjectionDefender: { detect: vi.fn(() => ({ isInjection: true, riskScore: 8, findings: [{ pattern: 'x', matched: 'ignore previous instructions', severity: 'high' }], recommendation: 'BLOCK: High-risk injection detected', })), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect((result as { success: boolean }).success).toBe(false); expect((result as { error: string }).error).toContain('blocked by prompt injection defense'); // 用户消息不保存(在注入检测前已保存?—— 现实现:先保存再检测,验证已保存) expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( expect.objectContaining({ role: 'user', sessionId: 'sess_1' }), ); // 引擎不启动 expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); }); it('SOUL.md 缺失降级时发送 toast 提示', async () => { const { ctx } = makeCtx({ contextBuilder: { buildSystemPrompt: vi.fn(() => ({ roleDefinition: 'fallback', outputConstraints: 'c', safetyGuidelines: 's', })), isUsingFallbackRole: vi.fn(() => true), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); await handler(null, VALID_MESSAGE, 'sess_1'); const toastCall = broadcastMock.mock.calls.find(([ch]) => ch === 'toast:show'); expect(toastCall).toBeDefined(); expect((toastCall![1] as { message: string }).message).toContain('SOUL.md'); }); }); describe('agent:sendMessage — 成功路径', () => { it('完整编排:保存消息 → 运行引擎 → 持久化 assistant 消息 → 审计 → 异步固化', async () => { const { ctx, ctxRaw, engine } = makeCtx(); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toEqual({ success: true }); // 1. 用户消息保存 expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( expect.objectContaining({ role: 'user', content: VALID_MESSAGE.content, sessionId: 'sess_1', }), ); // 2. 引擎启动(每会话引擎)—— v0.7.3 P1-1: 引擎收到带上下文前置块的消息副本 expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1'); expect(engine.runStream).toHaveBeenCalledWith( expect.objectContaining({ role: 'user', content: expect.stringContaining(String(VALID_MESSAGE.content)), }), 'sess_1', [], expect.objectContaining({ roleDefinition: 'role' }), ); // 3. assistant 消息保存(含思考内容) expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( expect.objectContaining({ role: 'assistant', content: '本轮思考文本', reasoningContent: '推理过程', }), ); // 4. Token 统计更新 expect(ctxRaw.sessionService.updateTokenUsage).toHaveBeenCalledWith('sess_1', 150); // 5. MEMORY.md 时间戳更新 expect(ctxRaw.workspaceService.updateMemoryTimestamp).toHaveBeenCalled(); // 6. 审计 + 录制结束 expect(ctxRaw.auditService.logSessionEnd).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'sess_1', terminationReason: 'completed' }), ); expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); // 7. 输出验证执行 expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith( expect.stringContaining('这是最终回答'), expect.anything(), ); // 8. 摘要评估(异步触发) await vi.waitFor(() => expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'), ); // 9. 记忆固化(异步触发) await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled()); }); it('注入相关记忆到用户消息上下文前置块(P1-1:system 保持缓存稳定)', async () => { const { ctx, ctxRaw } = makeCtx({ memoryManager: { search: vi.fn(() => [ { id: 'm1', type: 'semantic', content: '用户偏好深色主题', importance: 0.9, createdAt: Date.now(), score: 0.8, }, ]), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); await handler(null, VALID_MESSAGE, 'sess_1'); expect(ctxRaw.memoryManager.search).toHaveBeenCalled(); // P1-1: 记忆注入迁移到首条 user 消息前置块(system 跨 run 字节稳定 → 缓存命中) const prompt = engine_runStreamPrompt(ctxRaw); expect(prompt.dynamicReminders).not.toContain('用户偏好深色主题'); const userMessage = engine_runStreamUserMessage(ctxRaw); expect(userMessage.content).toContain('[Contextual information for this message'); expect(userMessage.content).toContain('用户偏好深色主题'); // DB 持久化仍使用原始干净内容(前置块只存在于引擎副本) expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( expect.objectContaining({ content: VALID_MESSAGE.content }), ); }); it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => { const { ctx, ctxRaw } = makeCtx({ outputValidator: { validate: vi.fn().mockResolvedValue({ valid: false, score: 0.7, issues: [ { severity: 'warning', type: 'hallucination', message: 'Path not found in context' }, { severity: 'info', type: 'format', message: 'noise' }, ], }), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); await handler(null, VALID_MESSAGE, 'sess_1'); const validationCall = broadcastMock.mock.calls.find( ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'validation', ); expect(validationCall).toBeDefined(); const payload = (validationCall![1] as { validation: { issues: unknown[] } }).validation; // info 级噪声不推送 expect(payload.issues).toHaveLength(1); expect(ctxRaw.outputValidator.validate).toHaveBeenCalled(); }); }); describe('agent:sendMessage — 异常路径', () => { it('引擎抛错时返回失败并记录审计错误', async () => { const engine = makeEngineMock({ runStream: vi.fn().mockRejectedValue(new Error('LLM connection failed')), }); const engineManager = new EventEmitter() as EventEmitter & { getEngine: Mock; abort: Mock; waitForAbort: Mock; isRunning: Mock; disposeEngine: Mock; }; (engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine); (engineManager as unknown as { abort: Mock }).abort = vi.fn(); (engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi .fn() .mockResolvedValue(true); (engineManager as unknown as { isRunning: Mock }).isRunning = vi.fn(() => false); (engineManager as unknown as { disposeEngine: Mock }).disposeEngine = vi.fn(); const { ctx, ctxRaw } = makeCtx({ agentEngineManager: engineManager, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect((result as { success: boolean }).success).toBe(false); expect((result as { error: string }).error).toBe('LLM connection failed'); // 审计记录错误 expect(ctxRaw.auditService.log).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'sess_1', eventType: 'error', outcome: 'error' }), ); // ERROR 流事件广播 const errorCall = broadcastMock.mock.calls.find( ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error', ); expect(errorCall).toBeDefined(); }); }); describe('agent:abortSession — 中断编排', () => { it('联动 SubAgent 中断 + 引擎中断 + 清理确认', async () => { const { ctx, ctxRaw } = makeCtx(); registerAgentHandlers(ctx); const handler = getHandler('agent:abortSession'); const result = await handler(null, 'sess_1'); expect(result).toEqual({ success: true }); expect(ctxRaw.orchestrator.abortByParent).toHaveBeenCalledWith('sess_1'); expect(ctxRaw.confirmationHook.clearPending).toHaveBeenCalled(); expect(ctxRaw.auditService.log).toHaveBeenCalledWith( expect.objectContaining({ eventType: 'session_end', outcome: 'denied' }), ); }); }); /** 从 runStream 调用参数中提取首条用户消息(P1-1 前置块断言用) */ function engine_runStreamUserMessage(ctxRaw: Record): { content: string } { const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as { runStream: Mock; }; return engine.runStream.mock.calls[0][0]; } /** 从 runStream 调用参数中提取 systemPrompt */ function engine_runStreamPrompt(ctxRaw: Record): { roleDefinition: string; dynamicReminders?: string; } { const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as { runStream: Mock; }; return engine.runStream.mock.calls[0][3]; } // ===== 追加:sendMessage 并发/会话预检 ===== describe('agent:sendMessage — 并发防重入与会话预检(v0.7.4 P1-3/P1-4)', () => { it('同会话并发:isRunning=true 时拒绝第二次调用并广播 ERROR+DONE(不启动引擎)', async () => { const { ctx, ctxRaw } = makeCtx({ agentEngineManager: Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => true), disposeEngine: vi.fn(), getAdapter: vi.fn(() => null), }), }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toMatchObject({ success: false }); expect((result as { error: string }).error).toContain('正在执行任务'); const errorCall = broadcastMock.mock.calls.find( ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error', ); expect(errorCall).toBeDefined(); const doneCall = broadcastMock.mock.calls.find( ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'done', ); expect(doneCall).toBeDefined(); // 引擎不启动(防重入的核心) expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled(); // 用户消息不落库(拒绝发生在持久化前) expect(ctxRaw.sessionService.saveMessage).not.toHaveBeenCalled(); }); it('会话不存在:预检拒绝 + stopRecording 收尾(ERROR+DONE 防 isStreaming 卡死)', async () => { const { ctx, ctxRaw } = makeCtx({ sessionService: { saveMessage: vi.fn(), getMessages: vi.fn(() => []), updateTokenUsage: vi.fn(), getSession: vi.fn(() => null), // 会话不存在 }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_deleted'); expect(result).toMatchObject({ success: false }); expect((result as { error: string }).error).toContain('会话不存在'); // 录制终止 + ERROR/DONE 双事件 expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); const eventTypes = broadcastMock.mock.calls.map(([, ev]) => (ev as { type: string }).type); expect(eventTypes).toContain('error'); expect(eventTypes).toContain('done'); }); it('数据准备抛错(历史加载/记忆检索失败)→ ERROR 事件 + stopRecording 收尾', async () => { const { ctx, ctxRaw } = makeCtx({ sessionSummaryService: { buildHistoryMessages: vi.fn(() => { throw new Error('DB locked'); }), maybeSummarize: vi.fn().mockResolvedValue(undefined), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toMatchObject({ success: false, error: 'DB locked' }); expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled(); const errorCall = broadcastMock.mock.calls.find( ([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error', ); expect(errorCall).toBeDefined(); }); it('记忆检索失败 → 前置块退化为仅时间/时区,run 仍正常继续', async () => { const { ctx, ctxRaw } = makeCtx({ memoryManager: { search: vi.fn(() => { throw new Error('search db down'); }), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toEqual({ success: true }); // 前置块仍生成(含日期上下文),不阻断主流程 const userMessage = engine_runStreamUserMessage(ctxRaw); expect(userMessage.content).toContain('[Contextual information for this message'); }); it('注入防御被配置关闭(security.promptInjectionDefense=false)→ 跳过检测直接运行', async () => { const detect = vi.fn(); const { ctx } = makeCtx({ configService: { get: vi.fn((key: string) => (key === 'security.promptInjectionDefense' ? false : '')), }, promptInjectionDefender: { detect }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toEqual({ success: true }); expect(detect).not.toHaveBeenCalled(); }); it('注入风险 4-6(警告级)→ 记录警告但放行', async () => { const { ctx, ctxRaw } = makeCtx({ promptInjectionDefender: { detect: vi.fn(() => ({ isInjection: true, riskScore: 5, findings: [{ pattern: 'suspicious', matched: 'x', severity: 'medium' }], recommendation: 'CAUTION', })), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toEqual({ success: true }); expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalled(); }); it('assistant 仅有工具调用无文本时 content 为 null 落库(C-6 契约)', async () => { const engine = makeEngineMock({ runStream: vi.fn().mockResolvedValue({ finalAnswer: 'answer', terminationReason: 'completed', iterations: [ { iteration: 1, state: 'TOOL_CALL', startedAt: 1, completedAt: 2, thought: { content: '', reasoningContent: '', timestamp: 1, iteration: 1 }, toolCalls: [ { id: 'tc_1', name: 'read_file', args: { path: 'a.txt' }, iteration: 1, timestamp: 1, }, ], toolResults: [ { toolCallId: 'tc_1', toolName: 'read_file', result: 'data', success: true, durationMs: 5, timestamp: 1, }, ], }, ], totalTokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, durationMs: 10, metadata: {}, }), }); const engineManager = new EventEmitter() as EventEmitter & { getEngine: Mock; abort: Mock; waitForAbort: Mock; isRunning: Mock; disposeEngine: Mock; getAdapter: Mock; }; (engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine); (engineManager as unknown as { abort: Mock }).abort = vi.fn(); (engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi .fn() .mockResolvedValue(true); (engineManager as unknown as { isRunning: Mock }).isRunning = vi.fn(() => false); (engineManager as unknown as { disposeEngine: Mock }).disposeEngine = vi.fn(); (engineManager as unknown as { getAdapter: Mock }).getAdapter = vi.fn(() => null); const { ctx, ctxRaw } = makeCtx({ agentEngineManager: engineManager }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); await handler(null, VALID_MESSAGE, 'sess_1'); // assistant 消息 content=null(纯工具调用轮) expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( expect.objectContaining({ role: 'assistant', content: null }), ); // tool 结果消息落库 expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith( expect.objectContaining({ role: 'tool', toolResult: expect.objectContaining({ toolCallId: 'tc_1' }), }), ); }); it('输出验证抛错不阻断成功路径(验证失败仅记录)', async () => { const { ctx, ctxRaw } = makeCtx({ outputValidator: { validate: vi.fn(() => { throw new Error('validator crashed'); }), }, }); registerAgentHandlers(ctx); const handler = getHandler('agent:sendMessage'); const result = await handler(null, VALID_MESSAGE, 'sess_1'); expect(result).toEqual({ success: true }); expect(ctxRaw.auditService.logSessionEnd).toHaveBeenCalled(); }); }); // ===== 追加:abortSession 深入 ===== describe('agent:abortSession — SubAgent 清理', () => { function makeCtxWithOrchestrator(abortedTasks: string[]) { return makeCtx({ orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => abortedTasks) }), confirmationHook: { clearPending: vi.fn(), forgetSession: vi.fn(), }, }); } it('abortByParent 返回的每个 taskId 都做 forgetSession + stopRecording(TRACE 收尾)', async () => { const { ctx, ctxRaw } = makeCtxWithOrchestrator(['task_1', 'task_2']); registerAgentHandlers(ctx); const handler = getHandler('agent:abortSession'); const result = await handler(null, 'sess_1'); expect(result).toEqual({ success: true }); expect(ctxRaw.orchestrator.abortByParent).toHaveBeenCalledWith('sess_1'); expect(ctxRaw.confirmationHook.forgetSession).toHaveBeenCalledTimes(2); expect(ctxRaw.confirmationHook.forgetSession).toHaveBeenCalledWith('task_1'); expect(ctxRaw.confirmationHook.forgetSession).toHaveBeenCalledWith('task_2'); expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalledWith( 'task_1', expect.objectContaining({ terminationReason: 'user_interrupt' }), ); }); it('无 SubAgent 时不调用 forgetSession(空数组短路)', async () => { const { ctx, ctxRaw } = makeCtxWithOrchestrator([]); registerAgentHandlers(ctx); const handler = getHandler('agent:abortSession'); await handler(null, 'sess_1'); expect(ctxRaw.confirmationHook.forgetSession).not.toHaveBeenCalled(); expect(ctxRaw.confirmationHook.clearPending).toHaveBeenCalledWith('sess_1'); expect(ctxRaw.agentEngineManager.abort).toHaveBeenCalledWith('sess_1'); expect(ctxRaw.agentEngineManager.waitForAbort).toHaveBeenCalledWith('sess_1'); }); it('waitForAbort 在返回前被 await(MT-1:防重发卡在等待中)', async () => { const waitForAbort = vi.fn().mockResolvedValue(true); const { ctx } = makeCtx({ agentEngineManager: Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort, isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => null), }), }); registerAgentHandlers(ctx); const handler = getHandler('agent:abortSession'); await handler(null, 'sess_1'); expect(waitForAbort).toHaveBeenCalled(); // abort 在 waitForAbort 之前调用 const abortOrder = (ctx.agentEngineManager as unknown as { abort: Mock }).abort.mock .invocationCallOrder[0]; const waitOrder = waitForAbort.mock.invocationCallOrder[0]; expect(abortOrder).toBeLessThan(waitOrder); }); }); // ===== 追加:llm:getBalance / llm:listModels / llm:ollamaPull ===== describe('llm:getBalance — DeepSeek 余额查询', () => { it('adapter 为 DeepSeek 且余额存在 → 返回余额', async () => { const adapter = new DeepSeekAdapter({ provider: 'deepseek', baseURL: 'https://api.deepseek.com', apiKey: 'sk-test', defaultModel: 'deepseek-v4-pro', }); vi.spyOn(adapter, 'getBalance').mockResolvedValue({ currency: 'CNY', totalBalance: '10.00', grantedBalance: '0', toppedUpBalance: '10.00', }); const { ctx } = makeCtx({ agentEngineManager: Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => adapter), }), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:getBalance')(null)) as { success: boolean; data?: unknown; }; expect(result).toMatchObject({ success: true, data: { currency: 'CNY' } }); }); it('adapter 非 DeepSeek → 明确拒绝(仅 DeepSeek 支持)', async () => { const { ctx } = makeCtx({ agentEngineManager: Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => ({ providerId: 'openai' })), }), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:getBalance')(null)) as { success: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toContain('only supported for the DeepSeek provider'); }); it('getBalance 返回 null → 失败(API Key 无效或网络错误)', async () => { const adapter = new DeepSeekAdapter({ provider: 'deepseek', baseURL: 'https://api.deepseek.com', apiKey: 'bad', defaultModel: 'deepseek-v4-pro', }); vi.spyOn(adapter, 'getBalance').mockResolvedValue(null); const { ctx } = makeCtx({ agentEngineManager: Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => adapter), }), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:getBalance')(null)) as { success: boolean }; expect(result.success).toBe(false); }); it('getBalance 抛异常 → 返回错误信息', async () => { const adapter = new DeepSeekAdapter({ provider: 'deepseek', baseURL: 'https://api.deepseek.com', apiKey: 'k', defaultModel: 'deepseek-v4-pro', }); vi.spyOn(adapter, 'getBalance').mockRejectedValue(new Error('network down')); const { ctx } = makeCtx({ agentEngineManager: Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => adapter), }), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:getBalance')(null)) as { success: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toBe('network down'); }); }); describe('llm:listModels — 动态模型列表', () => { function managerWithAdapter(adapter: unknown) { return Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => adapter), }); } it('Provider/Model 未配置 → 明确失败', async () => { const { ctx } = makeCtx({ configService: { get: vi.fn(() => '') }, }); registerAgentHandlers(ctx); const result = (await getHandler('llm:listModels')(null)) as { success: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toContain('LLM 未配置'); }); it('非 ollama Provider 且无 API Key → 失败', async () => { const { ctx } = makeCtx({ configService: { get: vi.fn((key: string) => key === 'llm.provider' ? 'deepseek' : key === 'llm.model' ? 'deepseek-v4-pro' : '', ), }, }); registerAgentHandlers(ctx); const result = (await getHandler('llm:listModels')(null)) as { success: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toContain('API Key 未配置'); }); it('ollama Provider 无需 API Key → 放行', async () => { const adapter = new OllamaAdapter({ provider: 'ollama', baseURL: 'http://localhost:11434', defaultModel: 'qwen3', }); vi.spyOn(adapter, 'listModels').mockResolvedValue([{ id: 'qwen3' }]); const { ctx } = makeCtx({ configService: { get: vi.fn((key: string) => key === 'llm.provider' ? 'ollama' : key === 'llm.model' ? 'qwen3' : '', ), }, agentEngineManager: managerWithAdapter(adapter), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:listModels')(null)) as { success: boolean; data?: unknown[]; }; expect(result.success).toBe(true); expect(result.data).toEqual([{ id: 'qwen3' }]); }); it('reloadAdapter 失败 → 返回配置校验失败', async () => { const adapter = new DeepSeekAdapter({ provider: 'deepseek', baseURL: 'https://api.deepseek.com', apiKey: 'k', defaultModel: 'deepseek-v4-pro', }); const { ctx } = makeCtx({ configService: { get: vi.fn((key: string) => key === 'llm.provider' ? 'deepseek' : key === 'llm.model' ? 'deepseek-v4-pro' : key === 'llm.apiKey' ? 'k' : '', ), }, reloadAdapter: vi.fn(() => false), agentEngineManager: managerWithAdapter(adapter), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:listModels')(null)) as { success: boolean }; expect(result.success).toBe(false); }); it('adapter 无 listModels 方法 → 明确失败', async () => { const { ctx } = makeCtx({ configService: { get: vi.fn((key: string) => key === 'llm.provider' ? 'deepseek' : key === 'llm.model' ? 'm' : key === 'llm.apiKey' ? 'k' : '', ), }, agentEngineManager: managerWithAdapter({ providerId: 'x' }), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:listModels')(null)) as { success: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toContain('不支持模型列表查询'); }); }); describe('llm:ollamaPull / ollamaPullCancel — 模型下载', () => { function managerWithAdapter(adapter: unknown) { return Object.assign(new EventEmitter(), { getEngine: vi.fn(), abort: vi.fn(), waitForAbort: vi.fn().mockResolvedValue(true), isRunning: vi.fn(() => false), disposeEngine: vi.fn(), getAdapter: vi.fn(() => adapter), }); } it('非法模型名(非字符串 / 空 / 非法字符)拒绝', async () => { const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter(null) }); registerAgentHandlers(ctx); const handler = getHandler('llm:ollamaPull'); expect(await handler(null, '')).toMatchObject({ success: false }); expect(await handler(null, 42)).toMatchObject({ success: false }); expect(await handler(null, 'rm -rf /')).toMatchObject({ success: false }); expect(await handler(null, 'model with spaces')).toMatchObject({ success: false }); }); it('非 Ollama Provider → 拒绝', async () => { const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter({ providerId: 'deepseek' }) }); registerAgentHandlers(ctx); const result = (await getHandler('llm:ollamaPull')(null, 'qwen3:8b')) as { success: boolean; error?: string; }; expect(result.success).toBe(false); expect(result.error).toContain('仅 Ollama Provider'); }); it('成功路径:进度广播 + ollamaPullEnded 收尾', async () => { const adapter = new OllamaAdapter({ provider: 'ollama', baseURL: 'http://localhost:11434', defaultModel: 'qwen3', }); vi.spyOn(adapter, 'pullModel').mockImplementation(async (_m, onProgress) => { onProgress?.({ status: 'downloading', completed: 50, total: 100 }); }); const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter(adapter), }); registerAgentHandlers(ctx); const result = await getHandler('llm:ollamaPull')(null, 'qwen3:8b'); expect(result).toEqual({ success: true }); const progressCall = broadcastMock.mock.calls.find(([ch]) => ch === 'llm:ollamaPullProgress'); expect(progressCall).toBeDefined(); expect(progressCall![1]).toMatchObject({ model: 'qwen3:8b', status: 'downloading' }); const endedCall = broadcastMock.mock.calls.find(([ch]) => ch === 'llm:ollamaPullEnded'); expect(endedCall).toBeDefined(); expect(endedCall![1]).toEqual({ model: 'qwen3:8b' }); }); it('pullModel 抛错 → 返回错误 + aborted 标志(取消信号语义)', async () => { const adapter = new OllamaAdapter({ provider: 'ollama', baseURL: 'http://localhost:11434', defaultModel: 'qwen3', }); vi.spyOn(adapter, 'pullModel').mockRejectedValue(new Error('download failed')); const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter(adapter), }); registerAgentHandlers(ctx); const result = (await getHandler('llm:ollamaPull')(null, 'qwen3:8b')) as { success: boolean; error?: string; aborted?: boolean; }; expect(result.success).toBe(false); expect(result.error).toBe('download failed'); expect(result.aborted).toBe(false); }); it('无进行中的下载时 ollamaPullCancel → 失败', async () => { const { ctx } = makeCtx({ agentEngineManager: managerWithAdapter(null) }); registerAgentHandlers(ctx); const result = (await getHandler('llm:ollamaPullCancel')(null)) as { success: boolean }; expect(result.success).toBe(false); }); }); // ===== v0.8.0 P1-1a: 后台会话回放缓冲 ===== describe('agent:getReplayState — v0.8.0 P1-1a 后台会话回放缓冲', () => { it('streamEvent + stateChange 按序入缓冲,runId 记录,INIT 清空上一 run', async () => { const { ctx } = makeCtx(); registerAgentHandlers(ctx); const em = ctx.agentEngineManager as unknown as EventEmitter; em.emit('streamEvent', { type: 'text_delta', sessionId: 'sess_1', seq: 0, timestamp: Date.now(), delta: 'a', }); em.emit('stateChange', { sessionId: 'sess_1', state: 'THINKING', iteration: 1, runId: 'run_x', }); em.emit('streamEvent', { type: 'text_delta', sessionId: 'sess_1', seq: 1, timestamp: Date.now(), delta: 'b', runId: 'run_x', }); const r = (await getHandler('agent:getReplayState')(null, 'sess_1')) as { success: boolean; data: { isRunning: boolean; runId: string | null; truncated: boolean; events: Array<{ channel: string }>; }; }; expect(r.success).toBe(true); expect(r.data.events.length).toBe(3); expect(r.data.runId).toBe('run_x'); expect(r.data.events.map((e) => e.channel)).toEqual([ 'streamEvent', 'stateChange', 'streamEvent', ]); // 新 run INIT → 上一 run 缓冲作废 em.emit('stateChange', { sessionId: 'sess_1', state: 'INIT', iteration: 0, runId: 'run_y', }); const r2 = (await getHandler('agent:getReplayState')(null, 'sess_1')) as { success: boolean; data: { events: unknown[] }; }; expect(r2.data.events.length).toBe(0); }); it('无效 sessionId → 明确失败', async () => { const { ctx } = makeCtx(); registerAgentHandlers(ctx); const r = (await getHandler('agent:getReplayState')(null, '')) as { success: boolean }; expect(r.success).toBe(false); }); });