From 496c49b4ce1e7bea8aa0a77807005f945d15bd97 Mon Sep 17 00:00:00 2001 From: Ron Borysowski Date: Wed, 18 Mar 2026 11:11:32 +0200 Subject: [PATCH 1/2] fix(dotnet): handle unknown session event types gracefully Add UnknownSessionEvent type and TryFromJson method so that unrecognized event types from newer CLI versions do not crash GetMessagesAsync or real-time event dispatch. --- dotnet/src/Client.cs | 7 +- dotnet/src/Session.cs | 3 +- dotnet/src/SessionEventExtensions.cs | 59 ++++++++ dotnet/src/UnknownSessionEvent.cs | 43 ++++++ dotnet/test/UnknownSessionEventTests.cs | 176 ++++++++++++++++++++++++ 5 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 dotnet/src/SessionEventExtensions.cs create mode 100644 dotnet/src/UnknownSessionEvent.cs create mode 100644 dotnet/test/UnknownSessionEventTests.cs diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a9ad1fccd..a722bd95a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1303,11 +1303,8 @@ public void OnSessionEvent(string sessionId, JsonElement? @event) var session = client.GetSession(sessionId); if (session != null && @event != null) { - var evt = SessionEvent.FromJson(@event.Value.GetRawText()); - if (evt != null) - { - session.DispatchEvent(evt); - } + var evt = SessionEvent.TryFromJson(@event.Value.GetRawText(), client._logger); + session.DispatchEvent(evt); } } diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 606c0b052..7263d0923 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -681,8 +681,7 @@ public async Task> GetMessagesAsync(CancellationToke "session.getMessages", [new GetMessagesRequest { SessionId = SessionId }], cancellationToken); return response.Events - .Select(e => SessionEvent.FromJson(e.ToJsonString())) - .OfType() + .Select(e => SessionEvent.TryFromJson(e.ToJsonString(), _logger)) .ToList(); } diff --git a/dotnet/src/SessionEventExtensions.cs b/dotnet/src/SessionEventExtensions.cs new file mode 100644 index 000000000..6f1c89c9f --- /dev/null +++ b/dotnet/src/SessionEventExtensions.cs @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace GitHub.Copilot.SDK; + +public abstract partial class SessionEvent +{ + /// + /// Attempts to deserialize a JSON string into a . + /// + /// The JSON string representing a session event. + /// Optional logger for recording deserialization warnings. + /// + /// The deserialized on success, or an + /// when the event type is not recognized by this + /// version of the SDK. + /// + /// + /// Unlike , this method never throws for unknown event types. + /// It catches and returns an + /// that preserves the raw JSON and type discriminator for diagnostic purposes. + /// + public static SessionEvent TryFromJson(string json, ILogger? logger = null) + { + try + { + return FromJson(json); + } + catch (JsonException ex) + { + var rawType = ExtractTypeDiscriminator(json); + logger?.LogWarning(ex, "Skipping unrecognized session event type '{EventType}'", rawType); + + return new UnknownSessionEvent + { + RawType = rawType, + RawJson = json, + }; + } + } + + private static string? ExtractTypeDiscriminator(string json) + { + try + { + var node = JsonNode.Parse(json); + return node?["type"]?.GetValue(); + } + catch + { + return null; + } + } +} diff --git a/dotnet/src/UnknownSessionEvent.cs b/dotnet/src/UnknownSessionEvent.cs new file mode 100644 index 000000000..4d3268c24 --- /dev/null +++ b/dotnet/src/UnknownSessionEvent.cs @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using System.Text.Json.Serialization; + +namespace GitHub.Copilot.SDK; + +/// +/// Represents a session event whose type discriminator is not recognized by this +/// version of the SDK. +/// +/// +/// +/// When the Copilot CLI emits an event type that the SDK has not yet been updated to +/// support, deserialization via would normally throw +/// a . Instead, +/// catches the failure and returns an +/// that preserves the raw JSON for diagnostic purposes. +/// +/// +/// Consumers can pattern-match on this type to detect and log forward-compatibility gaps +/// without losing the rest of the event stream. +/// +/// +public sealed class UnknownSessionEvent : SessionEvent +{ + /// + [JsonIgnore] + public override string Type => RawType ?? "unknown"; + + /// + /// The original type discriminator value from the JSON payload, if it could be + /// extracted. null when the type field is missing or unreadable. + /// + public string? RawType { get; init; } + + /// + /// The complete, unparsed JSON string of the event. Useful for logging, debugging, + /// or forwarding to systems that may understand the event. + /// + public string? RawJson { get; init; } +} diff --git a/dotnet/test/UnknownSessionEventTests.cs b/dotnet/test/UnknownSessionEventTests.cs new file mode 100644 index 000000000..15affd349 --- /dev/null +++ b/dotnet/test/UnknownSessionEventTests.cs @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +/// +/// Tests for forward-compatible handling of unknown session event types. +/// Verifies that the SDK gracefully handles event types introduced by newer CLI versions. +/// +public class UnknownSessionEventTests +{ + [Fact] + public void FromJson_KnownEventType_DeserializesNormally() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "user.message", + "data": { + "content": "Hello" + } + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.IsType(result); + Assert.Equal("user.message", result.Type); + } + + [Fact] + public void FromJson_UnknownEventType_Throws() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000007", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "future.feature_from_server", + "data": {} + } + """; + + Assert.Throws(() => SessionEvent.FromJson(json)); + } + + [Fact] + public void UnknownSessionEvent_Type_ReturnsRawType() + { + var evt = new UnknownSessionEvent + { + RawType = "future.feature", + RawJson = """{"type":"future.feature"}""", + }; + + Assert.Equal("future.feature", evt.Type); + Assert.Equal("future.feature", evt.RawType); + Assert.NotNull(evt.RawJson); + } + + [Fact] + public void UnknownSessionEvent_Type_FallsBackToUnknown_WhenRawTypeIsNull() + { + var evt = new UnknownSessionEvent { RawType = null, RawJson = null }; + + Assert.Equal("unknown", evt.Type); + } + + [Fact] + public void UnknownSessionEvent_PreservesRawJson() + { + var rawJson = """{"type":"new.event","data":{"nested":{"deep":true},"list":[1,2,3]}}"""; + var evt = new UnknownSessionEvent + { + RawType = "new.event", + RawJson = rawJson, + }; + + Assert.Equal(rawJson, evt.RawJson); + Assert.Contains("nested", evt.RawJson); + } + + [Fact] + public void UnknownSessionEvent_IsSessionEvent() + { + var evt = new UnknownSessionEvent { RawType = "future.event" }; + + Assert.IsAssignableFrom(evt); + } + + [Fact] + public void TryFromJson_KnownEventType_DeserializesNormally() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000010", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "user.message", + "data": { + "content": "Hello" + } + } + """; + + var result = SessionEvent.TryFromJson(json); + + Assert.IsType(result); + Assert.Equal("user.message", result.Type); + } + + [Fact] + public void TryFromJson_UnknownEventType_ReturnsUnknownSessionEvent() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000011", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "future.feature_from_server", + "data": { "key": "value" } + } + """; + + var result = SessionEvent.TryFromJson(json); + + var unknown = Assert.IsType(result); + Assert.Equal("future.feature_from_server", unknown.RawType); + Assert.Equal("future.feature_from_server", unknown.Type); + Assert.NotNull(unknown.RawJson); + Assert.Contains("future.feature_from_server", unknown.RawJson); + } + + [Fact] + public void TryFromJson_UnknownEventType_PreservesRawJson() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000012", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "some.new.event", + "data": { "nested": { "deep": true }, "list": [1, 2, 3] } + } + """; + + var result = SessionEvent.TryFromJson(json); + + var unknown = Assert.IsType(result); + Assert.Contains("\"nested\"", unknown.RawJson); + Assert.Contains("\"deep\"", unknown.RawJson); + } + + [Fact] + public void TryFromJson_MultipleEvents_MixedKnownAndUnknown() + { + var events = new[] + { + """{"id":"00000000-0000-0000-0000-000000000013","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Hi"}}""", + """{"id":"00000000-0000-0000-0000-000000000014","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"future.unknown_type","data":{}}""", + """{"id":"00000000-0000-0000-0000-000000000015","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Bye"}}""", + }; + + var results = events.Select(e => SessionEvent.TryFromJson(e)).ToList(); + + Assert.Equal(3, results.Count); + Assert.IsType(results[0]); + Assert.IsType(results[1]); + Assert.IsType(results[2]); + } +} From 40009e159ac4dba37942655a63606e7ee1d84d42 Mon Sep 17 00:00:00 2001 From: Ron Borysowski Date: Thu, 19 Mar 2026 17:15:13 +0200 Subject: [PATCH 2/2] refactor: use IgnoreUnrecognizedTypeDiscriminators per review feedback --- dotnet/src/Client.cs | 7 +- dotnet/src/Generated/SessionEvents.cs | 6 +- dotnet/src/Session.cs | 3 +- dotnet/src/SessionEventExtensions.cs | 59 -------- dotnet/src/UnknownSessionEvent.cs | 43 ------ dotnet/test/ForwardCompatibilityTests.cs | 100 +++++++++++++ dotnet/test/UnknownSessionEventTests.cs | 176 ----------------------- scripts/codegen/csharp.ts | 6 +- 8 files changed, 113 insertions(+), 287 deletions(-) delete mode 100644 dotnet/src/SessionEventExtensions.cs delete mode 100644 dotnet/src/UnknownSessionEvent.cs create mode 100644 dotnet/test/ForwardCompatibilityTests.cs delete mode 100644 dotnet/test/UnknownSessionEventTests.cs diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a722bd95a..a9ad1fccd 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1303,8 +1303,11 @@ public void OnSessionEvent(string sessionId, JsonElement? @event) var session = client.GetSession(sessionId); if (session != null && @event != null) { - var evt = SessionEvent.TryFromJson(@event.Value.GetRawText(), client._logger); - session.DispatchEvent(evt); + var evt = SessionEvent.FromJson(@event.Value.GetRawText()); + if (evt != null) + { + session.DispatchEvent(evt); + } } } diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index 5ef1be352..33dce2ec6 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -17,7 +17,7 @@ namespace GitHub.Copilot.SDK; [DebuggerDisplay("{DebuggerDisplay,nq}")] [JsonPolymorphic( TypeDiscriminatorPropertyName = "type", - UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)] + IgnoreUnrecognizedTypeDiscriminators = true)] [JsonDerivedType(typeof(AbortEvent), "abort")] [JsonDerivedType(typeof(AssistantIntentEvent), "assistant.intent")] [JsonDerivedType(typeof(AssistantMessageEvent), "assistant.message")] @@ -79,7 +79,7 @@ namespace GitHub.Copilot.SDK; [JsonDerivedType(typeof(UserInputCompletedEvent), "user_input.completed")] [JsonDerivedType(typeof(UserInputRequestedEvent), "user_input.requested")] [JsonDerivedType(typeof(UserMessageEvent), "user.message")] -public abstract partial class SessionEvent +public partial class SessionEvent { /// Unique event identifier (UUID v4), generated when the event is emitted. [JsonPropertyName("id")] @@ -102,7 +102,7 @@ public abstract partial class SessionEvent /// The event type discriminator. /// [JsonIgnore] - public abstract string Type { get; } + public virtual string Type => "unknown"; /// Deserializes a JSON string into a . public static SessionEvent FromJson(string json) => diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index 7263d0923..606c0b052 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -681,7 +681,8 @@ public async Task> GetMessagesAsync(CancellationToke "session.getMessages", [new GetMessagesRequest { SessionId = SessionId }], cancellationToken); return response.Events - .Select(e => SessionEvent.TryFromJson(e.ToJsonString(), _logger)) + .Select(e => SessionEvent.FromJson(e.ToJsonString())) + .OfType() .ToList(); } diff --git a/dotnet/src/SessionEventExtensions.cs b/dotnet/src/SessionEventExtensions.cs deleted file mode 100644 index 6f1c89c9f..000000000 --- a/dotnet/src/SessionEventExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.Extensions.Logging; - -namespace GitHub.Copilot.SDK; - -public abstract partial class SessionEvent -{ - /// - /// Attempts to deserialize a JSON string into a . - /// - /// The JSON string representing a session event. - /// Optional logger for recording deserialization warnings. - /// - /// The deserialized on success, or an - /// when the event type is not recognized by this - /// version of the SDK. - /// - /// - /// Unlike , this method never throws for unknown event types. - /// It catches and returns an - /// that preserves the raw JSON and type discriminator for diagnostic purposes. - /// - public static SessionEvent TryFromJson(string json, ILogger? logger = null) - { - try - { - return FromJson(json); - } - catch (JsonException ex) - { - var rawType = ExtractTypeDiscriminator(json); - logger?.LogWarning(ex, "Skipping unrecognized session event type '{EventType}'", rawType); - - return new UnknownSessionEvent - { - RawType = rawType, - RawJson = json, - }; - } - } - - private static string? ExtractTypeDiscriminator(string json) - { - try - { - var node = JsonNode.Parse(json); - return node?["type"]?.GetValue(); - } - catch - { - return null; - } - } -} diff --git a/dotnet/src/UnknownSessionEvent.cs b/dotnet/src/UnknownSessionEvent.cs deleted file mode 100644 index 4d3268c24..000000000 --- a/dotnet/src/UnknownSessionEvent.cs +++ /dev/null @@ -1,43 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -using System.Text.Json.Serialization; - -namespace GitHub.Copilot.SDK; - -/// -/// Represents a session event whose type discriminator is not recognized by this -/// version of the SDK. -/// -/// -/// -/// When the Copilot CLI emits an event type that the SDK has not yet been updated to -/// support, deserialization via would normally throw -/// a . Instead, -/// catches the failure and returns an -/// that preserves the raw JSON for diagnostic purposes. -/// -/// -/// Consumers can pattern-match on this type to detect and log forward-compatibility gaps -/// without losing the rest of the event stream. -/// -/// -public sealed class UnknownSessionEvent : SessionEvent -{ - /// - [JsonIgnore] - public override string Type => RawType ?? "unknown"; - - /// - /// The original type discriminator value from the JSON payload, if it could be - /// extracted. null when the type field is missing or unreadable. - /// - public string? RawType { get; init; } - - /// - /// The complete, unparsed JSON string of the event. Useful for logging, debugging, - /// or forwarding to systems that may understand the event. - /// - public string? RawJson { get; init; } -} diff --git a/dotnet/test/ForwardCompatibilityTests.cs b/dotnet/test/ForwardCompatibilityTests.cs new file mode 100644 index 000000000..d3f5b7785 --- /dev/null +++ b/dotnet/test/ForwardCompatibilityTests.cs @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +using Xunit; + +namespace GitHub.Copilot.SDK.Test; + +/// +/// Tests for forward-compatible handling of unknown session event types. +/// Verifies that the SDK gracefully handles event types introduced by newer CLI versions. +/// +public class ForwardCompatibilityTests +{ + [Fact] + public void FromJson_KnownEventType_DeserializesNormally() + { + var json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "parentId": null, + "type": "user.message", + "data": { + "content": "Hello" + } + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.IsType(result); + Assert.Equal("user.message", result.Type); + } + + [Fact] + public void FromJson_UnknownEventType_ReturnsBaseSessionEvent() + { + var json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "parentId": "abcdefab-abcd-abcd-abcd-abcdefabcdef", + "type": "future.feature_from_server", + "data": { "key": "value" } + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.IsType(result); + Assert.Equal("unknown", result.Type); + } + + [Fact] + public void FromJson_UnknownEventType_PreservesBaseMetadata() + { + var json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "parentId": "abcdefab-abcd-abcd-abcd-abcdefabcdef", + "type": "future.feature_from_server", + "data": {} + } + """; + + var result = SessionEvent.FromJson(json); + + Assert.Equal(Guid.Parse("12345678-1234-1234-1234-123456789abc"), result.Id); + Assert.Equal(DateTimeOffset.Parse("2026-06-15T10:30:00Z"), result.Timestamp); + Assert.Equal(Guid.Parse("abcdefab-abcd-abcd-abcd-abcdefabcdef"), result.ParentId); + } + + [Fact] + public void FromJson_MultipleEvents_MixedKnownAndUnknown() + { + var events = new[] + { + """{"id":"00000000-0000-0000-0000-000000000001","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Hi"}}""", + """{"id":"00000000-0000-0000-0000-000000000002","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"future.unknown_type","data":{}}""", + """{"id":"00000000-0000-0000-0000-000000000003","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Bye"}}""", + }; + + var results = events.Select(SessionEvent.FromJson).ToList(); + + Assert.Equal(3, results.Count); + Assert.IsType(results[0]); + Assert.IsType(results[1]); + Assert.IsType(results[2]); + } + + [Fact] + public void SessionEvent_Type_DefaultsToUnknown() + { + var evt = new SessionEvent(); + + Assert.Equal("unknown", evt.Type); + } +} diff --git a/dotnet/test/UnknownSessionEventTests.cs b/dotnet/test/UnknownSessionEventTests.cs deleted file mode 100644 index 15affd349..000000000 --- a/dotnet/test/UnknownSessionEventTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------------------------------------------*/ - -using Xunit; - -namespace GitHub.Copilot.SDK.Test; - -/// -/// Tests for forward-compatible handling of unknown session event types. -/// Verifies that the SDK gracefully handles event types introduced by newer CLI versions. -/// -public class UnknownSessionEventTests -{ - [Fact] - public void FromJson_KnownEventType_DeserializesNormally() - { - var json = """ - { - "id": "00000000-0000-0000-0000-000000000001", - "timestamp": "2026-01-01T00:00:00Z", - "parentId": null, - "type": "user.message", - "data": { - "content": "Hello" - } - } - """; - - var result = SessionEvent.FromJson(json); - - Assert.IsType(result); - Assert.Equal("user.message", result.Type); - } - - [Fact] - public void FromJson_UnknownEventType_Throws() - { - var json = """ - { - "id": "00000000-0000-0000-0000-000000000007", - "timestamp": "2026-01-01T00:00:00Z", - "parentId": null, - "type": "future.feature_from_server", - "data": {} - } - """; - - Assert.Throws(() => SessionEvent.FromJson(json)); - } - - [Fact] - public void UnknownSessionEvent_Type_ReturnsRawType() - { - var evt = new UnknownSessionEvent - { - RawType = "future.feature", - RawJson = """{"type":"future.feature"}""", - }; - - Assert.Equal("future.feature", evt.Type); - Assert.Equal("future.feature", evt.RawType); - Assert.NotNull(evt.RawJson); - } - - [Fact] - public void UnknownSessionEvent_Type_FallsBackToUnknown_WhenRawTypeIsNull() - { - var evt = new UnknownSessionEvent { RawType = null, RawJson = null }; - - Assert.Equal("unknown", evt.Type); - } - - [Fact] - public void UnknownSessionEvent_PreservesRawJson() - { - var rawJson = """{"type":"new.event","data":{"nested":{"deep":true},"list":[1,2,3]}}"""; - var evt = new UnknownSessionEvent - { - RawType = "new.event", - RawJson = rawJson, - }; - - Assert.Equal(rawJson, evt.RawJson); - Assert.Contains("nested", evt.RawJson); - } - - [Fact] - public void UnknownSessionEvent_IsSessionEvent() - { - var evt = new UnknownSessionEvent { RawType = "future.event" }; - - Assert.IsAssignableFrom(evt); - } - - [Fact] - public void TryFromJson_KnownEventType_DeserializesNormally() - { - var json = """ - { - "id": "00000000-0000-0000-0000-000000000010", - "timestamp": "2026-01-01T00:00:00Z", - "parentId": null, - "type": "user.message", - "data": { - "content": "Hello" - } - } - """; - - var result = SessionEvent.TryFromJson(json); - - Assert.IsType(result); - Assert.Equal("user.message", result.Type); - } - - [Fact] - public void TryFromJson_UnknownEventType_ReturnsUnknownSessionEvent() - { - var json = """ - { - "id": "00000000-0000-0000-0000-000000000011", - "timestamp": "2026-01-01T00:00:00Z", - "parentId": null, - "type": "future.feature_from_server", - "data": { "key": "value" } - } - """; - - var result = SessionEvent.TryFromJson(json); - - var unknown = Assert.IsType(result); - Assert.Equal("future.feature_from_server", unknown.RawType); - Assert.Equal("future.feature_from_server", unknown.Type); - Assert.NotNull(unknown.RawJson); - Assert.Contains("future.feature_from_server", unknown.RawJson); - } - - [Fact] - public void TryFromJson_UnknownEventType_PreservesRawJson() - { - var json = """ - { - "id": "00000000-0000-0000-0000-000000000012", - "timestamp": "2026-01-01T00:00:00Z", - "parentId": null, - "type": "some.new.event", - "data": { "nested": { "deep": true }, "list": [1, 2, 3] } - } - """; - - var result = SessionEvent.TryFromJson(json); - - var unknown = Assert.IsType(result); - Assert.Contains("\"nested\"", unknown.RawJson); - Assert.Contains("\"deep\"", unknown.RawJson); - } - - [Fact] - public void TryFromJson_MultipleEvents_MixedKnownAndUnknown() - { - var events = new[] - { - """{"id":"00000000-0000-0000-0000-000000000013","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Hi"}}""", - """{"id":"00000000-0000-0000-0000-000000000014","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"future.unknown_type","data":{}}""", - """{"id":"00000000-0000-0000-0000-000000000015","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Bye"}}""", - }; - - var results = events.Select(e => SessionEvent.TryFromJson(e)).ToList(); - - Assert.Equal(3, results.Count); - Assert.IsType(results[0]); - Assert.IsType(results[1]); - Assert.IsType(results[2]); - } -} diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 3aeb0eef3..0df541b24 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -521,11 +521,11 @@ namespace GitHub.Copilot.SDK; lines.push(`/// Provides the base class from which all session events derive.`); lines.push(`/// `); lines.push(`[DebuggerDisplay("{DebuggerDisplay,nq}")]`); - lines.push(`[JsonPolymorphic(`, ` TypeDiscriminatorPropertyName = "type",`, ` UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]`); + lines.push(`[JsonPolymorphic(`, ` TypeDiscriminatorPropertyName = "type",`, ` IgnoreUnrecognizedTypeDiscriminators = true)]`); for (const variant of [...variants].sort((a, b) => a.typeName.localeCompare(b.typeName))) { lines.push(`[JsonDerivedType(typeof(${variant.className}), "${variant.typeName}")]`); } - lines.push(`public abstract partial class SessionEvent`, `{`); + lines.push(`public partial class SessionEvent`, `{`); lines.push(...xmlDocComment(baseDesc("id"), " ")); lines.push(` [JsonPropertyName("id")]`, ` public Guid Id { get; set; }`, ""); lines.push(...xmlDocComment(baseDesc("timestamp"), " ")); @@ -535,7 +535,7 @@ namespace GitHub.Copilot.SDK; lines.push(...xmlDocComment(baseDesc("ephemeral"), " ")); lines.push(` [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, ` [JsonPropertyName("ephemeral")]`, ` public bool? Ephemeral { get; set; }`, ""); lines.push(` /// `, ` /// The event type discriminator.`, ` /// `); - lines.push(` [JsonIgnore]`, ` public abstract string Type { get; }`, ""); + lines.push(` [JsonIgnore]`, ` public virtual string Type => "unknown";`, ""); lines.push(` /// Deserializes a JSON string into a .`); lines.push(` public static SessionEvent FromJson(string json) =>`, ` JsonSerializer.Deserialize(json, SessionEventsJsonContext.Default.SessionEvent)!;`, ""); lines.push(` /// Serializes this event to a JSON string.`);