/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/
#if NET8_0_OR_GREATER
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using GitHub.Copilot.Test.Harness;
using Xunit;
using Xunit.Abstractions;
namespace GitHub.Copilot.Test.E2E;
#pragma warning disable GHCP001 // The LLM inference surface is intentionally experimental.
///
/// Drives a full agent turn over the WebSocket inference transport through a
/// subclass. A single handler services both
/// transports against an in-process fake upstream: model-layer GETs and the
/// single-shot HTTP /responses call are forwarded over HTTP, while the
/// main turn flows over a real WebSocket opened by a
/// .
///
///
/// This is the regression test for the WebSocket upgrade deadlock: the runtime
/// blocks the WebSocket connect until it observes the 101 response head, so the
/// handler must emit it eagerly rather than waiting for the first upstream
/// message. Without the eager start the turn never completes and this test
/// times out.
///
[Trait(E2ETestTraits.Backend, E2ETestTraits.SelfConfiguredBackend)]
public class CopilotRequestWebSocketE2ETests(E2ETestFixture fixture, ITestOutputHelper output)
: E2ETestBase(fixture, "copilot_request_websocket", output)
{
[Fact]
public async Task Services_A_WebSocket_Turn_End_To_End_Via_The_Request_Handler()
{
await using var upstream = new FakeCopilotUpstream();
var counters = new HandlerCounters();
var handler = new ForwardingUpstreamHandler(upstream.BaseUrl, counters);
// Enable the WebSocket Responses transport in the spawned runtime so the
// main agent turn picks the WS path; single-shot calls still go over HTTP
// through the same handler.
var env = Ctx.GetEnvironment();
env["COPILOT_EXP_COPILOT_CLI_WEBSOCKET_RESPONSES"] = "true";
await using var client = Ctx.CreateClient(options: new CopilotClientOptions
{
Connection = RuntimeConnection.ForStdio(),
RequestHandler = handler,
}, environment: env);
await client.StartAsync();
var session = await Ctx.CreateSessionAsync(client, new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll,
});
string content;
try
{
var msg = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Say OK." });
content = msg?.Data.Content ?? string.Empty;
}
finally
{
await session.DisposeAsync();
}
// The HTTP hooks fired — the runtime issued model-layer GETs (catalog,
// policy) and possibly a single-shot inference, all forwarded over HTTP.
Assert.True(counters.HttpRequests > 0, "expected SendRequestAsync to fire");
// The WebSocket hooks fired — the main agent turn went over the WS path
// and we observed messages in both directions.
Assert.True(counters.WsRequestMessages > 0, "expected SendRequestMessageAsync (runtime -> upstream) to fire");
Assert.True(counters.WsResponseMessages > 0, "expected SendResponseMessageAsync (upstream -> runtime) to fire");
Assert.True(upstream.WsRequestMessageCount > 0, "expected upstream WS to receive request messages");
// The synthetic content surfaced in the assistant turn — proves the full
// chain (runtime -> handler -> upstream -> handler -> runtime) over the
// WebSocket transport is intact.
// Validate the final assistant response arrived (guards against truncated captures)
Assert.Contains("OK from synthetic", content);
}
}
/// Cross-direction message counters shared with the test assertions.
internal sealed class HandlerCounters
{
public int HttpRequests;
public int WsRequestMessages;
public int WsResponseMessages;
}
///
/// A that points every intercepted request at
/// the in-process : HTTP requests are rewritten
/// and forwarded by the base class, and WebSocket connections are opened against
/// the rewritten URL via a counting .
///
internal sealed class ForwardingUpstreamHandler(string upstreamBaseUrl, HandlerCounters counters) : CopilotRequestHandler
{
private readonly Uri _upstream = new(upstreamBaseUrl);
protected override Task SendRequestAsync(HttpRequestMessage request, CopilotRequestContext ctx)
{
Interlocked.Increment(ref counters.HttpRequests);
request.RequestUri = Rewrite(request.RequestUri!);
return base.SendRequestAsync(request, ctx);
}
protected override Task OpenWebSocketAsync(CopilotRequestContext ctx)
{
ctx = new CopilotRequestContext(ctx) { Url = Rewrite(new Uri(ctx.Url)).ToString() };
return Task.FromResult(new CountingForwardingWebSocketHandler(ctx, counters));
}
private Uri Rewrite(Uri original) => new UriBuilder(original)
{
Scheme = _upstream.Scheme,
Host = _upstream.Host,
Port = _upstream.Port,
}.Uri;
}
///
/// A pass-through forwarding handler that counts messages in both directions.
///
internal sealed class CountingForwardingWebSocketHandler(
CopilotRequestContext context,
HandlerCounters counters)
: CopilotWebSocketForwarder(context)
{
public override Task SendRequestMessageAsync(CopilotWebSocketMessage message)
{
Interlocked.Increment(ref counters.WsRequestMessages);
return base.SendRequestMessageAsync(message);
}
public override Task SendResponseMessageAsync(CopilotWebSocketMessage message)
{
Interlocked.Increment(ref counters.WsResponseMessages);
return base.SendResponseMessageAsync(message);
}
}
///
/// In-process upstream that speaks the CAPI shapes the runtime needs: model
/// catalog (advertising the WebSocket /responses endpoint), policy, a
/// single-shot HTTP /responses SSE stream, and a WebSocket endpoint at
/// /responses that answers each inbound response.create with the
/// ordered /responses events the reducer expects.
///
internal sealed class FakeCopilotUpstream : IAsyncDisposable
{
private const string HttpText = "OK from synthetic HTTP upstream.";
private const string WsText = "OK from synthetic WS upstream.";
private readonly HttpListener _listener = new();
private readonly CancellationTokenSource _cts = new();
private readonly Task _loop;
private int _wsRequestMessages;
public string BaseUrl { get; }
public int WsRequestMessageCount => Volatile.Read(ref _wsRequestMessages);
public FakeCopilotUpstream()
{
var port = GetFreePort();
BaseUrl = $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(BaseUrl);
_listener.Start();
_loop = Task.Run(() => AcceptLoopAsync(_cts.Token), _cts.Token);
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
HttpListenerContext context;
try
{
context = await _listener.GetContextAsync().ConfigureAwait(false);
}
catch
{
break;
}
_ = Task.Run(() => HandleContextAsync(context, ct), ct);
}
}
private async Task HandleContextAsync(HttpListenerContext context, CancellationToken ct)
{
try
{
if (context.Request.IsWebSocketRequest)
{
await HandleWebSocketAsync(context, ct).ConfigureAwait(false);
}
else
{
await HandleHttpAsync(context, ct).ConfigureAwait(false);
}
}
catch
{
// Best-effort: the runtime tears connections down as turns complete.
}
}
private async Task HandleWebSocketAsync(HttpListenerContext context, CancellationToken ct)
{
var wsContext = await context.AcceptWebSocketAsync(subProtocol: null).ConfigureAwait(false);
var socket = wsContext.WebSocket;
var buffer = new byte[16 * 1024];
while (socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
var message = await ReceiveTextAsync(socket, buffer, ct).ConfigureAwait(false);
if (message is null)
{
break;
}
Interlocked.Increment(ref _wsRequestMessages);
foreach (var (_, json) in ResponseEvents(WsText, "resp_stub_ws"))
{
var bytes = Encoding.UTF8.GetBytes(json);
await socket.SendAsync(
new ArraySegment(bytes),
WebSocketMessageType.Text,
endOfMessage: true,
ct).ConfigureAwait(false);
}
}
if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived)
{
try
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None).ConfigureAwait(false);
}
catch
{
// Already torn down.
}
}
}
private static async Task ReceiveTextAsync(WebSocket socket, byte[] buffer, CancellationToken ct)
{
using var assembled = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await socket.ReceiveAsync(new ArraySegment(buffer), ct).ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
assembled.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
return Encoding.UTF8.GetString(assembled.ToArray());
}
private static async Task HandleHttpAsync(HttpListenerContext context, CancellationToken ct)
{
if (context.Request.HasEntityBody)
{
using var input = context.Request.InputStream;
var drain = new byte[8 * 1024];
while (await input.ReadAsync(drain.AsMemory(), ct).ConfigureAwait(false) > 0)
{
// Discard the request body; the synthetic response is fixed.
}
}
var path = context.Request.Url!.AbsolutePath.ToLowerInvariant();
string contentType = "application/json";
string body;
if (path.EndsWith("/models", StringComparison.Ordinal))
{
body = ModelCatalogJson;
}
else if (path.Contains("/models/session"))
{
body = "{}";
}
else if (path.Contains("/policy"))
{
body = "{\"state\":\"enabled\"}";
}
else if (path.EndsWith("/responses", StringComparison.Ordinal))
{
contentType = "text/event-stream";
body = BuildSse(HttpText, "resp_stub_http");
}
else
{
body = "{}";
}
var bytes = Encoding.UTF8.GetBytes(body);
context.Response.StatusCode = 200;
context.Response.ContentType = contentType;
context.Response.ContentLength64 = bytes.Length;
await context.Response.OutputStream.WriteAsync(bytes.AsMemory(), ct).ConfigureAwait(false);
context.Response.OutputStream.Close();
}
private static string BuildSse(string text, string id)
{
var sb = new StringBuilder();
foreach (var (type, json) in ResponseEvents(text, id))
{
sb.Append("event: ").Append(type).Append("\ndata: ").Append(json).Append("\n\n");
}
return sb.ToString();
}
private static (string Type, string Json)[] ResponseEvents(string text, string id) =>
[
("response.created",
"{\"type\":\"response.created\",\"response\":{\"id\":\"" + id + "\",\"object\":\"response\",\"status\":\"in_progress\",\"output\":[]}}"),
("response.output_item.added",
"{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[]}}"),
("response.content_part.added",
"{\"type\":\"response.content_part.added\",\"output_index\":0,\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\"}}"),
("response.output_text.delta",
"{\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"" + text + "\"}"),
("response.output_text.done",
"{\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":0,\"text\":\"" + text + "\"}"),
("response.completed",
"{\"type\":\"response.completed\",\"response\":{\"id\":\"" + id + "\",\"object\":\"response\",\"status\":\"completed\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"" + text + "\"}]}],\"usage\":{\"input_tokens\":5,\"output_tokens\":7,\"total_tokens\":12}}}"),
];
private const string ModelCatalogJson =
"{\"data\":[{\"id\":\"claude-sonnet-5\",\"name\":\"Claude Sonnet 5\",\"object\":\"model\",\"vendor\":\"Anthropic\",\"version\":\"1\",\"preview\":false,\"model_picker_enabled\":true,\"supported_endpoints\":[\"/responses\",\"ws:/responses\"],\"capabilities\":{\"type\":\"chat\",\"family\":\"claude-sonnet-5\",\"tokenizer\":\"o200k_base\",\"limits\":{\"max_context_window_tokens\":200000,\"max_output_tokens\":8192},\"supports\":{\"streaming\":true,\"tool_calls\":true,\"parallel_tool_calls\":true,\"vision\":true}}}]}";
private static int GetFreePort()
{
using var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
return ((IPEndPoint)probe.LocalEndpoint).Port;
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try
{
_listener.Stop();
_listener.Close();
}
catch
{
// Already stopped.
}
try
{
await _loop.ConfigureAwait(false);
}
catch
{
// Accept loop unwinds on listener shutdown.
}
_cts.Dispose();
}
}
#endif