Skip to content

Commit e759700

Browse files
stephentoubCopilot
andauthored
Use string enums for .NET session events (#1226)
* Use string enums for session event values Generate string-backed value types for session event schema enums so unknown values emitted by newer runtimes deserialize without throwing and preserve their raw string values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use string enums for all generated C# enums Extend the generated string-backed value type enum model to RPC schema enums so unknown wire values from newer runtimes deserialize without throwing and preserve their raw values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize default string enum values Update generated C# string enum value types to back Value with a nullable field so default instances expose an empty string instead of null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix C# enum converter token validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Share generated string enum JSON helpers Move generated string enum JSON read/write validation into a handwritten SDK helper and have generated converters delegate to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move generated string enum values after constructors Update the C# generator so known string enum static properties are emitted after the constructor, then regenerate the C# protocol outputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move generated string enum Value after constructors Update the C# generator so generated string enum structs place only the backing field before the constructor, with Value and known static properties after it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 066a69c commit e759700

10 files changed

Lines changed: 3076 additions & 589 deletions

dotnet/src/Generated/Rpc.cs

Lines changed: 1451 additions & 322 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/Generated/SessionEvents.cs

Lines changed: 1427 additions & 250 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dotnet/src/Types.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,35 @@
1313

1414
namespace GitHub.Copilot.SDK;
1515

16+
internal static class GeneratedStringEnumJson
17+
{
18+
internal static string ReadValue(ref Utf8JsonReader reader, Type typeToConvert)
19+
{
20+
if (reader.TokenType != JsonTokenType.String)
21+
{
22+
throw new JsonException($"Expected a string token when reading {typeToConvert.Name}, but found {reader.TokenType}.");
23+
}
24+
25+
var value = reader.GetString();
26+
if (string.IsNullOrWhiteSpace(value))
27+
{
28+
throw new JsonException($"Expected a non-empty string token when reading {typeToConvert.Name}.");
29+
}
30+
31+
return value;
32+
}
33+
34+
internal static void WriteValue(Utf8JsonWriter writer, string value, Type typeToConvert)
35+
{
36+
if (string.IsNullOrWhiteSpace(value))
37+
{
38+
throw new JsonException($"Expected a non-empty string value when writing {typeToConvert.Name}.");
39+
}
40+
41+
writer.WriteStringValue(value);
42+
}
43+
}
44+
1645
/// <summary>
1746
/// Represents the connection state of the Copilot client.
1847
/// </summary>

dotnet/test/E2E/RpcExtensionsLoadedE2ETests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,11 @@ await TestHelper.WaitForConditionAsync(
165165
}
166166

167167
[Theory]
168-
[InlineData(ExtensionSource.User)]
169-
[InlineData(ExtensionSource.Project)]
170-
public async Task Discovers_Loads_And_Reports_Running_Extension(ExtensionSource source)
168+
[InlineData("user")]
169+
[InlineData("project")]
170+
public async Task Discovers_Loads_And_Reports_Running_Extension(string sourceValue)
171171
{
172+
var source = new ExtensionSource(sourceValue);
172173
string extName;
173174
string extId;
174175
string? workingDirectory;
@@ -184,7 +185,7 @@ public async Task Discovers_Loads_And_Reports_Running_Extension(ExtensionSource
184185
}
185186
else
186187
{
187-
throw new ArgumentOutOfRangeException(nameof(source), source, null);
188+
throw new ArgumentOutOfRangeException(nameof(sourceValue), sourceValue, null);
188189
}
189190

190191
await using var client = CreateExtensionsClient();

dotnet/test/E2E/RpcMcpAndSkillsE2ETests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public async Task Should_List_Mcp_Servers_With_Configured_Server()
8181
var result = await session.Rpc.Mcp.ListAsync();
8282

8383
var server = Assert.Single(result.Servers, server => string.Equals(server.Name, serverName, StringComparison.Ordinal));
84-
Assert.True(Enum.IsDefined(server.Status));
84+
Assert.False(string.IsNullOrWhiteSpace(server.Status.Value));
8585
}
8686

8787
[Fact]

dotnet/test/E2E/RpcSessionStateE2ETests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ public async Task Should_Get_And_Set_Session_Mode()
6363
}
6464

