Files
Assistant/backend.tests/RealtimeSessionLifecycleTests.cs
T

93 lines
3.3 KiB
C#

using System.Text.Json.Nodes;
using backend.Conversations;
using backend.Data;
using backend.Realtime;
using backend.Tools;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace backend.tests;
public class RealtimeSessionLifecycleTests(ApiFactory factory) : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory = factory;
[Fact]
public async Task Open_emits_session_update_then_session_started_then_session_ended_on_cancel()
{
using var scope = _factory.Services.CreateScope();
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
var registry = new ToolRegistry(new ITool[] { new GetCurrentTimeTool() });
var upstream = new ScriptedRealtimeUpstream();
var sink = new RecordingSink();
var cfg = new RealtimeSettings(
Model: "gpt-4o-realtime-preview",
Voice: "alloy",
SystemPrompt: "test",
IdleTimeoutSeconds: 30,
EnabledTools: new HashSet<string> { "get_current_time" });
var session = new RealtimeSession(
deviceId: Guid.NewGuid(),
upstream, sink, log, registry, cfg,
channel: new FakeDeviceChannel(),
logger: NullLogger.Instance);
upstream.Push(new JsonObject { ["type"] = "session.updated" });
var cts = new CancellationTokenSource();
var runTask = session.RunAsync(cts.Token);
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
cts.Cancel();
await runTask;
upstream.Sent.Select(e => (string?)e["type"]).Should().StartWith("session.update");
sink.Envelopes.Select(e => (string?)e["type"]).Should().Contain("session_started");
sink.Envelopes.Select(e => (string?)e["type"]).Should().Contain("session_ended");
}
}
internal class NullLogger : Microsoft.Extensions.Logging.ILogger
{
public static readonly NullLogger Instance = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => false;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel l, Microsoft.Extensions.Logging.EventId e,
TState s, Exception? ex, Func<TState, Exception?, string> f) { }
}
internal class RecordingSink : ISessionOutput
{
public List<JsonObject> Envelopes { get; } = new();
public List<byte[]> Binary { get; } = new();
public Task WriteEnvelopeAsync(JsonObject e, CancellationToken ct)
{
lock (Envelopes) Envelopes.Add(e);
_gate.Release();
return Task.CompletedTask;
}
public Task WriteBinaryAsync(ReadOnlyMemory<byte> b, CancellationToken ct)
{
Binary.Add(b.ToArray());
return Task.CompletedTask;
}
private readonly SemaphoreSlim _gate = new(0, int.MaxValue);
public async Task WaitForAsync(string type, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
lock (Envelopes)
{
if (Envelopes.Any(e => (string?)e["type"] == type)) return;
}
await _gate.WaitAsync(timeout);
}
throw new TimeoutException($"never saw {type}");
}
}