using MeetingAssistant.Summary; using Microsoft.Extensions.AI; using System.Net; namespace MeetingAssistant.Tests; public sealed class LiteLlmResponsesChatClientTests { [Fact] public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText() { const string json = """ { "id": "resp_test", "created_at": 1779147100, "model": "gpt-5.5-2026-04-23", "output": [ { "type": "reasoning", "summary": [], "status": null }, { "type": "message", "role": "assistant", "status": "completed", "content": [ { "type": "output_text", "text": "OK" } ] } ] } """; var response = LiteLlmResponsesChatClient.ParseResponseJson(json); Assert.Equal("OK", response.Text); Assert.Equal("resp_test", response.ResponseId); Assert.Equal("gpt-5.5-2026-04-23", response.ModelId); } [Fact] public void ParserReadsFunctionCalls() { const string json = """ { "output": [ { "type": "function_call", "call_id": "call_1", "name": "write_summary", "arguments": "{\"markdown\":\"# Summary\\nDone\"}", "status": "completed" } ] } """; var response = LiteLlmResponsesChatClient.ParseResponseJson(json); var call = Assert.IsType(Assert.Single(response.Messages[0].Contents)); Assert.Equal("call_1", call.CallId); Assert.Equal("write_summary", call.Name); Assert.NotNull(call.Arguments); Assert.Equal("# Summary\nDone", call.Arguments["markdown"]); } [Fact] public void ParserReadsUsage() { const string json = """ { "usage": { "input_tokens": 123, "output_tokens": 45, "total_tokens": 168 }, "output": [ { "type": "message", "content": [ { "type": "output_text", "text": "OK" } ] } ] } """; var response = LiteLlmResponsesChatClient.ParseResponseJson(json); Assert.NotNull(response.Usage); Assert.Equal(123, response.Usage.InputTokenCount); Assert.Equal(45, response.Usage.OutputTokenCount); Assert.Equal(168, response.Usage.TotalTokenCount); } [Fact] public async Task ClientRetriesTransientServerFailure() { var handler = new SequencedHttpMessageHandler( new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("Internal Server Error") }, new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "output": [ { "type": "message", "content": [ { "type": "output_text", "text": "Done." } ] } ] } """) }); var retryCount = 0; using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]); Assert.Equal("Done.", response.Text); Assert.Equal(2, handler.RequestCount); Assert.Equal(1, retryCount); } [Fact] public async Task ClientDoesNotRetryNonTransientClientFailure() { var handler = new SequencedHttpMessageHandler( new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("bad request") }); using var client = CreateClient(handler, reconnectionAttempts: 3); var exception = await Assert.ThrowsAsync( () => client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")])); Assert.Contains("400", exception.Message); Assert.Equal(1, handler.RequestCount); } [Fact] public async Task ClientSendsUserInitiatorOnceThenAgentInitiator() { var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "output": [ { "type": "message", "content": [ { "type": "output_text", "text": "Done." } ] } ] } """) }); using var client = CreateClient(handler, reconnectionAttempts: 0); await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]); await client.GetResponseAsync([new ChatMessage(ChatRole.Tool, "tool result")]); await client.GetResponseAsync([new ChatMessage(ChatRole.Assistant, "continue")]); Assert.Equal(["user", "agent", "agent"], handler.RequestHeaders["X-Initiator"]); } [Fact] public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached() { var handler = new RecordingHttpMessageHandler(request => { if (request.RequestUri?.PathAndQuery == "/v1/responses/compact") { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "input": [ { "role": "user", "content": "compacted" } ] } """) }; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "output": [ { "type": "message", "content": [ { "type": "output_text", "text": "Done." } ] } ] } """) }; }); using var client = CreateClient( handler, reconnectionAttempts: 0, new LiteLlmResponsesCompactionOptions { Enabled = true, ContextWindowTokens = 100, MaxOutputTokens = 10, RemainingRatio = 0.10, CompactPath = "responses/compact" }); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, new string('x', 800))]); Assert.Equal("Done.", response.Text); Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths); Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]); Assert.Equal(["agent", "user"], handler.RequestHeaders["X-Initiator"]); } [Fact] public async Task ClientFallsBackToAgentFrameworkCompactionWhenResponsesCompactionFails() { var handler = new RecordingHttpMessageHandler(request => { if (request.RequestUri?.PathAndQuery == "/v1/responses/compact") { return new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("missing") }; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" { "output": [ { "type": "message", "content": [ { "type": "output_text", "text": "Done." } ] } ] } """) }; }); using var client = CreateClient( handler, reconnectionAttempts: 0, new LiteLlmResponsesCompactionOptions { Enabled = true, ContextWindowTokens = 100, MaxOutputTokens = 10, RemainingRatio = 0.10, CompactPath = "responses/compact", FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests( contextWindowTokens: 100, maxOutputTokens: 10, remainingRatio: 0.10, summaryClient: null) }); var response = await client.GetResponseAsync( Enumerable.Range(0, 8) .Select(index => new ChatMessage(ChatRole.User, $"turn {index} {new string('x', 200)}"))); Assert.Equal("Done.", response.Text); Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths); Assert.DoesNotContain("turn 0", handler.RequestBodies[1]); } private static LiteLlmResponsesChatClient CreateClient( HttpMessageHandler handler, int reconnectionAttempts, LiteLlmResponsesCompactionOptions? compactionOptions = null, Action? retrying = null) { return new LiteLlmResponsesChatClient( new HttpClient(handler) { BaseAddress = new Uri("https://litellm.example/v1/") }, "test-key", "test-model", enableThinking: false, reasoningEffort: "none", reconnectionAttempts, TimeSpan.Zero, compactionOptions, retrying: retrying); } private sealed class SequencedHttpMessageHandler : HttpMessageHandler { private readonly Queue responses; public SequencedHttpMessageHandler(params HttpResponseMessage[] responses) { this.responses = new Queue(responses); } public int RequestCount { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { RequestCount++; return Task.FromResult(responses.Dequeue()); } } private sealed class RecordingHttpMessageHandler : HttpMessageHandler { private readonly Func handler; public RecordingHttpMessageHandler(Func handler) { this.handler = handler; } public List RequestPaths { get; } = []; public List RequestBodies { get; } = []; public Dictionary> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase); protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { RequestPaths.Add(request.RequestUri?.PathAndQuery ?? string.Empty); RequestBodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)); foreach (var header in request.Headers) { if (!RequestHeaders.TryGetValue(header.Key, out var values)) { values = []; RequestHeaders[header.Key] = values; } values.AddRange(header.Value); } return handler(request); } } }