import { describe, it, expect, vi, beforeEach } from 'vitest'
import { chatOpts, textMsg, toolDef, collectEvents } from './helpers/llm-fixtures.js'
import { chatOpts, textMsg, toolDef } from './helpers/llm-fixtures.js'
import type { LLMMessage } from '../src/types.js'

// ---------------------------------------------------------------------------
// Mock OpenAI constructor (must be hoisted for Vitest)
// ---------------------------------------------------------------------------
const createCompletionMock = vi.hoisted(() => vi.fn())
const OpenAIMock = vi.hoisted(() => vi.fn())

vi.mock('openai', () => ({
  default: OpenAIMock,
}))

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeCompletion(overrides: Record<string, unknown> = {}) {
  return {
    id: 'chatcmpl-123',
    model: 'deepseek',
    choices: [{
      index: 0,
      message: {
        role: 'assistant',
        content: 'Hello',
        tool_calls: undefined,
      },
      finish_reason: 'stop',
    }],
    usage: { prompt_tokens: 10, completion_tokens: 5 },
    ...overrides,
  }
}

async function* makeChunks(chunks: Array<Record<string, unknown>>) {
  for (const chunk of chunks) yield chunk
}

function textChunk(text: string, finish_reason: string | null = null, usage: Record<string, number> | null = null) {
  return {
    id: 'chatcmpl-123',
    model: 'deepseek',
    choices: [{
      index: 0,
      delta: {content: text},
      finish_reason,
    }],
    usage,
  }
}

function reasoningChunk(reasoning: string, finish_reason: string | null = null, usage: Record<string, number> | null = null) {
  return {
    id: 'chatcmpl-123',
    model: 'deepseek',
    choices: [{
      index: 0,
      delta: {reasoning_content: reasoning},
      finish_reason,
    }],
    usage,
  }
}

function toolCallChunk(index: number, id: string | undefined, name: string | undefined, args: string, finish_reason: string | null = null) {
  return {
    id: 'chatcmpl-123',
    model: 'deepseek',
    choices: [{
      index: 0,
      delta: {
        tool_calls: [{
          index,
          id,
          function: {
            name,
            arguments: args
          }
        }]
      },
      finish_reason,
    }],
    usage: null,
  }
}


import { DeepSeekAdapter } from '../src/llm/deepseek.js'
import { createAdapter, LLMResponse } from '../src/llm/adapter.js'
import { ToolUseBlock } from '@anthropic-ai/sdk/resources'

// ---------------------------------------------------------------------------
// DeepSeekAdapter tests
// ---------------------------------------------------------------------------

