Files
metona-ai-desktop/electron/ipc/__tests__/agent.test.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

1126 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(() => []), clearWorkingMemory: vi.fn() },
promptInjectionDefender: {
detect: vi.fn(() => ({
isInjection: false,
riskScore: 0,
findings: [],
recommendation: 'PASS: ok',
})),
},
outputValidator: {
validate: vi.fn().mockResolvedValue({ valid: true, issues: [], score: 1 }),
},
memoryConsolidator: {
consolidate: vi.fn().mockResolvedValue({ appended: 0, entries: [], skipped: 0 }),
isRunning: vi.fn(() => false),
},
sessionSummaryService: {
buildHistoryMessages: vi.fn(() => []),
maybeSummarize: vi.fn().mockResolvedValue(undefined),
},
// v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制),
// mock 改用 EventEmitter 提供真实 on/emit 行为
// v0.5.1: abortByParent 返回 taskId[]abortSession 据此清理 SubAgent pending 确认)
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }),
confirmationHook: { clearPending: vi.fn(), forgetSession: vi.fn() },
// v0.7.3 P4-1: 会话标题生成器接线(sendMessage 完成路径消费)
titleGenerator: { maybeGenerateTitle: vi.fn().mockResolvedValue(null) },
reloadAdapter: vi.fn(() => true),
...overrides,
};
return { ctx: ctx as unknown as IPCContext, engine, engineManager, ctxRaw: ctx };
}
function getHandler(channel: string): (...args: unknown[]) => Promise<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');
// v0.8.2 P1-4: 检测前移 —— 被阻断的消息**不**落库(旧实现先保存后检测,
// 恶意内容滞留会话历史)
expect(ctxRaw.sessionService.saveMessage).not.toHaveBeenCalled();
// 引擎不启动
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);
});
});