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"
}
}
});
}
}
}
|