硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
973 lines
32 KiB
TypeScript
973 lines
32 KiB
TypeScript
/**
|
||
* AnthropicAdapter 独立测试(v0.7.4 P1-2 / P3-1 差异点锁定)
|
||
*
|
||
* 覆盖契约:
|
||
* - sendStream:cache_control 保留、message_start input_tokens、thinking budget 钳制、
|
||
* pendingToolUseIds 孤立 tool_result 过滤、断流 flush、错误码归一化矩阵
|
||
* - send:system 块数组、图片 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 图片 → 下载后转 base64(content-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' }]);
|
||
});
|
||
});
|
||
|
||
// ===== sendStream:message_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 缺省 0(USAGE 汇总不炸)', 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 — 断流 flush(v0.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);
|
||
});
|
||
|
||
// v0.8.1: 窗口唯一来源是设置面板 llm.contextWindow,未配置返回 0(无写死兜底)
|
||
it('未知模型且未配置 → 返回 0(无写死兜底窗口)', () => {
|
||
expect(makeAdapter('claude-unknown').getContextWindow()).toBe(0);
|
||
});
|
||
|
||
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',
|
||
]);
|
||
// v0.8.1 硬性契约: 元信息不再承载 contextWindow / maxOutputTokens
|
||
expect(models[0]).toMatchObject({ supportsThinking: true });
|
||
expect(models[0].contextWindow).toBeUndefined();
|
||
expect(models[0].maxOutputTokens).toBeUndefined();
|
||
expect(mockFetch).not.toHaveBeenCalled();
|
||
});
|
||
});
|