6565
[Theory]
66-
[InlineData(SessionMode.Interactive)]
67-
[InlineData(SessionMode.Plan)]
68-
[InlineData(SessionMode.Autopilot)]
69-
public async Task Should_Set_And_Get_Each_Session_Mode_Value(SessionMode mode)
66+
[InlineData("interactive")]
67+
[InlineData("plan")]
68+
[InlineData("autopilot")]
69+
public async Task Should_Set_And_Get_Each_Session_Mode_Value(string modeValue)
7070
{
7171
await using var session = await CreateSessionAsync();
72+
var mode = new SessionMode(modeValue);
7273

7374
await session.Rpc.Mode.SetAsync(mode);
7475
Assert.Equal(mode, await session.Rpc.Mode.GetAsync());

dotnet/test/E2E/RpcShellEdgeCaseE2ETests.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ public async Task Shell_Kill_Unknown_ProcessId_Returns_False()
113113
}
114114

115115
[Theory]
116-
[InlineData(ShellKillSignal.SIGTERM)]
117-
[InlineData(ShellKillSignal.SIGKILL)]
118-
public async Task Shell_Kill_Cleans_Up_After_Terminating_Signal(ShellKillSignal signal)
116+
[InlineData("SIGTERM")]
117+
[InlineData("SIGKILL")]
118+
public async Task Shell_Kill_Cleans_Up_After_Terminating_Signal(string signalValue)
119119
{
120120
var session = await CreateSessionAsync();
121+
var signal = new ShellKillSignal(signalValue);
121122
var command = OperatingSystem.IsWindows()
122123
? "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 60\""
123124
: "sleep 60";

dotnet/test/E2E/RpcTasksAndHandlersE2ETests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ await TestHelper.WaitForConditionAsync(
114114
task = await FindAgentTaskAsync(session, started.AgentId);
115115
return task?.LatestResponse?.Contains("TASK_AGENT_DONE", StringComparison.Ordinal) == true
116116
|| task?.Result?.Contains("TASK_AGENT_DONE", StringComparison.Ordinal) == true
117-
|| task?.Status is TaskAgentInfoStatus.Completed or TaskAgentInfoStatus.Failed;
117+
|| task?.Status == TaskAgentInfoStatus.Completed
118+
|| task?.Status == TaskAgentInfoStatus.Failed;
118119
},
119120
timeout: TimeSpan.FromSeconds(60),
120121
timeoutMessage: $"Background agent task '{started.AgentId}' did not produce a final observable state.");

dotnet/test/Unit/ForwardCompatibilityTests.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Copyright (c) Microsoft Corporation. All rights reserved.
33
*--------------------------------------------------------------------------------------------*/
44

5+
using System.Text.Json;
6+
using System.Text.Json.Serialization;
57
using Xunit;
68

79
namespace GitHub.Copilot.SDK.Test.Unit;
@@ -166,6 +168,96 @@ public void FromJson_UnknownEventType_WithUnknownEnumInData_DoesNotThrow()
166168
Assert.Equal("unknown", result.Type);
167169
}
168170

