Skip to content

Commit 89c9d28

Browse files
authored
Merge pull request #1348 from wakatime/develop
Release v2.3.1
2 parents eaf4827 + 2b0759b commit 89c9d28

File tree

5 files changed

+722
-97
lines changed

5 files changed

+722
-97
lines changed

pkg/ai/ai.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,8 +498,43 @@ func mergeHeartbeatCounts(dst *heartbeat.Heartbeat, src heartbeat.Heartbeat) {
498498
dst.HumanLineChanges = addIntPointers(dst.HumanLineChanges, src.HumanLineChanges)
499499
dst.AIPromptLength += src.AIPromptLength
500500
dst.AIInputTokens += src.AIInputTokens
501+
501502
dst.AIOutputTokens += src.AIOutputTokens
502-
dst.AIPromptLength += src.AIPromptLength
503+
if dst.Project == nil || *dst.Project == "" {
504+
dst.Project = src.Project
505+
}
506+
507+
if dst.ProjectAlternate == "" {
508+
dst.ProjectAlternate = src.ProjectAlternate
509+
}
510+
511+
if dst.Branch == nil || *dst.Branch == "" {
512+
dst.Branch = src.Branch
513+
}
514+
515+
if dst.BranchAlternate == "" {
516+
dst.BranchAlternate = src.BranchAlternate
517+
}
518+
519+
if dst.Language == nil || *dst.Language == "" {
520+
dst.Language = src.Language
521+
}
522+
523+
if dst.LanguageAlternate == "" {
524+
dst.LanguageAlternate = src.LanguageAlternate
525+
}
526+
527+
if dst.ProjectOverride == "" {
528+
dst.ProjectOverride = src.ProjectOverride
529+
}
530+
531+
if dst.ProjectPath == "" {
532+
dst.ProjectPath = src.ProjectPath
533+
}
534+
535+
if dst.ProjectPathOverride == "" {
536+
dst.ProjectPathOverride = src.ProjectPathOverride
537+
}
503538
}
504539

505540
func addIntPointers(dst *int, src *int) *int {

pkg/ai/ai_test.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,263 @@ func TestWithAISyncSkipsTwoMinuteAICodingForHumanEditsWithChanges(t *testing.T)
652652
assert.Equal(t, "ai coding", categoriesByEntity[humanWithinTwoMinutesNoChanges])
653653
}
654654

