diff --git a/backend.tests/RealtimeSessionTranscriptTests.cs b/backend.tests/RealtimeSessionTranscriptTests.cs new file mode 100644 index 0000000..d6b40e3 --- /dev/null +++ b/backend.tests/RealtimeSessionTranscriptTests.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Nodes; +using backend.Conversations; +using backend.Data; +using backend.Realtime; +using backend.Tools; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace backend.tests; + +public class RealtimeSessionTranscriptTests(ApiFactory factory) : IClassFixture +{ + private readonly ApiFactory _factory = factory; + + [Fact] + public async Task User_and_assistant_transcripts_are_persisted_and_response_done_emits_assistant_done() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var log = scope.ServiceProvider.GetRequiredService(); + var reg = new ToolRegistry(Array.Empty()); + var upstream = new ScriptedRealtimeUpstream(); + var sink = new RecordingSink(); + var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet()); + var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg, + new FakeDeviceChannel(), NullLogger.Instance); + + upstream.Push(new JsonObject { ["type"] = "session.updated" }); + var cts = new CancellationTokenSource(); + var run = session.RunAsync(cts.Token); + await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2)); + + upstream.Push(new JsonObject + { + ["type"] = "conversation.item.input_audio_transcription.completed", + ["transcript"] = "hello", + }); + upstream.Push(new JsonObject + { + ["type"] = "response.audio_transcript.done", + ["transcript"] = "hi there", + }); + upstream.Push(new JsonObject { ["type"] = "response.done" }); + + await sink.WaitForAsync("assistant_done", TimeSpan.FromSeconds(2)); + + var convoId = session.ConversationId!.Value; + var turns = await db.Turns + .Where(t => t.ConversationId == convoId) + .OrderBy(t => t.Index).ToListAsync(); + turns.Select(t => t.Role).Should().Equal(TurnRole.User, TurnRole.Assistant); + turns[0].Text.Should().Be("hello"); + turns[1].Text.Should().Be("hi there"); + + cts.Cancel(); + await run; + } +} diff --git a/backend/Realtime/RealtimeSession.cs b/backend/Realtime/RealtimeSession.cs index c4b04d1..c49c2a9 100644 --- a/backend/Realtime/RealtimeSession.cs +++ b/backend/Realtime/RealtimeSession.cs @@ -121,6 +121,24 @@ public class RealtimeSession case "response.audio.delta": await ForwardAudioDeltaAsync(evt, ct); break; + case "conversation.item.input_audio_transcription.completed": + { + var text = (string?)evt["transcript"]; + if (_conversation is not null && !string.IsNullOrEmpty(text)) + await _log.AppendUserAsync(_conversation.Id, text!, ct); + break; + } + case "response.audio_transcript.done": + { + var text = (string?)evt["transcript"]; + if (_conversation is not null && !string.IsNullOrEmpty(text)) + await _log.AppendAssistantAsync(_conversation.Id, text!, ct); + break; + } + case "response.done": + await _output.WriteEnvelopeAsync( + new JsonObject { ["type"] = "assistant_done" }, ct); + break; } } }