Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dotnet/src/Generated/Rpc.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3845,6 +3845,7 @@ public sealed class PingResponse
/// <summary>
/// ISO 8601 timestamp when the ping was processed.
/// </summary>
[JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))]
public DateTimeOffset Timestamp { get; set; }
/// <summary>
/// Protocol version supported by the server.
Expand Down
14 changes: 13 additions & 1 deletion dotnet/src/UnixMillisecondsDateTimeOffsetConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,30 @@
*--------------------------------------------------------------------------------------------*/

using System.ComponentModel;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace GitHub.Copilot;

/// <summary>Converts between JSON numeric milliseconds-since-Unix-epoch and <see cref="DateTimeOffset"/>.</summary>
/// <summary>Converts JSON numeric milliseconds-since-Unix-epoch or ISO 8601 strings to <see cref="DateTimeOffset"/>.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class UnixMillisecondsDateTimeOffsetConverter : JsonConverter<DateTimeOffset>
{
/// <inheritdoc />
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.
Expand Down
76 changes: 76 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHub.Copilot.PingResponse>(
"""{"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<GitHub.Copilot.PingResponse>(
"""{"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<GitHub.Copilot.PingResponse>(
"""{"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<PingResult>(
"""{"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<PingResult>(
"""{"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<PingResult>(
"""{"message":"pong","timestamp":"1779352370134","protocolVersion":3}""",
options);

Assert.NotNull(result);
Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1779352370134), result.Timestamp);
}

[Fact]
public void AgentStopHookOutput_SerializesBlockDecision_WithSdkOptions()
{
Expand Down
6 changes: 6 additions & 0 deletions scripts/codegen/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}")]`);

Expand Down