import {vi, type MockedFunction, beforeEach, expect, describe, it} from 'vitest' import * as core from '../__fixtures__/core.js' // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockCreate = vi.fn() as MockedFunction const mockCompletions = {create: mockCreate} const mockChat = {completions: mockCompletions} const mockOpenAIClient = vi.fn(() => ({ chat: mockChat, })) vi.mock('openai', () => ({ default: mockOpenAIClient, })) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockExecuteToolCalls = vi.fn() as MockedFunction vi.mock('../src/mcp.js', () => ({ executeToolCalls: mockExecuteToolCalls, })) vi.mock('@actions/core', () => core) // Import the module being tested const {simpleInference, mcpInference} = await import('../src/inference.js') describe('inference.ts', () => { const mockRequest = { messages: [ {role: 'system' as const, content: 'You are a test assistant'}, {role: 'user' as const, content: 'Hello, AI!'}, ], modelName: 'gpt-4', maxCompletionTokens: 100, endpoint: 'https://api.test.com', token: 'test-token', } beforeEach(() => { vi.clearAllMocks() }) describe('simpleInference', () => { it('performs simple inference without tools', async () => { const mockResponse = { choices: [ { message: { content: 'Hello, user!', }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await simpleInference(mockRequest) expect(result).toBe('Hello, user!') expect(core.info).toHaveBeenCalledWith('Running simple inference without tools') expect(core.debug).toHaveBeenCalledWith('Model response: Hello, user!') // Verify the request structure expect(mockCreate).toHaveBeenCalledWith({ messages: [ { role: 'system', content: 'You are a test assistant', }, { role: 'user', content: 'Hello, AI!', }, ], max_completion_tokens: 100, model: 'gpt-4', }) // Verify OpenAI client was initialized with empty custom headers expect(mockOpenAIClient).toHaveBeenCalledWith({ apiKey: 'test-token', baseURL: 'https://api.test.com', defaultHeaders: {}, }) }) it('includes custom headers in OpenAI client', async () => { const requestWithHeaders = { ...mockRequest, customHeaders: { 'X-Custom-Header': 'custom-value', 'Ocp-Apim-Subscription-Key': 'secret123', }, } const mockResponse = { choices: [ { message: { content: 'Response with headers', }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await simpleInference(requestWithHeaders) expect(result).toBe('Response with headers') // Verify OpenAI client was initialized with custom headers expect(mockOpenAIClient).toHaveBeenCalledWith({ apiKey: 'test-token', baseURL: 'https://api.test.com', defaultHeaders: { 'X-Custom-Header': 'custom-value', 'Ocp-Apim-Subscription-Key': 'secret123', }, }) }) it('handles null response content', async () => { const mockResponse = { choices: [ { message: { content: null, }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await simpleInference(mockRequest) expect(result).toBeNull() expect(core.debug).toHaveBeenCalledWith('Model response: No response content') }) it('includes response format when specified', async () => { const requestWithResponseFormat = { ...mockRequest, responseFormat: { type: 'json_schema' as const, json_schema: {type: 'object'}, }, } const mockResponse = { choices: [ { message: { content: '{"result": "success"}', }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await simpleInference(requestWithResponseFormat) expect(result).toBe('{"result": "success"}') // Verify response format was included in the request expect(mockCreate).toHaveBeenCalledWith({ messages: [ { role: 'system', content: 'You are a test assistant', }, { role: 'user', content: 'Hello, AI!', }, ], max_completion_tokens: 100, model: 'gpt-4', response_format: requestWithResponseFormat.responseFormat, }) }) }) describe('mcpInference', () => { const mockMcpClient = { // eslint-disable-next-line @typescript-eslint/no-explicit-any client: {} as any, tools: [ { type: 'function' as const, function: { name: 'test-tool', description: 'A test tool', parameters: {type: 'object'}, }, }, ], } it('performs inference without tool calls', async () => { const mockResponse = { choices: [ { message: { content: 'Hello, user!', tool_calls: null, }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await mcpInference(mockRequest, mockMcpClient) expect(result).toBe('Hello, user!') expect(core.info).toHaveBeenCalledWith('Running GitHub MCP inference with tools') expect(core.info).toHaveBeenCalledWith('MCP inference iteration 1') expect(core.info).toHaveBeenCalledWith('No tool calls requested, ending GitHub MCP inference loop') // The MCP inference loop will always add the assistant message, even when there are no tool calls // So we don't check the exact messages, just that tools were included expect(mockCreate).toHaveBeenCalledTimes(1) // eslint-disable-next-line @typescript-eslint/no-explicit-any const callArgs = mockCreate.mock.calls[0][0] as any expect(callArgs.tools).toEqual(mockMcpClient.tools) expect(callArgs.response_format).toBeUndefined() expect(callArgs.model).toBe('gpt-4') expect(callArgs.max_completion_tokens).toBe(100) // Verify OpenAI client was initialized with empty custom headers expect(mockOpenAIClient).toHaveBeenCalledWith({ apiKey: 'test-token', baseURL: 'https://api.test.com', defaultHeaders: {}, }) }) it('includes custom headers in MCP inference', async () => { const requestWithHeaders = { ...mockRequest, customHeaders: { serviceName: 'test-service', 'X-Team': 'engineering', }, } const mockResponse = { choices: [ { message: { content: 'MCP response with headers', tool_calls: null, }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await mcpInference(requestWithHeaders, mockMcpClient) expect(result).toBe('MCP response with headers') // Verify OpenAI client was initialized with custom headers expect(mockOpenAIClient).toHaveBeenCalledWith({ apiKey: 'test-token', baseURL: 'https://api.test.com', defaultHeaders: { serviceName: 'test-service', 'X-Team': 'engineering', }, }) }) it('executes tool calls and continues conversation', async () => { const toolCalls = [ { id: 'call-123', function: { name: 'test-tool', arguments: '{"param": "value"}', }, }, ] const toolResults = [ { tool_call_id: 'call-123', role: 'tool', name: 'test-tool', content: 'Tool result', }, ] // First response with tool calls const firstResponse = { choices: [ { message: { content: 'I need to use a tool.', tool_calls: toolCalls, }, }, ], } // Second response after tool execution const secondResponse = { choices: [ { message: { content: 'Here is the final answer.', tool_calls: null, }, }, ], } mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse) mockExecuteToolCalls.mockResolvedValue(toolResults) const result = await mcpInference(mockRequest, mockMcpClient) expect(result).toBe('Here is the final answer.') expect(mockExecuteToolCalls).toHaveBeenCalledWith(mockMcpClient.client, toolCalls) expect(mockCreate).toHaveBeenCalledTimes(2) // Verify the second call includes the conversation history // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondCall = mockCreate.mock.calls[1][0] as any expect(secondCall.messages).toHaveLength(5) // system, user, assistant, tool, assistant expect(secondCall.messages[2].role).toBe('assistant') expect(secondCall.messages[2].tool_calls).toEqual(toolCalls) expect(secondCall.messages[3]).toEqual(toolResults[0]) }) it('handles maximum iteration limit', async () => { const toolCalls = [ { id: 'call-123', function: { name: 'test-tool', arguments: '{}', }, }, ] const toolResults = [ { tool_call_id: 'call-123', role: 'tool', name: 'test-tool', content: 'Tool result', }, ] // Always respond with tool calls to trigger infinite loop const responseWithToolCalls = { choices: [ { message: { content: 'Using tool again.', tool_calls: toolCalls, }, }, ], } mockCreate.mockResolvedValue(responseWithToolCalls) mockExecuteToolCalls.mockResolvedValue(toolResults) const result = await mcpInference(mockRequest, mockMcpClient) expect(mockCreate).toHaveBeenCalledTimes(5) // Max iterations reached expect(core.warning).toHaveBeenCalledWith('GitHub MCP inference loop exceeded maximum iterations (5)') expect(result).toBe('Using tool again.') // Last assistant message }) it('handles empty tool calls array', async () => { const mockResponse = { choices: [ { message: { content: 'Hello, user!', tool_calls: [], }, }, ], } mockCreate.mockResolvedValue(mockResponse) const result = await mcpInference(mockRequest, mockMcpClient) expect(result).toBe('Hello, user!') expect(core.info).toHaveBeenCalledWith('No tool calls requested, ending GitHub MCP inference loop') expect(mockExecuteToolCalls).not.toHaveBeenCalled() }) it('returns last assistant message when no content in final iteration', async () => { const toolCalls = [ { id: 'call-123', function: {name: 'test-tool', arguments: '{}'}, }, ] const firstResponse = { choices: [ { message: { content: 'First message', tool_calls: toolCalls, }, }, ], } const secondResponse = { choices: [ { message: { content: 'Second message', tool_calls: toolCalls, }, }, ], } mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValue(secondResponse) mockExecuteToolCalls.mockResolvedValue([ { tool_call_id: 'call-123', role: 'tool', name: 'test-tool', content: 'result', }, ]) const result = await mcpInference(mockRequest, mockMcpClient) expect(result).toBe('Second message') }) it('makes additional loop with response format when no tool calls are made', async () => { const requestWithResponseFormat = { ...mockRequest, responseFormat: { type: 'json_schema' as const, json_schema: {type: 'object'}, }, } // First response without tool calls const firstResponse = { choices: [ { message: { content: 'First response', tool_calls: null, }, }, ], } // Second response with response format applied const secondResponse = { choices: [ { message: { content: '{"result": "formatted response"}', tool_calls: null, }, }, ], } mockCreate.mockResolvedValueOnce(firstResponse).mockResolvedValueOnce(secondResponse) const result = await mcpInference(requestWithResponseFormat, mockMcpClient) expect(result).toBe('{"result": "formatted response"}') expect(mockCreate).toHaveBeenCalledTimes(2) expect(core.info).toHaveBeenCalledWith('Making one more MCP loop with the requested response format...') // First call should have tools but no response format // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstCall = mockCreate.mock.calls[0][0] as any expect(firstCall.tools).toEqual(mockMcpClient.tools) expect(firstCall.response_format).toBeUndefined() // Second call should have response format but no tools // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondCall = mockCreate.mock.calls[1][0] as any expect(secondCall.tools).toBeUndefined() expect(secondCall.response_format).toEqual(requestWithResponseFormat.responseFormat) // Second call should include the user message requesting JSON format expect(secondCall.messages).toHaveLength(5) // system, user, assistant, user, assistant expect(secondCall.messages[3].role).toBe('user') expect(secondCall.messages[3].content).toContain('Please provide your response in the exact') }) it('uses response format only on final iteration after tool calls', async () => { const requestWithResponseFormat = { ...mockRequest, responseFormat: { type: 'json_schema' as const, json_schema: {type: 'object'}, }, } const toolCalls = [ { id: 'call-123', function: { name: 'test-tool', arguments: '{"param": "value"}', }, }, ] const toolResults = [ { tool_call_id: 'call-123', role: 'tool', name: 'test-tool', content: 'Tool result', }, ] // First response with tool calls const firstResponse = { choices: [ { message: { content: 'Using tool', tool_calls: toolCalls, }, }, ], } // Second response without tool calls, but should trigger final message loop const secondResponse = { choices: [ { message: { content: 'Intermediate result', tool_calls: null, }, }, ], } // Third response with response format const thirdResponse = { choices: [ { message: { content: '{"final": "result"}', tool_calls: null, }, }, ], } mockCreate .mockResolvedValueOnce(firstResponse) .mockResolvedValueOnce(secondResponse) .mockResolvedValueOnce(thirdResponse) mockExecuteToolCalls.mockResolvedValue(toolResults) const result = await mcpInference(requestWithResponseFormat, mockMcpClient) expect(result).toBe('{"final": "result"}') expect(mockCreate).toHaveBeenCalledTimes(3) // First call: tools but no response format // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstCall = mockCreate.mock.calls[0][0] as any expect(firstCall.tools).toEqual(mockMcpClient.tools) expect(firstCall.response_format).toBeUndefined() // Second call: tools but no response format (after tool execution) // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondCall = mockCreate.mock.calls[1][0] as any expect(secondCall.tools).toEqual(mockMcpClient.tools) expect(secondCall.response_format).toBeUndefined() // Third call: response format but no tools (final message) // eslint-disable-next-line @typescript-eslint/no-explicit-any const thirdCall = mockCreate.mock.calls[2][0] as any expect(thirdCall.tools).toBeUndefined() expect(thirdCall.response_format).toEqual(requestWithResponseFormat.responseFormat) }) it('returns immediately when response format is set and finalMessage is already true', async () => { const requestWithResponseFormat = { ...mockRequest, responseFormat: { type: 'json_schema' as const, json_schema: {type: 'object'}, }, } // Response without tool calls on what would be the final message iteration const mockResponse = { choices: [ { message: { content: '{"immediate": "result"}', tool_calls: null, }, }, ], } mockCreate.mockResolvedValue(mockResponse) // We need to test a scenario where finalMessage would already be true // This happens when we're already in the final iteration const result = await mcpInference(requestWithResponseFormat, mockMcpClient) // The function should make two calls: one normal, then one with response format expect(mockCreate).toHaveBeenCalledTimes(2) expect(result).toBe('{"immediate": "result"}') }) }) describe('token param routing', () => { it('sends max_tokens when only maxTokens is set', async () => { const requestWithMaxTokens = { ...mockRequest, maxCompletionTokens: undefined, maxTokens: 100, } const mockResponse = { choices: [ { message: { content: 'Direct max_tokens response', }, }, ], } mockCreate.mockResolvedValueOnce(mockResponse) const result = await simpleInference(requestWithMaxTokens) expect(result).toBe('Direct max_tokens response') expect(mockCreate).toHaveBeenCalledTimes(1) // Should have sent max_tokens directly expect(mockCreate.mock.calls[0][0]).toHaveProperty('max_tokens', 100) expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('max_completion_tokens') }) it('sends neither token param when both are undefined', async () => { const requestWithNoTokens = { ...mockRequest, maxCompletionTokens: undefined, maxTokens: undefined, } const mockResponse = { choices: [ { message: { content: 'No token limit response', }, }, ], } mockCreate.mockResolvedValueOnce(mockResponse) const result = await simpleInference(requestWithNoTokens) expect(result).toBe('No token limit response') expect(mockCreate).toHaveBeenCalledTimes(1) const params = mockCreate.mock.calls[0][0] expect(params).not.toHaveProperty('max_tokens') expect(params).not.toHaveProperty('max_completion_tokens') }) }) })