Skip to content
Merged
11 changes: 11 additions & 0 deletions cmd/agentsview/session_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ func newSessionExportCommand() *cobra.Command {
}
return err
}
if dbPath, sessionID, ok := parser.SplitWindsurfVirtualPath(storedPath); ok {
err := parser.WriteWindsurfSessionJSON(
cmd.OutOrStdout(), dbPath, sessionID,
)
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf(
"source file not found: %s", dbPath,
)
}
return err
}
path := parser.ResolveSourceFilePath(storedPath)
f, err := os.Open(path)
if err != nil {
Expand Down
19 changes: 11 additions & 8 deletions cmd/benchgate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@
//
// Lines that look like benchmark results but fail to parse (for
// example test log output interleaved into a result line) are a
// corrupted capture: they are reported and the gate exits 2, because
// the corrupted benchmark would otherwise silently vanish from both
// sides and never gate again.
// corrupted capture. Candidate corruption exits 2, because it is
// under this workflow's control and would otherwise silently disable
// a gate. Baseline corruption is reported but treated as a partial
// baseline, because the merge base may legitimately predate fixes to
// the benchmark capture itself.
package main

import (
Expand Down Expand Up @@ -369,10 +371,11 @@ type results struct {
oldSyntax, newSyntax []string
}

// render formats the human-readable outcome and picks the exit
// code: 2 for unusable input or configuration errors, 1 for
// regressions, 0 otherwise. Violations always print, even when a
// config issue or corrupted capture also occurred, so a detected
// render formats the human-readable outcome and picks the exit code:
// 2 for unusable candidate input or configuration errors, 1 for
// regressions, 0 otherwise. Baseline syntax errors are reported as a
// partial baseline. Violations always print, even when a config issue
// or corrupted candidate capture also occurred, so a detected
// regression is never hidden behind an exit-2.
func render(r results) (string, int) {
var b strings.Builder
Expand All @@ -395,7 +398,7 @@ func render(r results) (string, int) {
}
}
switch {
case len(r.oldSyntax)+len(r.newSyntax) > 0 || len(r.issues) > 0:
case len(r.newSyntax) > 0 || len(r.issues) > 0:
return b.String(), 2
case r.newCount == 0:
fmt.Fprintln(&b, "benchgate: candidate output contains no benchmarks")
Expand Down
28 changes: 27 additions & 1 deletion cmd/benchgate/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ func TestRender(t *testing.T) {
},
},
{
name: "corrupted capture exits 2 and is described",
name: "candidate corrupted capture exits 2 and is described",
r: results{
newCount: 1,
newSyntax: []string{"test:3: no iteration count"},
Expand All @@ -429,6 +429,32 @@ func TestRender(t *testing.T) {
"no iteration count",
},
},
{
name: "baseline corrupted capture is reported but does not fail",
r: results{
newCount: 1,
oldSyntax: []string{"test:3: no iteration count"},
},
wantCode: 0,
wantOut: []string{
"baseline capture is corrupted",
"no iteration count",
"no regressions beyond thresholds",
},
},
{
name: "baseline corruption still allows regressions to fail",
r: results{
violations: []violation{sampleViolation},
newCount: 1,
oldSyntax: []string{"test:3: no iteration count"},
},
wantCode: 1,
wantOut: []string{
"baseline capture is corrupted",
"1 regression(s)",
},
},
{
name: "empty candidate exits 2",
r: results{newCount: 0},
Expand Down
13 changes: 13 additions & 0 deletions internal/db/messages_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package db
import (
"encoding/json"
"fmt"
"io"
"log"
"testing"
)

Expand Down Expand Up @@ -85,6 +87,7 @@ func seedBenchSession(
// single in-place UPDATE; cost must not scale with the number of
// unchanged stored rows being rewritten.
func BenchmarkReplaceSessionMessagesStreamingMerge(b *testing.B) {
silenceBenchmarkLogs(b)
const stored = 1000
d := testDB(b)
msgs := seedBenchSession(b, d, "bench-replace", stored)
Expand Down Expand Up @@ -115,6 +118,7 @@ func BenchmarkReplaceSessionMessagesStreamingMerge(b *testing.B) {
// -benchtime=Nx (see bench.yml and the Makefile) so baseline and
// candidate insert into identically sized databases.
func BenchmarkInsertMessagesBatch(b *testing.B) {
silenceBenchmarkLogs(b)
const batch = 200
d := testDB(b)

Expand Down Expand Up @@ -144,3 +148,12 @@ func BenchmarkInsertMessagesBatch(b *testing.B) {
}
}
}

func silenceBenchmarkLogs(b *testing.B) {
b.Helper()
origLog := log.Writer()
log.SetOutput(io.Discard)
b.Cleanup(func() {
log.SetOutput(origLog)
})
}
6 changes: 6 additions & 0 deletions internal/db/usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package db
import (
"context"
"encoding/json"
"io"
"log"
"math"
"os"
"path/filepath"
Expand Down Expand Up @@ -3005,6 +3007,9 @@ func TestExcludeModelFilter(t *testing.T) {
func BenchmarkGetDailyUsage(b *testing.B) {
d := testDB(b)
ctx := context.Background()
origLog := log.Writer()
log.SetOutput(io.Discard)
defer log.SetOutput(origLog)

if err := d.UpsertModelPricing([]ModelPricing{
{ModelPattern: "claude-sonnet-4-20250514",
Expand Down Expand Up @@ -3083,6 +3088,7 @@ func BenchmarkGetDailyUsage(b *testing.B) {
}
}

log.SetOutput(origLog)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
Expand Down
2 changes: 2 additions & 0 deletions internal/parser/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory {
return newVisualStudioCopilotProviderFactory(def)
case AgentVSCodeCopilot:
return newVSCodeCopilotProviderFactory(def)
case AgentWindsurf:
return newWindsurfProviderFactory(def)
case AgentVibe:
return newVibeProviderFactory(def)
case AgentZCode:
Expand Down
1 change: 1 addition & 0 deletions internal/parser/provider_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{
AgentAmp: ProviderMigrationProviderAuthoritative,
AgentZencoder: ProviderMigrationProviderAuthoritative,
AgentVSCodeCopilot: ProviderMigrationProviderAuthoritative,
AgentWindsurf: ProviderMigrationProviderAuthoritative,
AgentVSCopilot: ProviderMigrationProviderAuthoritative,
AgentPi: ProviderMigrationProviderAuthoritative,
AgentQwen: ProviderMigrationProviderAuthoritative,
Expand Down
7 changes: 4 additions & 3 deletions internal/parser/qoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
)
Expand Down Expand Up @@ -165,9 +166,9 @@ func DecodeQoderProjectDir(encoded string) string {
}
}
}
for i := len(parts) - 1; i >= 0; i-- {
if parts[i] != "" {
return NormalizeName(parts[i])
for _, v := range slices.Backward(parts) {
if v != "" {
return NormalizeName(v)
}
}
return NormalizeName(encoded)
Expand Down
27 changes: 27 additions & 0 deletions internal/parser/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
AgentAmp AgentType = "amp"
AgentZencoder AgentType = "zencoder"
AgentVSCodeCopilot AgentType = "vscode-copilot"
AgentWindsurf AgentType = "windsurf"
AgentVSCopilot AgentType = "visualstudio-copilot"
AgentPi AgentType = "pi"
AgentOMP AgentType = "omp"
Expand Down Expand Up @@ -286,6 +287,32 @@ var Registry = []AgentDef{
AICreditsDenominated: true,
},
},
{
Type: AgentWindsurf,
DisplayName: "Windsurf",
EnvVar: "WINDSURF_DIR",
ConfigKey: "windsurf_dirs",
DefaultDirs: []string{
// Windows
"AppData/Roaming/Windsurf/User",
"AppData/Roaming/Windsurf - Next/User",
// macOS
"Library/Application Support/Windsurf/User",
"Library/Application Support/Windsurf - Next/User",
// Linux
".config/Windsurf/User",
".config/Windsurf - Next/User",
},
IDPrefix: "windsurf:",
WatchSubdirs: []string{
"workspaceStorage",
},
FileBased: true,
Usage: UsageCapabilities{
NoPerMessageTokenData: true,
AICreditsDenominated: true,
},
},
{
Type: AgentVSCopilot,
DisplayName: "Visual Studio Copilot",
Expand Down
30 changes: 30 additions & 0 deletions internal/parser/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ func TestRegistryCompleteness(t *testing.T) {
AgentCursor,
AgentAmp,
AgentVSCodeCopilot,
AgentWindsurf,
AgentVSCopilot,
AgentPi,
AgentOMP,
Expand Down Expand Up @@ -1154,6 +1155,35 @@ func TestVSCodeCopilotDefaultDirs(t *testing.T) {
}
}

func TestWindsurfRegistryEntry(t *testing.T) {
def, ok := AgentByType(AgentWindsurf)
require.True(t, ok, "AgentWindsurf not in Registry")

assert.Equal(t, "Windsurf", def.DisplayName)
assert.Equal(t, "WINDSURF_DIR", def.EnvVar)
assert.Equal(t, "windsurf_dirs", def.ConfigKey)
assert.Equal(t, "windsurf:", def.IDPrefix)
assert.True(t, def.FileBased)
assert.Contains(t, def.WatchSubdirs, "workspaceStorage")

required := []string{
"AppData/Roaming/Windsurf/User",
"AppData/Roaming/Windsurf - Next/User",
"Library/Application Support/Windsurf/User",
"Library/Application Support/Windsurf - Next/User",
".config/Windsurf/User",
".config/Windsurf - Next/User",
}
for _, path := range required {
assert.Truef(t, slices.Contains(def.DefaultDirs, path),
"missing default dir: %s", path)
}

byPrefix, ok := AgentByPrefix("windsurf:session-a")
require.True(t, ok)
assert.Equal(t, AgentWindsurf, byPrefix.Type)
}

func TestApplyUsageEventTokenTotals(t *testing.T) {
// Verify that applyUsageEventTokenTotals computes PeakContextTokens
// correctly including cache-creation and cache-read tokens.
Expand Down
9 changes: 7 additions & 2 deletions internal/parser/visualstudio_copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,20 @@ func isVisualStudioCopilotVS2026Hex(c rune) bool {
// be opened on disk. Visual Studio Copilot stores a
// <traceFile>#<conversationID> virtual path whose conversations share one
// physical trace file, and aider stores a <historyFile>#<runIdx> virtual
// path whose runs share one physical history file; both resolve to the
// physical file. Every other agent stores a real path, returned unchanged.
// path whose runs share one physical history file, and Windsurf stores a
// <state.vscdb>#<sessionID> virtual path whose chats share one SQLite DB.
// These resolve to the physical source file. Every other agent stores a real
// path, returned unchanged.
func ResolveSourceFilePath(storedPath string) string {
if tracePath, _, ok := splitVisualStudioCopilotVirtualPath(storedPath); ok {
return tracePath
}
if historyPath, _, ok := ParseAiderVirtualPath(storedPath); ok {
return historyPath
}
if dbPath, _, ok := SplitWindsurfVirtualPath(storedPath); ok {
return dbPath
}
return storedPath
}

Expand Down
8 changes: 8 additions & 0 deletions internal/parser/visualstudio_copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,14 @@ func TestResolveSourceFilePath(t *testing.T) {
VisualStudioCopilotVirtualPath(sessionPath, conversationID),
),
"VS 2026 session virtual path should resolve to its physical session file")
assert.Equal(t, "/profile/User/workspaceStorage/hash/state.vscdb",
ResolveSourceFilePath(
"/profile/User/workspaceStorage/hash/state.vscdb#windsurf-session",
),
"Windsurf virtual path should resolve to its physical workspace DB")
assert.Equal(t, "/logs/session#draft.jsonl",
ResolveSourceFilePath("/logs/session#draft.jsonl"),
"non-Windsurf paths containing # should be returned unchanged")
assert.Equal(t, "/logs/session.jsonl",
ResolveSourceFilePath("/logs/session.jsonl"),
"a plain source path should be returned unchanged")
Expand Down
Loading