feat: 初始化NewLife Studio项目,完成基础框架与数据管理模块
何炳宏 authored at 2026-05-26 12:09:09
18.21 KiB
NewLife.Studio
using NewLife.Studio.AI;
using NewLife.Studio.AI.Models;
using NewLife.Studio.AI.ToolCalling;
using Xunit;

namespace NewLife.Studio.AI.Tests;

public class AIServiceTests
{
    private readonly ToolRegistry _toolRegistry;
    private readonly FakeAIProvider _fakeProvider;

    public AIServiceTests()
    {
        _toolRegistry = new ToolRegistry();
        _fakeProvider = new FakeAIProvider();
    }

    [Fact]
    public void Constructor_WithSystemPrompt_AddsSystemMessageToHistory()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Hello!" },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry, "You are a helpful assistant.");

        Assert.Single(service.History);
        Assert.Equal("system", service.History[0].Role);
        Assert.Equal("You are a helpful assistant.", service.History[0].Content);
    }

    [Fact]
    public void Constructor_WithNullSystemPrompt_DoesNotAddSystemMessage()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Hello!" },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry, null);

        Assert.Empty(service.History);
    }

    [Fact]
    public async Task ChatAsync_SimpleMessage_ReturnsAssistantContent()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "I received your query." },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("What tables exist?");

        Assert.Equal("I received your query.", result);
    }

    [Fact]
    public async Task ChatAsync_StoresHistoryAfterConversation()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Response text" },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry);
        await service.ChatAsync("User message");

        Assert.Equal(2, service.History.Count);
        Assert.Equal("user", service.History[0].Role);
        Assert.Equal("User message", service.History[0].Content);
        Assert.Equal("assistant", service.History[1].Role);
        Assert.Equal("Response text", service.History[1].Content);
    }

    [Fact]
    public async Task ChatAsync_WithSystemPrompt_PreservesSystemMessageInHistory()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "I understand." },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry, "System instruction");
        await service.ChatAsync("User query");

        Assert.Equal("system", service.History[0].Role);
        Assert.Equal("System instruction", service.History[0].Content);
        Assert.Equal("user", service.History[1].Role);
        Assert.Equal("assistant", service.History[2].Role);
        Assert.Equal(3, service.History.Count);
    }

    [Fact]
    public async Task ChatAsync_ToolCallingLoop_ExecutesToolsAndReturnsFinalResponse()
    {
        // First response: AI returns a tool call
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage
                    {
                        Role = "assistant",
                        Content = null,
                        ToolCalls = new List<ToolCall>
                        {
                            new()
                            {
                                Id = "call_100",
                                Type = "function",
                                Function = new FunctionCall
                                {
                                    Name = "get_data",
                                    Arguments = "{\"table\":\"users\"}"
                                }
                            }
                        }
                    },
                    FinishReason = "tool_calls"
                }
            }
        });

        // Second response: AI processes tool result and returns final answer
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Found 3 users." },
                    FinishReason = "stop"
                }
            }
        });

        _toolRegistry.Register("get_data", "Get data from table", null,
            args => Task.FromResult("[\"Alice\", \"Bob\", \"Charlie\"]"));

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("Show me all users");

        Assert.Equal("Found 3 users.", result);
    }

    [Fact]
    public async Task ChatAsync_ToolCallFiresOnToolCallCallback()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage
                    {
                        Role = "assistant",
                        Content = null,
                        ToolCalls = new List<ToolCall>
                        {
                            new()
                            {
                                Id = "call_cb",
                                Type = "function",
                                Function = new FunctionCall
                                {
                                    Name = "callback_test",
                                    Arguments = "{\"key\":\"value\"}"
                                }
                            }
                        }
                    },
                    FinishReason = "tool_calls"
                }
            }
        });

        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Done." },
                    FinishReason = "stop"
                }
            }
        });

        _toolRegistry.Register("callback_test", "Callback test tool", null,
            args => Task.FromResult("done"));

        ToolCall? capturedToolCall = null;
        ToolResult? capturedToolResult = null;

        var service = new AIService(_fakeProvider, _toolRegistry);
        await service.ChatAsync("Trigger callback", (tc, tr) =>
        {
            capturedToolCall = tc;
            capturedToolResult = tr;
        });

        Assert.NotNull(capturedToolCall);
        Assert.Equal("call_cb", capturedToolCall!.Id);
        Assert.Equal("callback_test", capturedToolCall.Function.Name);
        Assert.Equal("{\"key\":\"value\"}", capturedToolCall.Function.Arguments);
        Assert.NotNull(capturedToolResult);
        Assert.Equal("call_cb", capturedToolResult!.ToolCallId);
        Assert.Equal("done", capturedToolResult.Output);
    }

    [Fact]
    public void ClearHistory_ResetsState()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Hi" },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry, "system prompt");

        Assert.NotEmpty(service.History);

        service.ClearHistory();

        Assert.Single(service.History);
        Assert.Equal("system", service.History[0].Role);
        Assert.Equal("system prompt", service.History[0].Content);
    }

    [Fact]
    public async Task ClearHistory_WithoutSystemPrompt_ResultsInEmptyHistory()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Hi" },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry, null);

        // Add a user message to make history non-empty
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Hi again" },
                    FinishReason = "stop"
                }
            }
        });
        await service.ChatAsync("Hello");

        Assert.NotEmpty(service.History);

        service.ClearHistory();

        Assert.Empty(service.History);
    }

    [Fact]
    public async Task ChatAsync_MultipleConversationTurns_AccumulatesHistory()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "First response" },
                    FinishReason = "stop"
                }
            }
        });

        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Second response" },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry);

        await service.ChatAsync("First message");
        await service.ChatAsync("Second message");

        // Turn 1: user, assistant → 2 messages
        // Turn 2: user, assistant → +2 = 4 total
        Assert.Equal(4, service.History.Count);
        Assert.Contains(service.History, m => m.Content == "First response");
        Assert.Contains(service.History, m => m.Content == "Second response");
    }

    [Fact]
    public async Task ChatAsync_MaxToolCallLoop_DoesNotInfiniteLoop()
    {
        // Simulate tool call responses that always trigger another tool call
        for (int i = 0; i < 10; i++)
        {
            _fakeProvider.EnqueueResponse(new ChatResponse
            {
                Choices = new List<ChatChoice>
                {
                    new()
                    {
                        Message = new ChatMessage
                        {
                            Role = "assistant",
                            Content = null,
                            ToolCalls = new List<ToolCall>
                            {
                                new()
                                {
                                    Id = $"call_{i}",
                                    Type = "function",
                                    Function = new FunctionCall
                                    {
                                        Name = "loop_test",
                                        Arguments = "{}"
                                    }
                                }
                            }
                        },
                        FinishReason = "tool_calls"
                    }
                }
            });
        }

        _toolRegistry.Register("loop_test", "Loop test", null,
            _ => Task.FromResult("looping"));

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("Start loop");

        // Should stop after max 5 loops
        Assert.Equal("AI 未返回有效响应", result);
    }

    [Fact]
    public async Task ChatAsync_NullContentToolCall_ContinuesLoop()
    {
        // First: tool call with null content
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage
                    {
                        Role = "assistant",
                        Content = null,
                        ToolCalls = new List<ToolCall>
                        {
                            new()
                            {
                                Id = "call_nc",
                                Type = "function",
                                Function = new FunctionCall
                                {
                                    Name = "null_continue",
                                    Arguments = "{}"
                                }
                            }
                        }
                    },
                    FinishReason = "tool_calls"
                }
            }
        });

        // Second: final response
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Final answer" },
                    FinishReason = "stop"
                }
            }
        });

        _toolRegistry.Register("null_continue", "Null continue test", null,
            _ => Task.FromResult("processed"));

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("Query");

        Assert.Equal("Final answer", result);
    }

    [Fact]
    public async Task ChatAsync_EmptyChoices_ReturnsFallbackMessage()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>()
        });

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("Any query");

        Assert.Equal("AI 未返回有效响应", result);
    }

    [Fact]
    public async Task ChatAsync_NullContentAndNoToolCalls_ReturnsFallbackMessage()
    {
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = null, ToolCalls = null },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("Query");

        Assert.Equal("AI 未返回有效响应", result);
    }

    [Fact]
    public async Task ChatAsync_ToolCallError_StillAddsToolMessageToHistory()
    {
        // First: tool call to unknown tool
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage
                    {
                        Role = "assistant",
                        Content = null,
                        ToolCalls = new List<ToolCall>
                        {
                            new()
                            {
                                Id = "call_err",
                                Type = "function",
                                Function = new FunctionCall
                                {
                                    Name = "unknown_tool",
                                    Arguments = "{}"
                                }
                            }
                        }
                    },
                    FinishReason = "tool_calls"
                }
            }
        });

        // Second: final response
        _fakeProvider.EnqueueResponse(new ChatResponse
        {
            Choices = new List<ChatChoice>
            {
                new()
                {
                    Message = new ChatMessage { Role = "assistant", Content = "Tool failed, but I'll answer anyway." },
                    FinishReason = "stop"
                }
            }
        });

        var service = new AIService(_fakeProvider, _toolRegistry);
        var result = await service.ChatAsync("Query");

        // Verify tool response was stored in history
        Assert.Contains(service.History, m => m.Role == "tool" && m.Content!.Contains("Unknown tool"));
        Assert.Equal("Tool failed, but I'll answer anyway.", result);
    }

    /// <summary>Fake IAIProvider that returns queued responses for testing.</summary>
    private class FakeAIProvider : IAIProvider
    {
        private readonly Queue<ChatResponse> _responses = new();

        public string ProviderName => "Fake";

        public void EnqueueResponse(ChatResponse response)
        {
            _responses.Enqueue(response);
        }

        public Task<ChatResponse> ChatAsync(ChatRequest request, CancellationToken ct = default)
        {
            if (_responses.Count > 0)
            {
                return Task.FromResult(_responses.Dequeue());
            }
            return Task.FromResult(new ChatResponse
            {
                Choices = new List<ChatChoice>
                {
                    new()
                    {
                        Message = new ChatMessage { Role = "assistant", Content = "Default response" },
                        FinishReason = "stop"
                    }
                }
            });
        }
    }
}