Skip to content

Commit b9eeb2b

Browse files
authored
Merge pull request #1330 from wakatime/develop
Release v2.2.3
2 parents 6d1423f + 0989388 commit b9eeb2b

File tree

5 files changed

+200
-7
lines changed

5 files changed

+200
-7
lines changed

pkg/ai/ai.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func WithAISync(config Config) heartbeat.HandleOption {
141141
return next(ctx, hh)
142142
}
143143

144-
heartbeats = preserveAttributes(heartbeats, hh)
144+
heartbeats, firstHumanEdit := preserveAttributes(heartbeats, hh)
145145

146146
heartbeats = applyProject(heartbeats, config)
147147

@@ -176,7 +176,19 @@ func WithAISync(config Config) heartbeat.HandleOption {
176176
continue // remove this human heartbeat, it's actually AI
177177
}
178178

179-
if h.Time > minHeartbeatTime-1 && h.Time < maxHeartbeatTime+1 {
179+
inRange := func(windowMinutes float64) bool {
180+
const secondsPerMinute = 60.0
181+
182+
windowSeconds := windowMinutes * secondsPerMinute
183+
184+
return h.Time > minHeartbeatTime-windowSeconds && h.Time < maxHeartbeatTime+windowSeconds
185+
}
186+
187+
if inRange(2) {
188+
h.Category = "ai coding"
189+
}
190+
191+
if (firstHumanEdit == nil || *firstHumanEdit > h.Time) && inRange(30) {
180192
h.Category = "ai coding"
181193
}
182194

@@ -302,15 +314,21 @@ func getLastParsedAt(ctx context.Context, v *viper.Viper) (time.Time, error) {
302314
// preserveAttributes mutates aiHeartbeats pulling in the attributes from
303315
// humanHeartbeats, which should normally have more details already populated
304316
// from the IDE than available on aiHeartbeats.
305-
func preserveAttributes(aiHeartbeats []heartbeat.Heartbeat, humanHeartbeats []heartbeat.Heartbeat) Heartbeats {
317+
func preserveAttributes(aiHeartbeats []heartbeat.Heartbeat, humanHeartbeats []heartbeat.Heartbeat) (Heartbeats, *float64) {
306318
if len(humanHeartbeats) == 0 {
307-
return aiHeartbeats
319+
return aiHeartbeats, nil
308320
}
309321

310322
originals := make(map[string][]heartbeat.Heartbeat, len(humanHeartbeats))
311323
fallbackProjectFolder := ""
312324

325+
var firstHumanEdit *float64
326+
313327
for _, h := range humanHeartbeats {
328+
if (firstHumanEdit == nil || h.Time < *firstHumanEdit) && h.HumanLineChanges != nil && *h.HumanLineChanges != 0 {
329+
firstHumanEdit = &h.Time
330+
}
331+
314332
originals[h.Entity] = append(originals[h.Entity], h)
315333

316334
if fallbackProjectFolder == "" {
@@ -333,7 +351,7 @@ func preserveAttributes(aiHeartbeats []heartbeat.Heartbeat, humanHeartbeats []he
333351
}
334352
}
335353

336-
return aiHeartbeats
354+
return aiHeartbeats, firstHumanEdit
337355
}
338356

339357
func preserveAttributesFromHumanHeartbeat(aiHeartbeat *heartbeat.Heartbeat, human heartbeat.Heartbeat) {

pkg/ai/ai_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,117 @@ func TestSendHeartbeats_WithAIParsingBatchAppliedOnce(t *testing.T) {
432432
assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond)
433433
}
434434

435+
func TestWithAISyncMarksHumanHeartbeatsAsAICoding(t *testing.T) {
436+
home := t.TempDir()
437+
t.Setenv("HOME", home)
438+
t.Setenv("USERPROFILE", home)
439+
440+
entity, err := filepath.Abs("testdata/main.go")
441+
require.NoError(t, err)
442+
443+
entity = strings.ReplaceAll(entity, "\\", "/")
444+
445+
local, err := filepath.Abs("testdata/localfile.go")
446+
require.NoError(t, err)
447+
448+
local = strings.ReplaceAll(local, "\\", "/")
449+
450+
transcriptDir := filepath.Join(home, ".claude", "projects", "sample-project")
451+
require.NoError(t, os.MkdirAll(transcriptDir, 0o755))
452+
453+
transcriptPath := filepath.Join(transcriptDir, "session.jsonl")
454+
transcript := strings.Join([]string{
455+
"{\"timestamp\":\"2026-03-18T12:00:00Z\",\"version\":\"2.1.45\"," +
456+
"\"toolUseResult\":{\"filePath\":\"" + entity + "\"," +
457+
"\"structuredPatch\":[{\"oldLines\":3,\"newLines\":5}]}}",
458+
"{\"timestamp\":\"2026-03-18T12:30:00Z\",\"toolUseResult\":{" +
459+
"\"filePath\":\"" + local + "\",\"content\":\"first\\nsecond\\nthird\"}}",
460+
}, "\n") + "\n"
461+
require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o644))
462+
463+
tmpInternal, err := os.CreateTemp(t.TempDir(), "wakatime-internal")
464+
require.NoError(t, err)
465+
466+
defer tmpInternal.Close()
467+
468+
v := viper.New()
469+
v.Set("internal-config", tmpInternal.Name())
470+
v.Set("internal.ai_heartbeats_last_parsed_at", time.Date(2026, 3, 18, 11, 0, 0, 0, time.UTC).Format(ini.DateFormat))
471+
472+
handle := ai.WithAISync(ai.Config{
473+
Plugin: "plugin/0.0.1",
474+
V: v,
475+
})(func(_ context.Context, hh []heartbeat.Heartbeat) ([]heartbeat.Result, error) {
476+
results := make([]heartbeat.Result, len(hh))
477+
for i := range hh {
478+
results[i] = heartbeat.Result{Heartbeat: hh[i]}
479+
}
480+
481+
return results, nil
482+
})
483+
484+
humanWithinTwoMinutes := "/tmp/human-within-two-minutes.go"
485+
humanWithinThirtyMinutesNoChangesBeforeEdit := "/tmp/human-within-thirty-minutes-no-changes-before-edit.go"
486+
humanWithinThirtyMinutesWithChanges := "/tmp/human-within-thirty-minutes-with-changes.go"
487+
humanWithinThirtyMinutesNoChangesAfterEdit := "/tmp/human-within-thirty-minutes-no-changes-after-edit.go"
488+
humanOutsideThirtyMinutes := "/tmp/human-outside-thirty-minutes.go"
489+
490+
got, err := handle(t.Context(), []heartbeat.Heartbeat{
491+
{
492+
Entity: humanWithinTwoMinutes,
493+
EntityType: heartbeat.FileType,
494+
Category: "debugging",
495+
HumanLineChanges: heartbeat.PointerTo(3),
496+
Time: 1773835260.1,
497+
UserAgent: "editor/1.2.3",
498+
},
499+
{
500+
Entity: humanWithinThirtyMinutesNoChangesBeforeEdit,
501+
EntityType: heartbeat.FileType,
502+
Category: "debugging",
503+
HumanLineChanges: heartbeat.PointerTo(0),
504+
Time: 1773836999.1,
505+
UserAgent: "editor/1.2.3",
506+
},
507+
{
508+
Entity: humanWithinThirtyMinutesWithChanges,
509+
EntityType: heartbeat.FileType,
510+
Category: "debugging",
511+
HumanLineChanges: heartbeat.PointerTo(1),
512+
Time: 1773838500.2,
513+
UserAgent: "editor/1.2.3",
514+
},
515+
{
516+
Entity: humanWithinThirtyMinutesNoChangesAfterEdit,
517+
EntityType: heartbeat.FileType,
518+
Category: "debugging",
519+
HumanLineChanges: heartbeat.PointerTo(0),
520+
Time: 1773838500.1,
521+
UserAgent: "editor/1.2.3",
522+
},
523+
{
524+
Entity: humanOutsideThirtyMinutes,
525+
EntityType: heartbeat.FileType,
526+
Category: "debugging",
527+
HumanLineChanges: heartbeat.PointerTo(0),
528+
Time: 1773839100.1,
529+
UserAgent: "editor/1.2.3",
530+
},
531+
})
532+
require.NoError(t, err)
533+
534+
categoriesByEntity := make(map[string]string, len(got))
535+
for _, result := range got {
536+
categoriesByEntity[result.Heartbeat.Entity] = result.Heartbeat.Category
537+
}
538+
539+
assert.Equal(t, "ai coding", categoriesByEntity[humanWithinTwoMinutes])
540+
assert.Equal(t, "ai coding", categoriesByEntity[humanWithinThirtyMinutesNoChangesBeforeEdit])
541+
assert.Equal(t, "debugging", categoriesByEntity[humanWithinThirtyMinutesWithChanges])
542+
assert.Equal(t, "debugging", categoriesByEntity[humanWithinThirtyMinutesNoChangesAfterEdit])
543+
assert.Equal(t, "debugging", categoriesByEntity[humanOutsideThirtyMinutes])
544+
}
545+
435546
func resetSingleton(t *testing.T) {
436547
t.Helper()
437548

pkg/ai/copilot.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,33 @@ type (
149149
}
150150
)
151151

152+
func (v *copilotVariable) UnmarshalJSON(data []byte) error {
153+
type alias struct {
154+
Kind string `json:"kind"`
155+
ID string `json:"id"`
156+
Value json.RawMessage `json:"value"`
157+
}
158+
159+
var decoded alias
160+
if err := json.Unmarshal(data, &decoded); err != nil {
161+
return err
162+
}
163+
164+
v.Kind = decoded.Kind
165+
v.ID = decoded.ID
166+
167+
if len(decoded.Value) == 0 || string(decoded.Value) == "null" {
168+
return nil
169+
}
170+
171+
var pathValue copilotPathValue
172+
if err := json.Unmarshal(decoded.Value, &pathValue); err == nil {
173+
v.Value = &pathValue
174+
}
175+
176+
return nil
177+
}
178+
152179
func (m *copilotMessageWithURIs) UnmarshalJSON(data []byte) error {
153180
if data == nil || string(data) == "null" {
154181
return nil

pkg/ai/copilot_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,40 @@ func TestCopilotParseJSONLSession(t *testing.T) {
221221
assert.Equal(t, "Copilot session-2", got[2].Entity)
222222
assert.Equal(t, heartbeat.AppType, got[2].EntityType)
223223
}
224+
225+
func TestCopilotParseJSONLSession_IgnoresStringVariableValues(t *testing.T) {
226+
home := t.TempDir()
227+
t.Setenv("HOME", home)
228+
t.Setenv("USERPROFILE", home)
229+
230+
workspaceDir := filepath.Join(home, "Library", "Application Support", "Code", "User", "workspaceStorage", "workspace-3")
231+
require.NoError(t, os.MkdirAll(filepath.Join(workspaceDir, "chatSessions"), 0o755))
232+
233+
sessionPath := filepath.Join(workspaceDir, "chatSessions", "session-3.jsonl")
234+
lines := []string{
235+
strings.Join([]string{
236+
`{"kind":0,"v":{"version":3,"creationDate":1771000000000,`,
237+
`"sessionId":"session-3","requests":[{"requestId":"request-b",`,
238+
`"timestamp":1771000005000,`,
239+
`"agent":{"extensionVersion":"0.43.0"},`,
240+
`"message":{"text":"Find bugs in this repo"},`,
241+
`"variableData":{"variables":[{"id":"vscode.customizations.index",`,
242+
`"kind":"promptText","value":"<skills>...</skills>"}]},`,
243+
`"response":[]}]}}`,
244+
}, ""),
245+
`{"kind":1,"k":["requests",0,"modelState"],"v":{"value":1,"completedAt":1771000009000}}`,
246+
}
247+
require.NoError(t, os.WriteFile(sessionPath, []byte(strings.Join(lines, "\n")+"\n"), 0o644))
248+
249+
got, err := ai.Copilot{
250+
After: time.Unix(1771000000, 0),
251+
FallbackUserAgent: "editor/1.0.0",
252+
}.Parse(t.Context())
253+
require.NoError(t, err)
254+
require.Len(t, got, 2)
255+
256+
assert.Equal(t, "Copilot session-3", got[0].Entity)
257+
assert.Equal(t, heartbeat.AppType, got[0].EntityType)
258+
assert.Equal(t, "Copilot session-3", got[1].Entity)
259+
assert.Equal(t, heartbeat.AppType, got[1].EntityType)
260+
}

pkg/ai/helpers_internal_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ func TestPreserveAttributesMutatesAIHeartbeats(t *testing.T) {
449449
},
450450
}
451451

452-
got := preserveAttributes(aiHeartbeats, humanHeartbeats)
452+
got, _ := preserveAttributes(aiHeartbeats, humanHeartbeats)
453453

454454
require.Len(t, got, 1)
455455
assert.Same(t, &aiHeartbeats[0], &got[0])
@@ -490,7 +490,7 @@ func TestPreserveAttributes_AppHeartbeatFallsBackToHumanProjectFolder(t *testing
490490
},
491491
}
492492

493-
got := preserveAttributes(aiHeartbeats, humanHeartbeats)
493+
got, _ := preserveAttributes(aiHeartbeats, humanHeartbeats)
494494

495495
require.Len(t, got, 1)
496496
assert.Equal(t, "/tmp/project-override", got[0].ProjectPathOverride)

0 commit comments

Comments
 (0)