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.Start → verifyProtocolVersion → Ping → json.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.
Ping/Client.Startfails when the CLI sendstimestampas a JSON stringSummary
copilot.Client.Startaborts before any RPC can be issued whenever theconnected Copilot CLI serializes the
pingresponse'stimestampfield as aJSON string instead of a JSON number.
The Go SDK declares
PingResponse.Timestampas a hardint64:…and
Client.Start→verifyProtocolVersion→Ping→json.Unmarshalbubbles 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 cannever start, never enumerate models, never create a session — full stop —
with this hard-to-debug error message:
I am filing a sibling issue against
copilot-cliso that the wire formatgets unified across platforms (currently Windows builds emit a number,
Linux builds emit an ISO string at the same CLI version
1.0.51— seeattached 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
UnmarshalJSONonPingResponse(or change the field to ashape-tolerant type) so that the SDK can survive both wire shapes.
Reproduction (no CLI required)
A minimal, dependency-isolated reproducer is in
tmp/sdk-reproof this gist / branch — two Go files (go.mod,repro_test.go) totalling ~60 lines. Bothpayloadconstants below arereal captures taken via a hand-rolled JSON-RPC harness against a real
copilot --headless --stdioprocess; they are not synthetic.Inside
docker.io/library/golang:latest(go1.26.3 / linux/amd64):Note neither test requires the Copilot CLI to be installed — the bug lives
purely in the exported SDK type.
Where it bites in
Client.Startclient.go(v0.3.0):There is no
ClientOptionsknob (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-
int64ergonomics for existingcallers:
Option A — tolerant
UnmarshalJSONonPingResponse(recommended)Option B — change
Timestamptojson.Numberand addTime()/Int64()helpers. Less invasive but pushes the parse responsibility ontoevery caller and silently accepts garbage strings.
Option C —
ClientOptions.SkipProtocolVerification boolas an escapehatch 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
github.com/github/copilot-sdk/go v0.3.0v1.0.0-beta.4.)go1.26.3 linux/amd64insidedocker.io/library/golang:latestgithub/copilot-cliissue.