import { describe, expect, it, vi } from 'vitest' import { RecoverableModelToolError, type ModelToolDefinition, type ModelToolProviderLike, type ModelToolResult } from './model-tool-provider' import { ModelAgentRuntime } from './model-runtime' const toolPng = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a ]).toString('base64') function createTextToolResult(text: string): ModelToolResult { return { parts: [{ type: 'text', text }], contextBytes: Buffer.byteLength(text) } } function createMultimodalToolResult(): ModelToolResult { return { parts: [ { type: 'text', text: 'tool result' }, { type: 'image', mimeType: 'image/png', data: toolPng } ], contextBytes: Buffer.byteLength('tool result') + Buffer.byteLength(toolPng) } } function createEventStream(text: string, thinking?: string): string { return [ 'event: message_start', `data: ${JSON.stringify({ type: 'message_start', message: { id: 'message-1', model: 'claude-sonnet-provider', usage: { input_tokens: 23, cache_creation_input_tokens: 5, cache_read_input_tokens: 7 } } })}`, '', ...(thinking ? [ 'event: content_block_delta', `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'thinking_delta', thinking } })}`, '' ] : []), 'event: content_block_delta', `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text } })}`, '', 'event: message_delta', `data: ${JSON.stringify({ type: 'message_delta', usage: { output_tokens: 11 } })}`, '', 'event: message_stop', 'data: {"type":"message_stop"}', '', '' ].join('\n') } function createResponsesEventStream( text: string, reasoning?: string ): string { return [ ...(reasoning ? [ 'event: response.reasoning_summary_text.delta', `data: ${JSON.stringify({ type: 'response.reasoning_summary_text.delta', delta: reasoning })}`, '' ] : []), 'event: response.output_text.delta', `data: ${JSON.stringify({ type: 'response.output_text.delta', delta: text })}`, '', 'event: response.completed', `data: ${JSON.stringify({ type: 'response.completed', response: { id: 'resp-provider-1', model: 'gpt-5-provider', usage: { input_tokens: 29, output_tokens: 8, total_tokens: 37, input_tokens_details: { cached_tokens: 11 } } } })}`, '', '' ].join('\n') } function createToolProvider( overrides: Partial = {} ): ModelToolProviderLike { const tool: ModelToolDefinition = { name: 'workspace_read_text', displayName: '读取工作区文本', description: 'Read text', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }, source: 'builtin' } return { listTools: vi.fn(async () => [tool]), getApproval: vi.fn((_definition, _arguments, summary) => ({ scopeKey: 'model:builtin:workspace_read_text', title: '允许读取工作区文本?', description: '读取文件', toolName: '读取工作区文本', argumentSummary: summary })), callTool: vi.fn(async () => createTextToolResult('tool result')), releaseConversation: vi.fn(async () => {}), dispose: vi.fn(async () => {}), ...overrides } } describe('ModelAgentRuntime', () => { it('performs a real minimal request when testing the connection', async () => { const fetcher = vi.fn(async () => Response.json({ content: [{ type: 'text', text: 'OK' }] }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', fetcher }) await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, id: 'model' }) const body = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as { max_tokens: number; stream: boolean } expect(body).toMatchObject({ max_tokens: 1, stream: false }) }) it('uses the Anthropic messages endpoint and streams text deltas', async () => { const fetcher = vi.fn(async () => { return new Response(createEventStream('真实模型回答', '先分析问题'), { status: 200, headers: { 'content-type': 'text/event-stream' } }) }) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', skillInstructions: '# 文档写作', fetcher }) const events: Array<{ type: string; state?: string }> = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed125', conversationId: 'conversation-1', prompt: '你好', trustedInstructions: 'Trusted specialist system instruction.' }, new AbortController().signal )) { events.push(event) } expect(fetcher).toHaveBeenCalledOnce() const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe('https://bigtoken.ai/v1/messages') expect(init?.method).toBe('POST') const body = JSON.parse(init?.body as string) as { model: string stream: boolean system: string } expect(body).toMatchObject({ model: 'sonnet-5', stream: true }) expect(body.system).toContain('# 文档写作') expect(body.system).toContain('Trusted specialist system instruction.') expect(events).toContainEqual( expect.objectContaining({ type: 'reasoning', delta: '先分析问题' }) ) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '真实模型回答' }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed125', type: 'model-usage', callId: 'message-1', runtime: 'model', provider: 'anthropic', model: 'claude-sonnet-provider', inputTokens: 23, outputTokens: 11, cacheReadTokens: 7, cacheWriteTokens: 5 } ]) expect(events.at(-2)).toMatchObject({ type: 'model-usage' }) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('rejects a stream that ends without message_stop', async () => { const fetcher = vi.fn(async () => { return new Response( 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}', { status: 200, headers: { 'content-type': 'text/event-stream' } } ) }) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', fetcher }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed126', conversationId: 'conversation-2', prompt: '你好' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow('意外中断') }) it('preserves bounded provider error messages', async () => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'claude-sonnet-5', protocol: 'anthropic-messages', authentication: 'api-key', fetcher: vi.fn(async () => Response.json( { error: { message: 'upstream failed Authorization: Bearer secret-token' } }, { status: 502 } ) ) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: 'test' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow( 'upstream failed Authorization: Bearer secret-token' ) }) it('uses OpenAI Chat Completions SSE and omits auth for Ollama', async () => { const stream = [ `data: ${JSON.stringify({ choices: [{ delta: { content: '本机回答' } }] })}`, '', `data: ${JSON.stringify({ id: 'chatcmpl-provider-1', model: 'qwen3-provider', choices: [], usage: { prompt_tokens: 31, completion_tokens: 9, total_tokens: 40, prompt_tokens_details: { cached_tokens: 13 }, cache_write_tokens: 4 } })}`, '', 'data: [DONE]', '', '' ].join('\n') const fetcher = vi.fn(async () => new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }) ) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed127', conversationId: 'conversation-3', prompt: '你好' }, new AbortController().signal )) { events.push(event) } const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe( 'http://127.0.0.1:11434/v1/chat/completions' ) expect(init?.headers).toEqual({ 'content-type': 'application/json' }) expect(JSON.parse(init?.body as string)).toMatchObject({ model: 'qwen3', stream: true, stream_options: { include_usage: true }, messages: [ expect.objectContaining({ role: 'system' }), expect.objectContaining({ role: 'user', content: '你好' }) ] }) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '本机回答' }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed127', type: 'model-usage', callId: 'chatcmpl-provider-1', runtime: 'model', provider: 'openai', model: 'qwen3-provider', inputTokens: 31, outputTokens: 9, cacheReadTokens: 13, cacheWriteTokens: 4, reportedTotalTokens: 40 } ]) expect(events.at(-2)).toMatchObject({ type: 'model-usage' }) expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(toolProvider.listTools).not.toHaveBeenCalled() }) it.each(['ask', 'plan'] as const)( 'keeps browser and workspace tools out of %s mode', async (workMode) => { const fetcher = vi.fn(async () => new Response('data: {"choices":[{"delta":{"content":"只读回答"}}]}\n\ndata: [DONE]\n\n', { status: 200, headers: { 'content-type': 'text/event-stream' } }) ) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: `conversation-${workMode}`, prompt: '只读', workMode }, new AbortController().signal )) { void _event } expect(toolProvider.listTools).not.toHaveBeenCalled() expect(toolProvider.callTool).not.toHaveBeenCalled() } ) it('uses the OpenAI Responses endpoint and streams output text', async () => { const fetcher = vi.fn(async () => new Response( createResponsesEventStream('Responses 回答', 'Responses 推理'), { status: 200, headers: { 'content-type': 'text/event-stream' } } ) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-5', protocol: 'openai-responses', authentication: 'api-key', fetcher }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed133', conversationId: 'conversation-responses', prompt: '你好' }, new AbortController().signal )) { events.push(event) } const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe('https://api.openai.com/v1/responses') expect(init?.headers).toEqual({ authorization: 'Bearer test-key', 'content-type': 'application/json' }) expect(JSON.parse(init?.body as string)).toMatchObject({ model: 'gpt-5', max_output_tokens: 4096, stream: true, instructions: expect.stringContaining('GoodBuddy'), input: [ expect.objectContaining({ role: 'user', content: '你好' }) ] }) expect(events).toContainEqual( expect.objectContaining({ type: 'reasoning', delta: 'Responses 推理' }) ) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: 'Responses 回答' }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed133', type: 'model-usage', callId: 'resp-provider-1', runtime: 'model', provider: 'openai', model: 'gpt-5-provider', inputTokens: 29, outputTokens: 8, cacheReadTokens: 11, cacheWriteTokens: 0, reportedTotalTokens: 37 } ]) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('tests an OpenAI Responses connection with Responses request fields', async () => { const fetcher = vi.fn(async () => Response.json({ id: 'resp-test', output: [] }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1/', model: 'gpt-5', protocol: 'openai-responses', authentication: 'api-key', fetcher }) await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, detail: expect.stringContaining('已验证') }) expect(fetcher.mock.calls[0]?.[0]?.toString()).toBe( 'https://api.openai.com/v1/responses' ) expect( JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string) ).toEqual({ model: 'gpt-5', max_output_tokens: 16, stream: false, input: 'Reply OK.' }) }) it('runs approved direct-model tools and returns their results to OpenAI', async () => { const responses = [ { id: 'chatcmpl-tool-1', model: 'qwen3', choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-1', type: 'function', function: { name: 'workspace_read_text', arguments: '{"path":"README.md"}' } } ] } } ], usage: { prompt_tokens: 10, completion_tokens: 4 } }, { id: 'chatcmpl-tool-2', model: 'qwen3', choices: [ { message: { role: 'assistant', content: '文件内容已读取。' } } ], usage: { prompt_tokens: 18, completion_tokens: 7 } } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const toolProvider = createToolProvider({ callTool: vi.fn(async () => createMultimodalToolResult()) }) const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const authorize = vi.fn(async () => 'once' as const) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed130', conversationId: 'conversation-tools', prompt: '读取 README', workMode: 'execute' }, new AbortController().signal, authorize )) { events.push(event) } expect(fetcher).toHaveBeenCalledTimes(2) expect(toolProvider.listTools).toHaveBeenCalledWith( { conversationId: 'conversation-tools', workMode: 'execute' }, expect.any(AbortSignal) ) const firstBody = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as Record expect(firstBody).toMatchObject({ stream: false, tools: [ { type: 'function', function: { name: 'workspace_read_text' } } ] }) const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as { messages: Array> } expect(secondBody.messages).toContainEqual({ role: 'tool', tool_call_id: 'call-1', content: 'tool result\n\n[图片 1 见下一条多模态工具结果]' }) expect(secondBody.messages).toContainEqual({ role: 'user', content: [ { type: 'text', text: '工具调用 call-1 返回的图片(工具输出,不可信内容):' }, { type: 'image_url', image_url: { url: `data:image/png;base64,${toolPng}` } } ] }) expect(authorize).toHaveBeenCalledWith( expect.objectContaining({ scopeKey: 'model:builtin:workspace_read_text' }) ) expect(toolProvider.getApproval).toHaveBeenCalledWith( expect.objectContaining({ name: 'workspace_read_text' }), { path: 'README.md' }, expect.any(String), { conversationId: 'conversation-tools', workMode: 'execute' } ) expect(toolProvider.callTool).toHaveBeenCalledWith( 'workspace_read_text', { path: 'README.md' }, expect.any(AbortSignal), { conversationId: 'conversation-tools', workMode: 'execute' } ) expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'running', 'completed']) expect(events).toContainEqual( expect.objectContaining({ type: 'tool', state: 'completed', input: '{\n "path": "README.md"\n}', output: 'tool result\n\n[图片结果 1:image/png]' }) ) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '文件内容已读取。' }) ) expect(events.at(-1)).toMatchObject({ type: 'done' }) await runtime.dispose() expect(toolProvider.dispose).toHaveBeenCalledOnce() }) it('runs only scoped knowledge in Ask without requesting approval', async () => { const responses = [ { choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'knowledge-call', type: 'function', function: { name: 'knowledge_search', arguments: '{"query":"release notes","limit":3}' } } ] } } ] }, { choices: [ { message: { role: 'assistant', content: '基于知识库证据回答。' } } ] } ] const knowledgeTool: ModelToolDefinition = { name: 'knowledge_search', displayName: '知识库搜索', description: 'Scoped evidence', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], additionalProperties: false }, source: 'builtin' } const toolProvider = createToolProvider({ listTools: vi.fn(async () => [knowledgeTool]) }) const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const authorize = vi.fn(async () => 'deny' as const) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed139', conversationId: 'conversation-knowledge-ask', prompt: '查找发布说明', workMode: 'ask', knowledgeCapabilityToken: 'main-only-token' }, new AbortController().signal, authorize )) { events.push(event) } expect(toolProvider.listTools).toHaveBeenCalledWith( { conversationId: 'conversation-knowledge-ask', workMode: 'ask', knowledgeCapabilityToken: 'main-only-token' }, expect.any(AbortSignal) ) expect(toolProvider.callTool).toHaveBeenCalledWith( 'knowledge_search', { query: 'release notes', limit: 3 }, expect.any(AbortSignal), expect.objectContaining({ workMode: 'ask', knowledgeCapabilityToken: 'main-only-token' }) ) expect(authorize).not.toHaveBeenCalled() expect(toolProvider.getApproval).not.toHaveBeenCalled() expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('returns recoverable tool failures to the model instead of aborting the run', async () => { const responses = [ { choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-stale-ref', type: 'function', function: { name: 'workspace_read_text', arguments: '{"path":"README.md"}' } } ] } } ] }, { choices: [ { message: { role: 'assistant', content: '已获取新快照并继续。' } } ] } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const toolProvider = createToolProvider({ callTool: vi.fn(async () => { throw new RecoverableModelToolError( '浏览器元素引用已失效,请重新获取快照', '调用 browser_snapshot 后重试' ) }) }) const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed130', conversationId: 'conversation-recoverable-tool-error', prompt: '继续浏览器操作', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { events.push(event) } expect(fetcher).toHaveBeenCalledTimes(2) const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as { messages: Array> } const toolMessage = secondBody.messages.find( (message) => message.role === 'tool' ) expect(JSON.parse(toolMessage?.content as string)).toEqual({ ok: false, recoverable: true, error: '浏览器元素引用已失效,请重新获取快照', nextAction: '调用 browser_snapshot 后重试' }) expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'running', 'recoverable']) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: '已获取新快照并继续。' }) ) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('continues OpenAI Responses with function_call_output', async () => { const responses = [ { id: 'resp-tool-1', model: 'gpt-5', output: [ { id: 'msg-responses-1', type: 'message', role: 'assistant', status: 'completed', content: [ { type: 'output_text', text: '先读取 README。' } ] }, { id: 'fc-responses-1', type: 'function_call', call_id: 'call-responses-1', name: 'workspace_read_text', arguments: '{"path":"README.md"}' } ], usage: { input_tokens: 14, output_tokens: 3 } }, { id: 'resp-tool-2', model: 'gpt-5', output: [ { id: 'fc-responses-2', type: 'function_call', call_id: 'call-responses-2', name: 'workspace_read_text', arguments: '{"path":"DESIGN.md"}' } ], usage: { input_tokens: 21, output_tokens: 4 } }, { id: 'resp-tool-3', model: 'gpt-5', output: [ { type: 'message', role: 'assistant', content: [ { type: 'output_text', text: 'Responses 工具调用完成。' } ] } ], usage: { input_tokens: 30, output_tokens: 6 } } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://api.openai.com/v1', model: 'gpt-5', protocol: 'openai-responses', authentication: 'api-key', fetcher, toolProvider: createToolProvider({ callTool: vi.fn(async () => createMultimodalToolResult()) }) }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed134', conversationId: 'conversation-responses-tools', prompt: '读取 README', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { events.push(event) } const firstBody = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as Record expect(firstBody).toMatchObject({ model: 'gpt-5', stream: false, tools: [ { type: 'function', name: 'workspace_read_text', strict: false } ] }) expect(firstBody).not.toHaveProperty('previous_response_id') const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as Record expect(secondBody).toMatchObject({ input: [ { role: 'user', content: '读取 README' }, { id: 'msg-responses-1', type: 'message', role: 'assistant', status: 'completed', content: [ { type: 'output_text', text: '先读取 README。' } ] }, { id: 'fc-responses-1', type: 'function_call', call_id: 'call-responses-1', name: 'workspace_read_text', arguments: '{"path":"README.md"}' }, { type: 'function_call_output', call_id: 'call-responses-1', output: [ { type: 'input_text', text: 'tool result' }, { type: 'input_image', image_url: `data:image/png;base64,${toolPng}` } ] } ] }) const thirdBody = JSON.parse( fetcher.mock.calls[2]?.[1]?.body as string ) as { input: Array> } expect(thirdBody.input).toEqual([ ...(secondBody.input as Array>), { id: 'fc-responses-2', type: 'function_call', call_id: 'call-responses-2', name: 'workspace_read_text', arguments: '{"path":"DESIGN.md"}' }, { type: 'function_call_output', call_id: 'call-responses-2', output: [ { type: 'input_text', text: 'tool result' }, { type: 'input_image', image_url: `data:image/png;base64,${toolPng}` } ] } ]) for (const [, init] of fetcher.mock.calls) { expect(JSON.parse(init?.body as string)).not.toHaveProperty( 'previous_response_id' ) } expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual([ 'pending', 'running', 'completed', 'pending', 'running', 'completed' ]) expect(events).toContainEqual( expect.objectContaining({ type: 'text', delta: 'Responses 工具调用完成。' }) ) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('fails closed when a direct-model tool is denied', async () => { const fetcher = vi.fn(async () => Response.json({ choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-denied', type: 'function', function: { name: 'workspace_read_text', arguments: '{"path":"secret.txt"}' } } ] } } ] }) ) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const events: Array<{ type: string; state?: string }> = [] const consume = async (): Promise => { for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed131', conversationId: 'conversation-denied', prompt: '读取 secret', workMode: 'execute' }, new AbortController().signal, async () => 'deny' )) { events.push(event) } } await expect(consume()).rejects.toThrow('用户拒绝') expect( events .filter((event) => event.type === 'tool') .map((event) => event.state) ).toEqual(['pending', 'failed']) expect(toolProvider.callTool).not.toHaveBeenCalled() }) it('uses Anthropic tool_use and tool_result messages in Execute mode', async () => { const responses = [ { id: 'message-tool-1', model: 'claude', content: [ { type: 'tool_use', id: 'toolu-1', name: 'workspace_read_text', input: { path: 'notes.md' } } ], usage: { input_tokens: 12, output_tokens: 3 } }, { id: 'message-tool-2', model: 'claude', content: [{ type: 'text', text: '读取完成。' }], usage: { input_tokens: 20, output_tokens: 5 } } ] const fetcher = vi.fn(async () => Response.json(responses.shift()) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai', model: 'claude', protocol: 'anthropic-messages', authentication: 'api-key', fetcher, toolProvider: createToolProvider({ callTool: vi.fn(async () => createMultimodalToolResult()) }) }) for await (const _event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed132', conversationId: 'conversation-anthropic-tools', prompt: '读取 notes', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { void _event } const firstBody = JSON.parse( fetcher.mock.calls[0]?.[1]?.body as string ) as Record expect(firstBody).toMatchObject({ stream: false, tools: [ { name: 'workspace_read_text', input_schema: expect.objectContaining({ type: 'object' }) } ] }) const secondBody = JSON.parse( fetcher.mock.calls[1]?.[1]?.body as string ) as { messages: Array> } expect(secondBody.messages.at(-1)).toEqual({ role: 'user', content: [ { type: 'tool_result', tool_use_id: 'toolu-1', content: [ { type: 'text', text: 'tool result' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: toolPng } } ] } ] }) }) it('does not issue a follow-up model request after tool cancellation', async () => { const response = { choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-aborted', type: 'function', function: { name: 'workspace_read_text', arguments: '{}' } } ] } } ] } const fetcher = vi.fn(async () => Response.json(response)) const controller = new AbortController() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider: createToolProvider({ callTool: vi.fn(async () => { controller.abort() return createTextToolResult('late result') }) }) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: 'run', workMode: 'execute' }, controller.signal, async () => 'once' )) { void _event } } await expect(consume()).rejects.toThrow() expect(fetcher).toHaveBeenCalledOnce() }) it('terminates repeated identical tool rounds without exhausting hard limits', async () => { let callId = 0 const fetcher = vi.fn(async () => { callId += 1 return Response.json({ choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: `call-repeat-${callId}`, type: 'function', function: { name: 'workspace_read_text', arguments: '{"path":"README.md"}' } } ] } } ] }) }) const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: 'conversation-repeat', prompt: 'repeat', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { void _event } } await expect(consume()).rejects.toThrow('没有取得进展') expect(fetcher).toHaveBeenCalledTimes(3) expect(toolProvider.callTool).toHaveBeenCalledTimes(2) }) it('releases provider state for only the requested conversation', async () => { const toolProvider = createToolProvider() const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', toolProvider }) await runtime.releaseConversation('conversation-release') expect(toolProvider.releaseConversation).toHaveBeenCalledOnce() expect(toolProvider.releaseConversation).toHaveBeenCalledWith( 'conversation-release' ) }) it('releases known conversations before provider disposal and permits replacement reuse', async () => { const lifecycle: string[] = [] const released = new Set() const createProvider = (): ModelToolProviderLike => createToolProvider({ releaseConversation: vi.fn(async (conversationId) => { lifecycle.push(`release:${conversationId}`) released.add(conversationId) }), dispose: vi.fn(async () => { lifecycle.push('dispose') }) }) const createRuntime = (toolProvider: ModelToolProviderLike) => new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher: vi.fn(async () => new Response( 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', { status: 200, headers: { 'content-type': 'text/event-stream' } } ) ), toolProvider }) const request = { requestId: crypto.randomUUID(), conversationId: 'conversation-replacement', prompt: 'hello', workMode: 'ask' as const } const firstProvider = createProvider() const firstRuntime = createRuntime(firstProvider) for await (const _event of firstRuntime.run( request, new AbortController().signal )) { void _event } await firstRuntime.dispose() expect(lifecycle).toEqual([ 'release:conversation-replacement', 'dispose' ]) expect(released).toContain('conversation-replacement') const replacement = createRuntime(createProvider()) const replacementEvents = [] for await (const event of replacement.run( { ...request, requestId: crypto.randomUUID() }, new AbortController().signal )) { replacementEvents.push(event) } expect(replacementEvents.at(-1)).toMatchObject({ type: 'done' }) await replacement.dispose() }) it('counts image base64 data against the aggregate tool context limit', async () => { const response = { choices: [ { message: { role: 'assistant', content: null, tool_calls: [ { id: 'call-large-image', type: 'function', function: { name: 'workspace_read_text', arguments: '{}' } } ] } } ] } const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64') const fetcher = vi.fn(async () => Response.json(response)) const runtime = new ModelAgentRuntime({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen3', protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider: createToolProvider({ callTool: vi.fn(async () => ({ parts: [ { type: 'image' as const, mimeType: 'image/png' as const, data: imageData } ], contextBytes: Buffer.byteLength(imageData) })) }) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: 'run', workMode: 'execute' }, new AbortController().signal, async () => 'once' )) { void _event } } await expect(consume()).rejects.toThrow('结果总量超过 1MB') expect(fetcher).toHaveBeenCalledOnce() }) it('reports image configuration checks without pretending to generate', async () => { const fetcher = vi.fn() const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', imageGenerationQuality: 'medium', fetcher }) await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, capability: 'image-generation', detail: expect.stringContaining( '发送提示词时执行实际生成验证' ) }) expect(fetcher).not.toHaveBeenCalled() }) it('generates a bounded image through the BigToken-compatible endpoint', async () => { const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00 ]).toString('base64') const fetcher = vi.fn(async () => Response.json({ id: 'image-provider-1', model: 'gpt-image-provider', usage: { input_tokens: 17, output_tokens: 29, total_tokens: 46 }, data: [{ b64_json: png }] }) ) const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', imageGenerationQuality: 'high', fetcher }) const events = [] for await (const event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed128', conversationId: 'conversation-image', prompt: '一只在窗边睡觉的猫' }, new AbortController().signal )) { events.push(event) } const [input, init] = fetcher.mock.calls[0] ?? [] expect(input?.toString()).toBe( 'https://bigtoken.ai/v1/images/generations' ) expect(init?.headers).toEqual({ authorization: 'Bearer test-key', 'content-type': 'application/json' }) expect(JSON.parse(init?.body as string)).toEqual({ model: 'gpt-image-2', prompt: '一只在窗边睡觉的猫', n: 1, quality: 'high', response_format: 'b64_json' }) expect(events).toContainEqual( expect.objectContaining({ type: 'generated-image', mimeType: 'image/png', data: png }) ) expect(events.filter((event) => event.type === 'model-usage')).toEqual([ { requestId: 'a431666e-5ec8-45e6-beb4-654132eed128', type: 'model-usage', callId: 'image-provider-1', runtime: 'model', provider: 'openai', model: 'gpt-image-provider', inputTokens: 17, outputTokens: 29, cacheReadTokens: 0, cacheWriteTokens: 0, reportedTotalTokens: 46 } ]) expect(events.findIndex((event) => event.type === 'model-usage')).toBeLessThan( events.findIndex((event) => event.type === 'generated-image') ) expect(events.at(-1)).toMatchObject({ type: 'done' }) }) it('rejects remote image URLs instead of fetching provider output', async () => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher: vi.fn(async () => Response.json({ data: [{ url: 'https://untrusted.example/image.png' }] }) ) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: 'a431666e-5ec8-45e6-beb4-654132eed129', conversationId: 'conversation-image-url', prompt: '测试图片' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow('未返回 base64 图片') }) it('accepts a bounded inline image data URL from compatible gateways', async () => { const png = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00 ]).toString('base64') const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher: vi.fn(async () => Response.json({ data: [{ url: `data:image/png;base64,${png}` }] }) ) }) const events = [] for await (const event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: '测试内联图片' }, new AbortController().signal )) { events.push(event) } expect(events).toContainEqual( expect.objectContaining({ type: 'generated-image', mimeType: 'image/png', data: png }) ) }) it.each([ { body: JSON.stringify({ error: { message: 'upstream unavailable Authorization: Bearer secret-token' } }), headers: { 'content-type': 'application/json', 'x-request-id': 'image-request-502' }, expected: 'upstream unavailable Authorization: Bearer secret-token(HTTP 502,请求 ID image-request-502)' }, { body: 'Bad Gateway', headers: { 'content-type': 'text/html' }, expected: '图像生成请求失败(HTTP 502)' }, { body: JSON.stringify({ error: '模型接口请求失败(HTTP 502)' }), headers: { 'content-type': 'application/json' }, expected: '上游图像服务暂时不可用,请稍后重试或联系服务商(HTTP 502)' } ])( 'retains HTTP status for image gateway failures', async ({ body, headers, expected }) => { const runtime = new ModelAgentRuntime({ apiKey: 'test-key', baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key', fetcher: vi.fn(async () => new Response(body, { status: 502, headers }) ) }) const consume = async (): Promise => { for await (const _event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: '测试网关错误' }, new AbortController().signal )) { void _event } } await expect(consume()).rejects.toThrow(expected) } ) it.runIf( process.env.GOODBUDDY_BIGTOKEN_IMAGE_INTEGRATION === '1' )( 'generates a real synthetic image with BigToken gpt-image-2', async () => { const apiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY if (!apiKey) { throw new Error('GOODBUDDY_BIGTOKEN_API_KEY is required') } const runtime = new ModelAgentRuntime({ apiKey, baseUrl: 'https://bigtoken.ai/v1', model: 'gpt-image-2', protocol: 'openai-images-generations', authentication: 'api-key' }) const events = [] for await (const event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: 'A simple solid blue circle centered on a plain white background.' }, new AbortController().signal )) { events.push(event) } expect(events).toContainEqual( expect.objectContaining({ type: 'generated-image', mimeType: expect.stringMatching(/^image\//u) }) ) await runtime.dispose() }, 180_000 ) })