diff --git a/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs b/src/AgentCore.AspNetCore/Vendors/OpenAiLive/Call/OpenAiLiveCall.cs index 121a220..c2e100e 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) { 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..8e01bd0 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,14 @@ 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. + // 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 ACallerHangUpBeforeTheAckFinishesTheCallWithNoHangUp() + public async Task AClosedEventFinishesTheCallWithNoHangUpAndNoWarning() { - 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 +148,35 @@ 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.Call.HasEnded); + Assert.Equal((ConversationEndReason.CallerHungUp, "close_requested"), (ended.Reason, ended.Call!.Cause)); + Assert.Equal(0, warned.Count); + } + + // 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() + { + 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