@@ -3621,6 +3621,132 @@ func TestHistoryAggregatesConsecutiveFileChanges(t *testing.T) {
36213621 }
36223622}
36233623
3624+ func TestHistoryUsageDoesNotResetLiveFileChangeGroup (t * testing.T ) {
3625+ cleanup := initTestDB (t )
3626+ defer cleanup ()
3627+
3628+ project := seedProject (t )
3629+ session := seedWebSession (t , project .ID , "Usage Between File Changes" , 1000 )
3630+ manager , err := NewManager (Config {DataDir : t .TempDir ()}, zap .NewNop ())
3631+ if err != nil {
3632+ t .Fatalf ("NewManager returned error: %v" , err )
3633+ }
3634+
3635+ appendHistoryEvent (t , manager , session .ID , testFileChangeEvent ("fc1" , 1 , "tool_st" , "ui/src/App.vue" , "" ))
3636+ appendHistoryEvent (t , manager , session .ID , testFileChangeEvent ("fc1" , 2 , "tool_end" , "ui/src/App.vue" , "" ))
3637+ appendHistoryEvent (t , manager , session .ID , Event {
3638+ ID : "evt_usage" ,
3639+ Seq : 3 ,
3640+ Type : "usage" ,
3641+ Timestamp : time .UnixMilli (2_500 ),
3642+ Payload : map [string ]any {
3643+ "in" : 19585 ,
3644+ "cin" : 5504 ,
3645+ "out" : 648 ,
3646+ },
3647+ })
3648+ appendHistoryEvent (t , manager , session .ID , testFileChangeEvent ("fc2" , 4 , "tool_st" , "ui/src/components/Panel.vue" , "" ))
3649+ appendHistoryEvent (t , manager , session .ID , testFileChangeEvent ("fc2" , 5 , "tool_end" , "ui/src/components/Panel.vue" , "" ))
3650+
3651+ events , err := manager .store .readEvents (session .ID )
3652+ if err != nil {
3653+ t .Fatalf ("readEvents returned error: %v" , err )
3654+ }
3655+ fc2Start , ok := historyEventByID (events , "evt_fc2_tool_st" )
3656+ if ! ok {
3657+ t .Fatalf ("expected fc2 start event in raw history" )
3658+ }
3659+ if got := eventExplicitCommandGroupID (fc2Start ); got != commandExecutionGroupID ("fc1" ) {
3660+ t .Fatalf ("expected usage to preserve live group id %q, got %q" , commandExecutionGroupID ("fc1" ), got )
3661+ }
3662+
3663+ history , err := manager .History (context .Background (), session .ID , 20 , nil )
3664+ if err != nil {
3665+ t .Fatalf ("History returned error: %v" , err )
3666+ }
3667+ if len (history .Events ) != 2 {
3668+ t .Fatalf ("expected usage plus grouped file_change event, got %d" , len (history .Events ))
3669+ }
3670+ if history .Events [0 ].Type != "usage" {
3671+ t .Fatalf ("expected first projected event usage, got %#v" , history .Events [0 ])
3672+ }
3673+ if got := eventToolKind (history .Events [1 ]); got != "file_change" {
3674+ t .Fatalf ("expected grouped file_change event, got %q" , got )
3675+ }
3676+ groupMeta := decodeRawObject (decodeRawObject (history .Events [1 ].Payload ["meta" ])["commandGroup" ])
3677+ if got := int (numberValue (groupMeta ["count" ])); got != 2 {
3678+ t .Fatalf ("expected grouped count 2, got %d" , got )
3679+ }
3680+
3681+ snapshot , err := manager .Snapshot (context .Background (), session .ID , 20 )
3682+ if err != nil {
3683+ t .Fatalf ("Snapshot returned error: %v" , err )
3684+ }
3685+ if len (snapshot .History .Items ) != 1 {
3686+ t .Fatalf ("expected 1 grouped history item, got %d" , len (snapshot .History .Items ))
3687+ }
3688+ item := snapshot .History .Items [0 ]
3689+ if item .Tool == nil || item .Tool .CommandGroup == nil {
3690+ t .Fatalf ("expected grouped file_change history item, got %#v" , item )
3691+ }
3692+ if item .Tool .CommandGroup .ID != commandExecutionGroupID ("fc1" ) || item .Tool .CommandGroup .Count != 2 {
3693+ t .Fatalf ("expected file_change group %q count 2, got %#v" , commandExecutionGroupID ("fc1" ), item .Tool .CommandGroup )
3694+ }
3695+ if got := len (decodeHistoryGroupItems (item .Payload )); got != 2 {
3696+ t .Fatalf ("expected 2 file_change detail items, got %d" , got )
3697+ }
3698+ }
3699+
3700+ func TestProjectHistoryEventsTreatsUsageAsTransparentBetweenExplicitFileChangeGroups (t * testing.T ) {
3701+ events := []Event {
3702+ testFileChangeEvent ("fc1" , 1 , "tool_st" , "ui/src/App.vue" , commandExecutionGroupID ("fc1" )),
3703+ testFileChangeEvent ("fc1" , 2 , "tool_end" , "ui/src/App.vue" , commandExecutionGroupID ("fc1" )),
3704+ {
3705+ ID : "evt_usage" ,
3706+ Seq : 3 ,
3707+ Type : "usage" ,
3708+ Timestamp : time .UnixMilli (2_500 ),
3709+ Payload : map [string ]any {
3710+ "in" : 19585 ,
3711+ "cin" : 5504 ,
3712+ "out" : 648 ,
3713+ },
3714+ },
3715+ testFileChangeEvent ("fc2" , 4 , "tool_st" , "ui/src/components/Panel.vue" , commandExecutionGroupID ("fc2" )),
3716+ testFileChangeEvent ("fc2" , 5 , "tool_end" , "ui/src/components/Panel.vue" , commandExecutionGroupID ("fc2" )),
3717+ }
3718+
3719+ projected := projectHistoryEvents (events , AgentCodex )
3720+ if len (projected ) != 2 {
3721+ t .Fatalf ("expected usage plus one grouped file_change event, got %d" , len (projected ))
3722+ }
3723+ if projected [0 ].Type != "usage" {
3724+ t .Fatalf ("expected usage event to be preserved, got %#v" , projected [0 ])
3725+ }
3726+ if got := eventCommandGroupID (projected [1 ]); got != commandExecutionGroupID ("fc1" ) {
3727+ t .Fatalf ("expected projected group id %q, got %q" , commandExecutionGroupID ("fc1" ), got )
3728+ }
3729+ groupMeta := decodeRawObject (decodeRawObject (projected [1 ].Payload ["meta" ])["commandGroup" ])
3730+ if got := int (numberValue (groupMeta ["count" ])); got != 2 {
3731+ t .Fatalf ("expected grouped count 2, got %d" , got )
3732+ }
3733+
3734+ groups := buildCommandExecutionGroupLookup (events , AgentCodex )
3735+ if len (groups ) != 1 {
3736+ t .Fatalf ("expected 1 command group detail, got %d" , len (groups ))
3737+ }
3738+ group , ok := groups [commandExecutionGroupID ("fc1" )]
3739+ if ! ok {
3740+ t .Fatalf ("expected group %q in lookup" , commandExecutionGroupID ("fc1" ))
3741+ }
3742+ if group .Count != 2 || len (group .Items ) != 2 {
3743+ t .Fatalf ("expected 2 file_change detail items, got count=%d items=%d" , group .Count , len (group .Items ))
3744+ }
3745+ if _ , ok := groups [commandExecutionGroupID ("fc2" )]; ok {
3746+ t .Fatalf ("expected usage-separated stale group id %q to be folded into %q" , commandExecutionGroupID ("fc2" ), commandExecutionGroupID ("fc1" ))
3747+ }
3748+ }
3749+
36243750func TestFileChangeSnapshotKeepsCurrentFileWhenToolEndOmitsInput (t * testing.T ) {
36253751 cleanup := initTestDB (t )
36263752 defer cleanup ()
@@ -5133,6 +5259,58 @@ func historyToolEventByKind(events []Event, kind string) (Event, bool) {
51335259 return Event {}, false
51345260}
51355261
5262+ func historyEventByID (events []Event , id string ) (Event , bool ) {
5263+ for _ , event := range events {
5264+ if event .ID == id {
5265+ return event , true
5266+ }
5267+ }
5268+ return Event {}, false
5269+ }
5270+
5271+ func testFileChangeEvent (toolID string , seq int64 , eventType string , path string , groupID string ) Event {
5272+ meta := map [string ]any {
5273+ "kind" : "file_change" ,
5274+ "title" : "FileChange" ,
5275+ "subtitle" : path ,
5276+ }
5277+ if groupID != "" {
5278+ meta ["commandGroup" ] = map [string ]any {
5279+ "id" : groupID ,
5280+ "count" : 1 ,
5281+ "firstSeq" : seq ,
5282+ "lastSeq" : seq ,
5283+ "latestToolId" : toolID ,
5284+ "compacted" : true ,
5285+ }
5286+ }
5287+ payload := map [string ]any {
5288+ "tid" : toolID ,
5289+ "name" : "FileChange" ,
5290+ "kind" : "file_change" ,
5291+ "meta" : meta ,
5292+ }
5293+ if eventType == "tool_st" {
5294+ payload ["in" ] = map [string ]any {
5295+ "path" : path ,
5296+ "changes" : []any {
5297+ map [string ]any {"path" : path },
5298+ },
5299+ }
5300+ }
5301+ if eventType == "tool_end" {
5302+ payload ["out" ] = "patched"
5303+ payload ["ok" ] = true
5304+ }
5305+ return Event {
5306+ ID : fmt .Sprintf ("evt_%s_%s" , toolID , eventType ),
5307+ Seq : seq ,
5308+ Type : eventType ,
5309+ Timestamp : time .UnixMilli (seq * 1_000 ),
5310+ Payload : payload ,
5311+ }
5312+ }
5313+
51365314func testLongPlanText () string {
51375315 return "## Plan\n " + strings .Repeat ("- 计划步骤:保持中文内容完整,不要被截断。\n " , 240 )
51385316}
0 commit comments