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:
local
2026-04-04 23:59:13 +01:00
parent 3278a408b9
commit 471e9ce935
27 changed files with 1082 additions and 201 deletions

View File

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