1265 lines
43 KiB
TypeScript
1265 lines
43 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 { __imageFetcher } from '../shared/ssrf-image-fetch';
|
||
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');
|
||
// v0.8.2 P0-1: 图片下载改走 SSRF 安全通道(独立下载器注入点,不走 global fetch)
|
||
const fetchRestore = __imageFetcher.current;
|
||
__imageFetcher.current = (async () =>
|
||
new Response(imageBytes, {
|
||
status: 200,
|
||
headers: { 'content-type': 'image/jpeg' },
|
||
})) as typeof __imageFetcher.current;
|
||
try {
|
||
// chat 请求返回 ok
|
||
mockFetch.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');
|
||
} finally {
|
||
__imageFetcher.current = fetchRestore;
|
||
}
|
||
});
|
||
|
||
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
|
||
const adapter = makeAdapter();
|
||
// v0.8.2 P0-1: 下载失败同样走注入点(SSRF 拒绝/网络失败均降级为跳过图片)
|
||
const fetchRestore = __imageFetcher.current;
|
||
__imageFetcher.current = (async () => {
|
||
throw new Error('ECONNREFUSED');
|
||
}) as typeof __imageFetcher.current;
|
||
try {
|
||
mockFetch.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'); // 请求未被阻断
|
||
} finally {
|
||
__imageFetcher.current = fetchRestore;
|
||
}
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|
||
|
||
// ===== v0.8.2 P1-1: thinking 块回传 + pause_turn 续传 =====
|
||
|
||
describe('AnthropicAdapter — v0.8.2 P1-1', () => {
|
||
function okJson(data: Record<string, unknown>): Response {
|
||
return new Response(JSON.stringify(data), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
}
|
||
|
||
function sse(lines: string[]): Response {
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(new TextEncoder().encode(lines.join('\n') + '\n'));
|
||
controller.close();
|
||
},
|
||
});
|
||
return new Response(body, { status: 200 });
|
||
}
|
||
|
||
it('流式 DONE 携带带签名的 thinking 块(redacted 原样)', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch.mockResolvedValue(
|
||
sse([
|
||
'event: message_start',
|
||
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 10 } } }),
|
||
'event: content_block_start',
|
||
jsonLine({
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'thinking', thinking: '' },
|
||
}),
|
||
'event: content_block_delta',
|
||
jsonLine({
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'thinking_delta', thinking: 'deep thought' },
|
||
}),
|
||
'event: content_block_delta',
|
||
jsonLine({
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'signature_delta', signature: 'sig-abc' },
|
||
}),
|
||
'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: 'redacted_thinking', data: 'opaque' },
|
||
}),
|
||
'event: content_block_stop',
|
||
jsonLine({ type: 'content_block_stop', index: 1 }),
|
||
'event: content_block_start',
|
||
jsonLine({
|
||
type: 'content_block_start',
|
||
index: 2,
|
||
content_block: { type: 'text', text: '' },
|
||
}),
|
||
'event: content_block_delta',
|
||
jsonLine({
|
||
type: 'content_block_delta',
|
||
index: 2,
|
||
delta: { type: 'text_delta', text: 'Answer' },
|
||
}),
|
||
'event: content_block_stop',
|
||
jsonLine({ type: 'content_block_stop', index: 2 }),
|
||
'event: message_delta',
|
||
jsonLine({
|
||
type: 'message_delta',
|
||
delta: { stop_reason: 'end_turn' },
|
||
usage: { output_tokens: 20 },
|
||
}),
|
||
'event: message_stop',
|
||
jsonLine({ type: 'message_stop' }),
|
||
]),
|
||
);
|
||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||
const done = events.find((e) => e.type === MetonaStreamEventType.DONE);
|
||
expect(done).toBeDefined();
|
||
expect(done!.finishReason).toBe('stop');
|
||
expect(done!.thinkingBlocks).toEqual([
|
||
{ type: 'thinking', thinking: 'deep thought', signature: 'sig-abc' },
|
||
{ type: 'redacted_thinking', data: 'opaque' },
|
||
]);
|
||
});
|
||
|
||
it('下一轮请求按协议回传 thinking 块(thinking 开启时块前置;关闭时不回传)', async () => {
|
||
const adapter = makeAdapter();
|
||
const thinkingBlocks = [
|
||
{ type: 'thinking' as const, thinking: 'deep', signature: 'sig' },
|
||
{ type: 'redacted_thinking' as const, data: 'opaque' },
|
||
];
|
||
const withHistory = makeRequest({
|
||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
|
||
messages: [
|
||
{ role: 'user', content: 'q', timestamp: 1 },
|
||
{
|
||
role: 'assistant',
|
||
content: null,
|
||
thinkingBlocks,
|
||
toolCalls: [{ id: 'tc1', name: 'read_file', args: {}, iteration: 1, timestamp: 1 }],
|
||
timestamp: 1,
|
||
},
|
||
{
|
||
role: 'tool',
|
||
content: 'data',
|
||
toolResult: {
|
||
toolCallId: 'tc1',
|
||
toolName: 'read_file',
|
||
result: 'data',
|
||
success: true,
|
||
durationMs: 1,
|
||
timestamp: 1,
|
||
},
|
||
timestamp: 1,
|
||
},
|
||
{ role: 'user', content: 'go on', timestamp: 2 },
|
||
],
|
||
});
|
||
mockFetch.mockResolvedValue(
|
||
okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }),
|
||
);
|
||
await adapter.send(withHistory);
|
||
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
const assistant = (
|
||
body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
|
||
).find(
|
||
(m) =>
|
||
m.role === 'assistant' &&
|
||
Array.isArray(m.content) &&
|
||
m.content.some((c) => c.type === 'tool_use'),
|
||
);
|
||
expect(assistant).toBeDefined();
|
||
// thinking 块位于 assistant content 首位(协议要求)
|
||
expect(assistant!.content[0]).toEqual({ type: 'thinking', thinking: 'deep', signature: 'sig' });
|
||
expect(assistant!.content[1]).toEqual({ type: 'redacted_thinking', data: 'opaque' });
|
||
|
||
// 思考关闭(降级重试路径)→ 不回传 thinking 块
|
||
const disabled = makeRequest({
|
||
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
|
||
messages: withHistory.messages,
|
||
});
|
||
mockFetch.mockReset();
|
||
mockFetch.mockResolvedValue(
|
||
okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }),
|
||
);
|
||
await adapter.send(disabled);
|
||
const body2 = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
const assistant2 = (
|
||
body2.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
|
||
).find(
|
||
(m) =>
|
||
m.role === 'assistant' &&
|
||
Array.isArray(m.content) &&
|
||
m.content.some((c) => c.type === 'tool_use'),
|
||
);
|
||
expect(
|
||
assistant2!.content.some((c) => c.type === 'thinking' || c.type === 'redacted_thinking'),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('pause_turn 流式自动续传:两段文本拼接、单次 DONE、续传请求原样携带本段 content', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch
|
||
.mockResolvedValueOnce(
|
||
sse([
|
||
'event: message_start',
|
||
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }),
|
||
'event: content_block_start',
|
||
jsonLine({
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'text', text: '' },
|
||
}),
|
||
'event: content_block_delta',
|
||
jsonLine({
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'text_delta', text: 'part1' },
|
||
}),
|
||
'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: 'pause_turn' },
|
||
}),
|
||
'event: content_block_stop',
|
||
jsonLine({ type: 'content_block_stop', index: 1 }),
|
||
'event: message_delta',
|
||
jsonLine({
|
||
type: 'message_delta',
|
||
delta: { stop_reason: 'pause_turn' },
|
||
usage: { output_tokens: 10 },
|
||
}),
|
||
'event: message_stop',
|
||
jsonLine({ type: 'message_stop' }),
|
||
]),
|
||
)
|
||
.mockResolvedValueOnce(
|
||
sse([
|
||
'event: message_start',
|
||
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }),
|
||
'event: content_block_start',
|
||
jsonLine({
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'text', text: '' },
|
||
}),
|
||
'event: content_block_delta',
|
||
jsonLine({
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'text_delta', text: 'part2' },
|
||
}),
|
||
'event: content_block_stop',
|
||
jsonLine({ type: 'content_block_stop', index: 0 }),
|
||
'event: message_delta',
|
||
jsonLine({
|
||
type: 'message_delta',
|
||
delta: { stop_reason: 'end_turn' },
|
||
usage: { output_tokens: 10 },
|
||
}),
|
||
'event: message_stop',
|
||
jsonLine({ type: 'message_stop' }),
|
||
]),
|
||
);
|
||
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
|
||
const texts = events
|
||
.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)
|
||
.map((e) => e.delta);
|
||
expect(texts.join('')).toBe('part1part2');
|
||
const dones = events.filter((e) => e.type === MetonaStreamEventType.DONE);
|
||
expect(dones).toHaveLength(1);
|
||
expect(dones[0].finishReason).toBe('stop');
|
||
|
||
// 第二次请求体应把第一段 content(含 pause_turn 块)原样追加为 assistant 消息
|
||
const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as {
|
||
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||
};
|
||
const carried = secondBody.messages[secondBody.messages.length - 1];
|
||
expect(carried.role).toBe('assistant');
|
||
expect(carried.content.some((c) => c.type === 'text' && c.text === 'part1')).toBe(true);
|
||
expect(carried.content.some((c) => c.type === 'pause_turn')).toBe(true);
|
||
});
|
||
|
||
it('非流式 pause_turn 同样续传至自然结束', async () => {
|
||
const adapter = makeAdapter();
|
||
mockFetch
|
||
.mockResolvedValueOnce(
|
||
okJson({
|
||
content: [{ type: 'text', text: 'half' }, { type: 'pause_turn' }],
|
||
usage: {},
|
||
stop_reason: 'pause_turn',
|
||
}),
|
||
)
|
||
.mockResolvedValueOnce(
|
||
okJson({ content: [{ type: 'text', text: 'done' }], usage: {}, stop_reason: 'end_turn' }),
|
||
);
|
||
const res = await adapter.send(makeRequest());
|
||
expect(res.finishReason).toBe(MetonaFinishReason.STOP);
|
||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||
const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as {
|
||
messages: Array<{ role: string }>;
|
||
};
|
||
expect(secondBody.messages[secondBody.messages.length - 1].role).toBe('assistant');
|
||
});
|
||
});
|