using AIStudio.Provider; using AIStudio.Provider.OpenAI; namespace AIStudio.Tests.Provider.ToolCalling; /// /// Checks how a streamed Responses API call is read back. /// /// /// The API repeats the whole response when it is done, so there is little to reassemble here -- /// but there is one thing to get right: the reasoning items have to return exactly as they came, /// including the parts we do not understand. The API refuses a continuation whose reasoning is /// missing, and it would just as surely refuse one we rewrote. /// [TestFixture] public sealed class ResponsesStreamAccumulatorTests { private const string REASONING_ITEM = """{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"gAAAAAB0aXRs"}"""; private const string COMPLETED_PREFIX = """{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5","output":["""; [Test] public void TheReasoningItemComesBackWordForWord() { // // The test that nails down the main risk: whatever the reasoning item carries, including // fields nobody here knows about, is what goes back on the next request. // var response = Read(COMPLETED_PREFIX + REASONING_ITEM + "]}}"); Assert.That(response!.Output.Single().GetRawText(), Is.EqualTo(REASONING_ITEM), "Not a field added, not a field dropped: the item travels on as it arrived."); } [Test] public void TheCompletedEventCarriesTheWholeRound() { var response = Read(COMPLETED_PREFIX + REASONING_ITEM + "," + """{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Here is the answer."}]},""" + """{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}""" + "]}}"); Assert.Multiple(() => { Assert.That(response!.GetTextOutput(), Is.EqualTo("Here is the answer."), "The text of the round is read out of the completed response."); Assert.That(response.GetFunctionCalls().Single().CallId, Is.EqualTo("call_1"), "And so are the calls it asked for."); Assert.That(response.Output, Has.Count.EqualTo(3), "Every output item is kept, because every one of them goes back."); }); } [Test] public void TheTextIsShownWhileItIsBeingWritten() { var accumulator = new ResponsesStreamAccumulator(); var shown = string.Concat(Lines( """{"type":"response.output_text.delta","delta":"Let me "}""", """{"type":"response.output_text.delta","delta":"look that up."}""") .Select(accumulator.Process) .Where(part => part.HasContent) .Select(part => part.TextDelta)); Assert.That(shown, Is.EqualTo("Let me look that up."), "Each piece of text goes out as it arrives rather than at the end of the round."); } [Test] public void AnAnnouncedSourceTravelsWithItsLine() { var accumulator = new ResponsesStreamAccumulator(); var part = accumulator.Process(Event("""{"type":"response.output_text.annotation.added","annotation_index":0,"annotation":{"type":"url_citation","title":"Example","url":"https://example.org/"}}""")); Assert.Multiple(() => { Assert.That(part.Sources.Select(x => x.URL), Is.EqualTo(new[] { "https://example.org/" }), "A citation reaches the user as soon as the model makes it."); Assert.That(part.TextDelta, Is.Empty, "A line which only announces a source carries no text."); }); } [Test] public void AGatewayWithoutACompletedEventStillGetsARound() { // // Not every gateway in front of this API sends the closing event. The finished output // items are enough to put the round back together, reasoning included. // var response = Read( """{"type":"response.output_item.done","output_index":0,"item":""" + REASONING_ITEM + "}", """{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"web_search","arguments":"{}"}}"""); Assert.Multiple(() => { Assert.That(response, Is.Not.Null, "A round built from its items is still a round."); Assert.That(response!.Output.First().GetRawText(), Is.EqualTo(REASONING_ITEM), "And the reasoning item is as untouched as it would be in the completed event."); Assert.That(response.GetFunctionCalls().Single().Name, Is.EqualTo("web_search"), "The call is there to be executed."); }); } [Test] public void TheCompletedEventWinsOverTheCollectedItems() { // // When both arrive, the response the API itself assembled is the one to trust. // var response = Read( """{"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial"}]}}""", """{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Complete"}]}]}}"""); Assert.That(response!.GetTextOutput(), Is.EqualTo("Complete"), "The closing event is the round, and the collected items were only there in case it never came."); } [Test] public void AStreamWhichSaidNothingIsAFailedRound() { var response = Read("""{"type":"response.created","response":{"id":"resp_1"}}"""); Assert.That(response, Is.Null, "Neither a completed response nor a single finished item: there is no round here to continue from."); } private static ResponsesResponse? Read(params string[] data) { var accumulator = new ResponsesStreamAccumulator(); foreach (var serverSentEvent in Lines(data)) accumulator.Process(serverSentEvent); return accumulator.Build(); } private static IEnumerable Lines(params string[] data) => data.Select(Event); private static ServerSentEvent Event(string data) => new($"data: {data}", data); }