Files
metona-ai-desktop/electron/ipc/__tests__/agent.test.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
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 跳过
2026-09-05 20:06:26 +08:00

1127 lines
40 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);
});
});
// ===== 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);
});
});