feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s

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:
2026-08-30 00:09:25 +08:00
parent 36e4b28d93
commit 26169b7be4
55 changed files with 5673 additions and 434 deletions
@@ -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')]),
// 契约对齐真实 ToolRegistryget 返回 IMetonaTooldefinition 在 .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' });
});
});