/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ using Xunit; using System.Text.Json; #if !NET8_0_OR_GREATER using System.Runtime.Serialization; #endif using GitHub.Copilot.Rpc; namespace GitHub.Copilot.Test.Unit; /// /// Tests for JSON serialization compatibility with the SDK's configured options. /// public class SerializationTests { [Fact] public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() { var options = GetSerializerOptions(); var original = new ProviderConfig { BaseUrl = "https://example.com/provider", Headers = new Dictionary { ["Authorization"] = "Bearer provider-token" }, ModelId = "gpt-4o", WireModel = "my-finetune-v3", MaxPromptTokens = 100_000, MaxOutputTokens = 4096 }; var json = JsonSerializer.Serialize(original, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal("https://example.com/provider", root.GetProperty("baseUrl").GetString()); Assert.Equal("Bearer provider-token", root.GetProperty("headers").GetProperty("Authorization").GetString()); Assert.Equal("gpt-4o", root.GetProperty("modelId").GetString()); Assert.Equal("my-finetune-v3", root.GetProperty("wireModel").GetString()); Assert.Equal(100_000, root.GetProperty("maxPromptTokens").GetInt32()); Assert.Equal(4096, root.GetProperty("maxOutputTokens").GetInt32()); var deserialized = JsonSerializer.Deserialize(json, options); Assert.NotNull(deserialized); Assert.Equal("https://example.com/provider", deserialized.BaseUrl); Assert.Equal("Bearer provider-token", deserialized.Headers!["Authorization"]); Assert.Equal("gpt-4o", deserialized.ModelId); Assert.Equal("my-finetune-v3", deserialized.WireModel); Assert.Equal(100_000, deserialized.MaxPromptTokens); Assert.Equal(4096, deserialized.MaxOutputTokens); } [Fact] public void MessageOptions_CanSerializeRequestHeaders_WithSdkOptions() { var options = GetSerializerOptions(); var original = new MessageOptions { Prompt = "real prompt", Mode = "enqueue", RequestHeaders = new Dictionary { ["X-Trace"] = "trace-value" } }; var json = JsonSerializer.Serialize(original, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal("real prompt", root.GetProperty("prompt").GetString()); Assert.Equal("enqueue", root.GetProperty("mode").GetString()); Assert.Equal("trace-value", root.GetProperty("requestHeaders").GetProperty("X-Trace").GetString()); var deserialized = JsonSerializer.Deserialize(json, options); Assert.NotNull(deserialized); Assert.Equal("real prompt", deserialized.Prompt); Assert.Equal("enqueue", deserialized.Mode); Assert.Equal("trace-value", deserialized.RequestHeaders!["X-Trace"]); } [Fact] public void SendMessageRequest_CanSerializeRequestHeaders_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotSession), "SendMessageRequest"); var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("Prompt", "real prompt"), ("Mode", "enqueue"), ("RequestHeaders", new Dictionary { ["X-Trace"] = "trace-value" })); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal("session-id", root.GetProperty("sessionId").GetString()); Assert.Equal("real prompt", root.GetProperty("prompt").GetString()); Assert.Equal("enqueue", root.GetProperty("mode").GetString()); Assert.Equal("trace-value", root.GetProperty("requestHeaders").GetProperty("X-Trace").GetString()); } [Fact] public void CreateSessionRequest_CanSerializeInstructionDirectories_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("InstructionDirectories", new List { "C:\\extra-instructions", "C:\\more-instructions" })); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal("C:\\extra-instructions", root.GetProperty("instructionDirectories")[0].GetString()); Assert.Equal("C:\\more-instructions", root.GetProperty("instructionDirectories")[1].GetString()); } [Fact] public void CreateSessionRequest_CanSerializeCloudOptions_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); var request = CreateInternalRequest( requestType, ("Cloud", new CloudSessionOptions { Repository = new CloudSessionRepository { Owner = "github", Name = "copilot-sdk", Branch = "main" } })); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var repository = document.RootElement.GetProperty("cloud").GetProperty("repository"); Assert.Equal("github", repository.GetProperty("owner").GetString()); Assert.Equal("copilot-sdk", repository.GetProperty("name").GetString()); Assert.Equal("main", repository.GetProperty("branch").GetString()); } [Fact] public void CreateSessionRequest_CanSerializeModeRequestFlags_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); var request = CreateInternalRequest( requestType, ("RequestExitPlanMode", true), ("RequestAutoModeSwitch", true)); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.True(root.GetProperty("requestExitPlanMode").GetBoolean()); Assert.True(root.GetProperty("requestAutoModeSwitch").GetBoolean()); } [Fact] public void ResumeSessionRequest_CanSerializeInstructionDirectories_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("InstructionDirectories", new List { "C:\\resume-instructions" })); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal("C:\\resume-instructions", root.GetProperty("instructionDirectories")[0].GetString()); } [Fact] public void CreateSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "CreateSessionRequest"); var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("EnableSessionTelemetry", false)); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } [Fact] public void ResumeSessionRequest_CanSerializeEnableSessionTelemetry_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("EnableSessionTelemetry", false)); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.False(root.GetProperty("enableSessionTelemetry").GetBoolean()); } [Fact] public void ResumeSessionRequest_CanSerializeOpenCanvases_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var instances = new List { new() { CanvasId = "canvas-id", ExtensionId = "ext-id", InstanceId = "instance-1", Availability = CanvasInstanceAvailability.Ready, }, }; var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("OpenCanvases", instances)); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; var openCanvases = root.GetProperty("openCanvases"); Assert.Equal(1, openCanvases.GetArrayLength()); Assert.Equal("canvas-id", openCanvases[0].GetProperty("canvasId").GetString()); } [Fact] public void ResumeSessionRequest_CanSerializeModeRequestFlags_WithSdkOptions() { var options = GetSerializerOptions(); var requestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var request = CreateInternalRequest( requestType, ("SessionId", "session-id"), ("RequestExitPlanMode", true), ("RequestAutoModeSwitch", true)); var json = JsonSerializer.Serialize(request, requestType, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.True(root.GetProperty("requestExitPlanMode").GetBoolean()); Assert.True(root.GetProperty("requestAutoModeSwitch").GetBoolean()); } [Fact] public void AutoModeSwitchResponse_CanSerialize_WithSdkOptions() { var options = GetSerializerOptions(); var json = JsonSerializer.Serialize(AutoModeSwitchResponse.YesAlways, options); Assert.Equal("\"yes_always\"", json); } [Fact] public void McpHttpServerConfig_CanSerializeOauthOptions_WithSdkOptions() { var options = GetSerializerOptions(); McpServerConfig original = new McpHttpServerConfig { Url = "https://example.com/mcp", Headers = new Dictionary { ["Authorization"] = "Bearer token" }, OauthClientId = "client-id", OauthPublicClient = false, OauthGrantType = McpHttpServerConfigOauthGrantType.ClientCredentials, Tools = ["*"], Timeout = 3000 }; var json = JsonSerializer.Serialize(original, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal("http", root.GetProperty("type").GetString()); Assert.Equal("https://example.com/mcp", root.GetProperty("url").GetString()); Assert.Equal("Bearer token", root.GetProperty("headers").GetProperty("Authorization").GetString()); Assert.Equal("client-id", root.GetProperty("oauthClientId").GetString()); Assert.False(root.GetProperty("oauthPublicClient").GetBoolean()); Assert.Equal("client_credentials", root.GetProperty("oauthGrantType").GetString()); Assert.Equal("*", root.GetProperty("tools")[0].GetString()); Assert.Equal(3000, root.GetProperty("timeout").GetInt32()); var deserialized = JsonSerializer.Deserialize(json, options); var httpConfig = Assert.IsType(deserialized); Assert.Equal("https://example.com/mcp", httpConfig.Url); Assert.Equal("Bearer token", httpConfig.Headers!["Authorization"]); Assert.Equal("client-id", httpConfig.OauthClientId); Assert.False(httpConfig.OauthPublicClient); Assert.Equal(McpHttpServerConfigOauthGrantType.ClientCredentials, httpConfig.OauthGrantType); Assert.Equal("*", Assert.Single(httpConfig.Tools!)); Assert.Equal(3000, httpConfig.Timeout); } [Fact] public void QueuedCommandResult_SerializesHandledAsBoolean_WithSdkOptions() { var options = GetSerializerOptions(); var original = new QueuedCommandResult { Handled = true, StopProcessingQueue = false }; var json = JsonSerializer.Serialize(original, options); using var document = JsonDocument.Parse(json); var root = document.RootElement; Assert.Equal(JsonValueKind.True, root.GetProperty("handled").ValueKind); Assert.Equal(JsonValueKind.False, root.GetProperty("stopProcessingQueue").ValueKind); var deserialized = JsonSerializer.Deserialize("""{"handled":false}""", options); Assert.NotNull(deserialized); Assert.False(deserialized.Handled); Assert.Null(deserialized.StopProcessingQueue); } [Fact] public void PermissionDecision_SerializesBaseDiscriminator_WithSdkOptions() { var options = GetSerializerOptions(); var original = PermissionDecision.ApproveOnce(); var json = JsonSerializer.Serialize(original, options); using var document = JsonDocument.Parse(json); Assert.Equal("approve-once", document.RootElement.GetProperty("kind").GetString()); } [Fact] public void HooksInvokeResponse_SerializesPreMcpToolCallHookOutput_WithMetaToUse() { var options = GetSerializerOptions(); // Create the PreMcpToolCallHookOutput with meta using var doc = JsonDocument.Parse("""{"injected":"by-hook","source":"test"}"""); var meta = doc.RootElement.Clone(); var hookOutput = new PreMcpToolCallHookOutput { MetaToUse = meta }; // Create the HooksInvokeResponse using reflection (it's internal) var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); var response = CreateInternalRequest(responseType, ("Output", hookOutput)); // Serialize using the exact same path as SendResultResponseAsync var typeInfo = options.GetTypeInfo(response.GetType()); var json = JsonSerializer.SerializeToElement(response, typeInfo); // The JSON should be {"output":{"metaToUse":{"injected":"by-hook","source":"test"}}} Assert.True(json.TryGetProperty("output", out var outputProp), $"Expected 'output' property. Got: {json}"); Assert.True(outputProp.TryGetProperty("metaToUse", out var metaToUseProp), $"Expected 'metaToUse' property. Got: {outputProp}"); Assert.Equal("by-hook", metaToUseProp.GetProperty("injected").GetString()); Assert.Equal("test", metaToUseProp.GetProperty("source").GetString()); } [Fact] public void HooksInvokeResponse_SerializesPreMcpToolCallHookOutput_WithNullMetaToUse() { var options = GetSerializerOptions(); // Create the PreMcpToolCallHookOutput with null meta (remove meta) var hookOutput = new PreMcpToolCallHookOutput { MetaToUse = null }; // Create the HooksInvokeResponse using reflection (it's internal) var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); var response = CreateInternalRequest(responseType, ("Output", hookOutput)); // Serialize var typeInfo = options.GetTypeInfo(response.GetType()); var json = JsonSerializer.SerializeToElement(response, typeInfo); // Should be {"output":{"metaToUse":null}} Assert.True(json.TryGetProperty("output", out var outputProp), $"Expected 'output' property. Got: {json}"); Assert.True(outputProp.TryGetProperty("metaToUse", out var metaToUseProp), $"Expected 'metaToUse' property. Got: {outputProp}"); Assert.Equal(JsonValueKind.Null, metaToUseProp.ValueKind); } [Fact] public void HooksInvokeResponse_SerializesNullOutput_AsEmptyOrNoOutputProperty() { var options = GetSerializerOptions(); // Create the HooksInvokeResponse with null Output (preserve meta) var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); var response = CreateInternalRequest(responseType, ("Output", (object?)null)); // Serialize var typeInfo = options.GetTypeInfo(response.GetType()); var json = JsonSerializer.SerializeToElement(response, typeInfo); // With WhenWritingNull, output property should be omitted when null // OR if present, should be null if (json.TryGetProperty("output", out var outputProp)) { Assert.Equal(JsonValueKind.Null, outputProp.ValueKind); } // else: property omitted, which is fine (runtime treats undefined output as no-op) } private static JsonSerializerOptions GetSerializerOptions() { var prop = typeof(CopilotClient) .GetProperty("SerializerOptionsForMessageFormatter", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); var options = (JsonSerializerOptions?)prop?.GetValue(null); Assert.NotNull(options); return options; } private static Type GetNestedType(Type containingType, string name) { var type = containingType.GetNestedType(name, System.Reflection.BindingFlags.NonPublic); Assert.NotNull(type); return type!; } [Fact] public void HooksInvokeResponse_SerializesBoxedJsonElement_AsOutput() { // This tests the EXACT path used by SerializeHookOutput: // PreMcpToolCallHookOutput -> serialize to JsonElement -> box as object? in HooksInvokeResponse.Output var options = GetSerializerOptions(); using var metaDoc = JsonDocument.Parse("""{"injected":"by-hook","source":"test"}"""); var hookOutput = new PreMcpToolCallHookOutput { MetaToUse = metaDoc.RootElement.Clone() }; // SerializeHookOutput returns a JsonElement (value type) var hookTypeInfo = options.GetTypeInfo(typeof(PreMcpToolCallHookOutput)); JsonElement serializedOutput = JsonSerializer.SerializeToElement(hookOutput, hookTypeInfo); // HooksInvokeResponse stores this as object? (boxed JsonElement) var responseType = GetNestedType(typeof(CopilotClient), "HooksInvokeResponse"); var response = CreateInternalRequest(responseType, ("Output", (object)serializedOutput)); // Serialize via GetTypeInfo(response.GetType()) — same as SendResultResponseAsync var typeInfo = options.GetTypeInfo(response.GetType()); var json = JsonSerializer.SerializeToElement(response, typeInfo); // Expected: {"output":{"metaToUse":{"injected":"by-hook","source":"test"}}} Assert.True(json.TryGetProperty("output", out var outputProp), $"Expected 'output'. Got: {json}"); Assert.True(outputProp.TryGetProperty("metaToUse", out var metaToUseProp), $"Expected 'metaToUse' in output. Got: {outputProp}"); Assert.Equal("by-hook", metaToUseProp.GetProperty("injected").GetString()); Assert.Equal("test", metaToUseProp.GetProperty("source").GetString()); } private static object CreateInternalRequest(Type type, params (string Name, object? Value)[] properties) { #if NET8_0_OR_GREATER var instance = System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type); #else var instance = FormatterServices.GetUninitializedObject(type); #endif foreach (var (name, value) in properties) { var property = type.GetProperty(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(property); if (property!.SetMethod is not null) { property.SetValue(instance, value); continue; } var field = type.GetField($"<{name}>k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(field); field!.SetValue(instance, value); } return instance; } }