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> = {}
): 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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<void> => {
      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<typeof fetch>(async () =>
        Response.json(
          {
            error: {
              message:
                'upstream failed Authorization: Bearer secret-token'
            }
          },
          { status: 502 }
        )
      )
    })
    const consume = async (): Promise<void> => {
      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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<string, unknown>
    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<Record<string, unknown>> }
    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<typeof fetch>(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<typeof fetch>(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<Record<string, unknown>> }
    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<typeof fetch>(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<string, unknown>
    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<string, unknown>
    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<Record<string, unknown>>
    }
    expect(thirdBody.input).toEqual([
      ...(secondBody.input as Array<Record<string, unknown>>),
      {
        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<typeof fetch>(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<void> => {
      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<typeof fetch>(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<string, unknown>
    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<Record<string, unknown>> }
    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<typeof fetch>(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<void> => {
      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<typeof fetch>(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<void> => {
      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<string>()
    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<typeof fetch>(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<typeof fetch>(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<void> => {
      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<typeof fetch>()
    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<typeof fetch>(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<typeof fetch>(async () =>
        Response.json({
          data: [{ url: 'https://untrusted.example/image.png' }]
        })
      )
    })

    const consume = async (): Promise<void> => {
      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<typeof fetch>(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: '<html>Bad Gateway</html>',
      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<typeof fetch>(async () =>
          new Response(body, { status: 502, headers })
        )
      })
      const consume = async (): Promise<void> => {
        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
  )
})
