feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照, IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭, 与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀 按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身 (tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门 (file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断); 单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口) P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播, getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers 全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项 校验+设置页 JSON 输入, 远程 MCP 鉴权头可用) P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性 前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码 激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见); IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留) P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层, 外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1 文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2 测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/ orchestrator/workspace.service/session-recorder/config-layering/secure-config/ network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。 测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK), 固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过; Electron ABI 全量 737/737 零跳过
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* TaskOrchestrator 测试(v0.7.2 覆盖补齐 —— 此前仅被 mock、本体零测试)
|
||||
*
|
||||
* 锁定 SubAgent 编排的核心契约:
|
||||
* 1. 委派运行独立引擎(独立 adapter 实例)→ taskDelegated/taskStarted/taskCompleted 事件链
|
||||
* 2. 递归深度限制(3 层)与 sessionDepth 恢复(正常/紧急路径)
|
||||
* 3. 工具白名单:delegate_task 无条件排除(防递归);白名单外未知工具告警
|
||||
* 4. abortByParent/abortTask/abortAll 中断契约与 activeSubAgents 清理
|
||||
* 5. 引擎异常路径 → taskError 事件 + success:false 结果
|
||||
*
|
||||
* 测试设计说明(闩锁式可控流):引擎的 abort 生效依赖流产出下一个事件
|
||||
* (for-await 在每个事件前检查 aborted 标志)。因此"长任务"桩产出首个 delta 后
|
||||
* 挂起在 latch 上,测试先触发中断、等引擎到达闩锁(waitFor 计数)再释放 ——
|
||||
* 引擎随即收尾,delegate Promise 在测试时间尺度内可观测地 resolve,不依赖超时。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('electron-log', () => ({
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { TaskOrchestrator, type EngineProvider } from '../orchestrator';
|
||||
import type {
|
||||
IMetonaProviderAdapter,
|
||||
MetonaRequest,
|
||||
MetonaResponse,
|
||||
MetonaStreamEvent,
|
||||
MetonaToolDef,
|
||||
} from '../../types';
|
||||
import {
|
||||
MetonaFinishReason,
|
||||
MetonaStreamEventType,
|
||||
MetonaToolCategory,
|
||||
MetonaRiskLevel,
|
||||
} from '../../types';
|
||||
|
||||
const seenRequests: MetonaRequest[] = [];
|
||||
|
||||
function doneOnlyStream(request: MetonaRequest): AsyncGenerator<MetonaStreamEvent> {
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId: request.meta.sessionId,
|
||||
iteration: request.meta.iteration,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
} as MetonaStreamEvent;
|
||||
})();
|
||||
}
|
||||
|
||||
function toolDef(name: string): MetonaToolDef {
|
||||
return {
|
||||
name,
|
||||
description: `${name} def`,
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
category: MetonaToolCategory.CUSTOM,
|
||||
riskLevel: MetonaRiskLevel.SAFE,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAdapter(): IMetonaProviderAdapter {
|
||||
return {
|
||||
providerId: 'mock',
|
||||
supportedModels: [],
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
getContextWindow: () => 128_000,
|
||||
send: vi.fn(
|
||||
async (): Promise<MetonaResponse> => ({
|
||||
meta: {
|
||||
requestId: 'r',
|
||||
provider: 'mock',
|
||||
model: 'm',
|
||||
latencyMs: 1,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
content: '',
|
||||
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
finishReason: MetonaFinishReason.STOP,
|
||||
}),
|
||||
),
|
||||
sendStream: vi.fn((request: MetonaRequest) => {
|
||||
seenRequests.push(request);
|
||||
return doneOnlyStream(request);
|
||||
}),
|
||||
} as unknown as IMetonaProviderAdapter;
|
||||
}
|
||||
|
||||
let orchestrator: TaskOrchestrator;
|
||||
let createAdapter: ReturnType<typeof vi.fn>;
|
||||
let registry: { listTools: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn> };
|
||||
|
||||
/**
|
||||
* 安装"闩锁式"adapter:每次 createAdapter 产出一个流 —— 产出首个 delta 后挂起,
|
||||
* 直到测试调用 releases 之一。release 后流正常结束,引擎随即收尾。
|
||||
*/
|
||||
function installLatchedAdapter(): { releases: Array<() => void> } {
|
||||
const releases: Array<() => void> = [];
|
||||
createAdapter.mockImplementation(() => {
|
||||
const adapter = {
|
||||
providerId: 'mock',
|
||||
supportedModels: [],
|
||||
supportsToolCalling: true,
|
||||
supportsThinking: false,
|
||||
getContextWindow: () => 128_000,
|
||||
send: vi.fn(),
|
||||
sendStream: vi.fn((request: MetonaRequest) => {
|
||||
seenRequests.push(request);
|
||||
return (async function* () {
|
||||
yield {
|
||||
type: MetonaStreamEventType.TEXT_DELTA,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId: request.meta.sessionId,
|
||||
iteration: request.meta.iteration,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
delta: 'x',
|
||||
} as MetonaStreamEvent;
|
||||
await new Promise<void>((resolve) => {
|
||||
releases.push(resolve);
|
||||
});
|
||||
})();
|
||||
}),
|
||||
} as unknown as IMetonaProviderAdapter;
|
||||
return adapter;
|
||||
});
|
||||
return { releases };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
seenRequests.length = 0;
|
||||
createAdapter = vi.fn(() => makeAdapter());
|
||||
registry = {
|
||||
listTools: vi.fn(() => [toolDef('write_file'), toolDef('read_file'), toolDef('delegate_task')]),
|
||||
// 契约对齐真实 ToolRegistry:get 返回 IMetonaTool(definition 在 .definition 上)
|
||||
get: vi.fn((name: string) => {
|
||||
const def = [toolDef('write_file'), toolDef('read_file'), toolDef('delegate_task')].find(
|
||||
(d) => d.name === name,
|
||||
);
|
||||
return def ? { definition: def } : undefined;
|
||||
}),
|
||||
};
|
||||
const provider: EngineProvider = {
|
||||
getAdapter: () => makeAdapter(),
|
||||
createAdapter: createAdapter as unknown as EngineProvider['createAdapter'],
|
||||
getFallbackAdapter: () => null,
|
||||
getWorkspacePath: () => '/tmp/ws-orchestrator',
|
||||
};
|
||||
orchestrator = new TaskOrchestrator(provider, registry as never);
|
||||
});
|
||||
|
||||
function collect(orch: TaskOrchestrator, event: string): unknown[] {
|
||||
const calls: unknown[] = [];
|
||||
orch.on(event, (d: unknown) => calls.push(d));
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe('TaskOrchestrator — 委派成功路径', () => {
|
||||
it('委派 → 运行 → taskCompleted 事件 + success 结果(引擎使用独立 adapter 实例)', async () => {
|
||||
const completed = collect(orchestrator, 'taskCompleted');
|
||||
const delegated = collect(orchestrator, 'taskDelegated');
|
||||
const started = collect(orchestrator, 'taskStarted');
|
||||
|
||||
const result = await orchestrator.delegate({
|
||||
description: '调查文件结构',
|
||||
parentSessionId: 'sess-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.parentSessionId).toBe('sess-1');
|
||||
expect(result.iterations).toBeGreaterThan(0);
|
||||
expect(delegated).toHaveLength(1);
|
||||
expect((delegated[0] as { depth: number }).depth).toBe(1);
|
||||
expect(started).toHaveLength(1);
|
||||
expect(completed).toHaveLength(1);
|
||||
|
||||
// P2-10 契约:每个 SubAgent 拿到独立 adapter 实例(abort 信号隔离)
|
||||
expect(createAdapter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('委派的引擎收到任务描述作为用户消息(首条 user 消息 = description)', async () => {
|
||||
const result = await orchestrator.delegate({
|
||||
description: '调查文件结构并汇报',
|
||||
parentSessionId: 'sess-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(seenRequests).toHaveLength(1);
|
||||
const firstMessage = seenRequests[0].messages[0];
|
||||
expect(firstMessage.role).toBe('user');
|
||||
expect(firstMessage.content).toBe('调查文件结构并汇报');
|
||||
});
|
||||
|
||||
it('委派结束后 sessionDepth 自动恢复(finally 路径)——下一次委派仍为 depth 1', async () => {
|
||||
const delegated = collect(orchestrator, 'taskDelegated');
|
||||
await orchestrator.delegate({ description: 'first', parentSessionId: 's' });
|
||||
await orchestrator.delegate({ description: 'second', parentSessionId: 's' });
|
||||
|
||||
const depths = delegated.map((d) => (d as { depth: number }).depth);
|
||||
expect(depths).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskOrchestrator — 工具白名单', () => {
|
||||
it('未指定白名单 → 继承全部启用工具,但 delegate_task 无条件排除(防递归)', async () => {
|
||||
await orchestrator.delegate({ description: 'x', parentSessionId: 's' });
|
||||
const names = (seenRequests[0].tools ?? []).map((t) => t.name);
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).not.toContain('delegate_task');
|
||||
});
|
||||
|
||||
it('显式白名单:delegate_task 被静默剔除,未知工具不进入引擎', async () => {
|
||||
await orchestrator.delegate({
|
||||
description: 'x',
|
||||
parentSessionId: 's',
|
||||
tools: ['write_file', 'delegate_task', 'nonexistent_tool'],
|
||||
});
|
||||
const names = (seenRequests[0].tools ?? []).map((t) => t.name);
|
||||
expect(names).toEqual(['write_file']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskOrchestrator — 递归深度限制', () => {
|
||||
it('同一会话并发达到 3 层后,第 4 次委派被拒绝(同步计数)', async () => {
|
||||
const { releases } = installLatchedAdapter();
|
||||
|
||||
const d1 = orchestrator.delegate({ description: 'L1', parentSessionId: 's' });
|
||||
const d2 = orchestrator.delegate({ description: 'L2', parentSessionId: 's' });
|
||||
const d3 = orchestrator.delegate({ description: 'L3', parentSessionId: 's' });
|
||||
|
||||
const d4 = await orchestrator.delegate({ description: 'L4', parentSessionId: 's' });
|
||||
expect(d4.success).toBe(false);
|
||||
expect(d4.result).toContain('depth limit');
|
||||
expect(d4.iterations).toBe(0);
|
||||
|
||||
// 等三个引擎都挂起到闩锁再释放(过早释放会扑空)
|
||||
await vi.waitFor(() => expect(releases.length).toBe(3));
|
||||
releases.forEach((r) => r());
|
||||
const results = await Promise.all([d1, d2, d3]);
|
||||
expect(results.map((r) => r.success)).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
it('紧急中止(abortAll)清空深度表 → 后续委派从 depth 1 重新开始', async () => {
|
||||
const { releases } = installLatchedAdapter();
|
||||
const d1 = orchestrator.delegate({ description: 'stuck', parentSessionId: 's' });
|
||||
await vi.waitFor(() => expect(releases.length).toBe(1));
|
||||
|
||||
orchestrator.abortAll();
|
||||
releases.forEach((r) => r());
|
||||
await d1;
|
||||
|
||||
// 恢复常规 adapter —— 闩锁实现只服务本用例的"挂起任务"
|
||||
createAdapter.mockImplementation(() => makeAdapter());
|
||||
const delegated = collect(orchestrator, 'taskDelegated');
|
||||
const fresh = await orchestrator.delegate({ description: 'fresh', parentSessionId: 's' });
|
||||
expect(fresh.success).toBe(true);
|
||||
expect((delegated[0] as { depth: number }).depth).toBe(1);
|
||||
});
|
||||
|
||||
it('不同会话的深度互不干扰(A 深度 1 不阻断 B 的首次委派)', async () => {
|
||||
const { releases } = installLatchedAdapter();
|
||||
|
||||
const a = orchestrator.delegate({ description: 'A1', parentSessionId: 'sess-a' });
|
||||
const b = orchestrator.delegate({ description: 'B1', parentSessionId: 'sess-b' });
|
||||
// 等两个引擎都到达闩锁(active 计数在注册瞬间即达标,不能作为就绪信号)
|
||||
await vi.waitFor(() => expect(releases.length).toBe(2));
|
||||
|
||||
releases.forEach((r) => r());
|
||||
const results = await Promise.all([a, b]);
|
||||
expect(results.map((r) => r.success)).toEqual([true, true]);
|
||||
expect(orchestrator.getActiveAgentsStatus()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskOrchestrator — 中断契约', () => {
|
||||
it('abortByParent 中断该会话的运行中 SubAgent 并返回 taskId 列表;任务最终收尾清理', async () => {
|
||||
const { releases } = installLatchedAdapter();
|
||||
const completed = collect(orchestrator, 'taskCompleted');
|
||||
|
||||
const pending = orchestrator.delegate({
|
||||
description: 'long task',
|
||||
parentSessionId: 'sess-x',
|
||||
});
|
||||
await vi.waitFor(() => expect(orchestrator.getActiveAgentsStatus()).toHaveLength(1));
|
||||
const taskId = orchestrator.getActiveAgentsStatus()[0].taskId;
|
||||
|
||||
// abortByParent 同步移除活动句柄并返回被中止的 taskId(v0.5.1 契约)
|
||||
const aborted = orchestrator.abortByParent('sess-x');
|
||||
expect(aborted).toEqual([taskId]);
|
||||
expect(orchestrator.getActiveAgentsStatus()).toHaveLength(0);
|
||||
|
||||
releases.forEach((r) => r());
|
||||
await pending;
|
||||
expect(completed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('abortByParent 不影响其他会话的运行中 SubAgent', async () => {
|
||||
const { releases } = installLatchedAdapter();
|
||||
|
||||
const a = orchestrator.delegate({ description: 'A', parentSessionId: 'sess-a' });
|
||||
const b = orchestrator.delegate({ description: 'B', parentSessionId: 'sess-b' });
|
||||
// 等两个引擎都到达闩锁(active 计数在注册瞬间即达标,不能作为就绪信号)
|
||||
await vi.waitFor(() => expect(releases.length).toBe(2));
|
||||
|
||||
expect(orchestrator.abortByParent('sess-a')).toHaveLength(1);
|
||||
expect(orchestrator.abortByParent('sess-c')).toHaveLength(0); // 无任务会话返回空
|
||||
expect(orchestrator.getActiveAgentsStatus()).toHaveLength(1); // B 仍在运行
|
||||
|
||||
releases.forEach((r) => r());
|
||||
const results = await Promise.all([a, b]);
|
||||
// A 被中断、B 正常完成,两者都最终收尾
|
||||
expect(results.map((r) => r.success)).toEqual([true, true]);
|
||||
expect(orchestrator.getActiveAgentsStatus()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('abortTask 精确中断单个子任务;重复中断返回 false', async () => {
|
||||
const { releases } = installLatchedAdapter();
|
||||
const pending = orchestrator.delegate({ description: 'single', parentSessionId: 's' });
|
||||
await vi.waitFor(() => expect(orchestrator.getActiveAgentsStatus()).toHaveLength(1));
|
||||
|
||||
const taskId = orchestrator.getActiveAgentsStatus()[0].taskId;
|
||||
expect(orchestrator.abortTask(taskId)).toBe(true);
|
||||
expect(orchestrator.getActiveAgentsStatus()).toHaveLength(0); // 同步移除
|
||||
|
||||
releases.forEach((r) => r());
|
||||
await pending;
|
||||
expect(orchestrator.abortTask(taskId)).toBe(false); // 已移除,重复中断返回 false
|
||||
});
|
||||
|
||||
it('getActiveAgentsStatus 暴露 taskId/status/description/depth', async () => {
|
||||
installLatchedAdapter();
|
||||
const pending = orchestrator.delegate({ description: 'visible task', parentSessionId: 's' });
|
||||
await vi.waitFor(() => expect(orchestrator.getActiveAgentsStatus()).toHaveLength(1));
|
||||
|
||||
const status = orchestrator.getActiveAgentsStatus()[0];
|
||||
expect(status).toMatchObject({
|
||||
status: 'running',
|
||||
description: 'visible task',
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
orchestrator.abortAll();
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskOrchestrator — 异常路径', () => {
|
||||
it('引擎流错误被引擎内部消化为 ERROR 终止 → taskCompleted(success=false),不冒泡崩溃', async () => {
|
||||
// 引擎契约:chatStreamWithRetry 对不可重试错误 throw → executeRunStream catch
|
||||
// → finish(ERROR)。因此 orchestrator 收到的是正常 resolve 的 output
|
||||
// (terminationReason='error'),taskCompleted 以 success=false 收尾。
|
||||
const completed = collect(orchestrator, 'taskCompleted');
|
||||
createAdapter.mockImplementation(() => {
|
||||
const ad = makeAdapter();
|
||||
(ad.sendStream as unknown as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
(async function* () {
|
||||
yield await Promise.reject(new Error('stream exploded'));
|
||||
})(),
|
||||
);
|
||||
return ad;
|
||||
});
|
||||
|
||||
const result = await orchestrator.delegate({ description: 'x', parentSessionId: 's' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(completed).toHaveLength(1);
|
||||
expect((completed[0] as { success: boolean }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('registry 未注入时委派照常运行(零工具,防御性)', async () => {
|
||||
const provider: EngineProvider = {
|
||||
getAdapter: () => makeAdapter(),
|
||||
createAdapter: createAdapter as unknown as EngineProvider['createAdapter'],
|
||||
getFallbackAdapter: () => null,
|
||||
getWorkspacePath: () => '/tmp/ws',
|
||||
};
|
||||
const bare = new TaskOrchestrator(provider);
|
||||
const result = await bare.delegate({ description: 'x', parentSessionId: 's' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskOrchestrator — 配置热更新', () => {
|
||||
it('updateDefaultConfig 合并语义(保留未指定字段,接口稳定)', () => {
|
||||
orchestrator.updateDefaultConfig({ thinkingEnabled: false });
|
||||
orchestrator.updateDefaultConfig({ thinkingEffort: 'low' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user