Skip to content

Client.Start fails when CLI returns ping timestamp as a JSON string (PingResponse.Timestamp int64) #1356

Description

@lonegunmanb

Ping/Client.Start fails when the CLI sends timestamp as a JSON string

Summary

copilot.Client.Start aborts before any RPC can be issued whenever the
connected Copilot CLI serializes the ping response's timestamp field as a
JSON string instead of a JSON number.

The Go SDK declares PingResponse.Timestamp as a hard int64:

// types.go (v0.3.0)
type PingResponse struct {
    Message         string `json:"message"`
    Timestamp       int64  `json:"timestamp"`
    ProtocolVersion *int   `json:"protocolVersion,omitempty"`
}

…and Client.StartverifyProtocolVersionPingjson.Unmarshal
bubbles every unmarshal failure back to the caller verbatim, with no
compatibility shim.

In practice this means: if the CLI on a given platform returns
"timestamp":"2026-05-21T08:29:54.042Z" (ISO 8601 string), the SDK can
never start, never enumerate models, never create a session — full stop —
with this hard-to-debug error message:

json: cannot unmarshal string into Go struct field PingResponse.timestamp of type int64

I am filing a sibling issue against copilot-cli so that the wire format
gets unified across platforms (currently Windows builds emit a number,
Linux builds emit an ISO string at the same CLI version 1.0.51 — see
attached captures). However, even after the CLI is unified, the SDK should
not be one hostile string field away from refusing to start. Please add a
tolerant UnmarshalJSON on PingResponse (or change the field to a
shape-tolerant type) so that the SDK can survive both wire shapes.

Reproduction (no CLI required)

A minimal, dependency-isolated reproducer is in
tmp/sdk-repro of this gist / branch — two Go files (go.mod,
repro_test.go) totalling ~60 lines. Both payload constants below are
real captures taken via a hand-rolled JSON-RPC harness against a real
copilot --headless --stdio process; they are not synthetic.

// payloadIntTimestamp     - Windows host, copilot CLI 1.0.51-2  -> NUMBER
// payloadStringTimestamp  - Linux container, copilot CLI 1.0.51 -> STRING
const payloadStringTimestamp = `{"message":"pong","timestamp":"2026-05-21T08:29:54.042Z","protocolVersion":3}`
const payloadIntTimestamp    = `{"message":"pong","timestamp":1779352370134,"protocolVersion":3}`

func TestPingResponse_StringTimestamp_Reproduces(t *testing.T) {
    var resp copilot.PingResponse
    err := json.Unmarshal([]byte(payloadStringTimestamp), &resp)
    // err != nil; err.Error() contains:
    //   "cannot unmarshal string into Go struct field PingResponse.timestamp of type int64"
}

Inside docker.io/library/golang:latest (go1.26.3 / linux/amd64):

=== RUN   TestPingResponse_NumericTimestamp_OK
    baseline OK: {Message:pong Timestamp:1779352370134 ProtocolVersion:0x...}
--- PASS
=== RUN   TestPingResponse_StringTimestamp_Reproduces
    reproduced error: json: cannot unmarshal string into Go struct field PingResponse.timestamp of type int64
--- PASS

Note neither test requires the Copilot CLI to be installed — the bug lives
purely in the exported SDK type.

Where it bites in Client.Start

client.go (v0.3.0):

// 302: func (c *Client) Start(ctx context.Context) error
//   ...
//   329: if err := c.verifyProtocolVersion(ctx); err != nil {
//   330:     killErr := c.killProcess()
//   331:     c.state = StateError
//   332:     return errors.Join(err, killErr)
//   333: }

// 1326: func (c *Client) verifyProtocolVersion(ctx context.Context) error {
// 1328:     pingResult, err := c.Ping(ctx, "")
// 1329:     if err != nil { return err }   // <-- unmarshal error reaches here
//   ...

// 1216: func (c *Client) Ping(ctx context.Context, message string) (*PingResponse, error) {
//   ...
// 1226: var response PingResponse
// 1227: if err := json.Unmarshal(result, &response); err != nil {
// 1228:     return nil, err              // <-- error originates here
// 1229: }

There is no ClientOptions knob (e.g. SkipPingVerification,
PingResponseHook) that lets a downstream consumer bypass this code path.

Proposed fix

Any one of the following resolves the immediate breakage; the first is
preferred because it preserves the typed-int64 ergonomics for existing
callers:

Option A — tolerant UnmarshalJSON on PingResponse (recommended)

func (p *PingResponse) UnmarshalJSON(b []byte) error {
    type alias struct {
        Message         string          `json:"message"`
        Timestamp       json.RawMessage `json:"timestamp"`
        ProtocolVersion *int            `json:"protocolVersion,omitempty"`
    }
    var a alias
    if err := json.Unmarshal(b, &a); err != nil {
        return err
    }
    p.Message = a.Message
    p.ProtocolVersion = a.ProtocolVersion

    raw := bytes.TrimSpace(a.Timestamp)
    switch {
    case len(raw) == 0 || string(raw) == "null":
        // leave zero
    case raw[0] == '"':
        // String form. Support both ISO-8601 and stringified epoch ms.
        var s string
        if err := json.Unmarshal(raw, &s); err != nil {
            return err
        }
        if n, err := strconv.ParseInt(s, 10, 64); err == nil {
            p.Timestamp = n
        } else if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
            p.Timestamp = t.UnixMilli()
        } else {
            return fmt.Errorf("PingResponse.timestamp: unrecognised string %q", s)
        }
    default:
        if err := json.Unmarshal(raw, &p.Timestamp); err != nil {
            return err
        }
    }
    return nil
}

Option B — change Timestamp to json.Number and add Time() /
Int64() helpers. Less invasive but pushes the parse responsibility onto
every caller and silently accepts garbage strings.

Option C — ClientOptions.SkipProtocolVerification bool as an escape
hatch for consumers stuck on broken CLI builds. Only useful in combination
with (A) or (B); on its own it defers the same crash to the first real RPC
that hits a similarly-typed field.

I'm happy to send the PR for Option A if it's the direction you want.

Environment

  • SDK: github.com/github/copilot-sdk/go v0.3.0
  • (Also verified still affected on v1.0.0-beta.4.)
  • Reproducer: go1.26.3 linux/amd64 inside docker.io/library/golang:latest
  • Observed wire payloads: see captures attached in sibling
    github/copilot-cli issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions