Skip to content

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

@lonegunmanb

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions