diff --git a/dotnet/src/Generated/Rpc.cs b/dotnet/src/Generated/Rpc.cs index 3001520999..965af36ec0 100644 --- a/dotnet/src/Generated/Rpc.cs +++ b/dotnet/src/Generated/Rpc.cs @@ -31,6 +31,7 @@ public sealed class PingResult public long ProtocolVersion { get; set; } /// ISO 8601 timestamp when the server handled the ping. + [JsonConverter(typeof(GitHub.Copilot.UnixMillisecondsDateTimeOffsetConverter))] [JsonPropertyName("timestamp")] public DateTimeOffset Timestamp { get; set; } } @@ -29249,4 +29250,4 @@ public static void RegisterClientGlobalApiHandlers(JsonRpc rpc, ClientGlobalApiH [JsonSerializable(typeof(WorkspacesUpdateMetadataRequest))] [JsonSerializable(typeof(WorkspacesWriteAutopilotObjectiveRequest))] [JsonSerializable(typeof(WorkspacesWriteAutopilotObjectiveResult))] -internal partial class RpcJsonContext : JsonSerializerContext; \ No newline at end of file +internal partial class RpcJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6b511117cd..4167f8426e 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3845,6 +3845,7 @@ public sealed class PingResponse /// /// ISO 8601 timestamp when the ping was processed. /// + [JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))] public DateTimeOffset Timestamp { get; set; } /// /// Protocol version supported by the server. diff --git a/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs b/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs index 8e176fbafa..797e850d5a 100644 --- a/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs +++ b/dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs @@ -3,18 +3,30 @@ *--------------------------------------------------------------------------------------------*/ using System.ComponentModel; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; namespace GitHub.Copilot; -/// Converts between JSON numeric milliseconds-since-Unix-epoch and . +/// Converts JSON numeric milliseconds-since-Unix-epoch or ISO 8601 strings to . [EditorBrowsable(EditorBrowsableState.Never)] public sealed class UnixMillisecondsDateTimeOffsetConverter : JsonConverter { /// public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType == JsonTokenType.String) + { + string? value = reader.GetString(); + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long milliseconds)) + { + return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds); + } + + return reader.GetDateTimeOffset(); + } + // The CLI may serialize the epoch-millisecond timestamp as a JSON integer // or as a floating-point number (e.g. 1700000000000.0). GetInt64 throws on a // fractional token, so fall back to reading a double and truncating. diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 9108a81343..2700a33b67 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -913,6 +913,82 @@ public void AgentStopHookInput_DeserializesWireFields_WithSdkOptions() Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1700000000000), input.Timestamp); } + [Fact] + public void PingResponse_DeserializesIsoTimestamp_WithSdkOptions() + { + var options = GetSerializerOptions(); + var response = JsonSerializer.Deserialize( + """{"message":"pong","timestamp":"2026-05-21T08:29:54.042Z","protocolVersion":3}""", + options); + + Assert.NotNull(response); + Assert.Equal("pong", response.Message); + Assert.Equal(3, response.ProtocolVersion); + Assert.Equal(DateTimeOffset.Parse("2026-05-21T08:29:54.042Z"), response.Timestamp); + } + + [Fact] + public void PingResponse_DeserializesEpochMillisecondsTimestamp_WithSdkOptions() + { + var options = GetSerializerOptions(); + var response = JsonSerializer.Deserialize( + """{"message":"pong","timestamp":1779352370134,"protocolVersion":3}""", + options); + + Assert.NotNull(response); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1779352370134), response.Timestamp); + } + + [Fact] + public void PingResponse_DeserializesStringEpochMillisecondsTimestamp_WithSdkOptions() + { + var options = GetSerializerOptions(); + var response = JsonSerializer.Deserialize( + """{"message":"pong","timestamp":"1779352370134","protocolVersion":3}""", + options); + + Assert.NotNull(response); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1779352370134), response.Timestamp); + } + + [Fact] + public void RpcPingResult_DeserializesIsoTimestamp_WithSdkOptions() + { + var options = GetSerializerOptions(); + var result = JsonSerializer.Deserialize( + """{"message":"pong","timestamp":"2026-05-21T08:29:54.042Z","protocolVersion":3}""", + options); + + Assert.NotNull(result); + Assert.Equal("pong", result.Message); + Assert.Equal(3, result.ProtocolVersion); + Assert.Equal(DateTimeOffset.Parse("2026-05-21T08:29:54.042Z"), result.Timestamp); + } + + [Fact] + public void RpcPingResult_DeserializesEpochMillisecondsTimestamp_WithSdkOptions() + { + var options = GetSerializerOptions(); + var result = JsonSerializer.Deserialize( + """{"message":"pong","timestamp":1779352370134,"protocolVersion":3}""", + options); + + Assert.NotNull(result); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1779352370134), result.Timestamp); + } + + [Fact] + public void RpcPingResult_DeserializesStringEpochMillisecondsTimestamp_WithSdkOptions() + { + var options = GetSerializerOptions(); + var result = JsonSerializer.Deserialize( + """{"message":"pong","timestamp":"1779352370134","protocolVersion":3}""", + options); + + Assert.NotNull(result); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1779352370134), result.Timestamp); + } + [Fact] public void AgentStopHookOutput_SerializesBlockDecision_WithSdkOptions() { diff --git a/scripts/codegen/csharp.ts b/scripts/codegen/csharp.ts index 97fcebea67..af064d10e4 100644 --- a/scripts/codegen/csharp.ts +++ b/scripts/codegen/csharp.ts @@ -492,6 +492,9 @@ function isMillisecondsDurationProperty(propName: string | undefined, schema: JS return isDurationProperty(schema) && !isSecondsDurationPropertyName(propName); } +function isFlexiblePingTimestampProperty(className: string, propName: string, schema: JSONSchema7): boolean { + return className === "PingResult" && propName === "timestamp" && schema.format === "date-time"; +} const COPYRIGHT = `/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -1736,6 +1739,9 @@ function emitRpcClass( if (isSchemaDeprecated(prop)) pushObsoleteAttributes(lines, " "); if (isSchemaExperimental(prop)) pushExperimentalAttribute(lines, " "); if (isMillisecondsDurationProperty(propName, prop)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`); + if (isFlexiblePingTimestampProperty(inlineTypeParentName, propName, prop)) { + lines.push(` [JsonConverter(typeof(GitHub.Copilot.UnixMillisecondsDateTimeOffsetConverter))]`); + } const propVisibility = pushCSharpInternalAttribute(lines, prop); lines.push(` [JsonPropertyName("${propName}")]`);