Merge pull request #1796 from entireio/fix/copilot-171-float-timestamp · Entire

Merge pull request #1796 from entireio/fix/copilot-171-float-timestamp

ad28b36→main·

gtrrz-victor·16h ago·2 files·+120 added/-7 removed

Changes

2

3 unmodified lines
4
5
6
7
8
9
10
117 unmodified lines

128
129
130
130
131
132
131
132
133
134
135
136
137
138
139
140
138
141
142
140
143
144
145
146
147
148
149
150
151
143
152
153
154
155
20 unmodified lines

176
177
178
170
179
180
181
182

3 unmodified lines

"encoding/json"
    "errors"
    "fmt"
    "math"
    "slices"
    "time"
)
117 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); sub-millisecond
// precision is truncated. 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 {
            // Guard the float→int64 conversion: out-of-range values are
            // implementation-defined in Go, so reject them instead.
            if millis >= float64(math.MaxInt64) || millis <= float64(math.MinInt64) {
                return time.Time{}, fmt.Errorf("timestamp %v out of range", millis)
            }
            ms := int64(millis) // truncates any fractional milliseconds
            if ms == 0 {
                return time.Time{}, nil // Treat epoch as missing — triggers time.Now() fallback.
            }
            return time.UnixMilli(millis), nil
            return time.UnixMilli(ms), 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+16/-7

147 unmodified lines

148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257

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
    wantMillis int64
}{
    {
    name:       "sessionStart",
    raw:        `{"sessionId":"sess-123","timestamp":1784283185447.0,"cwd":"/tmp/repo","source":"new","initialPrompt":"hi"}`,
    wantMillis: 1784283185447,
    },
    {
    name:       "userPromptSubmitted",
    raw:        `{"sessionId":"sess-123","timestamp":1784283185370.0,"cwd":"/tmp/repo","prompt":"hi"}`,
    wantMillis: 1784283185370,
    },
    {
    name:       "agentStop",
    raw:        `{"sessionId":"sess-123","timestamp":1784283190710.0,"cwd":"/tmp/repo","transcriptPath":"/tmp/events.jsonl","stopReason":"end_turn"}`,
    wantMillis: 1784283190710,
    },
    {
    name:       "sessionEnd",
    raw:        `{"sessionId":"sess-123","timestamp":1784283190784.0,"cwd":"/tmp/repo","reason":"complete"}`,
    wantMillis: 1784283190784,
    },
}

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 != tt.wantMillis {
            t.Fatalf("Timestamp.UnixMilli() = %d, want %d", got, tt.wantMillis)
        }
        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 TestParseTimestamp_SubMillisecondFloatIsMissing(t *testing.T) {
    t.Parallel()

// 0.4 truncates to 0 ms — must be treated as "missing" (zero time),
    // not as the Unix epoch.
    ts, err := ParseTimestamp(json.RawMessage(`0.4`))
    if err != nil {
        t.Fatalf("ParseTimestamp() error = %v", err)
    }
    if !ts.IsZero() {
        t.Fatalf("ParseTimestamp(0.4) = %v, want zero time", ts)
    }
}

func TestParseTimestamp_OutOfRangeFloatErrors(t *testing.T) {
    t.Parallel()

for _, raw := range []string{`1e300`, `-1e300`} {
        if _, err := ParseTimestamp(json.RawMessage(raw)); err == nil {
            t.Fatalf("ParseTimestamp(%s) expected out-of-range error, got nil", raw)
        }
    }
}

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()