From d5ef48f10fe633244f7d23f5102fcdf90886cb92 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Sat, 1 Aug 2026 16:06:08 +0800 Subject: [PATCH] fix(go): tolerate numeric ping timestamps --- go/ping_response_json.go | 76 ++++++++++++++++++++++++++++++ go/rpc/generated_rpc_union_test.go | 40 ++++++++++++++++ go/rpc/ping_result_json.go | 76 ++++++++++++++++++++++++++++++ go/types_test.go | 40 ++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 go/ping_response_json.go create mode 100644 go/rpc/ping_result_json.go diff --git a/go/ping_response_json.go b/go/ping_response_json.go new file mode 100644 index 0000000000..a295e1af7f --- /dev/null +++ b/go/ping_response_json.go @@ -0,0 +1,76 @@ +package copilot + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "time" +) + +// UnmarshalJSON accepts both current ISO-8601 ping timestamps and older +// epoch-millisecond timestamps emitted by some CLI builds. +func (p *PingResponse) UnmarshalJSON(data []byte) error { + var wire struct { + Message string `json:"message"` + ProtocolVersion *int `json:"protocolVersion,omitempty"` + Timestamp json.RawMessage `json:"timestamp"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + timestamp, err := parsePingTimestamp(wire.Timestamp) + if err != nil { + return err + } + + p.Message = wire.Message + p.ProtocolVersion = wire.ProtocolVersion + p.Timestamp = timestamp + return nil +} + +func parsePingTimestamp(raw json.RawMessage) (time.Time, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return time.Time{}, nil + } + + if raw[0] == '"' { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return time.Time{}, fmt.Errorf("ping timestamp: %w", err) + } + return parsePingTimestampString(value) + } + + var number json.Number + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&number); err != nil { + return time.Time{}, fmt.Errorf("ping timestamp: %w", err) + } + return parsePingTimestampNumber(number) +} + +func parsePingTimestampString(value string) (time.Time, error) { + if milliseconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.UnixMilli(milliseconds), nil + } + if timestamp, err := time.Parse(time.RFC3339Nano, value); err == nil { + return timestamp, nil + } + return time.Time{}, fmt.Errorf("ping timestamp: unsupported string value %q", value) +} + +func parsePingTimestampNumber(number json.Number) (time.Time, error) { + if milliseconds, err := number.Int64(); err == nil { + return time.UnixMilli(milliseconds), nil + } + value, err := strconv.ParseFloat(number.String(), 64) + if err != nil { + return time.Time{}, fmt.Errorf("ping timestamp: %w", err) + } + return time.UnixMilli(int64(value)), nil +} diff --git a/go/rpc/generated_rpc_union_test.go b/go/rpc/generated_rpc_union_test.go index 92bcb4c077..7a54587207 100644 --- a/go/rpc/generated_rpc_union_test.go +++ b/go/rpc/generated_rpc_union_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "testing" + "time" "github.com/github/copilot-sdk/go/internal/jsonrpc2" ) @@ -46,6 +47,45 @@ func TestExternalToolResultJSONUnion(t *testing.T) { } } +func TestPingResultUnmarshalsIsoTimestamp(t *testing.T) { + var result PingResult + if err := json.Unmarshal([]byte(`{"message":"pong","timestamp":"2026-05-21T08:29:54.042Z","protocolVersion":3}`), &result); err != nil { + t.Fatalf("unmarshal ping result: %v", err) + } + + expected := time.Date(2026, 5, 21, 8, 29, 54, 42_000_000, time.UTC) + if !result.Timestamp.Equal(expected) { + t.Fatalf("timestamp = %s, want %s", result.Timestamp, expected) + } + if result.ProtocolVersion != 3 { + t.Fatalf("protocol version = %d, want 3", result.ProtocolVersion) + } +} + +func TestPingResultUnmarshalsEpochMillisecondsTimestamp(t *testing.T) { + var result PingResult + if err := json.Unmarshal([]byte(`{"message":"pong","timestamp":1779352370134,"protocolVersion":3}`), &result); err != nil { + t.Fatalf("unmarshal ping result: %v", err) + } + + expected := time.UnixMilli(1779352370134) + if !result.Timestamp.Equal(expected) { + t.Fatalf("timestamp = %s, want %s", result.Timestamp, expected) + } +} + +func TestPingResultUnmarshalsStringEpochMillisecondsTimestamp(t *testing.T) { + var result PingResult + if err := json.Unmarshal([]byte(`{"message":"pong","timestamp":"1779352370134","protocolVersion":3}`), &result); err != nil { + t.Fatalf("unmarshal ping result: %v", err) + } + + expected := time.UnixMilli(1779352370134) + if !result.Timestamp.Equal(expected) { + t.Fatalf("timestamp = %s, want %s", result.Timestamp, expected) + } +} + func TestFilterMappingJSONUnion(t *testing.T) { var mapping FilterMapping = FilterMappingEnumMap{"secret": ContentFilterModeHiddenCharacters} raw, err := json.Marshal(mapping) diff --git a/go/rpc/ping_result_json.go b/go/rpc/ping_result_json.go new file mode 100644 index 0000000000..a1ccc19346 --- /dev/null +++ b/go/rpc/ping_result_json.go @@ -0,0 +1,76 @@ +package rpc + +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + "time" +) + +// UnmarshalJSON accepts both current ISO-8601 ping timestamps and older +// epoch-millisecond timestamps emitted by some CLI builds. +func (p *PingResult) UnmarshalJSON(data []byte) error { + var wire struct { + Message string `json:"message"` + ProtocolVersion int64 `json:"protocolVersion"` + Timestamp json.RawMessage `json:"timestamp"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + timestamp, err := parsePingTimestamp(wire.Timestamp) + if err != nil { + return err + } + + p.Message = wire.Message + p.ProtocolVersion = wire.ProtocolVersion + p.Timestamp = timestamp + return nil +} + +func parsePingTimestamp(raw json.RawMessage) (time.Time, error) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return time.Time{}, nil + } + + if raw[0] == '"' { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return time.Time{}, fmt.Errorf("ping timestamp: %w", err) + } + return parsePingTimestampString(value) + } + + var number json.Number + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&number); err != nil { + return time.Time{}, fmt.Errorf("ping timestamp: %w", err) + } + return parsePingTimestampNumber(number) +} + +func parsePingTimestampString(value string) (time.Time, error) { + if milliseconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.UnixMilli(milliseconds), nil + } + if timestamp, err := time.Parse(time.RFC3339Nano, value); err == nil { + return timestamp, nil + } + return time.Time{}, fmt.Errorf("ping timestamp: unsupported string value %q", value) +} + +func parsePingTimestampNumber(number json.Number) (time.Time, error) { + if milliseconds, err := number.Int64(); err == nil { + return time.UnixMilli(milliseconds), nil + } + value, err := strconv.ParseFloat(number.String(), 64) + if err != nil { + return time.Time{}, fmt.Errorf("ping timestamp: %w", err) + } + return time.UnixMilli(int64(value)), nil +} diff --git a/go/types_test.go b/go/types_test.go index a76ebaad40..7283462820 100644 --- a/go/types_test.go +++ b/go/types_test.go @@ -3,6 +3,7 @@ package copilot import ( "encoding/json" "testing" + "time" ) func TestProviderConfig_JSONIncludesHeaders(t *testing.T) { @@ -33,6 +34,45 @@ func TestProviderConfig_JSONIncludesHeaders(t *testing.T) { } } +func TestPingResponseUnmarshalsIsoTimestamp(t *testing.T) { + var response PingResponse + if err := json.Unmarshal([]byte(`{"message":"pong","timestamp":"2026-05-21T08:29:54.042Z","protocolVersion":3}`), &response); err != nil { + t.Fatalf("unmarshal ping response: %v", err) + } + + expected := time.Date(2026, 5, 21, 8, 29, 54, 42_000_000, time.UTC) + if !response.Timestamp.Equal(expected) { + t.Fatalf("timestamp = %s, want %s", response.Timestamp, expected) + } + if response.ProtocolVersion == nil || *response.ProtocolVersion != 3 { + t.Fatalf("protocol version = %v, want 3", response.ProtocolVersion) + } +} + +func TestPingResponseUnmarshalsEpochMillisecondsTimestamp(t *testing.T) { + var response PingResponse + if err := json.Unmarshal([]byte(`{"message":"pong","timestamp":1779352370134,"protocolVersion":3}`), &response); err != nil { + t.Fatalf("unmarshal ping response: %v", err) + } + + expected := time.UnixMilli(1779352370134) + if !response.Timestamp.Equal(expected) { + t.Fatalf("timestamp = %s, want %s", response.Timestamp, expected) + } +} + +func TestPingResponseUnmarshalsStringEpochMillisecondsTimestamp(t *testing.T) { + var response PingResponse + if err := json.Unmarshal([]byte(`{"message":"pong","timestamp":"1779352370134","protocolVersion":3}`), &response); err != nil { + t.Fatalf("unmarshal ping response: %v", err) + } + + expected := time.UnixMilli(1779352370134) + if !response.Timestamp.Equal(expected) { + t.Fatalf("timestamp = %s, want %s", response.Timestamp, expected) + } +} + func TestSessionSendRequest_JSONIncludesRequestHeaders(t *testing.T) { req := sessionSendRequest{ SessionID: "session-1",