Files
metona-ai-desktop/electron/ipc/__tests__/agent.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
2026-08-30 19:19:07 +08:00

1057 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 electronipcMain =====
const ipcMainHandleMock = vi.fn();
const ipcMainOnMock = vi.fn();
vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => ipcMainHandleMock(...args),
on: (...args: unknown[]) => ipcMainOnMock(...args),
},
}));
// ===== Mock broadcastipc/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<string, unknown> = {}) {
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<string, unknown> = {}) {
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(() => []) },
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<unknown> {
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<unknown>;
}
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<string, unknown>): { 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<string, unknown>): {
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 + stopRecordingTRACE 收尾)', 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 在返回前被 awaitMT-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);
});
});