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
@@ -0,0 +1,968 @@
/**
* AnthropicAdapter 独立测试(v0.7.4 P1-2 / P3-1 差异点锁定)
*
* 覆盖契约:
* - sendStreamcache_control 保留、message_start input_tokens、thinking budget 钳制、
* pendingToolUseIds 孤立 tool_result 过滤、断流 flush、错误码归一化矩阵
* - sendsystem 块数组、图片 base64 转换(data URI / URL 下载 / 失败降级)
* - 非流式响应:多 thinking 块累加、tool_use 解析、stop_reason → finishReason 映射
*/
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 { AnthropicAdapter } from '../anthropic.adapter';
import { ContentFilterError } from '../base-adapter';
import type { MetonaRequest, MetonaStreamEvent } from '../../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../../types';
const mockFetch = vi.fn();
function makeAdapter(
model = 'claude-sonnet-4-5',
overrides: Record<string, unknown> = {},
): AnthropicAdapter {
return new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'https://api.anthropic.com',
apiKey: 'sk-ant-test',
defaultModel: model,
...overrides,
});
}
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
return {
meta: {
sessionId: 's1',
iteration: 1,
requestId: 'r1',
timestamp: Date.now(),
agentVersion: 'test',
},
systemPrompt: {
roleDefinition: 'You are Metona.',
outputConstraints: '',
safetyGuidelines: '',
},
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
params: { maxTokens: 4096, temperature: 0, stream: false },
...overrides,
};
}
function ssePayload(lines: string[]): string {
return lines.join('\n') + '\n';
}
function mockStreamResponse(lines: string[]): void {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(ssePayload(lines)));
controller.close();
},
});
mockFetch.mockResolvedValue(new Response(body, { status: 200 }));
}
async function collectStream(
adapter: AnthropicAdapter,
request: MetonaRequest,
): Promise<MetonaStreamEvent[]> {
const events: MetonaStreamEvent[] = [];
for await (const ev of adapter.sendStream(request)) events.push(ev);
return events;
}
function jsonLine(obj: unknown): string {
return `data: ${JSON.stringify(obj)}`;
}
beforeEach(() => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ===== 请求体:system 块数组 + cache_control =====
describe('AnthropicAdapter — 请求体 cache_control 与消息转换', () => {
function okResponse(): Response {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response;
}
function lastBody(): Record<string, unknown> {
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
return JSON.parse(String(call[1].body));
}
it('请求头携带 x-api-key 与 anthropic-version(非 Bearer', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(makeRequest());
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers['x-api-key']).toBe('sk-ant-test');
expect(headers['anthropic-version']).toBe('2023-06-01');
expect(headers['Authorization']).toBeUndefined();
});
it('system 打 cache_control ephemeral(稳定前缀提示缓存)', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(makeRequest());
const body = lastBody();
expect(body.system).toEqual([
{ type: 'text', text: 'You are Metona.', cache_control: { type: 'ephemeral' } },
]);
});
it('历史以 assistant 开头时补占位 user 消息(首条必须 user', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{ role: 'assistant', content: '先说话', timestamp: Date.now() },
{ role: 'user', content: 'hi', timestamp: Date.now() },
],
}),
);
const body = lastBody();
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
expect(msgs[0].role).toBe('user');
expect(msgs[0].content[0]).toMatchObject({
type: 'text',
text: '[Conversation history follows]',
});
// 占位后原 assistant 消息仍在
expect(msgs[1].role).toBe('assistant');
});
it('连续同角色消息合并(user/user → 单条 user 多块)', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: '第一条', timestamp: Date.now() },
{ role: 'user', content: '第二条', timestamp: Date.now() },
],
}),
);
const body = lastBody();
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
const userMsgs = msgs.filter((m) => m.role === 'user');
expect(userMsgs).toHaveLength(1);
expect(userMsgs[0].content.map((c) => c.text)).toEqual(['第一条', '第二条']);
});
it('system 角色消息被跳过(不进入 messages', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{ role: 'system', content: 'system content', timestamp: Date.now() },
{ role: 'user', content: 'hi', timestamp: Date.now() },
],
}),
);
const body = lastBody();
const msgs = body.messages as Array<{ role: string }>;
expect(msgs.some((m) => m.role === 'system')).toBe(false);
});
});
// ===== 图片 base64 转换 =====
describe('AnthropicAdapter — 图片块转换', () => {
function okResponse(): Response {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response;
}
function lastBody(): Record<string, unknown> {
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
return JSON.parse(String(call[1].body));
}
it('data URI 图片 → {type:image, source:{base64, media_type, data}}', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'data:image/png;base64,iVBORw0KGgo=', detail: 'auto' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
const imageBlock = userMsg.content.find((c) => c.type === 'image');
expect(imageBlock).toEqual({
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' },
});
});
it('http URL 图片 → 下载后转 base64content-type 作为 media_type', async () => {
const adapter = makeAdapter();
const imageBytes = new TextEncoder().encode('PNG-DATA');
// 第一个 fetch(图片下载)返回二进制;第二个 fetch(chat)返回 ok
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: { get: () => 'image/jpeg' },
arrayBuffer: async () => imageBytes.buffer,
} as unknown as Response)
.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.jpg' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
const imageBlock = userMsg.content.find((c) => c.type === 'image');
expect(imageBlock).toMatchObject({
type: 'image',
source: { type: 'base64', media_type: 'image/jpeg' },
});
const source = (imageBlock as { source: { data: string } }).source;
expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA');
});
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
const adapter = makeAdapter();
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response);
const request = makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.jpg' }],
timestamp: Date.now(),
},
],
});
const res = await adapter.send(request);
expect(res.content).toBe('ok'); // 请求未被阻断
});
it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response);
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'data:image/png,RAW' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
expect(userMsg.content.some((c) => c.type === 'image')).toBe(false);
});
it('无图片消息 content 保持 text 块', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response);
await adapter.send(makeRequest());
const body = lastBody();
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
expect(userMsg.content).toEqual([{ type: 'text', text: 'hi' }]);
});
});
// ===== sendStreammessage_start input_tokens + USAGE 汇总 =====
describe('AnthropicAdapter — sendStream 事件机', () => {
it('message_start 的 input_tokens 汇总进 message_delta 的 USAGE(含 cache 字段)', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: message_start',
jsonLine({
type: 'message_start',
message: { role: 'assistant', usage: { input_tokens: 100 } },
}),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: 'hi' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'hello' },
}),
'event: message_delta',
jsonLine({
type: 'message_delta',
usage: {
output_tokens: 50,
cache_read_input_tokens: 80,
cache_creation_input_tokens: 20,
},
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
expect(
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'hello'),
).toBe(true);
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
expect(usageEvent?.usage).toMatchObject({
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
cacheHitTokens: 80,
cacheMissTokens: 20,
});
});
it('thinking_delta → REASONING_DELTA 事件', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'let me think' },
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
expect(reasoning?.delta).toBe('let me think');
});
it('input_json_delta → TOOL_CALL_DELTA 事件并缓冲拼接', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'toolu_1', name: 'read' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{"pa' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: 'th":"a"}' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 0 }),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_DELTA);
expect(deltas).toHaveLength(2);
const complete = events.find((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(complete?.toolCall).toMatchObject({ id: 'toolu_1', name: 'read', args: { path: 'a' } });
});
it('多个 tool block 并行缓冲(index 隔离)', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 't0', name: 'a' },
}),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 1,
content_block: { type: 'tool_use', id: 't1', name: 'b' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 1,
delta: { type: 'input_json_delta', partial_json: '{"b":2}' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{"a":1}' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 0 }),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 1 }),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(2);
const argsById = new Map(completes.map((c) => [c.toolCall!.id, c.toolCall!.args]));
expect(argsById.get('t0')).toEqual({ a: 1 });
expect(argsById.get('t1')).toEqual({ b: 2 });
});
it('上游 error 事件 → 抛出归一化 status 异常(映射矩阵)', async () => {
const cases: Array<[string, number]> = [
['overloaded_error', 529],
['rate_limit_error', 429],
['api_error', 500],
['timeout_error', 504],
['authentication_error', 401],
['permission_error', 403],
['not_found_error', 404],
['invalid_request_error', 400],
['request_too_large', 400],
['unknown_error_type', 500],
];
for (const [code, expectedStatus] of cases) {
const adapter = makeAdapter();
mockStreamResponse([
'event: error',
jsonLine({ type: 'error', error: { type: code, message: 'boom' } }),
]);
const err = await collectStream(adapter, makeRequest({ params: { stream: true } })).catch(
(e: unknown) => e,
);
expect((err as Error & { status?: number }).status).toBe(expectedStatus);
expect((err as Error).message).toContain(code);
}
});
it('上游 error 事件 code=content_filter_error → ContentFilterError', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: error',
jsonLine({
type: 'error',
error: { type: 'content_filter_error', message: 'blocked by safety' },
}),
]);
const err = await collectStream(adapter, makeRequest({ params: { stream: true } })).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(ContentFilterError);
});
it('坏 JSON 的 SSE 行被跳过不中断流', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: content_block_delta',
'data: {broken json',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'ok' },
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
expect(
events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA && e.delta === 'ok'),
).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('[DONE] 数据行被忽略(Anthropic 事件机不用 [DONE] 结束)', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'data: [DONE]',
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'after done' },
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
});
it('无 message_start 时 input_tokens 缺省 0USAGE 汇总不炸)', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: message_delta',
jsonLine({ type: 'message_delta', usage: { output_tokens: 7 } }),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
expect(usageEvent?.usage?.inputTokens).toBe(0);
expect(usageEvent?.usage?.outputTokens).toBe(7);
});
});
// ===== sendStream:断流 flush =====
describe('AnthropicAdapter — 断流 flushv0.6.4 缺口 B', () => {
it('流在 message_stop 前断开 → 补发 DONE(无未完成块)', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'partial' },
}),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('断流时已完成块正常产出;未完成块 flush 为自愈调用', async () => {
const adapter = makeAdapter();
mockStreamResponse([
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'tool_use', id: 'toolu_done', name: 'ok' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'input_json_delta', partial_json: '{"a":1}' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 0 }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 1,
content_block: { type: 'tool_use', id: 'toolu_orphan', name: 'x' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 1,
delta: { type: 'input_json_delta', partial_json: '{bad' },
}),
]);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(2);
expect(completes[0].toolCall!.id).toBe('toolu_done');
expect(completes[0].toolCall!.args).toEqual({ a: 1 });
expect(completes[1].toolCall!.id).toBe('toolu_orphan');
expect((completes[1].toolCall!.args as Record<string, unknown>)._truncatedArguments).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
});
// ===== thinking budget 钳制 =====
describe('AnthropicAdapter — thinking budget_tokens 钳制', () => {
function okResponse(): Response {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response;
}
function lastBody(): Record<string, unknown> {
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
return JSON.parse(String(call[1].body));
}
it('thinking 开启时 max_tokens 提升到 ≥2048 且 budget < max_tokens', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
params: {
maxTokens: 1500,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'high',
},
}),
);
const body = lastBody();
expect(body.max_tokens).toBeGreaterThanOrEqual(2048);
const thinking = body.thinking as { budget_tokens: number };
expect(thinking.budget_tokens).toBeLessThan(body.max_tokens as number);
expect(thinking.budget_tokens).toBeGreaterThanOrEqual(1024);
});
it('budget 不超过 max_tokens 的一半(协议约束)', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
params: {
maxTokens: 10_000,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'max',
},
}),
);
const body = lastBody();
const thinking = body.thinking as { budget_tokens: number };
expect(thinking.budget_tokens).toBeLessThanOrEqual(Math.floor((body.max_tokens as number) / 2));
});
it('thinking 关闭时不发 thinking 字段且 temperature 透传', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0.5, stream: false, thinkingEnabled: false },
}),
);
const body = lastBody();
expect(body.thinking).toBeUndefined();
expect(body.temperature).toBe(0.5);
});
});
// ===== 非流式响应组装 =====
describe('AnthropicAdapter — 非流式响应组装', () => {
it('多个 thinking 块累加(不互相覆盖)', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [
{ type: 'thinking', thinking: '第一段思考' },
{ type: 'thinking', thinking: '第二段思考' },
{ type: 'text', text: 'final answer' },
],
usage: { input_tokens: 5, output_tokens: 3 },
stop_reason: 'end_turn',
}),
} as unknown as Response);
const res = await adapter.send(makeRequest());
expect(res.content).toBe('final answer');
expect(res.reasoningContent).toBe('第一段思考\n\n第二段思考');
});
it('tool_use 块解析为 toolCalls(含对象型 args', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'tool_use', id: 'toolu_9', name: 'read_file', input: { path: 'a.txt' } }],
usage: {},
stop_reason: 'tool_use',
}),
} as unknown as Response);
const res = await adapter.send(makeRequest());
expect(res.finishReason).toBe(MetonaFinishReason.TOOL_CALLS);
expect(res.toolCalls).toHaveLength(1);
expect(res.toolCalls![0]).toMatchObject({
id: 'toolu_9',
name: 'read_file',
args: { path: 'a.txt' },
});
});
it('stop_reason=max_tokens → LENGTH finishReason', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'truncated' }],
usage: {},
stop_reason: 'max_tokens',
}),
} as unknown as Response);
const res = await adapter.send(makeRequest());
expect(res.finishReason).toBe(MetonaFinishReason.LENGTH);
});
it('stop_reason=refusal → CONTENT_FILTER finishReason(不再折叠为 STOP', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: '' }],
usage: {},
stop_reason: 'refusal',
}),
} as unknown as Response);
const res = await adapter.send(makeRequest());
expect(res.finishReason).toBe(MetonaFinishReason.CONTENT_FILTER);
});
it('usage cache 字段透传到响应', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'x' }],
usage: {
input_tokens: 10,
output_tokens: 4,
cache_read_input_tokens: 8,
cache_creation_input_tokens: 2,
},
stop_reason: 'end_turn',
}),
} as unknown as Response);
const res = await adapter.send(makeRequest());
expect(res.usage.cacheHitTokens).toBe(8);
expect(res.usage.cacheMissTokens).toBe(2);
});
});
// ===== 孤立 tool_result 过滤(toNativeRequest 侧) =====
describe('AnthropicAdapter — 孤立 tool_result 过滤', () => {
function okResponse(): Response {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response;
}
function lastBody(): Record<string, unknown> {
const call = mockFetch.mock.calls[mockFetch.mock.calls.length - 1] as [string, RequestInit];
return JSON.parse(String(call[1].body));
}
it('tool_result 顺序乱序时按配对顺序映射(pending set 出队)', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: 'go', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_a', name: 'a', args: {}, iteration: 1, timestamp: Date.now() },
{ id: 'tc_b', name: 'b', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_b',
toolName: 'b',
result: 'B',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_a',
toolName: 'a',
result: 'A',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const toolResults = (
body.messages as Array<{ content: Array<Record<string, unknown>> }>
).flatMap((m) => m.content.filter((c) => c.type === 'tool_result'));
expect(toolResults).toHaveLength(2);
// 乱序结果保留在各自的 user 消息块中(顺序按消息流,非配对顺序)
const ids = toolResults.map((t) => (t as { tool_use_id: string }).tool_use_id);
expect(ids).toEqual(['tc_b', 'tc_a']);
});
it('孤立 tool_result(前面无 tool_use)被过滤,不触达 API', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_ghost',
toolName: 'x',
result: 'r',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const allBlocks = (body.messages as Array<{ content: Array<Record<string, unknown>> }>).flatMap(
(m) => m.content.filter((c) => c.type === 'tool_result'),
);
expect(allBlocks).toHaveLength(0);
});
it('工具失败时 error 字段作为 tool_result content', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(okResponse());
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: 'command failed',
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const toolMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[2];
expect(toolMsg.content[0]).toMatchObject({ type: 'tool_result', content: 'command failed' });
});
});
// ===== getContextWindow / listModels =====
describe('AnthropicAdapter — getContextWindow / listModels', () => {
it('config.contextWindow 优先', () => {
const adapter = makeAdapter('claude-sonnet-4-5', { contextWindow: 50_000 });
expect(adapter.getContextWindow()).toBe(50_000);
});
it('未知模型 → 兜底 200K', () => {
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(200_000);
});
it('listModels 返回本地模型元信息(无网络请求)', async () => {
const adapter = makeAdapter();
const models = await adapter.listModels();
expect(models.map((m) => m.id)).toEqual([
'claude-sonnet-4-5',
'claude-opus-4-1',
'claude-haiku-4-5',
]);
expect(models[0]).toMatchObject({ contextWindow: 200_000, maxOutputTokens: 64_000 });
expect(mockFetch).not.toHaveBeenCalled();
});
});