@@ -652,6 +652,87 @@ 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+
655736func resetSingleton (t * testing.T ) {
656737 t .Helper ()
657738
0 commit comments