Skip to content

Commit 1402f4c

Browse files
committed
fix(websession): keep usage events from splitting tool groups
1 parent 9f8410d commit 1402f4c

5 files changed

Lines changed: 413 additions & 26 deletions

File tree

service/websession/history_projection.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,15 @@ func projectHistoryEvents(events []Event, agent Agent) []Event {
167167
projected := make([]Event, 0, len(events))
168168
activeTools := make(map[string]toolSnapshot)
169169
var currentGroup *commandExecutionProjectionGroup
170+
var transparentSinceCompact bool
170171

171172
flushGroup := func() {
172173
if currentGroup == nil {
173174
return
174175
}
175176
projected = append(projected, currentGroup.toEvent())
176177
currentGroup = nil
178+
transparentSinceCompact = false
177179
}
178180

179181
for _, event := range events {
@@ -185,7 +187,15 @@ func projectHistoryEvents(events []Event, agent Agent) []Event {
185187
if isCompactToolEvent(event) {
186188
kind := compactToolKind(event)
187189
groupID := eventExplicitCommandGroupID(event)
188-
if groupID == "" {
190+
toolAlreadyInGroup := false
191+
if currentGroup != nil {
192+
_, toolAlreadyInGroup = currentGroup.toolIDs[toolID]
193+
}
194+
if currentGroup != nil && currentGroup.kind == kind && toolAlreadyInGroup {
195+
groupID = currentGroup.groupID
196+
} else if currentGroup != nil && currentGroup.kind == kind && transparentSinceCompact {
197+
groupID = currentGroup.groupID
198+
} else if groupID == "" {
189199
if currentGroup != nil && currentGroup.kind == kind {
190200
groupID = currentGroup.groupID
191201
} else {
@@ -206,6 +216,7 @@ func projectHistoryEvents(events []Event, agent Agent) []Event {
206216
if event.Type == "tool_end" && toolID != "" {
207217
delete(activeTools, toolID)
208218
}
219+
transparentSinceCompact = false
209220
continue
210221
}
211222

@@ -222,6 +233,14 @@ func projectHistoryEvents(events []Event, agent Agent) []Event {
222233
continue
223234
}
224235

236+
if isCommandGroupTransparentEvent(event) {
237+
projected = append(projected, event)
238+
if currentGroup != nil {
239+
transparentSinceCompact = true
240+
}
241+
continue
242+
}
243+
225244
flushGroup()
226245
projected = append(projected, event)
227246
if event.Type == "tool_end" && toolID != "" {
@@ -237,6 +256,7 @@ func buildCommandExecutionGroupLookup(events []Event, agent Agent) map[string]Co
237256
groups := make(map[string]*commandExecutionDetailAccumulator)
238257
activeTools := make(map[string]toolSnapshot)
239258
var currentGroup *commandExecutionDetailAccumulator
259+
var transparentSinceCompact bool
240260

241261
for _, event := range events {
242262
toolID := eventToolID(event)
@@ -247,7 +267,15 @@ func buildCommandExecutionGroupLookup(events []Event, agent Agent) map[string]Co
247267
if isCompactToolEvent(event) {
248268
kind := compactToolKind(event)
249269
groupID := eventExplicitCommandGroupID(event)
250-
if groupID == "" {
270+
toolAlreadyInGroup := false
271+
if currentGroup != nil {
272+
_, toolAlreadyInGroup = currentGroup.itemIndex[toolID]
273+
}
274+
if currentGroup != nil && currentGroup.kind == kind && toolAlreadyInGroup {
275+
groupID = currentGroup.groupID
276+
} else if currentGroup != nil && currentGroup.kind == kind && transparentSinceCompact {
277+
groupID = currentGroup.groupID
278+
} else if groupID == "" {
251279
if currentGroup != nil && currentGroup.kind == kind {
252280
groupID = currentGroup.groupID
253281
} else {
@@ -256,6 +284,7 @@ func buildCommandExecutionGroupLookup(events []Event, agent Agent) map[string]Co
256284
}
257285
if currentGroup != nil && (currentGroup.groupID != groupID || currentGroup.kind != kind) {
258286
currentGroup = nil
287+
transparentSinceCompact = false
259288
}
260289
if currentGroup == nil {
261290
currentGroup = &commandExecutionDetailAccumulator{
@@ -269,6 +298,7 @@ func buildCommandExecutionGroupLookup(events []Event, agent Agent) map[string]Co
269298
if event.Type == "tool_end" && toolID != "" {
270299
delete(activeTools, toolID)
271300
}
301+
transparentSinceCompact = false
272302
continue
273303
}
274304

@@ -282,7 +312,15 @@ func buildCommandExecutionGroupLookup(events []Event, agent Agent) map[string]Co
282312
continue
283313
}
284314

315+
if isCommandGroupTransparentEvent(event) {
316+
if currentGroup != nil {
317+
transparentSinceCompact = true
318+
}
319+
continue
320+
}
321+
285322
currentGroup = nil
323+
transparentSinceCompact = false
286324
if event.Type == "tool_end" && toolID != "" {
287325
delete(activeTools, toolID)
288326
}
@@ -519,6 +557,15 @@ func reasoningEventHasDisplayContent(event Event) bool {
519557
return strings.TrimSpace(eventToolOutput(event)) != ""
520558
}
521559

560+
func isCommandGroupTransparentEvent(event Event) bool {
561+
switch strings.TrimSpace(event.Type) {
562+
case "usage":
563+
return true
564+
default:
565+
return false
566+
}
567+
}
568+
522569
func compactToolTitle(kind string) string {
523570
switch strings.TrimSpace(kind) {
524571
case "command_execution":

service/websession/manager.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3569,6 +3569,9 @@ func (m *Manager) decorateProjectedEvent(sessionID string, event *Event) {
35693569
}
35703570
return
35713571
}
3572+
if isCommandGroupTransparentEvent(*event) {
3573+
return
3574+
}
35723575
m.resetCommandExecutionGroup(sessionID)
35733576
}
35743577

service/websession/manager_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
36243750
func 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+
51365314
func testLongPlanText() string {
51375315
return "## Plan\n" + strings.Repeat("- 计划步骤:保持中文内容完整,不要被截断。\n", 240)
51385316
}

0 commit comments

Comments
 (0)