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 跳过
713 lines
27 KiB
TypeScript
713 lines
27 KiB
TypeScript
/**
|
||
* SSE 流式解析器单元测试(v0.4.1 测试补齐)
|
||
* 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE /
|
||
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
|
||
*/
|
||
|
||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||
import {
|
||
parseSSEStream,
|
||
parseOpenAICompatibleResponse,
|
||
readStreamChunkWithIdleTimeout,
|
||
SseUpstreamError,
|
||
} from '../shared/sse-stream';
|
||
import { MetonaStreamEventType, type MetonaStreamEvent } 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_DELTA(Thinking 模式)事件产出', 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 转为截断错误 tool call 回传模型(不丢弃、不中断流)', 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',
|
||
]),
|
||
);
|
||
// v0.6.3 契约: 坏 JSON 的工具调用不再静默丢弃(丢弃会让引擎误判 COMPLETED
|
||
// 空回复终止会话)— 转为携带 _truncatedArguments 错误说明的 tool call,
|
||
// 工具执行失败后错误结果回传模型触发自愈
|
||
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('bad_tool');
|
||
expect(tc.args._truncatedArguments).toBe(true);
|
||
// 流继续处理后续事件
|
||
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_content(Thinking 模式)', () => {
|
||
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 转为 _truncatedArguments 自愈载荷(v0.6.4: 不再静默降级 {})', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{
|
||
message: {
|
||
content: null,
|
||
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }],
|
||
},
|
||
finish_reason: 'tool_calls',
|
||
},
|
||
],
|
||
usage: {},
|
||
});
|
||
const args = result.toolCalls![0].args as Record<string, unknown>;
|
||
expect(args._truncatedArguments).toBe(true);
|
||
expect(String(args._truncatedReason)).toContain('truncated');
|
||
});
|
||
|
||
it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }],
|
||
usage: {},
|
||
});
|
||
expect(result.finishReason).toBe('stop');
|
||
});
|
||
});
|
||
|
||
// ===== v0.7.4 P1-2: 流空闲超时 =====
|
||
|
||
describe('parseSSEStream — 流空闲超时(P1-2)', () => {
|
||
it('连续无数据超过空闲阈值时抛 SseUpstreamError(504)', async () => {
|
||
const { readStreamChunkWithIdleTimeout } = await import('../shared/sse-stream');
|
||
|
||
// 构造一个永不返回数据的 reader(挂死流)
|
||
const neverReader = {
|
||
read: () => new Promise<{ done: boolean; value?: Uint8Array }>(() => {}),
|
||
releaseLock: () => {},
|
||
cancel: () => Promise.resolve(),
|
||
closed: Promise.resolve(),
|
||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||
|
||
// 使用共享辅助函数直接验证:空闲超时抛 SseUpstreamError(504)
|
||
await expect(readStreamChunkWithIdleTimeout(neverReader, 50)).rejects.toMatchObject({
|
||
status: 504,
|
||
});
|
||
|
||
// 数据到达时重置空闲窗口(不抛错)
|
||
const delayedReader = {
|
||
read: () =>
|
||
new Promise<{ done: boolean; value?: Uint8Array }>((resolve) => {
|
||
setTimeout(() => resolve({ done: true, value: undefined }), 10);
|
||
}),
|
||
releaseLock: () => {},
|
||
cancel: () => Promise.resolve(),
|
||
closed: Promise.resolve(),
|
||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||
const result = await readStreamChunkWithIdleTimeout(delayedReader, 200);
|
||
expect(result.done).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ===== 追加:readStreamChunkWithIdleTimeout 专项 =====
|
||
|
||
describe('readStreamChunkWithIdleTimeout — 空闲超时语义', () => {
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
function makeReader(
|
||
reads: Array<{ delay: number; value?: string; done?: boolean }>,
|
||
): ReadableStreamDefaultReader<Uint8Array> {
|
||
const encoder = new TextEncoder();
|
||
let i = 0;
|
||
return {
|
||
read: () =>
|
||
new Promise<{ done: boolean; value?: Uint8Array }>((resolve) => {
|
||
const spec = reads[Math.min(i, reads.length - 1)];
|
||
i++;
|
||
setTimeout(() => {
|
||
if (spec.done) resolve({ done: true, value: undefined });
|
||
else
|
||
resolve({ done: false, value: spec.value ? encoder.encode(spec.value) : undefined });
|
||
}, spec.delay);
|
||
}),
|
||
releaseLock: () => {},
|
||
cancel: () => Promise.resolve(),
|
||
closed: Promise.resolve(),
|
||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||
}
|
||
|
||
it('空闲超时抛 SseUpstreamError(504),消息含 idle timeout', async () => {
|
||
const reader = makeReader([{ delay: 10_000 }]);
|
||
const promise = readStreamChunkWithIdleTimeout(reader, 30);
|
||
await expect(promise).rejects.toMatchObject({
|
||
name: 'SseUpstreamError',
|
||
status: 504,
|
||
message: expect.stringContaining('idle timeout'),
|
||
});
|
||
});
|
||
|
||
it('数据在超时前到达 → 正常返回数据(空闲窗口被重置)', async () => {
|
||
const reader = makeReader([
|
||
{ delay: 5, value: 'data: {}' },
|
||
{ done: true, delay: 1 },
|
||
]);
|
||
const first = await readStreamChunkWithIdleTimeout(reader, 100);
|
||
expect(first.done).toBe(false);
|
||
expect(new TextDecoder().decode(first.value)).toBe('data: {}');
|
||
const second = await readStreamChunkWithIdleTimeout(reader, 100);
|
||
expect(second.done).toBe(true);
|
||
});
|
||
|
||
it('连续多次 read 均未超时(每次数据到达重置计时器)', async () => {
|
||
const reader = makeReader([
|
||
{ delay: 5, value: 'a' },
|
||
{ delay: 5, value: 'b' },
|
||
{ done: true, delay: 5 },
|
||
]);
|
||
for (const expectValue of ['a', 'b']) {
|
||
const r = await readStreamChunkWithIdleTimeout(reader, 50);
|
||
expect(new TextDecoder().decode(r.value)).toBe(expectValue);
|
||
}
|
||
const last = await readStreamChunkWithIdleTimeout(reader, 50);
|
||
expect(last.done).toBe(true);
|
||
});
|
||
|
||
it('超时后 timer 被清理(fake timers 验证无遗留定时器)', async () => {
|
||
vi.useFakeTimers();
|
||
const reader = {
|
||
read: () => new Promise<{ done: boolean; value?: Uint8Array }>(() => {}),
|
||
releaseLock: () => {},
|
||
cancel: () => Promise.resolve(),
|
||
closed: Promise.resolve(),
|
||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||
const promise = readStreamChunkWithIdleTimeout(reader, 100);
|
||
// 先挂上 rejection 处理器,再推进计时器,避免 unhandledRejection 竞态
|
||
const assertion = expect(promise).rejects.toMatchObject({ status: 504 });
|
||
await vi.advanceTimersByTimeAsync(150);
|
||
await assertion;
|
||
// finally 清理后不应有残留 timer
|
||
expect(vi.getTimerCount()).toBe(0);
|
||
});
|
||
|
||
it('数据到达正常结束后 timer 同样被清理', async () => {
|
||
vi.useFakeTimers();
|
||
const encoder = new TextEncoder();
|
||
const reader = {
|
||
read: vi.fn(
|
||
() =>
|
||
Promise.resolve({ done: true, value: undefined }) as Promise<{
|
||
done: boolean;
|
||
value?: Uint8Array;
|
||
}>,
|
||
),
|
||
releaseLock: () => {},
|
||
cancel: () => Promise.resolve(),
|
||
closed: Promise.resolve(),
|
||
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
|
||
const r = await readStreamChunkWithIdleTimeout(reader, 100);
|
||
expect(r.done).toBe(true);
|
||
expect(vi.getTimerCount()).toBe(0);
|
||
void encoder;
|
||
});
|
||
});
|
||
|
||
// ===== 追加:parseSSEStream 更多解析细节 =====
|
||
|
||
describe('parseSSEStream — 帧格式兼容扩展', () => {
|
||
it('CRLF 行结束符(\\r\\n)正常解析', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"content":"crlf"}}]}\r\n\r\n',
|
||
'data: [DONE]\r\n\r\n',
|
||
]),
|
||
);
|
||
expect(
|
||
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'crlf'),
|
||
).toBe(true);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('同一 chunk 内连续多个 SSE 事件均被处理', 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.map((e) => e.delta)).toEqual(['一', '二']);
|
||
});
|
||
|
||
it('一个 SSE 行被切成 4 个网络 chunk 仍能拼接', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"del',
|
||
'ta":{"content":"跨',
|
||
'chunk拼接成功',
|
||
'"}}]}\n\ndata: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
|
||
expect(delta?.delta).toBe('跨chunk拼接成功');
|
||
});
|
||
|
||
it('data: 后无空格且无内容的空 data 行被忽略', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data:\n\n',
|
||
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
|
||
});
|
||
|
||
it('choices 存在但 delta 为空对象 → 不产生事件(静默帧)', async () => {
|
||
const events = await collect(
|
||
makeStream(['data: {"choices":[{"delta":{}}]}\n\n', 'data: [DONE]\n\n']),
|
||
);
|
||
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(0);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
|
||
it('工具调用:arguments 被切成 5 段拼接为合法 JSON', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"f","arguments":"{\\"a\\":"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1,"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"b\\":"}}]}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"[1,2]}"}}]}}]}\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);
|
||
expect((completes[0].toolCall as { args: Record<string, unknown> }).args).toEqual({
|
||
a: 1,
|
||
b: [1, 2],
|
||
});
|
||
});
|
||
|
||
it('工具调用 arguments 为空 → 完成时 args 为 {}(无参工具合法)', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"noop"}}]}}]}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(1);
|
||
expect((completes[0].toolCall as { args: Record<string, unknown> }).args).toEqual({});
|
||
});
|
||
|
||
it('流断开(无 [DONE])时 flush 缓冲工具调用 + 补发 DONE', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"last_tool","arguments":"{\\"ok\\":true}"}}]}}]}\n\n',
|
||
]),
|
||
);
|
||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||
expect(completes).toHaveLength(1);
|
||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
});
|
||
|
||
// ===== 追加:USAGE 扩展字段 =====
|
||
|
||
describe('parseSSEStream — USAGE 扩展字段', () => {
|
||
it('MiMo prompt_tokens_details.cached_tokens 映射 cacheHitTokens', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
expect(usage.cacheHitTokens).toBe(7);
|
||
expect(usage.cacheMissTokens).toBeUndefined();
|
||
});
|
||
|
||
it('DeepSeek reasoning_tokens 映射 reasoningTokens', async () => {
|
||
const events = await collect(
|
||
makeStream([
|
||
'data: {"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2,"completion_tokens_details":{"reasoning_tokens":99}}}\n\n',
|
||
'data: [DONE]\n\n',
|
||
]),
|
||
);
|
||
const usage = events.find((e) => e.type === MetonaStreamEventType.USAGE)?.usage as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
expect(usage.reasoningTokens).toBe(99);
|
||
});
|
||
|
||
it('无 usage 字段的数据帧不产生 USAGE 事件', async () => {
|
||
const events = await collect(
|
||
makeStream(['data: {"choices":[{"delta":{"content":"x"}}]}\n\n', 'data: [DONE]\n\n']),
|
||
);
|
||
expect(events.some((e) => e.type === MetonaStreamEventType.USAGE)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ===== 追加:上游错误帧 status 推断矩阵 =====
|
||
|
||
describe('parseSSEStream — 上游错误帧 status 推断矩阵', () => {
|
||
async function collectError(json: unknown): Promise<Error> {
|
||
const stream = makeStream([`data: ${JSON.stringify(json)}\n\n`]);
|
||
try {
|
||
for await (const _ev of parseSSEStream(stream, 'r', 's', 1)) void _ev;
|
||
} catch (err) {
|
||
return err as Error;
|
||
}
|
||
throw new Error('expected throw');
|
||
}
|
||
|
||
it.each([
|
||
[{ error: { code: 'rate_limit_exceeded', message: 'rl' } }, 429],
|
||
[{ error: { code: 'too_many_requests', message: 'rl' } }, 429],
|
||
[{ error: { code: 'insufficient_quota', message: 'q' } }, 402],
|
||
[{ error: { code: 'billing_error', message: 'q' } }, 402],
|
||
[{ error: { code: 'exceeded_balance', message: 'q' } }, 402],
|
||
[{ error: { code: 'invalid_api_key', message: 'auth' } }, 401],
|
||
[{ error: { code: 'authentication_error', message: 'auth' } }, 401],
|
||
[{ error: { code: 'forbidden', message: 'deny' } }, 403],
|
||
[{ error: { code: 'permission_denied', message: 'deny' } }, 403],
|
||
[{ error: { code: 'model_not_found', message: 'no model' } }, 404],
|
||
[{ error: { code: 'overloaded', message: 'busy' } }, 503],
|
||
[{ error: { code: 'capacity_exceeded', message: 'busy' } }, 503],
|
||
[{ error: { code: 'server_error', message: '500' } }, 500],
|
||
[{ error: { code: 'internal_error', message: '500' } }, 500],
|
||
] as const)('providerCode %s → status %d', async (errorObj, expectedStatus) => {
|
||
const err = await collectError(errorObj);
|
||
expect(err).toBeInstanceOf(SseUpstreamError);
|
||
expect((err as SseUpstreamError).status).toBe(expectedStatus);
|
||
});
|
||
|
||
it('无效参数类 code(无匹配规则)→ status undefined(非重试语义)', async () => {
|
||
const err = await collectError({
|
||
error: { code: 'invalid_request_error', message: 'bad param' },
|
||
});
|
||
expect(err).toBeInstanceOf(SseUpstreamError);
|
||
expect((err as SseUpstreamError).status).toBeUndefined();
|
||
expect((err as SseUpstreamError).providerCode).toBe('invalid_request_error');
|
||
});
|
||
|
||
it('status_code 数字字段被识别(非 status 命名)', async () => {
|
||
const err = await collectError({
|
||
error: { message: 'gateway timeout', status_code: 504 },
|
||
});
|
||
expect((err as SseUpstreamError).status).toBe(504);
|
||
});
|
||
|
||
it('msg 字段作为错误消息(非 message 命名)', async () => {
|
||
const err = await collectError({ error: { msg: 'custom msg', code: 'overloaded' } });
|
||
expect((err as Error).message).toContain('custom msg');
|
||
expect((err as SseUpstreamError).status).toBe(503);
|
||
});
|
||
|
||
it('error 为空对象 → 不构成错误帧(v0.8.0 P1-3.1 防御分支根治后契约)', async () => {
|
||
// v0.8.0 P1-3.1 根治: 旧实现 message 兜底取 JSON.stringify({})='{}' 恒真值,
|
||
// `{"error":{}}` 被误判为错误帧抛 SseUpstreamError 并触发不必要的重试。
|
||
// 新契约:无 message 且无 status/code 标识的空对象为无害帧,静默跳过。
|
||
const events: MetonaStreamEvent[] = [];
|
||
let error: Error | null = null;
|
||
try {
|
||
for await (const ev of parseSSEStream(
|
||
makeStream(['data: {"error":{}}\n\n', 'data: [DONE]\n\n']),
|
||
'r',
|
||
's',
|
||
1,
|
||
)) {
|
||
events.push(ev);
|
||
}
|
||
} catch (e) {
|
||
error = e as Error;
|
||
}
|
||
expect(error).toBeNull();
|
||
expect(events.some((ev) => ev.type === MetonaStreamEventType.DONE)).toBe(true);
|
||
});
|
||
|
||
it('error 有 code 无 message → 仍构成错误帧(标识存在,占位文案兜底)', async () => {
|
||
const err = await collectError({ error: { code: 'rate_limit_exceeded' } });
|
||
expect(err).toBeInstanceOf(SseUpstreamError);
|
||
expect((err as Error).message).toContain('upstream_error');
|
||
expect((err as SseUpstreamError).status).toBe(429);
|
||
});
|
||
|
||
it('error 为空白字符串 → 不算错误帧', async () => {
|
||
const events: string[] = [];
|
||
for await (const ev of parseSSEStream(
|
||
makeStream(['data: {"error":" "}\n\n', 'data: [DONE]\n\n']),
|
||
'r',
|
||
's',
|
||
1,
|
||
)) {
|
||
events.push(ev.type);
|
||
}
|
||
expect(events[events.length - 1]).toBe(MetonaStreamEventType.DONE);
|
||
});
|
||
});
|
||
|
||
// ===== 追加:parseOpenAICompatibleResponse 更多形态 =====
|
||
|
||
describe('parseOpenAICompatibleResponse — 补充形态', () => {
|
||
it('空 choices 数组 → content 空字符串 + finishReason stop + 无 toolCalls', () => {
|
||
const result = parseOpenAICompatibleResponse({ choices: [], usage: {} });
|
||
expect(result.content).toBe('');
|
||
expect(result.finishReason).toBe('stop');
|
||
expect(result.toolCalls).toBeUndefined();
|
||
});
|
||
|
||
it('无 message 的 choice → 安全回退', () => {
|
||
const result = parseOpenAICompatibleResponse({ choices: [{ finish_reason: 'stop' }] });
|
||
expect(result.content).toBe('');
|
||
expect(result.reasoningContent).toBeUndefined();
|
||
});
|
||
|
||
it('usage 缺失 → 全 0 token', () => {
|
||
const result = parseOpenAICompatibleResponse({ choices: [{ message: { content: 'x' } }] });
|
||
expect(result.usage).toEqual({ inputTokens: 0, outputTokens: 0, totalTokens: 0 });
|
||
});
|
||
|
||
it('usage cache 双格式回退(DeepSeek 命中 + MiMo cached_tokens)', () => {
|
||
const ds = parseOpenAICompatibleResponse({
|
||
choices: [{ message: { content: 'x' } }],
|
||
usage: { prompt_cache_hit_tokens: 5, prompt_cache_miss_tokens: 6 },
|
||
});
|
||
expect(ds.usage.cacheHitTokens).toBe(5);
|
||
expect(ds.usage.cacheMissTokens).toBe(6);
|
||
|
||
const mimo = parseOpenAICompatibleResponse({
|
||
choices: [{ message: { content: 'x' } }],
|
||
usage: { prompt_tokens_details: { cached_tokens: 9 } },
|
||
});
|
||
expect(mimo.usage.cacheHitTokens).toBe(9);
|
||
});
|
||
|
||
it('arguments 为空字符串 → JSON.parse 失败走截断自愈载荷(与坏参同轨)', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{
|
||
message: { tool_calls: [{ id: 'c', function: { name: 'f', arguments: '' } }] },
|
||
finish_reason: 'tool_calls',
|
||
},
|
||
],
|
||
});
|
||
expect((result.toolCalls![0].args as Record<string, unknown>)._truncatedArguments).toBe(true);
|
||
});
|
||
|
||
it('arguments 为对象类型 → 原样保留(不解析)', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{
|
||
message: { tool_calls: [{ id: 'c', function: { name: 'f', arguments: { a: 1 } } }] },
|
||
finish_reason: 'tool_calls',
|
||
},
|
||
],
|
||
});
|
||
expect(result.toolCalls![0].args).toEqual({ a: 1 });
|
||
});
|
||
|
||
it('reasoning_tokens 透传到 usage.reasoningTokens', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [{ message: { content: 'x' } }],
|
||
usage: { completion_tokens_details: { reasoning_tokens: 42 } },
|
||
});
|
||
expect(result.usage.reasoningTokens).toBe(42);
|
||
});
|
||
|
||
it('tool_calls 元素缺 id → id undefined 不抛错', () => {
|
||
const result = parseOpenAICompatibleResponse({
|
||
choices: [
|
||
{
|
||
message: { tool_calls: [{ function: { name: 'f', arguments: '{}' } }] },
|
||
finish_reason: 'tool_calls',
|
||
},
|
||
],
|
||
});
|
||
expect(result.toolCalls).toHaveLength(1);
|
||
expect(result.toolCalls![0].id).toBeUndefined();
|
||
});
|
||
});
|