655+
func TestWithAISync_ProducesExpectedHeartbeats(t *testing.T) {
656+
home := t.TempDir()
657+
t.Setenv("HOME", home)
658+
t.Setenv("USERPROFILE", home)
659+
660+
now := time.Now()
661+
transcriptDir := filepath.Join(home, ".codex", "sessions", now.Format("2026"), now.Format("04"), now.Format("15"))
662+
require.NoError(t, os.MkdirAll(transcriptDir, 0o755))
663+
664+
transcript := "rollout-2026-04-15T18-46-36-019d9353-333c-7c41-a909-26f5c6221a5a.jsonl"
665+
transcriptPath := filepath.Join(transcriptDir, transcript)
666+
copyFile(t, filepath.Join("testdata", transcript), transcriptPath)
667+
668+
after := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC)
669+
670+
tmpInternal, err := os.CreateTemp(t.TempDir(), "wakatime-internal")
671+
require.NoError(t, err)
672+
673+
defer tmpInternal.Close()
674+
675+
v := viper.New()
676+
v.Set("internal-config", tmpInternal.Name())
677+
v.Set("internal.ai_heartbeats_last_parsed_at", after.Format(ini.DateFormat))
678+
679+
handle := ai.WithAISync(ai.Config{
680+
Plugin: "plugin/0.0.1",
681+
V: v,
682+
})(func(_ context.Context, hh []heartbeat.Heartbeat) ([]heartbeat.Result, error) {
683+
results := make([]heartbeat.Result, len(hh))
684+
for i := range hh {
685+
results[i] = heartbeat.Result{Heartbeat: hh[i]}
686+
}
687+
688+
return results, nil
689+
})
690+
691+
heartbeats, err := handle(t.Context(), []heartbeat.Heartbeat{})
692+
require.NoError(t, err)
693+
694+
require.Len(t, heartbeats, 3)
695+
696+
h := heartbeats[0].Heartbeat
697+
assert.Equal(t, float64(1776297745), h.Time)
698+
assert.Equal(t, heartbeat.FileType, h.EntityType)
699+
assert.Equal(t, "/Users/user/git/wakatime-cli/templates/index.html", h.Entity)
700+
assert.Equal(t, "ai coding", h.Category)
701+
assert.False(t, *h.IsWrite)
702+
assert.Equal(t, int64(105134), h.AIInputTokens)
703+
assert.Equal(t, int64(479), h.AIOutputTokens)
704+
assert.Equal(t, 29, h.AIPromptLength)
705+
assert.Nil(t, h.AILineChanges)
706+
assert.Equal(t, "/Users/user/git/wakatime-cli", h.ProjectPathOverride)
707+
assert.Equal(t, "019d9353-333c-7c41-a909-26f5c6221a5a", h.AISession)
708+
709+
h = heartbeats[1].Heartbeat
710+
assert.Equal(t, float64(1776297784), h.Time)
711+
assert.Equal(t, heartbeat.FileType, h.EntityType)
712+
assert.Equal(t, "/Users/user/git/wakatime-cli/templates/index.html", h.Entity)
713+
assert.Equal(t, "ai coding", h.Category)
714+
assert.True(t, *h.IsWrite)
715+
assert.Equal(t, int64(326506), h.AIInputTokens)
716+
assert.Equal(t, int64(1442), h.AIOutputTokens)
717+
assert.Equal(t, 10, h.AIPromptLength)
718+
assert.Equal(t, -1, *h.AILineChanges)
719+
assert.Equal(t, "/Users/user/git/wakatime-cli", h.ProjectPathOverride)
720+
assert.Equal(t, "019d9353-333c-7c41-a909-26f5c6221a5a", h.AISession)
721+
722+
h = heartbeats[2].Heartbeat
723+
assert.Equal(t, float64(1776297789), h.Time)
724+
assert.Equal(t, heartbeat.FileType, h.EntityType)
725+
assert.Equal(t, "/Users/user/git/wakatime-cli/static/css/index.less", h.Entity)
726+
assert.Equal(t, "ai coding", h.Category)
727+
assert.True(t, *h.IsWrite)
728+
assert.Equal(t, int64(110200), h.AIInputTokens)
729+
assert.Equal(t, int64(223), h.AIOutputTokens)
730+
assert.Zero(t, h.AIPromptLength)
731+
assert.Equal(t, 23, *h.AILineChanges)
732+
assert.Equal(t, "/Users/user/git/wakatime-cli", h.ProjectPathOverride)
733+
assert.Equal(t, "019d9353-333c-7c41-a909-26f5c6221a5a", h.AISession)
734+
}
735+
736+
func TestWithAISync_PreservesCopilotTokensAfterMergingAppHeartbeat(t *testing.T) {
737+
home := t.TempDir()
738+
t.Setenv("HOME", home)
739+
t.Setenv("USERPROFILE", home)
740+
741+
workspaceDir := filepath.Join(home, "Library", "Application Support", "Code", "User", "workspaceStorage", "workspace-1")
742+
require.NoError(t, os.MkdirAll(filepath.Join(workspaceDir, "chatSessions"), 0o755))
743+
require.NoError(t, os.MkdirAll(filepath.Join(workspaceDir, "chatEditingSessions", "session-1"), 0o755))
744+
745+
mainFile := filepath.Join(home, "project", "main.go")
746+
secondFile := filepath.Join(home, "project", "util.go")
747+
748+
require.NoError(t, os.MkdirAll(filepath.Dir(mainFile), 0o755))
749+
require.NoError(t, os.WriteFile(mainFile, []byte("package main\n"), 0o644))
750+
require.NoError(t, os.WriteFile(secondFile, []byte("package main\nfunc util() {}\n"), 0o644))
751+
752+
sessionPath := filepath.Join(workspaceDir, "chatSessions", "session-1.json")
753+
session := map[string]any{
754+
"version": 3,
755+
"creationDate": int64(1770000000000),
756+
"lastMessageDate": int64(1770000100000),
757+
"sessionId": "session-1",
758+
"requests": []any{
759+
map[string]any{
760+
"requestId": "request-1",
761+
"timestamp": int64(1770000001000),
762+
"agent": map[string]any{
763+
"extensionVersion": "0.42.3",
764+
},
765+
"message": map[string]any{
766+
"text": "Update the file and explain what changed",
767+
},
768+
"result": map[string]any{
769+
"metadata": map[string]any{
770+
"promptTokens": 9,
771+
"outputTokens": 4,
772+
},
773+
},
774+
"modelState": map[string]any{
775+
"value": 1,
776+
"completedAt": int64(1770000009000),
777+
},
778+
"variableData": map[string]any{
779+
"variables": []any{
780+
map[string]any{
781+
"kind": "file",
782+
"id": "file://" + strings.ReplaceAll(mainFile, " ", "%20"),
783+
"value": map[string]any{
784+
"fsPath": mainFile,
785+
},
786+
},
787+
},
788+
},
789+
"response": []any{
790+
map[string]any{
791+
"kind": "toolInvocationSerialized",
792+
"invocationMessage": map[string]any{
793+
"value": "Reading files",
794+
"uris": map[string]any{
795+
"file://" + strings.ReplaceAll(secondFile, " ", "%20"): map[string]any{
796+
"fsPath": secondFile,
797+
},
798+
},
799+
},
800+
},
801+
},
802+
},
803+
},
804+
}
805+
data, err := json.Marshal(session)
806+
require.NoError(t, err)
807+
require.NoError(t, os.WriteFile(sessionPath, data, 0o644))
808+
809+
statePath := filepath.Join(workspaceDir, "chatEditingSessions", "session-1", "state.json")
810+
state := map[string]any{
811+
"version": 2,
812+
"timeline": map[string]any{
813+
"fileBaselines": []any{
814+
[]any{
815+
"file://" + mainFile + "::request-1",
816+
map[string]any{
817+
"content": "package main\n",
818+
},
819+
},
820+
},
821+
"operations": []any{
822+
map[string]any{
823+
"type": "textEdit",
824+
"requestId": "request-1",
825+
"uri": map[string]any{
826+
"fsPath": mainFile,
827+
},
828+
"epoch": 1,
829+
"edits": []any{
830+
map[string]any{
831+
"text": "package main\n\nfunc main() {}\n",
832+
"range": map[string]any{
833+
"startLineNumber": 1,
834+
"startColumn": 1,
835+
"endLineNumber": 2,
836+
"endColumn": 1,
837+
},
838+
},
839+
},
840+
},
841+
},
842+
},
843+
}
844+
data, err = json.Marshal(state)
845+
require.NoError(t, err)
846+
require.NoError(t, os.WriteFile(statePath, data, 0o644))
847+
848+
after := time.Unix(1769999990, 0)
849+
tmpInternal, err := os.CreateTemp(t.TempDir(), "wakatime-internal")
850+
require.NoError(t, err)
851+
852+
defer tmpInternal.Close()
853+
854+
v := viper.New()
855+
v.Set("internal-config", tmpInternal.Name())
856+
v.Set("internal.ai_heartbeats_last_parsed_at", after.Format(ini.DateFormat))
857+
858+
handle := ai.WithAISync(ai.Config{
859+
Plugin: "editor/1.0.0",
860+
V: v,
861+
})(func(_ context.Context, hh []heartbeat.Heartbeat) ([]heartbeat.Result, error) {
862+
results := make([]heartbeat.Result, len(hh))
863+
for i := range hh {
864+
results[i] = heartbeat.Result{Heartbeat: hh[i]}
865+
}
866+
867+
return results, nil
868+
})
869+
870+
results, err := handle(t.Context(), []heartbeat.Heartbeat{})
871+
require.NoError(t, err)
872+
require.Len(t, results, 4)
873+
874+
var (
875+
mergedRead heartbeat.Heartbeat
876+
writeEdit heartbeat.Heartbeat
877+
foundRead bool
878+
foundEdit bool
879+
)
880+
881+
for _, result := range results {
882+
h := result.Heartbeat
883+
if h.Entity == mainFile && h.IsWrite != nil && !*h.IsWrite && h.AIPromptLength > 0 {
884+
mergedRead = h
885+
foundRead = true
886+
}
887+
888+
if h.Entity == mainFile && h.IsWrite != nil && *h.IsWrite {
889+
writeEdit = h
890+
foundEdit = true
891+
}
892+
}
893+
894+
require.True(t, foundRead)
895+
assert.Equal(t, heartbeat.FileType, mergedRead.EntityType)
896+
assert.Equal(t, int64(9), mergedRead.AIInputTokens)
897+
assert.Equal(t, int64(4), mergedRead.AIOutputTokens)
898+
assert.Equal(t, len([]rune("Update the file and explain what changed")), mergedRead.AIPromptLength)
899+
assert.Equal(t, filepath.Dir(mainFile), mergedRead.ProjectPathOverride)
900+
assert.Equal(t, "session-1", mergedRead.AISession)
901+
902+
require.True(t, foundEdit)
903+
require.NotNil(t, writeEdit.AILineChanges)
904+
assert.Equal(t, 2, *writeEdit.AILineChanges)
905+
assert.Zero(t, writeEdit.AIInputTokens)
906+
assert.Zero(t, writeEdit.AIOutputTokens)
907+
assert.Zero(t, writeEdit.AIPromptLength)
908+
assert.Equal(t, filepath.Dir(mainFile), writeEdit.ProjectPathOverride)
909+
assert.Equal(t, "session-1", writeEdit.AISession)
910+
}
911+
655912
func resetSingleton(t *testing.T) {
656913
t.Helper()
657914

0 commit comments

Comments
 (0)