Files
metona-ai-desktop/electron/harness/adapters/__tests__/sse-stream.test.ts
T
thzxx 49c9b25538
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
2026-08-21 13:58:48 +08:00

239 lines
8.9 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.
/**
* SSE 流式解析器单元测试(v0.4.1 测试补齐)
* 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE /
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
*/
import { describe, it, expect } from 'vitest';
import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream';
import { MetonaStreamEventType } from '../../types';
/** 构造 SSE 测试流 */
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
}
async function collect(
stream: ReadableStream<Uint8Array>,
): Promise<Array<{ type: string; [k: string]: unknown }>> {
const events: Array<{ type: string; [k: string]: unknown }> = [];
for await (const ev of parseSSEStream(stream, 'req_test', 'sess_test', 1)) {
events.push(ev as unknown as { type: string; [k: string]: unknown });
}
return events;
}
describe('parseSSEStream — 文本与推理增量', () => {
it('TEXT_DELTA 事件按序产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n',
'data: {"choices":[{"delta":{"content":",世界"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
expect(deltas).toHaveLength(2);
expect(deltas[0].delta).toBe('你好');
expect(deltas[1].delta).toBe(',世界');
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('REASONING_DELTAThinking 模式)事件产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"reasoning_content":"让我想想"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
expect(reasoning?.delta).toBe('让我想想');
});
it('同一个 chunk 中 content 和 reasoning_content 同时产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"答","reasoning_content":"思考"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
expect(events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA)).toHaveLength(1);
});
});
describe('parseSSEStream — 工具调用增量拼接', () => {
it('分段 arguments 拼接为完整 JSON 并在 finish_reason=tool_calls 时 flush', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\\"pa"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"a.ts\\"}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
'data: [DONE]\n\n',
]),
);
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(1);
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
expect(tc.name).toBe('read_file');
expect(tc.args).toEqual({ path: 'a.ts' });
});
it('多个工具调用按 index 分别拼接', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"tool_a","arguments":"{}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"tool_b","arguments":"{}"}}]}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(2);
const names = completes.map((e) => (e.toolCall as { name: string }).name);
expect(names).toEqual(['tool_a', 'tool_b']);
});
it('损坏的 args JSON 跳过该工具调用且不中断流', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"content":"后续文本"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
// 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE
expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength(
0,
);
// 流继续处理后续事件
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
});
describe('parseSSEStream — USAGE 与容错', () => {
it('USAGE 事件解析 DeepSeek 缓存字段', async () => {
const events = await collect(
makeStream([
'data: {"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}\n\n',
'data: [DONE]\n\n',
]),
);
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
const usage = usageEvent?.usage as Record<string, number>;
expect(usage.inputTokens).toBe(100);
expect(usage.outputTokens).toBe(50);
expect(usage.totalTokens).toBe(150);
expect(usage.cacheHitTokens).toBe(80);
expect(usage.cacheMissTokens).toBe(20);
});
it('坏 JSON 行不中断后续事件(记录警告后继续)', async () => {
const events = await collect(
makeStream([
'data: {broken json\n\n',
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('跨 chunk 分割的 SSE 行正确拼接', async () => {
// "data: {...}\n\n" 被切到两个网络 chunk 中
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"cont',
'ent":"拼接成功"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
expect(delta?.delta).toBe('拼接成功');
});
it('空行与非 data 行被忽略', async () => {
const events = await collect(
makeStream([
': comment line\n\n',
'\n',
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
});
});
describe('parseOpenAICompatibleResponse — 非流式响应', () => {
it('解析普通文本响应', () => {
const result = parseOpenAICompatibleResponse({
choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
});
expect(result.content).toBe('hello');
expect(result.finishReason).toBe('stop');
expect(result.usage.inputTokens).toBe(10);
expect(result.toolCalls).toBeUndefined();
});
it('解析 reasoning_contentThinking 模式)', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{ message: { content: 'answer', reasoning_content: 'thinking...' }, finish_reason: 'stop' },
],
usage: {},
});
expect(result.reasoningContent).toBe('thinking...');
});
it('解析 tool_calls(字符串 arguments 反序列化)', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{"cmd":"ls"}' } }],
},
finish_reason: 'tool_calls',
},
],
usage: {},
});
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls![0].name).toBe('run');
expect(result.toolCalls![0].args).toEqual({ cmd: 'ls' });
});
it('损坏的 tool_calls arguments 降级为空对象', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }],
},
finish_reason: 'tool_calls',
},
],
usage: {},
});
expect(result.toolCalls![0].args).toEqual({});
});
it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => {
const result = parseOpenAICompatibleResponse({
choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }],
usage: {},
});
expect(result.finishReason).toBe('stop');
});
});