fix(copilot-cli): accept float timestamps in hook payloads · Entire
fix(copilot-cli): accept float timestamps in hook payloads
7a28de7→main·
gtrrz-victor·18h ago·2 files·+83 added/-6 removed
Copilot CLI 1.0.71 emits hook payload timestamps as float epoch-millis (e.g. 1784283185447.0) where earlier versions sent integers. The strict int64 parse in ParseTimestamp rejected the float, so every lifecycle hook (session-start, user-prompt-submitted, agent-stop, session-end) exited 1 before a session could start — no checkpoints were ever created, which is why 46/59 copilot-cli E2E tests time out on "checkpoint state did not advance". Tool hooks kept working because they skip stdin parsing entirely.
Parse the numeric form as float64 (truncating to millis) in both ParseTimestamp and isJSONNumber, so host detection also keeps classifying float-timestamp payloads as copilot-cli.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Changes
2
cmd/entire/cli/agent/copilotcli
Mcompat.go+8/-6
Mcompat_test.go+75
126 unmodified lines
// ParseTimestamp decodes a Copilot event timestamp, which may be either numeric
// epoch-millis or an RFC3339(Nano) string. A null/zero value returns the zero
// time (callers treat that as "missing"). Exported so transcript importers can
// decode the same dual-format field without re-implementing the logic.
// epoch-millis or an RFC3339(Nano) string. The numeric form may carry a
// fractional part (Copilot CLI 1.0.71 emits e.g. 1784283185447.0). A null/zero
// value returns the zero time (callers treat that as "missing"). Exported so
// transcript importers can decode the same dual-format field without
// re-implementing the logic.
func ParseTimestamp(raw json.RawMessage) (time.Time, error) {
if len(raw) == 0 || string(raw) == "null" {
return time.Time{}, nil
}
var millis int64
var millis float64
if err := json.Unmarshal(raw, &millis); err == nil {
if millis == 0 {
return time.Time{}, nil // Treat epoch as missing — triggers time.Now() fallback.
}
return time.UnixMilli(millis), nil
}
return time.UnixMilli(int64(millis)), nil
}
var ts string
20 unmodified lines
if len(raw) == 0 || raw[0] == 'n' {
return false
}
var n int64
var n float64
return json.Unmarshal(raw, &n) == nil
}
Mcmd/entire/cli/agent/copilotcli/compat.go+8/-6
147 unmodified lines
// Copilot CLI 1.0.71 started emitting timestamp as float epoch-millis
// (e.g. 1784283185447.0). The strict int64 parse rejected it, so every
// lifecycle hook (session-start, user-prompt-submitted, agent-stop,
// session-end) failed and no Entire session was ever created.
func TestParseHookEnvelope_AcceptsFloatTimestamp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw string
}{
{
name: "sessionStart",
raw: `{"sessionId":"sess-123","timestamp":1784283185447.0,"cwd":"/tmp/repo","source":"new","initialPrompt":"hi"}`,
},
{
name: "userPromptSubmitted",
raw: `{"sessionId":"sess-123","timestamp":1784283185370.0,"cwd":"/tmp/repo","prompt":"hi"}`,
},
{
name: "agentStop",
raw: `{"sessionId":"sess-123","timestamp":1784283190710.0,"cwd":"/tmp/repo","transcriptPath":"/tmp/events.jsonl","stopReason":"end_turn"}`,
},
{
name: "sessionEnd",
raw: `{"sessionId":"sess-123","timestamp":1784283190784.0,"cwd":"/tmp/repo","reason":"complete"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env, err := parseHookEnvelope([]byte(tt.raw))
if err != nil {
t.Fatalf("parseHookEnvelope() error = %v", err)
}
if env.Host != HostCopilotCLI {
t.Fatalf("Host = %q, want %q", env.Host, HostCopilotCLI)
}
if got := env.Timestamp.UnixMilli(); got < 1784283185000 || got > 1784283191000 {
t.Fatalf("Timestamp.UnixMilli() = %d, want the payload's epoch-millis value", got)
}
if env.SessionID != "sess-123" {
t.Fatalf("SessionID = %q, want %q", env.SessionID, "sess-123")
}
})
}
}
func TestParseTimestamp_FloatMillis(t *testing.T) {
t.Parallel()
ts, err := ParseTimestamp(json.RawMessage(`1784283185447.0`))
if err != nil {
t.Fatalf("ParseTimestamp() error = %v", err)
}
if got := ts.UnixMilli(); got != 1784283185447 {
t.Fatalf("UnixMilli() = %d, want 1784283185447", got)
}
}
func TestDetectHookHost_FloatTimestampIsCopilotCLI(t *testing.T) {
t.Parallel()
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(`{"timestamp":1784283185447.0,"sessionId":"s","prompt":"hi"}`), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := detectHookHost(raw); got != HostCopilotCLI {
t.Fatalf("detectHookHost() = %q, want %q", got, HostCopilotCLI)
}
}
func TestParseHookEnvelope_AcceptsSnakeCaseSessionID(t *testing.T) {
t.Parallel()