Skip to content

Commit 496c49b

Browse files
committed
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.
1 parent 7d8fb51 commit 496c49b

File tree

5 files changed

+281
-7
lines changed

5 files changed

+281
-7
lines changed

dotnet/src/Client.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,11 +1303,8 @@ public void OnSessionEvent(string sessionId, JsonElement? @event)
13031303
var session = client.GetSession(sessionId);
13041304
if (session != null && @event != null)
13051305
{
1306-
var evt = SessionEvent.FromJson(@event.Value.GetRawText());
1307-
if (evt != null)
1308-
{
1309-
session.DispatchEvent(evt);
1310-
}
1306+
var evt = SessionEvent.TryFromJson(@event.Value.GetRawText(), client._logger);
1307+
session.DispatchEvent(evt);
13111308
}
13121309
}
13131310

dotnet/src/Session.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,7 @@ public async Task<IReadOnlyList<SessionEvent>> GetMessagesAsync(CancellationToke
681681
"session.getMessages", [new GetMessagesRequest { SessionId = SessionId }], cancellationToken);
682682

683683
return response.Events
684-
.Select(e => SessionEvent.FromJson(e.ToJsonString()))
685-
.OfType<SessionEvent>()
684+
.Select(e => SessionEvent.TryFromJson(e.ToJsonString(), _logger))
686685
.ToList();
687686
}
688687

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using System.Text.Json;
6+
using System.Text.Json.Nodes;
7+
using Microsoft.Extensions.Logging;
8+
9+
namespace GitHub.Copilot.SDK;
10+
11+
public abstract partial class SessionEvent
12+
{
13+
/// <summary>
14+
/// Attempts to deserialize a JSON string into a <see cref="SessionEvent"/>.
15+
/// </summary>
16+
/// <param name="json">The JSON string representing a session event.</param>
17+
/// <param name="logger">Optional logger for recording deserialization warnings.</param>
18+
/// <returns>
19+
/// The deserialized <see cref="SessionEvent"/> on success, or an
20+
/// <see cref="UnknownSessionEvent"/> when the event type is not recognized by this
21+
/// version of the SDK.
22+
/// </returns>
23+
/// <remarks>
24+
/// Unlike <see cref="FromJson"/>, this method never throws for unknown event types.
25+
/// It catches <see cref="JsonException"/> and returns an <see cref="UnknownSessionEvent"/>
26+
/// that preserves the raw JSON and type discriminator for diagnostic purposes.
27+
/// </remarks>
28+
public static SessionEvent TryFromJson(string json, ILogger? logger = null)
29+
{
30+
try
31+
{
32+
return FromJson(json);
33+
}
34+
catch (JsonException ex)
35+
{
36+
var rawType = ExtractTypeDiscriminator(json);
37+
logger?.LogWarning(ex, "Skipping unrecognized session event type '{EventType}'", rawType);
38+
39+
return new UnknownSessionEvent
40+
{
41+
RawType = rawType,
42+
RawJson = json,
43+
};
44+
}
45+
}
46+
47+
private static string? ExtractTypeDiscriminator(string json)
48+
{
49+
try
50+
{
51+
var node = JsonNode.Parse(json);
52+
return node?["type"]?.GetValue<string>();
53+
}
54+
catch
55+
{
56+
return null;
57+
}
58+
}
59+
}

