feat: migrate chat backend to Semantic Kernel with tool calling support
Replace manual HTTP proxy in ChatController with Semantic Kernel's OpenAI chat completion service pointed at CLIProxyAPI. Add extraction plugin with validation function for structured field extraction from natural language, enabling an agentic loop with auto-retry and human-in-the-loop escalation. - Add Microsoft.SemanticKernel 1.74.0 with OpenAI connector - Create ExtractedFields schema and ValidationResult models - Create ExtractionPlugin with [KernelFunction] validation - Rewrite ChatController to use IChatCompletionService streaming - Configure FunctionChoiceBehavior.Auto() for tool calling - Preserve existing SSE contract (client unchanged) - Update tests to mock SK services, add plugin and integration tests - Archive multi-turn-conversations and migrate-to-semantic-kernel changes - Sync specs for agent-extraction, semantic-kernel-integration, chat-streaming Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ChatAgent.Shared.Models;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.SemanticKernel;
|
||||
using Microsoft.SemanticKernel.ChatCompletion;
|
||||
using Microsoft.SemanticKernel.Connectors.OpenAI;
|
||||
using Moq;
|
||||
|
||||
namespace ChatAgent.Api.Tests;
|
||||
|
||||
@@ -20,15 +25,24 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
[Fact]
|
||||
public async Task PostChat_StreamsTextDeltas_AndDone()
|
||||
{
|
||||
// Arrange: mock the upstream Responses API with canned SSE events
|
||||
var sseContent = BuildSSE(
|
||||
("response.created", null),
|
||||
("response.output_text.delta", "Hello"),
|
||||
("response.output_text.delta", " world"),
|
||||
("response.completed", null)
|
||||
);
|
||||
// Arrange: mock IChatCompletionService to return streaming text chunks
|
||||
var mockChatService = new Mock<IChatCompletionService>();
|
||||
|
||||
var client = CreateClientWithMockedUpstream(sseContent);
|
||||
var chunks = new List<StreamingChatMessageContent>
|
||||
{
|
||||
new(AuthorRole.Assistant, "Hello"),
|
||||
new(AuthorRole.Assistant, " world")
|
||||
};
|
||||
|
||||
mockChatService
|
||||
.Setup(s => s.GetStreamingChatMessageContentsAsync(
|
||||
It.IsAny<ChatHistory>(),
|
||||
It.IsAny<PromptExecutionSettings>(),
|
||||
It.IsAny<Kernel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(chunks.ToAsyncEnumerable());
|
||||
|
||||
var client = CreateClientWithMockedChatService(mockChatService.Object);
|
||||
|
||||
var request = new ChatRequest
|
||||
{
|
||||
@@ -54,16 +68,20 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostChat_HandlesUpstreamError_ReturnsErrorEvent()
|
||||
public async Task PostChat_HandlesServiceError_ReturnsErrorEvent()
|
||||
{
|
||||
// Arrange: upstream returns 500
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("Internal Server Error")
|
||||
});
|
||||
// Arrange: mock IChatCompletionService to throw HttpRequestException
|
||||
var mockChatService = new Mock<IChatCompletionService>();
|
||||
|
||||
var client = CreateClientWithHandler(mockHandler);
|
||||
mockChatService
|
||||
.Setup(s => s.GetStreamingChatMessageContentsAsync(
|
||||
It.IsAny<ChatHistory>(),
|
||||
It.IsAny<PromptExecutionSettings>(),
|
||||
It.IsAny<Kernel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ThrowingAsyncEnumerable(new HttpRequestException("Connection refused")));
|
||||
|
||||
var client = CreateClientWithMockedChatService(mockChatService.Object);
|
||||
|
||||
var request = new ChatRequest
|
||||
{
|
||||
@@ -83,47 +101,75 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
Assert.Contains(events, e => e == "[DONE]");
|
||||
}
|
||||
|
||||
private HttpClient CreateClientWithMockedUpstream(string sseContent)
|
||||
{
|
||||
var mockHandler = new MockHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(sseContent, Encoding.UTF8, "text/event-stream")
|
||||
});
|
||||
|
||||
return CreateClientWithHandler(mockHandler);
|
||||
}
|
||||
|
||||
private HttpClient CreateClientWithHandler(MockHttpMessageHandler handler)
|
||||
private HttpClient CreateClientWithMockedChatService(IChatCompletionService chatService)
|
||||
{
|
||||
return _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove existing IHttpClientFactory registrations for "ResponsesApi"
|
||||
// and replace with our mock
|
||||
services.AddHttpClient("ResponsesApi")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => handler);
|
||||
// Remove existing IChatCompletionService registration and replace with mock
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(IChatCompletionService));
|
||||
if (descriptor != null)
|
||||
services.Remove(descriptor);
|
||||
|
||||
services.AddSingleton(chatService);
|
||||
});
|
||||
}).CreateClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fake SSE stream mimicking the Responses API format.
|
||||
/// Creates an IAsyncEnumerable that throws the given exception when iterated.
|
||||
/// Used to simulate service failures in streaming responses.
|
||||
/// </summary>
|
||||
private static string BuildSSE(params (string type, string? delta)[] events)
|
||||
private static async IAsyncEnumerable<StreamingChatMessageContent> ThrowingAsyncEnumerable(
|
||||
Exception exception,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (type, delta) in events)
|
||||
await Task.CompletedTask;
|
||||
throw exception;
|
||||
yield break; // Required to make the compiler treat this as an async enumerable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostChat_ClarificationMessage_StreamedToClient()
|
||||
{
|
||||
// Arrange: simulate the LLM returning a clarification message
|
||||
// (what happens after the agent exhausts tool call retries)
|
||||
var mockChatService = new Mock<IChatCompletionService>();
|
||||
|
||||
var clarificationText = "I couldn't determine the currency. Could you specify whether this is USD or GBP?";
|
||||
var chunks = new List<StreamingChatMessageContent>
|
||||
{
|
||||
var data = delta != null
|
||||
? $"{{\"type\":\"{type}\",\"delta\":\"{delta}\"}}"
|
||||
: $"{{\"type\":\"{type}\"}}";
|
||||
sb.AppendLine($"event: {type}");
|
||||
sb.AppendLine($"data: {data}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
return sb.ToString();
|
||||
new(AuthorRole.Assistant, clarificationText)
|
||||
};
|
||||
|
||||
mockChatService
|
||||
.Setup(s => s.GetStreamingChatMessageContentsAsync(
|
||||
It.IsAny<ChatHistory>(),
|
||||
It.IsAny<PromptExecutionSettings>(),
|
||||
It.IsAny<Kernel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(chunks.ToAsyncEnumerable());
|
||||
|
||||
var client = CreateClientWithMockedChatService(mockChatService.Object);
|
||||
|
||||
var request = new ChatRequest
|
||||
{
|
||||
Messages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "user", Content = "Invoice for 3 hours consulting" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync("/api/chat", request);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var events = ParseSSEData(body);
|
||||
|
||||
// Assert: clarification message streamed as text, not swallowed
|
||||
Assert.Contains(events, e => e.Contains("currency"));
|
||||
Assert.Contains(events, e => e == "[DONE]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,22 +183,3 @@ public class ChatControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple mock HttpMessageHandler that returns a canned response.
|
||||
/// </summary>
|
||||
public class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpResponseMessage _response;
|
||||
|
||||
public MockHttpMessageHandler(HttpResponseMessage response)
|
||||
{
|
||||
_response = response;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_response);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user