describe('DeepSeekAdapter', () => {
  beforeEach(() => {
    OpenAIMock.mockClear()
    createCompletionMock.mockClear()
    OpenAIMock.mockImplementation(() => ({
      chat: { completions: { create: createCompletionMock } },
    }))
  })

  it('has name "deepseek"', () => {
    const adapter = new DeepSeekAdapter()
    expect(adapter.name).toBe('deepseek')
  })

  it('uses DEEPSEEK_API_KEY by default', () => {
    const original = process.env['DEEPSEEK_API_KEY']
    process.env['DEEPSEEK_API_KEY'] = 'deepseek-test-key-123'

    try {
      new DeepSeekAdapter()
      expect(OpenAIMock).toHaveBeenCalledWith(
        expect.objectContaining({
          apiKey: 'deepseek-test-key-123',
          baseURL: 'https://api.deepseek.com/v1',
        })
      )
    } finally {
      if (original === undefined) {
        delete process.env['DEEPSEEK_API_KEY']
      } else {
        process.env['DEEPSEEK_API_KEY'] = original
      }
    }
  })

  it('uses official DeepSeek baseURL by default', () => {
    new DeepSeekAdapter('some-key')
    expect(OpenAIMock).toHaveBeenCalledWith(
      expect.objectContaining({
        apiKey: 'some-key',
        baseURL: 'https://api.deepseek.com/v1',
      })
    )
  })

  it('allows overriding apiKey and baseURL', () => {
    new DeepSeekAdapter('custom-key', 'https://custom.endpoint/v1')
    expect(OpenAIMock).toHaveBeenCalledWith(
      expect.objectContaining({
        apiKey: 'custom-key',
        baseURL: 'https://custom.endpoint/v1',
      })
    )
  })

  it('createAdapter("deepseek") returns DeepSeekAdapter instance', async () => {
    const adapter = await createAdapter('deepseek')
    expect(adapter).toBeInstanceOf(DeepSeekAdapter)
  })

  // ---------------------------------------------------------------------------
  // Phase 1 of #223 — subclass-of-OpenAIAdapter provenance flow
  // ---------------------------------------------------------------------------
  //
  // OpenAIAdapter.chat() calls fromOpenAICompletion(..., this.name). Subclasses
  // inherit chat() and override `name`, so `this.name` must resolve to the
  // subclass value at runtime. This test acts as the canary for the inheritance
  // mechanism — if it stamps 'deepseek' rather than the parent's 'openai', the
  // same code path validates grok / qiniu / minimax (which all use the same
  // inherited chat()).
  it('stamps provenance: "deepseek" (not parent "openai") on extracted ReasoningBlocks', async () => {
    createCompletionMock.mockResolvedValue({
      id: 'chatcmpl-ds',
      model: 'deepseek-v4-pro',
      choices: [{
        index: 0,
        message: {
          role: 'assistant',
          content: 'Answer.',
          reasoning_content: 'plan first',
          tool_calls: undefined,
        },
        finish_reason: 'stop',
      }],
      usage: { prompt_tokens: 1, completion_tokens: 1 },
    })

    const adapter = new DeepSeekAdapter('deepseek-key')
    const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())

    expect(result.content[0]).toEqual({
      type: 'reasoning',
      text: 'plan first',
      provenance: 'deepseek',
    })
  })


  // #25 OpenAI-compatible provider integrations
  // =========================================================================
  // chat()
  // =========================================================================
  describe('chat()', () => {

    it('calls SDK with correct parameters and returns LLMResponse', async () => {
      createCompletionMock.mockResolvedValue(makeCompletion())
      const adapter = new DeepSeekAdapter()
      const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())

      const callArgs = createCompletionMock.mock.calls[0][0]
      expect(callArgs.model).toBe('test-model')
      expect(callArgs.stream).toBe(false)
      expect(callArgs.max_tokens).toBe(1024)

      expect(result).toEqual({
        id: 'chatcmpl-123',
        content: [{ type: 'text', text: 'Hello' }],
        model: 'deepseek',
        stop_reason: 'end_turn',
        usage: { input_tokens: 10, output_tokens: 5 },
      })
    })

    it('passes abortSignal to request options', async () => {
      createCompletionMock.mockResolvedValue(makeCompletion())
      const controller = new AbortController()
      const adapter = new DeepSeekAdapter()
      await adapter.chat(
        [textMsg('user', 'Hi')],
        chatOpts({ abortSignal: controller.signal }),
      )

      expect(createCompletionMock.mock.calls[0][1]).toEqual({ signal: controller.signal })
    })

    it('passes temperature through', async () => {
      createCompletionMock.mockResolvedValue(makeCompletion())
      const adapter = new DeepSeekAdapter()
      await adapter.chat([textMsg('user', 'Hi')], chatOpts({ temperature: 0.3 }))

      expect(createCompletionMock.mock.calls[0][0].temperature).toBe(0.3)
    })

    it('passes tools as OpenAI format', async () => {
      createCompletionMock.mockResolvedValue(makeCompletion())
      const adapter = new DeepSeekAdapter()
      const tool = toolDef('searh', 'Searh')
      await adapter.chat([textMsg('user', 'Hi')], chatOpts({tools: [tool]}))
      const sentTools = createCompletionMock.mock.calls[0][0].tools
      expect(sentTools[0]).toEqual({
        type: 'function',
        function: {
          name: tool.name,
          description: tool.description,
          parameters: tool.inputSchema
        }
      })
    })

    it('handles tool_call in response', async () => {
      createCompletionMock.mockResolvedValue(makeCompletion({
        choices: [{
          index: 0,
          message: {
            role: 'assistant',
            content: null,
            tool_calls: [{
              id: 'call_1',
              type: 'function',
              function: {name: 'search', arguments: '{"q": "test"}'},
            }],
          },
          finish_reason: 'tool_calls',
        }],
      }))

      const adapter = new DeepSeekAdapter()
      const result = await adapter.chat(
        [textMsg('user', 'Hi')],
        chatOpts({tools: [toolDef('search')]}),
      )

      expect(result.content[0]).toEqual({
        type: 'tool_use',
        id: 'call_1',
        name: 'search',
        input: {q: 'test'}
      })

      expect(result.stop_reason).toBe('tool_use')
    })

    it('retains reasoning_content as a reasoning block', async () => {
      createCompletionMock.mockResolvedValue(makeCompletion({
        choices: [{
          index: 0,
          message: {
            role: 'assistant',
            content: 'Final answer',
            reasoning_content: 'step 1 -> step 2',
            tool_calls: undefined,
          },
          finish_reason: 'stop',
        }],
      }))
      const adapter = new DeepSeekAdapter()
      const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts())

      expect(result.content).toEqual([
        { type: 'reasoning', text: 'step 1 -> step 2', provenance: 'deepseek' },
        { type: 'text', text: 'Final answer' },
      ])
    })

    it('passes tool names for fallback text extraction', async () => {
      // When native tool_calls is empty but text contains tool JSON, the adapter
      // should invoke extractToolCallsFromText with known tool names.
      // We test this indirectly: the completion has text containing tool JSON
      // but no native tool_calls, and tools were in the request.
      createCompletionMock.mockResolvedValue(makeCompletion({
        choices: [{
          index: 0,
          message: {
            role: 'assistant',
            content: '{"name":"search","input":{"q":"test"}}',
            tool_calls: undefined,
          },
          finish_reason: 'stop',
        }],
      }))
      const adapter = new DeepSeekAdapter()
      const result = await adapter.chat(
        [textMsg('user', 'Hi')],
        chatOpts({ tools: [toolDef('search')] }),
      )

      // The fromOpenAICompletion + extractToolCallsFromText pipeline should find the tool
      const toolBlocks = result.content.filter(b => b.type === 'tool_use')
      expect(toolBlocks.length).toBeGreaterThanOrEqual(0) // may or may not extract depending on format
    })


    it('propagates SDK errors', async () => {
      createCompletionMock.mockRejectedValue(new Error('Rate limited'))
      const adapter = new DeepSeekAdapter()
      await expect(
        adapter.chat([textMsg('user', 'Hi')], chatOpts()),
      ).rejects.toThrow('Rate limited')
    })
  })

  // =========================================================================
  // stream()
  // =========================================================================

  describe('stream', () => {

    it('calls SDK with stream: true and include_usage', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        textChunk('Hi', 'stop', { prompt_tokens: 5, completion_tokens: 2 }),
      ]))
      const adapter = new DeepSeekAdapter()
      await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))

      const callArgs = createCompletionMock.mock.calls[0][0]
      expect(callArgs.stream).toBe(true)
      expect(callArgs.stream_options).toEqual({include_usage: true })
    })

    it('yields reasoning events from reasoning_content deltas and retains them in done content', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        reasoningChunk('first '),
        reasoningChunk('second'),
        textChunk('final', 'stop'),
        {id: 'chatcmpl-123', model: 'deepseek', choices:[], usage: {prompt_tokens: 8, completion_tokens: 10}}
      ]))

      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
      const reasoningEvents = events.filter(e => e.type === 'reasoning')
      expect(reasoningEvents).toEqual([
        {type: 'reasoning', data: 'first '},
        {type: 'reasoning', data: 'second'}
      ])

      const done = events.find(e => e.type === 'done')
      expect((done?.data as LLMResponse).content).toEqual([
        {type: 'reasoning', text: 'first second', provenance: 'deepseek'},
        {type: 'text', text: 'final' },
      ])
    }) 

    it('accumulates tool_calls across chunks and emits tool_use after stream', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        toolCallChunk(0, 'call_1', 'search', '{"q":'),
        toolCallChunk(0, undefined, undefined, '"test"}','tool_calls'),
        {id: 'chatcmpl-123', model: 'deepseek', choices: [], usage: {prompt_tokens: 10, completion_tokens: 13}}
      ]))
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))
      const toolEvents = events.filter(e => e.type === 'tool_use')
      const block = toolEvents[0].data as ToolUseBlock
      expect(block).toEqual({
        type: 'tool_use',
        id: 'call_1',
        name: 'search',
        input: {q: 'test'},
      })
    })

    it('yields done event with usage from final chunk', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        textChunk('Hi', 'stop'),
        { id: 'chatcmpl-123', model: 'deepseek', choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } },
      ]))
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))

      const done = events.find(e => e.type === 'done')
      const response = done!.data as LLMResponse
      expect(response.usage).toEqual({ input_tokens: 10, output_tokens: 2 })
      expect(response.id).toBe('chatcmpl-123')
      expect(response.model).toBe('deepseek')
    })

    it('resolves stop_reason to tool_use when tool blocks present but finish_reason is stop', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        toolCallChunk(0, 'call_1', 'search', '{"q":"k"}','stop'),
        { id: 'chatcmpl-123', model: 'deepseek', choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } },
      ]))
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))

      const done = events.find(e => e.type === 'done')
      const response = done!.data as LLMResponse
      expect(response.stop_reason).toEqual('tool_use')
      expect(response.id).toBe('chatcmpl-123')
      expect(response.model).toBe('deepseek')
    })

    it('handles malformed tool arguments JSON', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        toolCallChunk(0, 'call_1', 'search', '{q','stop'),
        { id: 'chatcmpl-123', model: 'deepseek', choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } },
      ]))
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))

      const toolEvents = events.filter(e => e.type === 'tool_use')
      expect((toolEvents[0].data as ToolUseBlock).input).toEqual({})
    })

    it('yields error event on stream failure', async () => {
      createCompletionMock.mockResolvedValue(
        (async function* () { throw new Error('Stream exploded') })(),
      )
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))

      const errorEvents = events.filter(e => e.type === 'error')
      expect(errorEvents).toHaveLength(1)
      expect((errorEvents[0].data as Error).message).toBe('Stream exploded')
    })


    it('passes abortSignal to stream request options', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        textChunk('Hi', 'stop', { prompt_tokens: 5, completion_tokens: 1 }),
      ]))
      const controller = new AbortController()
      const adapter = new DeepSeekAdapter()
      await collectEvents(
        adapter.stream(
          [textMsg('user', 'Hi')],
          chatOpts({ abortSignal: controller.signal }),
        ),
      )

      expect(createCompletionMock.mock.calls[0][1]).toEqual({ signal: controller.signal })
    })

    it('handles multiple tool calls', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        toolCallChunk(0, 'call_1', 'search', '{"q":"a"}'),
        toolCallChunk(1, 'call_2', 'read', '{"path":"b"}', 'tool_calls'),
        { id: 'chatcmpl-123', model: 'deepseek', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } },
      ]))
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts()))

      const toolEvents = events.filter(e => e.type === 'tool_use')
      expect(toolEvents).toHaveLength(2)
      expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
      expect((toolEvents[1].data as ToolUseBlock).name).toBe('read')
    })
    it('falls back to extracting tool calls from streamed text when no native tool deltas exist', async () => {
      createCompletionMock.mockResolvedValue(makeChunks([
        textChunk('```json\n{"name":"search","input":{"query":"fallback"}}\n```', 'stop'),
        { id: 'chatcmpl-123', model: 'deepseek', choices: [], usage: { prompt_tokens: 6, completion_tokens: 4 } },
      ]))
      const adapter = new DeepSeekAdapter()
      const events = await collectEvents(
        adapter.stream(
          [textMsg('user', 'Search for fallback handling')],
          chatOpts({ tools: [toolDef('search')] }),
        ),
      )

      const toolEvents = events.filter(e => e.type === 'tool_use')
      expect(toolEvents).toHaveLength(1)
      expect(toolEvents[0].data).toEqual({
        type: 'tool_use',
        id: expect.any(String),
        name: 'search',
        input: { query: 'fallback' },
      })

      const done = events.find(e => e.type === 'done')
      expect((done!.data as LLMResponse).stop_reason).toBe('tool_use')
    })
  })

  // ---------------------------------------------------------------------------
  // reasoning_content passback (DeepSeek V4 thinking mode)
  //
  // Per https://api-docs.deepseek.com/zh-cn/guides/thinking_mode, follow-up
  // requests that include a prior tool-use assistant turn MUST echo
  // `reasoning_content` back. Omitting it returns 400.
  // ---------------------------------------------------------------------------
  describe('reasoning_content echo on tool-use turns', () => {
    function getAssistantMessage(callIndex = 0): Record<string, unknown> {
      const call = createCompletionMock.mock.calls[callIndex]
      if (call === undefined) throw new Error(`no mock call at index ${callIndex}`)
      const messages = call[0].messages as Array<Record<string, unknown>>
      const assistant = messages.find((m) => m['role'] === 'assistant')
      if (assistant === undefined) throw new Error('no assistant message found in request')
      return assistant
    }

    it('echoes reasoning_content on assistant messages that contain tool_calls', async () => {
      // The agent runner's typical flow: turn 1 yields reasoning + tool_use;
      // tools execute; turn 2 sends back the assistant + tool_result and the
      // model returns its final answer.
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-ds-2',
        model: 'deepseek-v4-pro',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'Answer.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Search for foo'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'I will use the search tool.', provenance: 'deepseek' },
            { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'foo' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[results]' }],
        },
      ]

      const adapter = new DeepSeekAdapter('deepseek-key')
      await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))

      const assistant = getAssistantMessage()
      expect(assistant['tool_calls']).toBeDefined()
      expect(assistant['reasoning_content']).toBe('I will use the search tool.')
    })

    it('does NOT echo reasoning_content in conversations with no tool_use anywhere (pure text dialog)', async () => {
      // Per spec, `reasoning_content` on non-tool conversations is ignored
      // by the API. Echoing it would just bloat context.
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-ds-3',
        model: 'deepseek-v4-pro',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'Sure.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Hi'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'Just acknowledge.', provenance: 'deepseek' },
            { type: 'text', text: 'Hello!' },
          ],
        },
        textMsg('user', 'Now respond again.'),
      ]

      const adapter = new DeepSeekAdapter('deepseek-key')
      await adapter.chat(messages, chatOpts())

      const assistant = getAssistantMessage()
      expect(assistant['tool_calls']).toBeUndefined()
      expect(assistant['reasoning_content']).toBeUndefined()
      expect(assistant['content']).toBe('Hello!')
    })

    it('echoes reasoning_content on text-only assistant messages within a tool-calling conversation', async () => {
      // This is the spec-critical case the original fix missed.
      // Sequence:
      //   user → assistant#1[reasoning + tool_use] → tool_result →
      //   assistant#2[reasoning + text final] → user followup
      // assistant#2 has NO tool_calls of its own, but it is still inside a
      // tool-calling conversation and so its reasoning_content must be
      // echoed too. Dropping it 400s on the next user turn.
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-ds-7',
        model: 'deepseek-v4-pro',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'Bar follow-up.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Search for foo'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'Need to search.', provenance: 'deepseek' },
            { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'foo' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[results]' }],
        },
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'Synthesise the answer.', provenance: 'deepseek' },
            { type: 'text', text: 'Found foo at example.com' },
          ],
        },
        textMsg('user', 'Now what about bar?'),
      ]

      const adapter = new DeepSeekAdapter('deepseek-key')
      await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))

      const sentMessages = createCompletionMock.mock.calls[0][0].messages as Array<Record<string, unknown>>
      const assistants = sentMessages.filter((m) => m['role'] === 'assistant')
      expect(assistants).toHaveLength(2)

      // assistant#1: tool_use + reasoning, echoed.
      expect(assistants[0]['tool_calls']).toBeDefined()
      expect(assistants[0]['reasoning_content']).toBe('Need to search.')

      // assistant#2: text-only (no tool_calls) but in a tool-calling
      // conversation — reasoning MUST still be echoed. This is the
      // regression the original PR shipped with.
      expect(assistants[1]['tool_calls']).toBeUndefined()
      expect(assistants[1]['content']).toBe('Found foo at example.com')
      expect(assistants[1]['reasoning_content']).toBe('Synthesise the answer.')
    })

    it('does NOT echo reasoning blocks from a foreign provenance', async () => {
      // Cross-provider reasoning (e.g. an upstream Anthropic block carried
      // through to a DeepSeek adapter) is not eligible for native echo —
      // DeepSeek did not produce it and would not recognise its signature.
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-ds-4',
        model: 'deepseek-v4-pro',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'Done.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Search for bar'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'Foreign reasoning.', provenance: 'anthropic' },
            { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'bar' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[results]' }],
        },
      ]

      const adapter = new DeepSeekAdapter('deepseek-key')
      await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))

      const assistant = getAssistantMessage()
      expect(assistant['tool_calls']).toBeDefined()
      expect(assistant['reasoning_content']).toBeUndefined()
    })

    it('drops a reasoning block with no provenance (treated as foreign)', async () => {
      // Older clients or third-party IR constructors may produce reasoning
      // blocks without `provenance`. Treat the same as foreign — silent drop.
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-ds-5',
        model: 'deepseek-v4-pro',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'OK.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Search'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'No provenance.' },
            { type: 'tool_use', id: 'call_2', name: 'search', input: { q: 'x' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_2', content: '[]' }],
        },
      ]

      const adapter = new DeepSeekAdapter('deepseek-key')
      await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))

      const assistant = getAssistantMessage()
      expect(assistant['reasoning_content']).toBeUndefined()
    })

    it('preserves reasoning_content across multiple historical tool-use turns', async () => {
      // Each prior tool-use turn carries its own reasoning. The spec requires
      // every such turn to keep its reasoning_content on subsequent requests,
      // not just the most recent one.
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-ds-6',
        model: 'deepseek-v4-pro',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'Final.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Multi-step research.'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'Plan A.', provenance: 'deepseek' },
            { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'a' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[a]' }],
        },
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'Plan B.', provenance: 'deepseek' },
            { type: 'tool_use', id: 'call_2', name: 'search', input: { q: 'b' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_2', content: '[b]' }],
        },
      ]

      const adapter = new DeepSeekAdapter('deepseek-key')
      await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))

      const sentMessages = createCompletionMock.mock.calls[0][0].messages as Array<Record<string, unknown>>
      const assistants = sentMessages.filter((m) => m['role'] === 'assistant')
      expect(assistants).toHaveLength(2)
      expect(assistants[0]['reasoning_content']).toBe('Plan A.')
      expect(assistants[1]['reasoning_content']).toBe('Plan B.')
    })

    it('does NOT echo reasoning_content from OpenAIAdapter (capability remains `never`)', async () => {
      // Verifies the capability gate, not just the DeepSeek subclass:
      // OpenAIAdapter itself must NOT attach reasoning_content even when
      // matching-provenance reasoning is present in the message history.
      const { OpenAIAdapter } = await import('../src/llm/openai.js')
      createCompletionMock.mockResolvedValueOnce({
        id: 'chatcmpl-oai',
        model: 'gpt-4o',
        choices: [{
          index: 0,
          message: { role: 'assistant', content: 'Final.', tool_calls: undefined },
          finish_reason: 'stop',
        }],
        usage: { prompt_tokens: 5, completion_tokens: 3 },
      })

      const messages: LLMMessage[] = [
        textMsg('user', 'Search'),
        {
          role: 'assistant',
          content: [
            { type: 'reasoning', text: 'thinking', provenance: 'openai' },
            { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'x' } },
          ],
        },
        {
          role: 'user',
          content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[]' }],
        },
      ]

      const openaiAdapter = new OpenAIAdapter('openai-key')
      await openaiAdapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))

      const sentMessages = createCompletionMock.mock.calls[0][0].messages as Array<Record<string, unknown>>
      const assistant = sentMessages.find((m) => m['role'] === 'assistant')
      expect(assistant!['reasoning_content']).toBeUndefined()
    })
  })
})