171+
[Fact]
172+
public void FromJson_KnownEventType_WithUnknownEnumInData_PreservesValue()
173+
{
174+
var json = """
175+
{
176+
"id": "00000000-0000-0000-0000-000000000001",
177+
"timestamp": "2026-01-01T00:00:00Z",
178+
"parentId": null,
179+
"type": "abort",
180+
"data": {
181+
"reason": "future_abort_reason"
182+
}
183+
}
184+
""";
185+
186+
var result = SessionEvent.FromJson(json);
187+
188+
var abort = Assert.IsType<AbortEvent>(result);
189+
Assert.Equal("future_abort_reason", abort.Data.Reason.Value);
190+
}
191+
192+
[Fact]
193+
public void FromJson_KnownEventType_WithNonStringEnumInData_ThrowsJsonException()
194+
{
195+
var json = """
196+
{
197+
"id": "00000000-0000-0000-0000-000000000001",
198+
"timestamp": "2026-01-01T00:00:00Z",
199+
"parentId": null,
200+
"type": "abort",
201+
"data": {
202+
"reason": false
203+
}
204+
}
205+
""";
206+
207+
var exception = Assert.Throws<JsonException>(() => SessionEvent.FromJson(json));
208+
Assert.Contains("AbortReason", exception.Message);
209+
}
210+
211+
[Fact]
212+
public void RpcEnum_WithUnknownValue_PreservesValue()
213+
{
214+
var mode = JsonSerializer.Deserialize(
215+
"""
216+
"future_mode"
217+
""",
218+
ForwardCompatibilityJsonContext.Default.SessionMode);
219+
220+
Assert.Equal("future_mode", mode.Value);
221+
Assert.Equal(
222+
"""
223+
"future_mode"
224+
""",
225+
JsonSerializer.Serialize(mode, ForwardCompatibilityJsonContext.Default.SessionMode));
226+
}
227+
228+
[Fact]
229+
public void RpcEnum_WithNonStringValue_ThrowsJsonException()
230+
{
231+
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize(
232+
"""
233+
42
234+
""",
235+
ForwardCompatibilityJsonContext.Default.SessionMode));
236+
237+
Assert.Contains("SessionMode", exception.Message);
238+
}
239+
240+
[Fact]
241+
public void RpcEnum_DefaultValue_HasEmptyStringValue()
242+
{
243+
GitHub.Copilot.SDK.Rpc.SessionMode mode = default;
244+
245+
Assert.Equal(string.Empty, mode.Value);
246+
Assert.Equal(string.Empty, mode.ToString());
247+
}
248+
249+
[Fact]
250+
public void RpcEnum_DefaultValueSerialization_ThrowsJsonException()
251+
{
252+
GitHub.Copilot.SDK.Rpc.SessionMode mode = default;
253+
254+
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Serialize(
255+
mode,
256+
ForwardCompatibilityJsonContext.Default.SessionMode));
257+
258+
Assert.Contains("SessionMode", exception.Message);
259+
}
260+
169261
[Fact]
170262
public void FromJson_KnownEventType_WithNullOptionalFields_DoesNotThrow()
171263
{
@@ -211,3 +303,6 @@ public void FromJson_UnknownEventType_PreservesAgentIdNull()
211303
Assert.Null(result.AgentId);
212304
}
213305
}
306+
307+
[JsonSerializable(typeof(GitHub.Copilot.SDK.Rpc.SessionMode))]
308+
internal partial class ForwardCompatibilityJsonContext : JsonSerializerContext;

scripts/codegen/csharp.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,16 @@ let generatedEnums = new Map<string, { enumName: string; values: string[] }>();
325325
/** Schema definitions available during session event generation (for $ref resolution). */
326326
let sessionDefinitions: DefinitionCollections = { definitions: {}, $defs: {} };
327327

