/** * 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 = {}, ): AnthropicAdapter { return new AnthropicAdapter({ provider: 'anthropic', baseURL: 'https://api.anthropic.com', apiKey: 'sk-ant-test', defaultModel: model, ...overrides, }); } function makeRequest(overrides?: Partial): 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({ 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 { 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 { 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; 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> }>; 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> }>; 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 { 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> }>)[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> }>)[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> }>)[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> }>)[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)._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 { 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 { 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> }> ).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> }>).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> }>)[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(); }); });