P0 会话可靠性收口(根治"模型思考着会话就停止"): - P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值 - P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷 - P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示 - P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层 - P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径 FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效 P1 修复面收口: - 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫 - 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码 - 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并 - 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口 P2 能力演进: - 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板 - 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口 - electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置 - @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入 - MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图 - 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表 P3 测试基建: - 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2) - 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3 - 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强 全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
1127 lines
40 KiB
TypeScript
1127 lines
40 KiB
TypeScript
/**
|
||
* 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<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 + 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);
|
||
});
|
||
});
|