feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -30,6 +30,8 @@ import { AnthropicAdapter } from '../anthropic.adapter';
import { OllamaAdapter } from '../ollama.adapter';
import { MimoAdapter } from '../mimo.adapter';
import { AgnesAdapter } from '../agnes-ai.adapter';
import { DeepSeekAdapter } from '../deepseek.adapter';
import { OpenAIAdapter } from '../openai.adapter';
import type { MetonaRequest } from '../../types';
/** 安装全局 fetch 捕获器:记录每次请求体并返回一个三家协议都能解析的合成响应 */
@@ -426,3 +428,880 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4', () => {
).toBe(false);
});
});
// ===== Anthropic 追加:system 四态 / thinking budget 矩阵 / maxTokens 钳制 =====
describe('AnthropicAdapter — system 块数组与 cache_control 四态', () => {
function makeAdapter(model = 'claude-sonnet-4-5'): AnthropicAdapter {
return new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: model,
});
}
it('system 四段全部填充 → 单一 text 块 + cache_control ephemeral(稳定前缀提示缓存)', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(makeRequest());
const system = bodies[0].system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
expect(system[0].type).toBe('text');
expect(system[0].text).toContain('You are Metona.');
expect(system[0].text).toContain('Be concise.');
expect(system[0].text).toContain('Stay safe.');
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
});
it('system 部分段为空 → 过滤后拼接,仍打 cache_control', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
systemPrompt: {
roleDefinition: 'Only role',
outputConstraints: '',
safetyGuidelines: '',
dynamicReminders: '',
},
}),
);
const system = bodies[0].system as Array<Record<string, unknown>>;
expect(system).toHaveLength(1);
expect(system[0].text).toBe('Only role');
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
});
it('system 全部为空 → 不发块数组,透传空字符串(无 cache_control', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
}),
);
expect(bodies[0].system).toBe('');
});
it('动态提醒 dynamicReminders 被拼入 system 块', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
systemPrompt: {
roleDefinition: 'rd',
outputConstraints: '',
safetyGuidelines: '',
dynamicReminders: 'Remember X',
},
}),
);
const system = bodies[0].system as Array<Record<string, unknown>>;
expect(system[0].text).toContain('Remember X');
});
});
describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => {
function makeAdapter(model = 'claude-sonnet-4-5'): AnthropicAdapter {
return new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: model,
});
}
it.each([
['low', 1024],
['medium', 4096],
['high', 16384],
// max=32768 但 sonnet 的 max_tokens 先钳到 64000 → budget 二次钳到 floor(64000/2)=32000
['max', 32000],
] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 100_000,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: effort,
},
}),
);
const thinking = bodies[0].thinking as { type: string; budget_tokens: number };
expect(thinking.type).toBe('enabled');
expect(thinking.budget_tokens).toBe(expectBudget);
expect(thinking.budget_tokens).toBeLessThan(bodies[0].max_tokens as number);
});
it('effort 未配置时缺省 high → budget 16384', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 100_000, temperature: 0, stream: false, thinkingEnabled: true },
}),
);
const thinking = bodies[0].thinking as { budget_tokens: number };
expect(thinking.budget_tokens).toBe(16384);
});
it('小 max_tokens 时 budget 被 max_tokens/2 二次钳制(budget < max_tokens 协议约束)', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 2048,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'high',
},
}),
);
const thinking = bodies[0].thinking as { budget_tokens: number };
// effort high=16384 但 max_tokens=2048 → budget 钳到 floor(2048/2)=1024
expect(thinking.budget_tokens).toBe(1024);
});
});
describe('AnthropicAdapter — max_tokens 钳制矩阵', () => {
it.each([
['claude-sonnet-4-5', 63_488, 63_488], // 引擎默认低于上限 → 原样
['claude-sonnet-4-5', 70_000, 64_000], // 超上限 → 钳到 sonnet 64000
['claude-opus-4-1', 63_488, 32_000], // opus 上限 32000
['claude-haiku-4-5', 63_488, 32_000], // haiku 上限 32000
['claude-sonnet-4-5', 500, 500], // 低于上限 → 原样
])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: model,
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: requested, temperature: 0, stream: false } }),
);
expect(bodies[0].max_tokens).toBe(expected);
});
it('thinking 开启时小 maxTokens 被抬升到安全下限 2048', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 800,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'low',
},
}),
);
expect(bodies[0].max_tokens).toBe(2048);
});
});
describe('AnthropicAdapter — temperature 传递与停止序列', () => {
it('thinking 关闭时 temperature 逐值透传', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const { bodies } = captureFetch();
for (const t of [0, 0.2, 1.0]) {
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: t, stream: false } }),
);
}
expect(bodies[0].temperature).toBe(0);
expect(bodies[1].temperature).toBe(0.2);
expect(bodies[2].temperature).toBe(1.0);
});
it('stopSequences 映射为 stop_sequences 数组', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, stopSequences: ['END', 'STOP'] },
}),
);
expect(bodies[0].stop_sequences).toEqual(['END', 'STOP']);
});
it('未配置 stopSequences 时不发送 stop_sequences', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const { bodies } = captureFetch();
await adapter.send(makeRequest());
expect(bodies[0].stop_sequences).toBeUndefined();
});
it('tools 定义映射为 input_schema 命名空间', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
tools: [
{
name: 'read_file',
description: 'Read a file',
parameters: {
type: 'object',
properties: { path: { type: 'string', description: 'file path' } },
required: ['path'],
},
category: 'filesystem' as never,
riskLevel: 'low' as never,
requiresPermission: false,
timeoutMs: 1000,
},
],
}),
);
const tools = bodies[0].tools as Array<Record<string, unknown>>;
expect(tools[0].name).toBe('read_file');
expect(tools[0].input_schema).toBeDefined();
expect((tools[0].input_schema as Record<string, unknown>).required).toEqual(['path']);
});
it('无 tools 时不发送 tools 字段', async () => {
const adapter = new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
const { bodies } = captureFetch();
await adapter.send(makeRequest());
expect(bodies[0].tools).toBeUndefined();
});
});
// ===== DeepSeek thinking 映射矩阵(v0.6.4 =====
describe('DeepSeekAdapter — thinking 映射矩阵', () => {
function makeAdapter(model = 'deepseek-v4-pro'): DeepSeekAdapter {
return new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'https://api.deepseek.com',
apiKey: 'k',
defaultModel: model,
});
}
it.each([
['low', 'high'],
['medium', 'high'],
['high', 'high'],
['max', 'max'],
] as const)(
'effort=%s → reasoning_effort=%slow/medium 归一 high',
async (effort, expected) => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: effort,
},
}),
);
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
expect(bodies[0].reasoning_effort).toBe(expected);
},
);
it('thinkingEnabled=false → 显式 {type:disabled}(服务端默认开启,必须显式关闭)', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
}),
);
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
expect(bodies[0].reasoning_effort).toBeUndefined();
});
it('thinkingEnabled 未配置 → 不发送 thinking 字段', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false } }));
expect(bodies[0].thinking).toBeUndefined();
});
it('effort 未配置缺省 high → reasoning_effort=high', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
}),
);
expect(bodies[0].reasoning_effort).toBe('high');
});
it('temperature 与 stop 序列原样传递', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0.5,
stream: false,
stopSequences: ['<END>'],
},
}),
);
expect(bodies[0].temperature).toBe(0.5);
expect(bodies[0].stop).toEqual(['<END>']);
});
it('max_tokens 按模型钳制(pro 384000 / vision 8192', async () => {
const pro = makeAdapter('deepseek-v4-pro');
const vision = makeAdapter('deepseek-v4-flash-vision-exp');
const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { maxTokens: 500_000, temperature: 0, stream: false } }));
await vision.send(
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
);
expect(bodies[0].max_tokens).toBe(384_000);
expect(bodies[1].max_tokens).toBe(8_192);
});
});
// ===== Agnes enable_thinking 对称性扩展 =====
describe('AgnesAdapter — enable_thinking 对称性矩阵', () => {
function makeAdapter(): AgnesAdapter {
return new AgnesAdapter({
provider: 'agnes',
baseURL: 'http://g.test/v1',
apiKey: 'k',
defaultModel: 'agnes-2.0-flash',
});
}
it.each([
['high', true],
['medium', true],
['max', true],
['low', false],
] as const)('effort=%s → enable_thinking=%slow 映射为关闭)', async (effort, expected) => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: effort,
},
}),
);
expect(
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).toBe(expected);
});
it('thinkingEnabled=true 但 effort 未配置 → 缺省 high → enable_thinking true', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
}),
);
expect(
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).toBe(true);
});
it('temperature 与 max_tokens 同时传递(Agnes 支持 temperature', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }),
);
expect(bodies[0].temperature).toBe(0.9);
// 65536 上限钳制
expect(bodies[0].max_tokens).toBe(65_536);
});
});
// ===== MiMo thinking 开关与 providerOptions 扩展 =====
describe('MimoAdapter — thinking 显式开关', () => {
function makeAdapter(overrides: Record<string, unknown> = {}): MimoAdapter {
return new MimoAdapter({
provider: 'mimo',
baseURL: 'http://m.test/v1',
apiKey: 'k',
defaultModel: 'mimo-v2.5',
...overrides,
});
}
it('thinkingEnabled=false → {type:disabled} + temperature/top_p 显式传递(非思考模式有效)', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0.7,
topP: 0.8,
stream: false,
thinkingEnabled: false,
},
}),
);
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
expect(bodies[0].temperature).toBe(0.7);
expect(bodies[0].top_p).toBe(0.8);
});
it('thinkingEnabled 未配置 → 默认 {type:enabled} 且不传 temperature/top_pAPI 强制覆盖)', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }),
);
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
expect(bodies[0].temperature).toBeUndefined();
expect(bodies[0].top_p).toBeUndefined();
});
it('max_completion_tokens 钳制矩阵(pro 131072 / standard 32768', async () => {
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
const std = makeAdapter({ defaultModel: 'mimo-v2.5' });
const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }));
await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }));
expect(bodies[0].max_completion_tokens).toBe(131_072);
expect(bodies[1].max_completion_tokens).toBe(32_768);
});
it('thinking 未关闭时未配置 maxTokens → 兜底 32768(思考占配额,防截断)', async () => {
const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' });
const { bodies } = captureFetch();
await pro.send(makeRequest({ params: { temperature: 0, stream: false } }));
expect(bodies[0].max_completion_tokens).toBe(32_768);
});
it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => {
const adapter = makeAdapter({ providerOptions: { enableWebSearch: true } });
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
tools: [
{
name: 'fs',
description: 'd',
parameters: { type: 'object', properties: {} },
category: 'filesystem' as never,
riskLevel: 'low' as never,
requiresPermission: false,
timeoutMs: 100,
},
],
}),
);
const tools = bodies[0].tools as Array<Record<string, unknown>>;
expect(tools).toHaveLength(2);
expect(tools[0].type).toBe('function');
expect(tools[1]).toEqual({ type: 'web_search' });
expect(bodies[0].tool_choice).toBe('auto');
});
it('responseFormatJson + thinking 默认开启可共存(response_format 独立于 thinking', async () => {
const adapter = makeAdapter({ providerOptions: { responseFormatJson: true } });
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
}),
);
expect(bodies[0].response_format).toEqual({ type: 'json_object' });
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
});
});
// ===== OpenAI reasoning_effort 与 maxTokens 路由 =====
describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1', () => {
function makeAdapter(model: string): OpenAIAdapter {
return new OpenAIAdapter({
provider: 'openai',
baseURL: 'https://api.openai.com/v1',
apiKey: 'k',
defaultModel: model,
});
}
it.each([
['low', 'low'],
['medium', 'medium'],
['high', 'high'],
['max', 'high'],
] as const)(
'o3-mini effort=%s → reasoning_effort=%smax 归一 high',
async (effort, expected) => {
const adapter = makeAdapter('o3-mini');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: effort,
},
}),
);
expect(bodies[0].reasoning_effort).toBe(expected);
expect(bodies[0].max_completion_tokens).toBe(4096); // o 系列用新字段名
},
);
it('o3-mini thinking 未开启 → 不传 reasoning_effort 也不传 temperatureo 系列不支持温度)', async () => {
const adapter = makeAdapter('o3-mini');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false },
}),
);
expect(bodies[0].reasoning_effort).toBeUndefined();
expect(bodies[0].temperature).toBeUndefined();
});
it('非推理模型 gpt-4o → max_tokens 字段 + temperature 透传', async () => {
const adapter = makeAdapter('gpt-4o');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.5, stream: false } }),
);
expect(bodies[0].max_tokens).toBe(4096);
expect(bodies[0].max_completion_tokens).toBeUndefined();
expect(bodies[0].temperature).toBe(0.5);
expect(bodies[0].reasoning_effort).toBeUndefined();
});
it('gpt-4.1 长上下文 1M → getContextWindow 返回 1M(模型元信息表)', () => {
const adapter = makeAdapter('gpt-4.1');
expect(adapter.getContextWindow()).toBe(1_000_000);
});
it('o3-mini max_completion_tokens 钳制到 100000', async () => {
const adapter = makeAdapter('o3-mini');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }),
);
expect(bodies[0].max_completion_tokens).toBe(100_000);
});
it('gpt-4o max_tokens 钳制到 16384', async () => {
const adapter = makeAdapter('gpt-4o');
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }),
);
expect(bodies[0].max_tokens).toBe(16_384);
});
});
// ===== Ollama options 缺省与工具映射 =====
describe('OllamaAdapter — options 缺省与工具定义', () => {
function makeOllama(): OllamaAdapter {
return new OllamaAdapter({
provider: 'ollama',
baseURL: 'http://localhost:11434',
defaultModel: 'qwen3',
});
}
it('未配置 topP/contextLength/stop 时 options 仅含 temperature/num_predict', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.2, stream: false } }),
);
const options = bodies[0].options as Record<string, unknown>;
expect(Object.keys(options).sort()).toEqual(['num_predict', 'temperature']);
expect(options.num_predict).toBe(4096);
});
it('tools 定义为 {type:function,function:{...}} 形态', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
tools: [
{
name: 'calc',
description: 'calc',
parameters: { type: 'object', properties: { a: { type: 'number', description: 'a' } } },
category: 'calculation' as never,
riskLevel: 'safe' as never,
requiresPermission: false,
timeoutMs: 100,
},
],
}),
);
const tools = bodies[0].tools as Array<Record<string, unknown>>;
expect(tools[0]).toMatchObject({ type: 'function' });
expect((tools[0].function as Record<string, unknown>).name).toBe('calc');
});
it('assistant 工具调用参数序列化为 JSON 字符串(Ollama REST 要求)', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{
id: 'tc1',
name: 'read',
args: { path: 'a.txt', lines: [1, 2] },
iteration: 1,
timestamp: Date.now(),
},
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc1',
toolName: 'read',
result: 'data',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
],
}),
);
const messages = bodies[0].messages as Array<Record<string, unknown>>;
const assistantMsg = messages.find((m) => m.role === 'assistant') as {
tool_calls: Array<Record<string, unknown>>;
};
const fn = assistantMsg.tool_calls[0].function as Record<string, unknown>;
expect(fn.arguments).toBe(JSON.stringify({ path: 'a.txt', lines: [1, 2] }));
// tool 消息映射 tool_call_id + 结果文本
const toolMsg = messages.find((m) => m.role === 'tool') as {
tool_call_id: string;
content: string;
};
expect(toolMsg.tool_call_id).toBe('tc1');
expect(toolMsg.content).toBe('data');
});
it('assistant reasoning_content 回传保持推理链完整', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: 'answer',
reasoningContent: 'thinking trace',
timestamp: Date.now(),
},
],
}),
);
const messages = bodies[0].messages as Array<Record<string, unknown>>;
const assistantMsg = messages.find((m) => m.role === 'assistant') as {
reasoning_content?: string;
};
expect(assistantMsg.reasoning_content).toBe('thinking trace');
});
it('assistant 无 content 时映射为空字符串(Ollama 不支持 null content', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: 'hi', timestamp: Date.now() },
{ role: 'assistant', content: null, timestamp: Date.now() },
],
}),
);
const messages = bodies[0].messages as Array<Record<string, unknown>>;
const assistantMsg = messages.find((m) => m.role === 'assistant') as { content: unknown };
expect(assistantMsg.content).toBe('');
});
it('纯 base64 图片(无 data: 前缀)原样透传', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'iVBORw0KGgoAAAANSUhEUg', detail: 'auto' }],
timestamp: Date.now(),
},
],
}),
);
const messages = bodies[0].messages as Array<Record<string, unknown>>;
const userMsg = messages[messages.length - 1];
expect(userMsg.images).toEqual(['iVBORw0KGgoAAAANSUhEUg']);
});
it('工具结果失败时 error 字段优先作为 contentCE-2', async () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{
id: 'tc_e',
name: 'run',
args: {},
iteration: 1,
timestamp: Date.now(),
},
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_e',
toolName: 'run',
result: null,
success: false,
error: 'exit code 2',
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
],
}),
);
const messages = bodies[0].messages as Array<Record<string, unknown>>;
const toolMsg = messages.find((m) => m.role === 'tool') as { content: string };
expect(toolMsg.content).toBe('exit code 2');
});
});
// ===== 跨 Provider maxTokens 钳制矩阵 =====
describe('跨 Provider — maxTokens 钳制矩阵汇总', () => {
it.each([
['anthropic', 'claude-opus-4-1', 100_000, 32_000],
['anthropic', 'claude-sonnet-4-5', 100_000, 64_000],
['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 8_192],
['agnes', 'agnes-2.0-flash', 100_000, 65_536],
['mimo', 'mimo-v2.5', 100_000, 32_768],
['openai', 'gpt-4o', 100_000, 16_384],
] as const)(
'%s %s maxTokens=100000 → 钳制为 %d',
async (provider, model, requested, expected) => {
const adapterMap: Record<string, unknown> = {
anthropic: new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a',
apiKey: 'k',
defaultModel: model,
}),
deepseek: new DeepSeekAdapter({
provider: 'deepseek',
baseURL: 'http://d',
apiKey: 'k',
defaultModel: model,
}),
agnes: new AgnesAdapter({
provider: 'agnes',
baseURL: 'http://g',
apiKey: 'k',
defaultModel: model,
}),
mimo: new MimoAdapter({
provider: 'mimo',
baseURL: 'http://m',
apiKey: 'k',
defaultModel: model,
}),
openai: new OpenAIAdapter({
provider: 'openai',
baseURL: 'http://o',
apiKey: 'k',
defaultModel: model,
}),
};
const adapter = adapterMap[provider] as { send: (r: MetonaRequest) => Promise<unknown> };
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: requested, temperature: 0, stream: false } }),
);
const body = bodies[bodies.length - 1] as Record<string, unknown>;
expect(body.max_tokens ?? body.max_completion_tokens).toBe(expected);
},
);
});