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)
This commit is contained in:
@@ -31,6 +31,8 @@ vi.mock('../context', () => ({
|
||||
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 依赖工厂 =====
|
||||
|
||||
@@ -71,12 +73,19 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
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,
|
||||
@@ -96,6 +105,8 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
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' })),
|
||||
@@ -140,7 +151,7 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
||||
// mock 改用 EventEmitter 提供真实 on/emit 行为
|
||||
// v0.5.1: abortByParent 返回 taskId[](abortSession 据此清理 SubAgent pending 确认)
|
||||
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }),
|
||||
confirmationHook: { clearPending: 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),
|
||||
@@ -379,12 +390,16 @@ describe('agent:sendMessage — 异常路径', () => {
|
||||
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,
|
||||
});
|
||||
@@ -440,3 +455,602 @@ function engine_runStreamPrompt(ctxRaw: Record<string, unknown>): {
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user