328-
function getOrCreateEnum(parentClassName: string, propName: string, values: string[], enumOutput: string[], description?: string, explicitName?: string, deprecated?: boolean): string {
328+
/** Emits a schema enum as a string-backed value type that preserves unknown runtime values. */
329+
function getOrCreateEnum(
330+
parentClassName: string,
331+
propName: string,
332+
values: string[],
333+
enumOutput: string[],
334+
description?: string,
335+
explicitName?: string,
336+
deprecated?: boolean
337+
): string {
329338
const enumName = explicitName ?? `${parentClassName}${propName}`;
330339
const existing = generatedEnums.get(enumName);
331340
if (existing) return existing.enumName;
@@ -334,11 +343,52 @@ function getOrCreateEnum(parentClassName: string, propName: string, values: stri
334343
const lines: string[] = [];
335344
lines.push(...xmlDocEnumComment(description, ""));
336345
if (deprecated) lines.push(`[Obsolete("This member is deprecated and will be removed in a future version.")]`);
337-
lines.push(`[JsonConverter(typeof(JsonStringEnumConverter<${enumName}>))]`, `public enum ${enumName}`, `{`);
346+
lines.push(`[JsonConverter(typeof(Converter))]`);
347+
lines.push(`[DebuggerDisplay("{Value,nq}")]`);
348+
lines.push(`public readonly struct ${enumName} : IEquatable<${enumName}>`);
349+
lines.push(`{`);
350+
lines.push(` private readonly string? _value;`, "");
351+
lines.push(` /// <summary>Initializes a new instance of the <see cref="${enumName}"/> struct.</summary>`);
352+
lines.push(` /// <param name="value">The value to associate with this <see cref="${enumName}"/>.</param>`);
353+
lines.push(` [JsonConstructor]`);
354+
lines.push(` public ${enumName}(string value)`);
355+
lines.push(` {`);
356+
lines.push(` ArgumentException.ThrowIfNullOrWhiteSpace(value);`);
357+
lines.push(` _value = value;`);
358+
lines.push(` }`, "");
359+
lines.push(` /// <summary>Gets the value associated with this <see cref="${enumName}"/>.</summary>`);
360+
lines.push(` public string Value => _value ?? string.Empty;`, "");
338361
for (const value of values) {
339-
lines.push(` /// <summary>The <c>${escapeXml(value)}</c> variant.</summary>`);
340-
lines.push(` [JsonStringEnumMemberName("${value}")]`, ` ${toPascalCaseEnumMember(value)},`);
362+
lines.push(` /// <summary>Gets the <c>${escapeXml(value)}</c> value.</summary>`);
363+
lines.push(` public static ${enumName} ${toPascalCaseEnumMember(value)} { get; } = new("${value}");`, "");
341364
}
365+
lines.push(` /// <summary>Returns a value indicating whether two <see cref="${enumName}"/> instances are equivalent.</summary>`);
366+
lines.push(` public static bool operator ==(${enumName} left, ${enumName} right) => left.Equals(right);`, "");
367+
lines.push(` /// <summary>Returns a value indicating whether two <see cref="${enumName}"/> instances are not equivalent.</summary>`);
368+
lines.push(` public static bool operator !=(${enumName} left, ${enumName} right) => !(left == right);`, "");
369+
lines.push(` /// <inheritdoc />`);
370+
lines.push(` public override bool Equals(object? obj) => obj is ${enumName} other && Equals(other);`, "");
371+
lines.push(` /// <inheritdoc />`);
372+
lines.push(` public bool Equals(${enumName} other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase);`, "");
373+
lines.push(` /// <inheritdoc />`);
374+
lines.push(` public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Value);`, "");
375+
lines.push(` /// <inheritdoc />`);
376+
lines.push(` public override string ToString() => Value;`, "");
377+
lines.push(` /// <summary>Provides a <see cref="JsonConverter{${enumName}}"/> for serializing <see cref="${enumName}"/> instances.</summary>`);
378+
lines.push(` [EditorBrowsable(EditorBrowsableState.Never)]`);
379+
lines.push(` public sealed class Converter : JsonConverter<${enumName}>`);
380+
lines.push(` {`);
381+
lines.push(` /// <inheritdoc />`);
382+
lines.push(` public override ${enumName} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
383+
lines.push(` {`);
384+
lines.push(` return new(GitHub.Copilot.SDK.GeneratedStringEnumJson.ReadValue(ref reader, typeToConvert));`);
385+
lines.push(` }`, "");
386+
lines.push(` /// <inheritdoc />`);
387+
lines.push(` public override void Write(Utf8JsonWriter writer, ${enumName} value, JsonSerializerOptions options)`);
388+
lines.push(` {`);
389+
lines.push(` GitHub.Copilot.SDK.GeneratedStringEnumJson.WriteValue(writer, value.Value, typeof(${enumName}));`);
390+
lines.push(` }`);
391+
lines.push(` }`);
342392
lines.push(`}`, "");
343393
enumOutput.push(lines.join("\n"));
344394
return enumName;
@@ -718,6 +768,7 @@ function generateSessionEventsCode(schema: JSONSchema7): string {
718768
#pragma warning disable CS0612 // Type or member is obsolete
719769
#pragma warning disable CS0618 // Type or member is obsolete (with message)
720770
771+
using System.ComponentModel;
721772
using System.ComponentModel.DataAnnotations;
722773
using System.Diagnostics;
723774
using System.Diagnostics.CodeAnalysis;
@@ -1556,7 +1607,9 @@ function generateRpcCode(schema: ApiSchema): string {
15561607
#pragma warning disable CS0612 // Type or member is obsolete
15571608
#pragma warning disable CS0618 // Type or member is obsolete (with message)
15581609
1610+
using System.ComponentModel;
15591611
using System.ComponentModel.DataAnnotations;
1612+
using System.Diagnostics;
15601613
using System.Diagnostics.CodeAnalysis;
15611614
using System.Text.Json;
15621615
using System.Text.Json.Serialization;

0 commit comments

Comments
 (0)