using MeetingAssistant.Summary; using Microsoft.Extensions.AI; using System.Net; using System.Text; using System.Text.Json; namespace MeetingAssistant.Tests; public sealed class LiteLlmResponsesChatClientTests { [Fact] public async Task ClientAssemblesStreamedTextResponseWithMetadataAndUsage() { var handler = new SequencedHttpMessageHandler( new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """ data: {"type":"response.created","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"in_progress","store":false},"sequence_number":0} data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_stream","type":"message","status":"in_progress","content":[],"role":"assistant"},"sequence_number":1} data: {"type":"response.content_part.added","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""},"sequence_number":2} data: {"type":"response.output_text.delta","item_id":"msg_stream","output_index":0,"content_index":0,"delta":"Streamed OK","sequence_number":3} data: {"type":"response.output_text.done","item_id":"msg_stream","output_index":0,"content_index":0,"text":"Streamed OK","sequence_number":4} data: {"type":"response.content_part.done","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"Streamed OK"},"sequence_number":5} data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_stream","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Streamed OK"}],"role":"assistant"},"sequence_number":6} data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}},"sequence_number":7} data: [DONE] """, Encoding.UTF8, "text/event-stream") }); using var client = CreateClient(handler, reconnectionAttempts: 0); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]); Assert.Equal("Streamed OK", response.Text); Assert.Equal("resp_stream", response.ResponseId); Assert.Equal("gpt-5.5", response.ModelId); Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1779147100), response.CreatedAt); Assert.NotNull(response.Usage); Assert.Equal(12, response.Usage.InputTokenCount); Assert.Equal(3, response.Usage.OutputTokenCount); Assert.Equal(15, response.Usage.TotalTokenCount); } [Fact] public async Task ClientAssemblesStreamedFunctionCall() { var handler = new SequencedHttpMessageHandler( new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """ data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_stream","name":"write_summary","arguments":"{\"markdown\":\"# Summary\\nDone\"}","status":"completed"}} data: {"type":"response.completed","response":{"id":"resp_stream","model":"gpt-5.5","output":[]}} data: [DONE] """, Encoding.UTF8, "text/event-stream") }); using var client = CreateClient(handler, reconnectionAttempts: 0); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]); var call = Assert.IsType(Assert.Single(response.Messages[0].Contents)); Assert.Equal("call_stream", call.CallId); Assert.Equal("write_summary", call.Name); Assert.NotNull(call.Arguments); Assert.Equal("# Summary\nDone", call.Arguments["markdown"]?.ToString()); } [Fact] public async Task ClientUsesNonStreamingResponsesWhenConfigured() { var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """ { "id": "resp_nonstream", "created_at": 1779147100, "model": "gpt-5.5", "object": "response", "output": [ { "id": "msg_nonstream", "type": "message", "status": "completed", "content": [ { "type": "output_text", "annotations": [], "text": "Non-streamed OK" } ], "role": "assistant" } ], "parallel_tool_calls": true, "status": "completed", "store": false, "usage": { "input_tokens": 12, "output_tokens": 4, "total_tokens": 16 } } """, Encoding.UTF8, "application/json") }); using var client = CreateClient(handler, reconnectionAttempts: 0, useStreaming: false); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]); Assert.Equal("Non-streamed OK", response.Text); Assert.Contains("\"stream\":false", Assert.Single(handler.RequestBodies)); } [Fact] public async Task ClientSendsChatMessagesAsResponsesMessageItems() { var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done.")); using var client = CreateClient(handler, reconnectionAttempts: 0); await client.GetResponseAsync( [ new ChatMessage(ChatRole.User, "write summary"), new ChatMessage(ChatRole.Assistant, "I will inspect the meeting.") ]); using var request = JsonDocument.Parse(Assert.Single(handler.RequestBodies)); var inputItems = request.RootElement.GetProperty("input").EnumerateArray().ToArray(); Assert.Equal(2, inputItems.Length); Assert.All(inputItems, item => Assert.Equal("message", item.GetProperty("type").GetString())); Assert.Equal("user", inputItems[0].GetProperty("role").GetString()); Assert.Equal( "input_text", Assert.Single(inputItems[0].GetProperty("content").EnumerateArray()) .GetProperty("type") .GetString()); Assert.Equal("assistant", inputItems[1].GetProperty("role").GetString()); Assert.Equal( "output_text", Assert.Single(inputItems[1].GetProperty("content").EnumerateArray()) .GetProperty("type") .GetString()); Assert.DoesNotContain("\"type\":\"unknown\"", handler.RequestBodies[0]); } [Fact] public async Task AgentLoopReturnsMalformedFunctionArgumentsWithoutInvokingTool() { var responses = new Queue( [ new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """ data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_invalid","name":"write_summary","arguments":"{not-json","status":"completed"}} data: {"type":"response.completed","response":{"id":"resp_invalid","model":"gpt-5.5","output":[]}} data: [DONE] """, Encoding.UTF8, "text/event-stream") }, CreateStreamedTextResponse("Recovered.") ]); var handler = new RecordingHttpMessageHandler(_ => responses.Dequeue()); var toolInvoked = false; var tool = AIFunctionFactory.Create( (string markdown) => { toolInvoked = true; return markdown; }, "write_summary", "Writes a summary."); using var innerClient = CreateClient(handler, reconnectionAttempts: 0); using var functionClient = innerClient .AsBuilder() .UseFunctionInvocation( loggerFactory: null, client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync) .Build(); var response = await functionClient.GetResponseAsync( [new ChatMessage(ChatRole.User, "write summary")], new ChatOptions { Tools = [tool] }); Assert.False(toolInvoked); Assert.Equal("Recovered.", response.Text); Assert.Equal(2, handler.RequestBodies.Count); Assert.Contains("invalid_tool_arguments", handler.RequestBodies[1]); Assert.Contains("call_invalid", handler.RequestBodies[1]); } [Fact] public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText() { var handler = new SequencedHttpMessageHandler( CreateStreamedTextResponse( "Done.", "Checked the configured rules file.", "Prepared a targeted update.")); var reasoningSummaries = new List(); using var client = CreateClient( handler, reconnectionAttempts: 0, reasoningSummaryChanged: reasoningSummaries.Add); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]); Assert.Equal("Done.", response.Text); Assert.Equal( [ "Checked the configured rules file.", "Prepared a targeted update." ], reasoningSummaries); } [Fact] public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails() { var handler = new SequencedHttpMessageHandler( CreateStreamedTextResponse("Done.", "Checked the configured rules file.")); using var client = CreateClient( handler, reconnectionAttempts: 0, reasoningSummaryChanged: _ => throw new InvalidOperationException("UI failed")); var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]); Assert.Equal("Done.", response.Text); } [Fact] public async Task ClientRetriesTransientServerFailure() { var handler = new SequencedHttpMessageHandler( new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("Internal Server Error") }, CreateStreamedTextResponse("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(_ => CreateStreamedTextResponse("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 CreateStreamedTextResponse("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 CreateStreamedTextResponse("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, Action? reasoningSummaryChanged = null, bool useStreaming = true) { 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, reasoningSummaryChanged: reasoningSummaryChanged, useStreaming: useStreaming); } private static HttpResponseMessage CreateStreamedTextResponse( string text, params string[] reasoningSummaries) { var events = new List(); for (var index = 0; index < reasoningSummaries.Length; index++) { events.Add("data: " + JsonSerializer.Serialize(new { type = "response.reasoning_summary_text.delta", item_id = "reasoning_stream", output_index = 0, summary_index = index, delta = reasoningSummaries[index] })); } var outputIndex = reasoningSummaries.Length > 0 ? 1 : 0; events.Add("data: " + JsonSerializer.Serialize(new { type = "response.output_text.delta", item_id = "msg_stream", output_index = outputIndex, content_index = 0, delta = text })); events.Add( """data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}}}"""); events.Add("data: [DONE]"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( string.Join($"{Environment.NewLine}{Environment.NewLine}", events) + Environment.NewLine + Environment.NewLine, Encoding.UTF8, "text/event-stream") }; } 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); } } }