From 91192602afff1bd14a1e17296e0080adee6ceac8 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Thu, 22 Oct 2026 15:26:16 -0600 Subject: [PATCH 1/2] fix(openai-live): hang up and log when OpenAI ends the session A Closed event or a sideband that ends mid-call now hangs the call up and logs a warning with the cause, so callers no longer sit in silence. --- .../Vendors/OpenAiLive/Call/OpenAiLiveCall.cs | 10 ++++- .../Vendors/OpenAiLive/OpenAiLiveLog.cs | 3 ++ .../OpenAiLive/OpenAiLiveCallEndTests.cs | 41 +++++++++++++++++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index 121a220..0944c2c 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -174,7 +174,9 @@ internal async Task RunAsync(CancellationToken cancellationToken) await _hearing.FlushAsync().ConfigureAwait(false); - await _finish.FinishAsync(ConversationEndReason.Faulted, SidebandClosedCause).ConfigureAwait(false); + OpenAiLiveLog.SessionEndedByOpenAi(Logger, CallId, SidebandClosedCause); + + await _finish.HangUpAndFinishAsync(ConversationEndReason.Faulted, SidebandClosedCause, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -267,7 +269,11 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken case LiveEvent.Closed end: await _hearing.FlushAsync().ConfigureAwait(false); - await _finish.FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); + string cause = end.Reason ?? SidebandClosedCause; + + OpenAiLiveLog.SessionEndedByOpenAi(Logger, CallId, cause); + + await _finish.HangUpAndFinishAsync(ConversationEndReason.CallerHungUp, cause, cancellationToken).ConfigureAwait(false); return true; diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs index b75a311..20e499f 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/OpenAiLiveLog.cs @@ -59,5 +59,8 @@ internal static partial class OpenAiLiveLog [LoggerMessage(EventId = 328, Level = LogLevel.Warning, Message = "a fact for the voice of the call {CallId} could not be sent, so the voice goes on without it.")] internal static partial void VoiceContextFailed(ILogger logger, string callId, Exception fault); + + [LoggerMessage(EventId = 329, Level = LogLevel.Warning, Message = "the session of the call {CallId} ended on OpenAI's side ({Cause}); the call is hung up.")] + internal static partial void SessionEndedByOpenAi(ILogger logger, string callId, string cause); } } diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs index 610ecc9..99d8524 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs @@ -5,6 +5,7 @@ using AgentCore.AspNetCore.Vendors.OpenAiLive.Wire; using AgentCore.Domain.Audit; using AgentCore.TestSupport; +using Microsoft.Extensions.Logging; using Xunit; using static AgentCore.AspNetCore.Tests.Vendors.OpenAiLive.LiveSent; @@ -132,11 +133,15 @@ public async Task AMultiPieceAnswerEndsOnlyAfterItsLastPieceIsAcked() Assert.False(hungUpOnTheFirstAck); } - // A caller who hangs up before the ack is not hung up again, and the call still finishes. + // OpenAI ending the session is not proof the caller's line is down (the audio stopped silently on live calls), + // so a Closed event hangs the line up once and says why in a warning. [Fact(Timeout = 30_000)] - public async Task ACallerHangUpBeforeTheAckFinishesTheCallWithNoHangUp() + public async Task AClosedEventHangsTheCallUpOnceAndLogsAWarning() { - await using RunningLiveCall running = await RunningLiveCall.StartAsync(new StallOnCueChatClient(), [], EndsAfterTheFirstAskYaml); + using EventObservedLoggerProvider warned = new("SessionEndedByOpenAi"); + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new StallOnCueChatClient(), [hook], logger: warned.CreateLogger("test")); IReadOnlyList log = LiveLog.Inbound("p1-a"); int delegation = LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated); @@ -144,9 +149,37 @@ public async Task ACallerHangUpBeforeTheAckFinishesTheCallWithNoHangUp() _ = await running.Sideband.WaitForSentAsync(IsCommentary); running.Sideband.Push([log[LiveLog.IndexOf(log, OpenAiLiveEvents.Closed)]]); await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); - Assert.False(running.HungUp.Task.IsCompleted); + Assert.True(running.HungUp.Task.IsCompleted); Assert.True(running.Call.HasEnded); + Assert.Equal((ConversationEndReason.CallerHungUp, "close_requested"), (ended.Reason, ended.Call!.Cause)); + Assert.Equal(1, warned.Count); + Assert.Equal(LogLevel.Warning, warned.Level); + Assert.Contains("close_requested", warned.Message, StringComparison.Ordinal); + } + + // A sideband that closes mid-call with no Closed event ends the same way: one hang-up, one warning. + [Fact(Timeout = 30_000)] + public async Task ASidebandClosingMidCallHangsTheCallUpOnceAndLogsAWarning() + { + using EventObservedLoggerProvider warned = new("SessionEndedByOpenAi"); + RecordingHook hook = new(); + await using RunningLiveCall running = await RunningLiveCall.StartAsync( + new StallOnCueChatClient(), [hook], logger: warned.CreateLogger("test")); + IReadOnlyList log = LiveLog.Inbound("p1-a"); + + running.Sideband.Push(log.Take(LiveLog.IndexOf(log, OpenAiLiveEvents.DelegationCreated) + 1)); + _ = await running.Sideband.WaitForSentAsync(IsCommentary); + running.Sideband.Complete(); + await running.Loop; + ConversationEnded ended = await hook.WaitForAsync(); + + Assert.True(running.HungUp.Task.IsCompleted); + Assert.Equal((ConversationEndReason.Faulted, "sideband_closed"), (ended.Reason, ended.Call!.Cause)); + Assert.Equal(1, warned.Count); + Assert.Equal(LogLevel.Warning, warned.Level); + Assert.Contains("sideband_closed", warned.Message, StringComparison.Ordinal); } // With no commentary to wait for, the hang-up is at once. The clock never moves, so no wait on a From f11370c34d133b99fdd8dc69022943e06e05e2af Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Thu, 22 Oct 2026 15:32:58 -0600 Subject: [PATCH 2/2] no-mistakes(review): Hang up and warn only when the sideband closes --- .../Vendors/OpenAiLive/Call/OpenAiLiveCall.cs | 6 +----- .../Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs | 13 +++++-------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index 0944c2c..c2e100e 100644 --- a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs +++ b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs @@ -269,11 +269,7 @@ private async ValueTask HandleAsync(LiveEvent liveEvent, CancellationToken case LiveEvent.Closed end: await _hearing.FlushAsync().ConfigureAwait(false); - string cause = end.Reason ?? SidebandClosedCause; - - OpenAiLiveLog.SessionEndedByOpenAi(Logger, CallId, cause); - - await _finish.HangUpAndFinishAsync(ConversationEndReason.CallerHungUp, cause, cancellationToken).ConfigureAwait(false); + await _finish.FinishAsync(ConversationEndReason.CallerHungUp, end.Reason ?? SidebandClosedCause).ConfigureAwait(false); return true; diff --git a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs index 99d8524..8e01bd0 100644 --- a/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs +++ b/tests/AgentCore.AspNetCore.Tests/Vendors/OpenAiLive/OpenAiLiveCallEndTests.cs @@ -133,10 +133,9 @@ public async Task AMultiPieceAnswerEndsOnlyAfterItsLastPieceIsAcked() Assert.False(hungUpOnTheFirstAck); } - // OpenAI ending the session is not proof the caller's line is down (the audio stopped silently on live calls), - // so a Closed event hangs the line up once and says why in a warning. + // A Closed event is how a caller's hang-up arrives, so the call finishes with no hang-up request and no warning. [Fact(Timeout = 30_000)] - public async Task AClosedEventHangsTheCallUpOnceAndLogsAWarning() + public async Task AClosedEventFinishesTheCallWithNoHangUpAndNoWarning() { using EventObservedLoggerProvider warned = new("SessionEndedByOpenAi"); RecordingHook hook = new(); @@ -151,15 +150,13 @@ public async Task AClosedEventHangsTheCallUpOnceAndLogsAWarning() await running.Loop; ConversationEnded ended = await hook.WaitForAsync(); - Assert.True(running.HungUp.Task.IsCompleted); + Assert.False(running.HungUp.Task.IsCompleted); Assert.True(running.Call.HasEnded); Assert.Equal((ConversationEndReason.CallerHungUp, "close_requested"), (ended.Reason, ended.Call!.Cause)); - Assert.Equal(1, warned.Count); - Assert.Equal(LogLevel.Warning, warned.Level); - Assert.Contains("close_requested", warned.Message, StringComparison.Ordinal); + Assert.Equal(0, warned.Count); } - // A sideband that closes mid-call with no Closed event ends the same way: one hang-up, one warning. + // A sideband that closes mid-call with no Closed event is an end on OpenAI's side: one hang-up, one warning. [Fact(Timeout = 30_000)] public async Task ASidebandClosingMidCallHangsTheCallUpOnceAndLogsAWarning() {