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 跳过
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
/**
|
||
* DeepSeekAdapter 多模态(vision 模型)请求格式测试(v0.5.4)
|
||
*
|
||
* 背景:DeepSeek 新增 vision 实验模型 deepseek-v4-flash-vision-exp
|
||
* (OpenAI image_url content parts 格式)。适配器行为:
|
||
* - vision 模型:带 images 的消息 content 转换为 [{type:'text'},{type:'image_url'}] parts
|
||
* - 非 vision 模型:images 静默丢弃(共享层行为,防 API 400)
|
||
*
|
||
* 测试策略(契约级):mock fetch 记录真实请求体断言。
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
vi.mock('electron-log', () => ({
|
||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||
}));
|
||
|
||
import { DeepSeekAdapter } from '../deepseek.adapter';
|
||
import type { MetonaRequest, MetonaImageContent } from '../../types';
|
||
|
||
const mockFetch = vi.fn();
|
||
vi.stubGlobal('fetch', mockFetch);
|
||
|
||
beforeEach(() => {
|
||
mockFetch.mockReset();
|
||
});
|
||
afterEach(() => {
|
||
mockFetch.mockReset();
|
||
});
|
||
|
||
function makeAdapter(model: string): DeepSeekAdapter {
|
||
return new DeepSeekAdapter({
|
||
provider: 'deepseek',
|
||
baseURL: 'https://api.deepseek.com',
|
||
apiKey: 'sk-test',
|
||
defaultModel: model,
|
||
});
|
||
}
|
||
|
||
function makeRequest(images?: MetonaImageContent[]): MetonaRequest {
|
||
return {
|
||
meta: {
|
||
sessionId: 's',
|
||
iteration: 1,
|
||
requestId: 'r',
|
||
timestamp: Date.now(),
|
||
agentVersion: '1',
|
||
},
|
||
systemPrompt: { roleDefinition: 'sys', outputConstraints: '', safetyGuidelines: '' },
|
||
messages: [{ role: 'user', content: '这张图片里有什么?', images, timestamp: Date.now() }],
|
||
params: { temperature: 0, stream: false, thinkingEnabled: false },
|
||
};
|
||
}
|
||
|
||
function okResponse(): Response {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ choices: [{ message: { content: 'ok' } }], usage: {} }),
|
||
} as unknown as Response;
|
||
}
|
||
|
||
// toNativeRequest 返回 Record<string, unknown>;这里收敛为「已知字段 + 任意扩展字段」
|
||
// 的交叉类型,测试可直接断言 max_tokens/thinking/temperature/stop/stream 等协议字段。
|
||
function requestBody(): { model: string; messages: Array<Record<string, unknown>> } & Record<
|
||
string,
|
||
unknown
|
||
> {
|
||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||
return JSON.parse(init.body as string);
|
||
}
|
||
|
||
describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => {
|
||
it('vision 模型:带图片的消息转换为 image_url content parts', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/jpeg;base64,TESTPIC' }]));
|
||
|
||
const body = requestBody();
|
||
expect(body.model).toBe('deepseek-v4-flash-vision-exp');
|
||
// system 消息 + user 消息(含 content parts)
|
||
expect(body.messages).toHaveLength(2);
|
||
const userMsg = body.messages[1];
|
||
expect(userMsg.role).toBe('user');
|
||
expect(Array.isArray(userMsg.content)).toBe(true);
|
||
const parts = userMsg.content as Array<Record<string, unknown>>;
|
||
expect(parts[0]).toEqual({ type: 'text', text: '这张图片里有什么?' });
|
||
expect(parts[1]).toEqual({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/jpeg;base64,TESTPIC' },
|
||
});
|
||
});
|
||
|
||
it('非 vision 模型:images 被静默丢弃(content 保持纯文本)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-pro');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/jpeg;base64,TESTPIC' }]));
|
||
|
||
const body = requestBody();
|
||
const userMsg = body.messages[1];
|
||
// 非 vision 模型 content 保持字符串(不转 parts,不发图片 → 不会 400)
|
||
expect(userMsg.content).toBe('这张图片里有什么?');
|
||
});
|
||
|
||
it('vision 模型 max_tokens 钳制到 8192(MODEL_INFO 上限)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
params: { maxTokens: 63_488, temperature: 0, stream: false },
|
||
} as MetonaRequest);
|
||
|
||
const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string);
|
||
expect(body.max_tokens).toBe(8_192);
|
||
});
|
||
|
||
it('vision 模型无图片时不转换(content 保持纯文本)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(makeRequest(undefined));
|
||
|
||
const body = requestBody();
|
||
const userMsg = body.messages[1];
|
||
expect(userMsg.content).toBe('这张图片里有什么?');
|
||
});
|
||
|
||
it('vision 模型:多张图片全部转为 image_url parts(保持顺序)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(
|
||
makeRequest([
|
||
{ url: 'data:image/png;base64,AAA' },
|
||
{ url: 'data:image/png;base64,BBB' },
|
||
{ url: 'data:image/jpeg;base64,CCC' },
|
||
]),
|
||
);
|
||
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
expect(parts).toHaveLength(4); // 1 text + 3 image
|
||
expect(parts[1]).toMatchObject({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/png;base64,AAA' },
|
||
});
|
||
expect(parts[2]).toMatchObject({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/png;base64,BBB' },
|
||
});
|
||
expect(parts[3]).toMatchObject({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/jpeg;base64,CCC' },
|
||
});
|
||
});
|
||
|
||
it('vision 模型:无文本消息时仍生成 image parts(不丢弃图片)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: null,
|
||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
// 无文本 → 只有 image_url part(text part 不生成)
|
||
expect(parts).toHaveLength(1);
|
||
expect(parts[0].type).toBe('image_url');
|
||
});
|
||
|
||
it('非 vision 模型:即使 content 为 null 也不转换图片(纯 null content)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-pro');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: null,
|
||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const userMsg = body.messages[1];
|
||
expect(userMsg.content).toBeNull();
|
||
});
|
||
|
||
it('vision 模型 detail 字段被丢弃(image_url 仅保留 url)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA', detail: 'high' }]));
|
||
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
expect(parts[1]).toEqual({
|
||
type: 'image_url',
|
||
image_url: { url: 'data:image/png;base64,AAA' },
|
||
});
|
||
});
|
||
|
||
it('vision 模型 max_tokens 未配置 → 默认 8192(MODEL_INFO 上限)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
params: { temperature: 0, stream: false },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
expect(body.max_tokens).toBe(8_192);
|
||
});
|
||
|
||
it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
// v0.8.0 P0-3 能力门控: vision 模型 supportsThinking=false → 显式 disabled,
|
||
// 且不发送 reasoning_effort(思考会耗尽该模型 8192 输出预算 —— 生产事故根因)
|
||
expect(body.thinking).toEqual({ type: 'disabled' });
|
||
expect(body.reasoning_effort).toBeUndefined();
|
||
});
|
||
|
||
it('vision 模型 messages 数组首位始终为 system 消息', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }]));
|
||
const body = requestBody();
|
||
expect(body.messages[0].role).toBe('system');
|
||
expect(body.messages[0].content as string).toContain('sys');
|
||
});
|
||
|
||
it('tool 结果消息不被 images 转换影响(role=tool 保留 tool_call_id)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: '请读图',
|
||
images: [{ url: 'data:image/png;base64,AAA' }],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: null,
|
||
toolCalls: [
|
||
{
|
||
id: 'tc1',
|
||
name: 'view_image',
|
||
args: { path: 'a.png' },
|
||
iteration: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
timestamp: Date.now(),
|
||
},
|
||
{
|
||
role: 'tool',
|
||
content: null,
|
||
toolResult: {
|
||
toolCallId: 'tc1',
|
||
toolName: 'view_image',
|
||
result: { path: 'a.png' },
|
||
success: true,
|
||
durationMs: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const toolMsg = body.messages[3];
|
||
expect(toolMsg.role).toBe('tool');
|
||
expect(toolMsg.tool_call_id).toBe('tc1');
|
||
expect(toolMsg.content).toBe('{"path":"a.png"}');
|
||
});
|
||
|
||
it('孤立 tool 消息被过滤(无前置 tool_calls)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
messages: [
|
||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||
{
|
||
role: 'tool',
|
||
content: null,
|
||
toolResult: {
|
||
toolCallId: 'tc_orphan',
|
||
toolName: 'x',
|
||
result: 'r',
|
||
success: true,
|
||
durationMs: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
timestamp: Date.now(),
|
||
},
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
// system + user,孤立 tool 被剔除
|
||
expect(body.messages).toHaveLength(2);
|
||
});
|
||
|
||
it('temperature 与 stop 序列透传', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
params: { maxTokens: 4096, temperature: 0.4, stream: false, stopSequences: ['<END>'] },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
expect(body.temperature).toBe(0.4);
|
||
expect(body.stop).toEqual(['<END>']);
|
||
});
|
||
|
||
it('send(非流式路径)强制 stream=false 且不附加 stream_options', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest([{ url: 'data:image/png;base64,AAA' }]),
|
||
params: { maxTokens: 4096, temperature: 0, stream: true },
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
// send() 契约:无论请求参数如何,非流式路径强制 stream=false,stream_options 仅流式路径注入
|
||
expect(body.stream).toBe(false);
|
||
expect(body.stream_options).toBeUndefined();
|
||
});
|
||
|
||
it('非 vision 模型带图片不产生 image_url(content 保持字符串)', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash');
|
||
|
||
await adapter.send(makeRequest([{ url: 'data:image/png;base64,AAA' }]));
|
||
const body = requestBody();
|
||
const userMsg = body.messages[1];
|
||
expect(typeof userMsg.content).toBe('string');
|
||
expect(userMsg.content).not.toContain('image_url');
|
||
});
|
||
|
||
it('vision 模型 base64 data URI 原样保留在 image_url 中', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
const dataUri = 'data:image/png;base64,' + 'Z'.repeat(50);
|
||
await adapter.send(makeRequest([{ url: dataUri }]));
|
||
const body = requestBody();
|
||
const parts = body.messages[1].content as Array<Record<string, unknown>>;
|
||
expect(parts[1]).toMatchObject({ image_url: { url: dataUri } });
|
||
});
|
||
|
||
it('vision 模型 reasoning_content 在 assistant 历史中保留', async () => {
|
||
mockFetch.mockResolvedValue(okResponse());
|
||
const adapter = makeAdapter('deepseek-v4-flash-vision-exp');
|
||
|
||
await adapter.send({
|
||
...makeRequest(),
|
||
messages: [
|
||
{ role: 'user', content: 'hi', timestamp: Date.now() },
|
||
{ role: 'assistant', content: 'answer', reasoningContent: 'trace', timestamp: Date.now() },
|
||
],
|
||
} as MetonaRequest);
|
||
|
||
const body = requestBody();
|
||
const assistantMsg = body.messages[2] as Record<string, unknown>;
|
||
expect(assistantMsg.reasoning_content).toBe('trace');
|
||
});
|
||
});
|