dotnet/src/UnknownSessionEvent.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using System.Text.Json.Serialization;
6+
7+
namespace GitHub.Copilot.SDK;
8+
9+
/// <summary>
10+
/// Represents a session event whose <c>type</c> discriminator is not recognized by this
11+
/// version of the SDK.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// When the Copilot CLI emits an event type that the SDK has not yet been updated to
16+
/// support, deserialization via <see cref="SessionEvent.FromJson"/> would normally throw
17+
/// a <see cref="System.Text.Json.JsonException"/>. Instead,
18+
/// <see cref="SessionEvent.TryFromJson"/> catches the failure and returns an
19+
/// <see cref="UnknownSessionEvent"/> that preserves the raw JSON for diagnostic purposes.
20+
/// </para>
21+
/// <para>
22+
/// Consumers can pattern-match on this type to detect and log forward-compatibility gaps
23+
/// without losing the rest of the event stream.
24+
/// </para>
25+
/// </remarks>
26+
public sealed class UnknownSessionEvent : SessionEvent
27+
{
28+
/// <inheritdoc />
29+
[JsonIgnore]
30+
public override string Type => RawType ?? "unknown";
31+
32+
/// <summary>
33+
/// The original <c>type</c> discriminator value from the JSON payload, if it could be
34+
/// extracted. <c>null</c> when the type field is missing or unreadable.
35+
/// </summary>
36+
public string? RawType { get; init; }
37+
38+
/// <summary>
39+
/// The complete, unparsed JSON string of the event. Useful for logging, debugging,
40+
/// or forwarding to systems that may understand the event.
41+
/// </summary>
42+
public string? RawJson { get; init; }
43+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
using Xunit;
6+
7+
namespace GitHub.Copilot.SDK.Test;
8+
9+
/// <summary>
10+
/// Tests for forward-compatible handling of unknown session event types.
11+
/// Verifies that the SDK gracefully handles event types introduced by newer CLI versions.
12+
/// </summary>
13+
public class UnknownSessionEventTests
14+
{
15+
[Fact]
16+
public void FromJson_KnownEventType_DeserializesNormally()
17+
{
18+
var json = """
19+
{
20+
"id": "00000000-0000-0000-0000-000000000001",
21+
"timestamp": "2026-01-01T00:00:00Z",
22+
"parentId": null,
23+
"type": "user.message",
24+
"data": {
25+
"content": "Hello"
26+
}
27+
}
28+
""";
29+
30+
var result = SessionEvent.FromJson(json);
31+
32+
Assert.IsType<UserMessageEvent>(result);
33+
Assert.Equal("user.message", result.Type);
34+
}
35+
36+
[Fact]
37+
public void FromJson_UnknownEventType_Throws()
38+
{
39+
var json = """
40+
{
41+
"id": "00000000-0000-0000-0000-000000000007",
42+
"timestamp": "2026-01-01T00:00:00Z",
43+
"parentId": null,
44+
"type": "future.feature_from_server",
45+
"data": {}
46+
}
47+
""";
48+
49+
Assert.Throws<System.Text.Json.JsonException>(() => SessionEvent.FromJson(json));
50+
}
51+
52+
[Fact]
53+
public void UnknownSessionEvent_Type_ReturnsRawType()
54+
{
55+
var evt = new UnknownSessionEvent
56+
{
57+
RawType = "future.feature",
58+
RawJson = """{"type":"future.feature"}""",
59+
};
60+
61+
Assert.Equal("future.feature", evt.Type);
62+
Assert.Equal("future.feature", evt.RawType);
63+
Assert.NotNull(evt.RawJson);
64+
}
65+
66+
[Fact]
67+
public void UnknownSessionEvent_Type_FallsBackToUnknown_WhenRawTypeIsNull()
68+
{
69+
var evt = new UnknownSessionEvent { RawType = null, RawJson = null };
70+
71+
Assert.Equal("unknown", evt.Type);
72+
}
73+
74+
[Fact]
75+
public void UnknownSessionEvent_PreservesRawJson()
76+
{
77+
var rawJson = """{"type":"new.event","data":{"nested":{"deep":true},"list":[1,2,3]}}""";
78+
var evt = new UnknownSessionEvent
79+
{
80+
RawType = "new.event",
81+
RawJson = rawJson,
82+
};
83+
84+
Assert.Equal(rawJson, evt.RawJson);
85+
Assert.Contains("nested", evt.RawJson);
86+
}
87+
88+
[Fact]
89+
public void UnknownSessionEvent_IsSessionEvent()
90+
{
91+
var evt = new UnknownSessionEvent { RawType = "future.event" };
92+
93+
Assert.IsAssignableFrom<SessionEvent>(evt);
94+
}
95+
96+
[Fact]
97+
public void TryFromJson_KnownEventType_DeserializesNormally()
98+
{
99+
var json = """
100+
{
101+
"id": "00000000-0000-0000-0000-000000000010",
102+
"timestamp": "2026-01-01T00:00:00Z",
103+
"parentId": null,
104+
"type": "user.message",
105+
"data": {
106+
"content": "Hello"
107+
}
108+
}
109+
""";
110+
111+
var result = SessionEvent.TryFromJson(json);
112+
113+
Assert.IsType<UserMessageEvent>(result);
114+
Assert.Equal("user.message", result.Type);
115+
}
116+
117+
[Fact]
118+
public void TryFromJson_UnknownEventType_ReturnsUnknownSessionEvent()
119+
{
120+
var json = """
121+
{
122+
"id": "00000000-0000-0000-0000-000000000011",
123+
"timestamp": "2026-01-01T00:00:00Z",
124+
"parentId": null,
125+
"type": "future.feature_from_server",
126+
"data": { "key": "value" }
127+
}
128+
""";
129+
130+
var result = SessionEvent.TryFromJson(json);
131+
132+
var unknown = Assert.IsType<UnknownSessionEvent>(result);
133+
Assert.Equal("future.feature_from_server", unknown.RawType);
134+
Assert.Equal("future.feature_from_server", unknown.Type);
135+
Assert.NotNull(unknown.RawJson);
136+
Assert.Contains("future.feature_from_server", unknown.RawJson);
137+
}
138+
139+
[Fact]
140+
public void TryFromJson_UnknownEventType_PreservesRawJson()
141+
{
142+
var json = """
143+
{
144+
"id": "00000000-0000-0000-0000-000000000012",
145+
"timestamp": "2026-01-01T00:00:00Z",
146+
"parentId": null,
147+
"type": "some.new.event",
148+
"data": { "nested": { "deep": true }, "list": [1, 2, 3] }
149+
}
150+
""";
151+
152+
var result = SessionEvent.TryFromJson(json);
153+
154+
var unknown = Assert.IsType<UnknownSessionEvent>(result);
155+
Assert.Contains("\"nested\"", unknown.RawJson);
156+
Assert.Contains("\"deep\"", unknown.RawJson);
157+
}
158+
159+
[Fact]
160+
public void TryFromJson_MultipleEvents_MixedKnownAndUnknown()
161+
{
162+
var events = new[]
163+
{
164+
"""{"id":"00000000-0000-0000-0000-000000000013","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Hi"}}""",
165+
"""{"id":"00000000-0000-0000-0000-000000000014","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"future.unknown_type","data":{}}""",
166+
"""{"id":"00000000-0000-0000-0000-000000000015","timestamp":"2026-01-01T00:00:00Z","parentId":null,"type":"user.message","data":{"content":"Bye"}}""",
167+
};
168+
169+
var results = events.Select(e => SessionEvent.TryFromJson(e)).ToList();
170+
171+
Assert.Equal(3, results.Count);
172+
Assert.IsType<UserMessageEvent>(results[0]);
173+
Assert.IsType<UnknownSessionEvent>(results[1]);
174+
Assert.IsType<UserMessageEvent>(results[2]);
175+
}
176+
}

0 commit comments

Comments
 (0)