diff --git a/cmd/agentsview/session_export.go b/cmd/agentsview/session_export.go index 73d361dfb..1a762dee3 100644 --- a/cmd/agentsview/session_export.go +++ b/cmd/agentsview/session_export.go @@ -114,7 +114,7 @@ func newSessionExportCommand() *cobra.Command { // conversations, so streaming the whole file would disclose // unrelated conversations. Filter to the requested conversation. if tracePath, conversationID, ok := - parser.ParseVisualStudioCopilotVirtualPath(storedPath); ok { + parser.SplitVisualStudioCopilotVirtualPath(storedPath); ok { err := parser.WriteVisualStudioCopilotConversationJSONL( cmd.OutOrStdout(), tracePath, conversationID, ) diff --git a/internal/parser/copilot_ide_provider_test.go b/internal/parser/copilot_ide_provider_test.go new file mode 100644 index 000000000..1d7aa9ce7 --- /dev/null +++ b/internal/parser/copilot_ide_provider_test.go @@ -0,0 +1,416 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCopilotIDEProvidersOwnLegacyEntrypoints guards the fold: the +// provider-specific Discover/Find/Parse free functions (and the Visual Studio +// virtual-path splitter) must stay deleted, and neither the provider files nor +// their legacy source files may reach back into them as a shim. Discovery and +// lookup live on the provider source sets; parse lives on the provider methods; +// the Visual Studio virtual-path resolution is reproduced via the +// provider-neutral ParseVirtualSourcePath helper. +func TestCopilotIDEProvidersOwnLegacyEntrypoints(t *testing.T) { + files := map[string]string{} + for _, name := range []string{ + "discovery.go", + "vscode_copilot.go", + "vscode_copilot_provider.go", + "visualstudio_copilot.go", + "visualstudio_copilot_provider.go", + } { + data, err := os.ReadFile(name) + require.NoError(t, err) + files[name] = string(data) + } + + deletedSymbols := []string{ + "func DiscoverVSCodeCopilotSessions", + "func FindVSCodeCopilotSourceFile", + "func ParseVSCodeCopilotSession", + "func DiscoverVisualStudioCopilotSessions", + "func FindVisualStudioCopilotSourceFile", + "func ParseVisualStudioCopilotConversation", + "func ParseVisualStudioCopilotSession", + "func ParseVisualStudioCopilotVirtualPath", + } + for name, src := range files { + for _, symbol := range deletedSymbols { + assert.NotContainsf(t, src, symbol, "%s still defines %s", name, symbol) + } + } + + deletedCalls := []string{ + "DiscoverVSCodeCopilotSessions(", + "FindVSCodeCopilotSourceFile(", + "ParseVSCodeCopilotSession(", + "DiscoverVisualStudioCopilotSessions(", + "FindVisualStudioCopilotSourceFile(", + "ParseVisualStudioCopilotConversation(", + "ParseVisualStudioCopilotSession(", + "ParseVisualStudioCopilotVirtualPath(", + } + for _, providerFile := range []string{ + "vscode_copilot_provider.go", + "visualstudio_copilot_provider.go", + } { + for _, call := range deletedCalls { + assert.NotContainsf( + t, files[providerFile], call, + "%s references removed legacy entrypoint %s", providerFile, call, + ) + } + } +} + +func TestCopilotIDEProviderFactoriesReplaceLegacyAdapter(t *testing.T) { + for _, agent := range []AgentType{AgentVSCodeCopilot, AgentVSCopilot} { + t.Run(string(agent), func(t *testing.T) { + factory, ok := ProviderFactoryByType(agent) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(agent, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) + }) + } +} + +func TestVSCodeCopilotProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sessionID := "vscode-provider" + hashDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + chatDir := filepath.Join(hashDir, "chatSessions") + jsonPath := filepath.Join(chatDir, sessionID+".json") + jsonlPath := filepath.Join(chatDir, sessionID+".jsonl") + writeSourceFile(t, filepath.Join(hashDir, "workspace.json"), + `{"folder":"file:///Users/alice/code/copilot-app"}`) + writeSourceFile(t, jsonPath, `{"version":3,"sessionId":"`+sessionID+`","requests":[]}`) + writeSourceFile(t, jsonlPath, strings.Join([]string{ + `{"kind":0,"v":{"version":3,"sessionId":"` + sessionID + `","creationDate":1770650022790,"requests":[]}}`, + `{"kind":2,"k":["requests"],"v":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"Hello VS Code","parts":[]},"response":[{"value":"Hi from VS Code"}],"modelId":"copilot/claude-opus-4.8","result":{"metadata":{"promptTokens":42,"outputTokens":7,"resolvedModel":"claude-opus-4-8"}}}]}`, + }, "\n")+"\n") + + provider, ok := NewProvider(AgentVSCodeCopilot, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 2) + assert.Equal(t, filepath.Join(root, "workspaceStorage"), plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Equal(t, filepath.Join(root, "globalStorage"), plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, jsonlPath, discovered[0].DisplayPath) + assert.Equal(t, "copilot-app", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~vscode-copilot:" + sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, jsonlPath, found.DisplayPath) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: jsonlPath, EventKind: "write", WatchRoot: filepath.Join(root, "workspaceStorage")}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, jsonlPath, changed[0].DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, jsonlPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.Positive(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: found, + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + require.False(t, outcome.ForceReplace) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "vscode-copilot:"+sessionID, result.Result.Session.ID) + assert.Equal(t, AgentVSCodeCopilot, result.Result.Session.Agent) + assert.Equal(t, "copilot-app", result.Result.Session.Project) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, fingerprint.Hash, result.Result.Session.File.Hash) + assert.Equal(t, fingerprint.Size, result.Result.Session.File.Size) + assert.Equal(t, fingerprint.MTimeNS, result.Result.Session.File.Mtime) + assert.Len(t, result.Result.Messages, 2) + require.Len(t, result.Result.UsageEvents, 1) + assert.Equal(t, "vscode-copilot", result.Result.UsageEvents[0].Source) + assert.Equal(t, "claude-opus-4-8", result.Result.UsageEvents[0].Model) + assert.Equal(t, 42, result.Result.UsageEvents[0].InputTokens) + assert.Equal(t, 7, result.Result.UsageEvents[0].OutputTokens) +} + +func TestVSCodeCopilotProviderClassifiesDeletedAndMetadataPaths(t *testing.T) { + root := t.TempDir() + hashDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + chatDir := filepath.Join(hashDir, "chatSessions") + workspacePath := filepath.Join(hashDir, "workspace.json") + jsonlPath := filepath.Join(chatDir, "deleted-jsonl.jsonl") + jsonPath := filepath.Join(chatDir, "fallback-json.json") + globalPath := filepath.Join( + root, + "globalStorage", + "emptyWindowChatSessions", + "deleted-global.json", + ) + writeSourceFile(t, workspacePath, + `{"folder":"file:///Users/alice/code/copilot-app"}`) + writeSourceFile(t, jsonlPath, vscodeCopilotProviderJSONL("deleted-jsonl", "Hello deleted")) + writeSourceFile(t, jsonPath, vscodeCopilotProviderJSON("fallback-json", "Hello fallback")) + writeSourceFile(t, globalPath, vscodeCopilotProviderJSON("deleted-global", "Hello global")) + + provider, ok := NewProvider(AgentVSCodeCopilot, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + metadataChanged, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: workspacePath, EventKind: "write"}, + ) + require.NoError(t, err) + assert.ElementsMatch(t, + []string{jsonlPath, jsonPath}, + sourceDisplayPaths(metadataChanged), + ) + require.Len(t, metadataChanged, 2) + beforeMetadata, err := provider.Fingerprint(context.Background(), metadataChanged[0]) + require.NoError(t, err) + writeSourceFile(t, workspacePath, + `{"folder":"file:///Users/alice/code/copilot-renamed-app"}`) + afterMetadata, err := provider.Fingerprint(context.Background(), metadataChanged[0]) + require.NoError(t, err) + assert.NotEqual(t, beforeMetadata.Hash, afterMetadata.Hash) + + require.NoError(t, os.Remove(jsonlPath)) + deletedJSONL, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: jsonlPath, EventKind: "remove"}, + ) + require.NoError(t, err) + require.Len(t, deletedJSONL, 1) + assert.Equal(t, jsonlPath, deletedJSONL[0].DisplayPath) + + require.NoError(t, os.Remove(globalPath)) + deletedGlobal, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: globalPath, EventKind: "remove"}, + ) + require.NoError(t, err) + require.Len(t, deletedGlobal, 1) + assert.Equal(t, globalPath, deletedGlobal[0].DisplayPath) +} + +func TestVisualStudioCopilotProviderSourceMethods(t *testing.T) { + root := t.TempDir() + conversationID := "4a8f63f6-7626-4416-a874-fc7bd2c3f005" + tracePath := filepath.Join( + root, + "20260612T194439_257709a3_VSGitHubCopilot_traces.jsonl", + ) + writeSourceFile(t, tracePath, strings.Join([]string{ + vsCopilotTraceLineJSON(conversationID, + "execute_tool run_command_in_terminal", + "1781293588624985000", "1781293588769581200", + map[string]string{ + "gen_ai.tool.name": "run_command_in_terminal", + "gen_ai.tool.call.id": "call_123", + "gen_ai.tool.call.arguments": `{"command":"go test ./..."}`, + "gen_ai.tool.call.result": `{"Value":"ok"}`, + }), + vsCopilotTraceLineJSON(conversationID, + "invoke_agent GitHub Copilot", + "1781293600000000000", "1781293610000000000", + map[string]string{ + "gen_ai.agent.name": "GitHub Copilot", + "gen_ai.request.model": "gpt-5.5", + "copilot_chat.mode": "Agent", + "copilot_chat.turn_count": "1", + }), + }, "\n")+"\n") + + provider, ok := NewProvider(AgentVSCopilot, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"*_VSGitHubCopilot_traces.jsonl"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + virtualPath := VisualStudioCopilotVirtualPath(tracePath, conversationID) + assert.Equal(t, virtualPath, discovered[0].DisplayPath) + assert.Equal(t, "visualstudio", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: conversationID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, virtualPath, found.DisplayPath) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: tracePath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, tracePath, changed[0].DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, virtualPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.Positive(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + foundWithProject := found + foundWithProject.ProjectHint = "stored-solution" + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: foundWithProject, + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.True(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "visualstudio-copilot:"+conversationID, result.Result.Session.ID) + assert.Equal(t, AgentVSCopilot, result.Result.Session.Agent) + assert.Equal(t, "stored-solution", result.Result.Session.Project) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, fingerprint.Hash, result.Result.Session.File.Hash) + assert.Len(t, result.Result.Messages, 1) +} + +func TestVisualStudioCopilotProviderClassifiesDeletedTraceAndFansOutPhysicalTrace( + t *testing.T, +) { + root := t.TempDir() + firstConversationID := "4a8f63f6-7626-4416-a874-fc7bd2c3f005" + secondConversationID := "5b9f63f6-7626-4416-a874-fc7bd2c3f006" + tracePath := filepath.Join( + root, + "20260612T194439_257709a3_VSGitHubCopilot_traces.jsonl", + ) + writeSourceFile(t, tracePath, strings.Join([]string{ + vsCopilotTraceLineJSON(firstConversationID, + "execute_tool run_command_in_terminal", + "1781293588624985000", "1781293588769581200", + map[string]string{ + "gen_ai.tool.name": "run_command_in_terminal", + "gen_ai.tool.call.id": "call_123", + "gen_ai.tool.call.arguments": `{"command":"go test ./..."}`, + "gen_ai.tool.call.result": `{"Value":"ok"}`, + }), + vsCopilotTraceLineJSON(secondConversationID, + "execute_tool run_command_in_terminal", + "1781293688624985000", "1781293688769581200", + map[string]string{ + "gen_ai.tool.name": "run_command_in_terminal", + "gen_ai.tool.call.id": "call_456", + "gen_ai.tool.call.arguments": `{"command":"go vet ./..."}`, + "gen_ai.tool.call.result": `{"Value":"ok"}`, + }), + }, "\n")+"\n") + + provider, ok := NewProvider(AgentVSCopilot, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.ElementsMatch(t, []string{ + VisualStudioCopilotVirtualPath(tracePath, firstConversationID), + VisualStudioCopilotVirtualPath(tracePath, secondConversationID), + }, sourceDisplayPaths(discovered)) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: tracePath, EventKind: "write"}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, tracePath, changed[0].DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), changed[0]) + require.NoError(t, err) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: changed[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 2) + assert.ElementsMatch(t, []string{ + "visualstudio-copilot:" + firstConversationID, + "visualstudio-copilot:" + secondConversationID, + }, parseOutcomeSessionIDs(outcome)) + + require.NoError(t, os.Remove(tracePath)) + deleted, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: tracePath, EventKind: "remove"}, + ) + require.NoError(t, err) + require.Len(t, deleted, 1) + assert.Equal(t, tracePath, deleted[0].DisplayPath) +} + +func vscodeCopilotProviderJSON(sessionID, prompt string) string { + return `{"version":3,"sessionId":"` + sessionID + `","creationDate":1770650022790,"requests":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"` + prompt + `","parts":[]},"response":[{"value":"Hi from VS Code"}],"modelId":"copilot/gpt-4o"}]}` +} + +func vscodeCopilotProviderJSONL(sessionID, prompt string) string { + return strings.Join([]string{ + `{"kind":0,"v":{"version":3,"sessionId":"` + sessionID + `","creationDate":1770650022790,"requests":[]}}`, + `{"kind":2,"k":["requests"],"v":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"` + prompt + `","parts":[]},"response":[{"value":"Hi from VS Code"}],"modelId":"copilot/gpt-4o"}]}`, + }, "\n") + "\n" +} + +func parseOutcomeSessionIDs(outcome ParseOutcome) []string { + ids := make([]string, 0, len(outcome.Results)) + for _, result := range outcome.Results { + ids = append(ids, result.Result.Session.ID) + } + return ids +} diff --git a/internal/parser/copilot_ide_test_helpers_test.go b/internal/parser/copilot_ide_test_helpers_test.go new file mode 100644 index 000000000..3dad2913f --- /dev/null +++ b/internal/parser/copilot_ide_test_helpers_test.go @@ -0,0 +1,113 @@ +package parser + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// newVSCodeCopilotTestProvider builds a concrete vscodeCopilotProvider for the +// given roots so package tests can exercise the folded parse, discovery, and +// source-lookup behavior directly through provider-owned methods. +func newVSCodeCopilotTestProvider( + t *testing.T, roots ...string, +) *vscodeCopilotProvider { + t.Helper() + provider, ok := NewProvider(AgentVSCodeCopilot, ProviderConfig{ + Roots: roots, + Machine: "local", + }) + require.True(t, ok) + p, ok := provider.(*vscodeCopilotProvider) + require.True(t, ok) + return p +} + +// parseVSCodeCopilotTestSession parses a VSCode Copilot session file through +// the provider-owned parse method, replacing the removed package-level +// ParseVSCodeCopilotSession entrypoint. +func parseVSCodeCopilotTestSession( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return newVSCodeCopilotTestProvider(t).parseSession(path, project, machine) +} + +// discoverVSCodeCopilotTestSessions discovers VSCode Copilot session files +// under root through the provider source set, returning the legacy +// DiscoveredFile shape the tests assert against. +func discoverVSCodeCopilotTestSessions( + t *testing.T, root string, +) []DiscoveredFile { + t.Helper() + return newVSCodeCopilotTestProvider(t, root).sources.discoverSessionFiles(root) +} + +// findVSCodeCopilotTestSourceFile resolves a raw VSCode Copilot session ID to a +// session file through the provider source set, replacing the removed +// FindVSCodeCopilotSourceFile. +func findVSCodeCopilotTestSourceFile( + t *testing.T, root, rawID string, +) string { + t.Helper() + return newVSCodeCopilotTestProvider(t, root).sources.findSourceFile(root, rawID) +} + +// parseVisualStudioCopilotTestConversation parses one Visual Studio Copilot +// conversation through the folded free function, replacing the removed +// package-level ParseVisualStudioCopilotConversation entrypoint. +func parseVisualStudioCopilotTestConversation( + t *testing.T, tracePath, conversationID, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return parseVisualStudioCopilotConversation( + tracePath, conversationID, project, machine, + ) +} + +// parseVisualStudioCopilotTestSession reproduces the removed package-level +// ParseVisualStudioCopilotSession entrypoint. The path may be a real trace file +// or a # virtual path; a real trace file resolves to +// its first conversation. +func parseVisualStudioCopilotTestSession( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + if tracePath, conversationID, ok := + splitVisualStudioCopilotVirtualPath(path); ok { + return parseVisualStudioCopilotConversation( + tracePath, conversationID, project, machine, + ) + } + if !IsVisualStudioCopilotTraceFile(path) { + return nil, nil, nil + } + ids, err := VisualStudioCopilotFileConversationIDs(path) + if err != nil { + return nil, nil, err + } + if len(ids) == 0 { + return nil, nil, nil + } + return parseVisualStudioCopilotConversation(path, ids[0], project, machine) +} + +// discoverVisualStudioCopilotTestSessions discovers Visual Studio Copilot +// session work items under root, replacing the removed +// DiscoverVisualStudioCopilotSessions. +func discoverVisualStudioCopilotTestSessions( + t *testing.T, root string, +) []DiscoveredFile { + t.Helper() + return discoverVisualStudioCopilotSessionFilesUnderRoot(root) +} + +// findVisualStudioCopilotTestSourceFile resolves a raw Visual Studio Copilot +// conversation ID to a conversation-scoped virtual path, replacing the removed +// FindVisualStudioCopilotSourceFile. +func findVisualStudioCopilotTestSourceFile( + t *testing.T, root, rawID string, +) string { + t.Helper() + return findVisualStudioCopilotSourceFile(root, rawID) +} diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 928e18bcd..fc2b4a28b 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -906,82 +906,11 @@ func isContainedIn(child, root string) bool { !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } -// DiscoverVSCodeCopilotSessions traverses the VSCode -// workspaceStorage directory to find chatSessions/*.json -// and *.jsonl files. When both formats exist for the same -// session UUID, the .jsonl file takes priority. -// It also checks globalStorage/emptyWindowChatSessions. -// The vscodeUserDir should point to e.g. -// -// ~/Library/Application Support/Code/User (macOS) -// ~/.config/Code/User (Linux) -func DiscoverVSCodeCopilotSessions( - vscodeUserDir string, -) []DiscoveredFile { - if vscodeUserDir == "" { - return nil - } - - var files []DiscoveredFile - - // 1. Scan workspaceStorage//chatSessions/*.{json,jsonl} - wsDir := filepath.Join(vscodeUserDir, "workspaceStorage") - hashDirs, err := os.ReadDir(wsDir) - if err == nil { - for _, entry := range hashDirs { - if !entry.IsDir() { - continue - } - - hashPath := filepath.Join(wsDir, entry.Name()) - chatDir := filepath.Join(hashPath, "chatSessions") - sessionFiles, err := os.ReadDir(chatDir) - if err != nil { - continue - } - - // Read workspace.json to get project name - project := ReadVSCodeWorkspaceManifest(hashPath) - if project == "" { - project = "unknown" - } - - files = append(files, - discoverVSCodeSessionFiles( - chatDir, sessionFiles, project, - )..., - ) - } - } - - // 2. Scan globalStorage/emptyWindowChatSessions/*.{json,jsonl} - for _, subdir := range []string{ - "globalStorage/emptyWindowChatSessions", - "globalStorage/transferredChatSessions", - } { - globalDir := filepath.Join(vscodeUserDir, subdir) - globalFiles, err := os.ReadDir(globalDir) - if err != nil { - continue - } - files = append(files, - discoverVSCodeSessionFiles( - globalDir, globalFiles, "empty-window", - )..., - ) - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - // discoverVSCodeSessionFiles collects .json and .jsonl // session files from a directory, preferring .jsonl when // both exist for the same UUID. func discoverVSCodeSessionFiles( - dir string, entries []os.DirEntry, project string, + dir string, entries []os.DirEntry, project string, agent AgentType, ) []DiscoveredFile { // Collect UUIDs that have .jsonl files hasJSONL := make(map[string]bool) @@ -1024,70 +953,6 @@ func discoverVSCodeSessionFiles( return files } -// FindVSCodeCopilotSourceFile locates a VSCode Copilot -// session file by UUID (.jsonl preferred over .json). -func FindVSCodeCopilotSourceFile( - vscodeUserDir, rawID string, -) string { - if vscodeUserDir == "" || !IsValidSessionID(rawID) { - return "" - } - - // Search through workspaceStorage - wsDir := filepath.Join(vscodeUserDir, "workspaceStorage") - hashDirs, err := os.ReadDir(wsDir) - if err == nil { - for _, entry := range hashDirs { - if !entry.IsDir() { - continue - } - base := filepath.Join( - wsDir, entry.Name(), "chatSessions", - ) - // Prefer .jsonl - for _, ext := range []string{".jsonl", ".json"} { - candidate := filepath.Join( - base, rawID+ext, - ) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - } - } - - // Check global dirs - for _, subdir := range []string{ - "globalStorage/emptyWindowChatSessions", - "globalStorage/transferredChatSessions", - } { - base := filepath.Join(vscodeUserDir, subdir) - for _, ext := range []string{".jsonl", ".json"} { - candidate := filepath.Join(base, rawID+ext) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - } - - return "" -} - -// DiscoverVisualStudioCopilotSessions finds Visual Studio Copilot -// trace files under the configured traces directory. -func DiscoverVisualStudioCopilotSessions(vsRoot string) []DiscoveredFile { - if vsRoot == "" { - return nil - } - entries, err := os.ReadDir(vsRoot) - if err != nil { - return nil - } - files := discoverVisualStudioCopilotSessionFiles(vsRoot, entries) - sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) - return files -} - // discoverVisualStudioCopilotSessionFiles emits one work item per conversation // found across the trace files in a directory. A single physical trace file // can hold spans for several conversations, and one conversation can be split @@ -1147,15 +1012,6 @@ func discoverVisualStudioCopilotSessionFiles( return files } -// FindVisualStudioCopilotSourceFile locates a Visual Studio Copilot -// trace file by conversation UUID. -func FindVisualStudioCopilotSourceFile(vsRoot, rawID string) string { - if vsRoot == "" || !IsValidSessionID(rawID) { - return "" - } - return findVisualStudioCopilotTraceSourceFile(vsRoot, rawID) -} - func findVisualStudioCopilotTraceSourceFile( dir, rawID string, ) string { diff --git a/internal/parser/provider.go b/internal/parser/provider.go index f750a668e..40c404dfa 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -398,6 +398,10 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newQwenProviderFactory(def) case AgentQwenPaw: return newQwenPawProviderFactory(def) + case AgentVSCopilot: + return newVisualStudioCopilotProviderFactory(def) + case AgentVSCodeCopilot: + return newVSCodeCopilotProviderFactory(def) case AgentVibe: return newVibeProviderFactory(def) case AgentWorkBuddy: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 9c1a903ee..dcafa9b8e 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -30,8 +30,8 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentIflow: ProviderMigrationProviderAuthoritative, AgentAmp: ProviderMigrationProviderAuthoritative, AgentZencoder: ProviderMigrationProviderAuthoritative, - AgentVSCodeCopilot: ProviderMigrationLegacyOnly, - AgentVSCopilot: ProviderMigrationLegacyOnly, + AgentVSCodeCopilot: ProviderMigrationProviderAuthoritative, + AgentVSCopilot: ProviderMigrationProviderAuthoritative, AgentPi: ProviderMigrationProviderAuthoritative, AgentQwen: ProviderMigrationProviderAuthoritative, AgentCommandCode: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 9a2a6c310..ce5b4b66b 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -47,22 +47,22 @@ var providerNeutralEntrypoints = map[string]bool{ // tip (the zero-legacy gate) asserts this list is empty, so a provider cannot // remain a permanent shim. var pendingShimProviderFiles = map[string]bool{ - "antigravity_cli_provider.go": true, - "antigravity_provider.go": true, - "claude_provider.go": true, - "codex_provider.go": true, - "cowork_provider.go": true, - "db_backed_provider.go": true, - "hermes_provider.go": true, - "kiro_ide_provider.go": true, - "kiro_provider.go": true, - "opencode_provider.go": true, - "positron_provider.go": true, - "shelley_provider.go": true, - "vibe_provider.go": true, - "visualstudio_copilot_provider.go": true, - "vscode_copilot_provider.go": true, - "zed_provider.go": true, + "antigravity_cli_provider.go": true, + "antigravity_provider.go": true, + "claude_provider.go": true, + "codex_provider.go": true, + "copilot_provider.go": true, + "cowork_provider.go": true, + "db_backed_provider.go": true, + "gemini_provider.go": true, + "hermes_provider.go": true, + "kiro_ide_provider.go": true, + "kiro_provider.go": true, + "opencode_provider.go": true, + "positron_provider.go": true, + "shelley_provider.go": true, + "vibe_provider.go": true, + "zed_provider.go": true, } // collectLegacyFreeFuncs returns the set of package-level free functions in the diff --git a/internal/parser/types.go b/internal/parser/types.go index 7f4d4c750..ee0773007 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -262,9 +262,7 @@ var Registry = []AgentDef{ "workspaceStorage", "globalStorage", }, - FileBased: true, - DiscoverFunc: DiscoverVSCodeCopilotSessions, - FindSourceFunc: FindVSCodeCopilotSourceFile, + FileBased: true, }, { Type: AgentVSCopilot, @@ -279,10 +277,8 @@ var Registry = []AgentDef{ // Linux ".cache/VSGitHubCopilotLogs/traces", }, - IDPrefix: "visualstudio-copilot:", - FileBased: true, - DiscoverFunc: DiscoverVisualStudioCopilotSessions, - FindSourceFunc: FindVisualStudioCopilotSourceFile, + IDPrefix: "visualstudio-copilot:", + FileBased: true, }, { Type: AgentPi, diff --git a/internal/parser/visualstudio_copilot.go b/internal/parser/visualstudio_copilot.go index b05755dde..e1ab9e13a 100644 --- a/internal/parser/visualstudio_copilot.go +++ b/internal/parser/visualstudio_copilot.go @@ -16,60 +16,24 @@ import ( "time" ) -// ParseVisualStudioCopilotSession parses a single Visual Studio Copilot -// conversation from an OpenTelemetry trace JSONL file. The path may be a real -// trace file or a # virtual path emitted by -// discovery. A real trace file resolves to the conversation it contains; when -// a file carries spans for more than one conversation, discovery emits one -// virtual-path work item per conversation, so production does not rely on this -// entry point to choose among several. -func ParseVisualStudioCopilotSession( - path, project, machine string, -) (*ParsedSession, []ParsedMessage, error) { - if tracePath, conversationID, ok := - ParseVisualStudioCopilotVirtualPath(path); ok { - return ParseVisualStudioCopilotConversation( - tracePath, conversationID, project, machine, - ) - } - if !IsVisualStudioCopilotTraceFile(path) { - return nil, nil, nil - } - ids, err := VisualStudioCopilotFileConversationIDs(path) - if err != nil { - return nil, nil, err - } - if len(ids) == 0 { - return nil, nil, nil - } - return ParseVisualStudioCopilotConversation( - path, ids[0], project, machine, - ) -} - // VisualStudioCopilotVirtualPath pairs a trace file with one conversation ID. // A single physical trace file can hold spans for multiple conversations, so // each conversation is tracked as its own work item under this virtual path. func VisualStudioCopilotVirtualPath(tracePath, conversationID string) string { - return tracePath + "#" + conversationID + return VirtualSourcePath(tracePath, conversationID) } -// ParseVisualStudioCopilotVirtualPath splits a # -// virtual path. It returns ok=false for a plain trace-file path. -func ParseVisualStudioCopilotVirtualPath( +// SplitVisualStudioCopilotVirtualPath splits a # +// virtual source path into its physical trace file and conversation ID. It +// builds on the provider-neutral ParseVirtualSourcePath splitter and adds the +// Visual Studio Copilot validation that the container names a trace file and +// the source ID is a valid conversation ID. It returns ok=false for a plain +// trace-file path. Callers outside the parser package use it to detect and +// resolve the virtual paths Visual Studio Copilot stores for its sessions. +func SplitVisualStudioCopilotVirtualPath( sourcePath string, ) (tracePath, conversationID string, ok bool) { - idx := strings.LastIndex(sourcePath, "#") - if idx <= 0 || idx >= len(sourcePath)-1 { - return "", "", false - } - tracePath = sourcePath[:idx] - conversationID = sourcePath[idx+1:] - if !IsVisualStudioCopilotTraceFile(tracePath) || - !IsValidSessionID(conversationID) { - return "", "", false - } - return tracePath, conversationID, true + return splitVisualStudioCopilotVirtualPath(sourcePath) } // IsVisualStudioCopilotTraceFile reports whether path names a Visual Studio @@ -89,7 +53,7 @@ func IsVisualStudioCopilotTraceFile(path string) bool { // path whose runs share one physical history file; both resolve to the // physical file. Every other agent stores a real path, returned unchanged. func ResolveSourceFilePath(storedPath string) string { - if tracePath, _, ok := ParseVisualStudioCopilotVirtualPath(storedPath); ok { + if tracePath, _, ok := splitVisualStudioCopilotVirtualPath(storedPath); ok { return tracePath } if historyPath, _, ok := ParseAiderVirtualPath(storedPath); ok { @@ -133,11 +97,11 @@ type vsCopilotTraceValue struct { BoolValue bool `json:"boolValue"` } -// ParseVisualStudioCopilotConversation parses one conversation, gathering its -// spans from the given trace file and every sibling trace file in the same -// directory. File metadata is recorded against the conversation's virtual path -// so that each conversation in a shared trace file is tracked independently. -func ParseVisualStudioCopilotConversation( +// parseConversation parses one conversation, gathering its spans from the given +// trace file and every sibling trace file in the same directory. File metadata +// is recorded against the conversation's virtual path so that each conversation +// in a shared trace file is tracked independently. +func parseVisualStudioCopilotConversation( tracePath, conversationID, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { if conversationID == "" { diff --git a/internal/parser/visualstudio_copilot_provider.go b/internal/parser/visualstudio_copilot_provider.go new file mode 100644 index 000000000..e4d782a25 --- /dev/null +++ b/internal/parser/visualstudio_copilot_provider.go @@ -0,0 +1,274 @@ +package parser + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// Visual Studio Copilot stores conversations inside shared trace files +// (*_VSGitHubCopilot_traces.jsonl). It is a multi-session container provider, +// but unlike the SQLite-backed containers it discovers one source per +// conversation (deduplicated across trace files, newest trace wins) plus a bare +// physical source for any trace whose conversation IDs could not be read, so +// the read failure surfaces instead of being silently dropped. Parse of a +// conversation virtual path yields that one session; Parse of a bare trace fans +// out every conversation in it. All behavior is wired into the shared +// multi-session-container base via options. +func newVisualStudioCopilotProviderFactory(def AgentDef) ProviderFactory { + return newMultiSessionProviderFactory( + def, + visualStudioCopilotProviderCapabilities(), + func(cfg ProviderConfig) multiSessionContainerSourceSet { + return newMultiSessionContainerSourceSet( + AgentVSCopilot, + cfg.Roots, + withSourceDiscovery(vsCopilotDiscoverSources), + withWatchRoots(vsCopilotWatchRoots), + withChangedPathClassifier(vsCopilotClassifyPath), + withMemberLookup(vsCopilotFindMember), + withFingerprint(vsCopilotFingerprintSource), + withContainerParse(vsCopilotParseContainer), + withMemberParse(vsCopilotParseMember), + // Every conversation in a trace shares the trace's content hash. + withContainerHashStamping(), + ) + }, + ) +} + +// vsCopilotDiscoverSources emits one match per conversation (virtual path) plus +// a bare physical match for each unreadable trace, mirroring the legacy +// per-conversation discovery. +func vsCopilotDiscoverSources(root string) []multiSessionMatch { + var out []multiSessionMatch + for _, file := range discoverVisualStudioCopilotSessionFilesUnderRoot(root) { + match, ok := vsCopilotDiscoveredMatch(root, file.Path) + if !ok { + continue + } + match.ProjectHint = file.Project + out = append(out, match) + } + return out +} + +// vsCopilotDiscoveredMatch classifies a discovery path. Discovery emits either a +// # virtual path for a readable trace, or a bare +// physical trace path for one whose conversation IDs could not be read. The +// unreadable physical file must still become a source so the engine surfaces +// the read failure instead of silently dropping it; the regular-file +// requirement is therefore relaxed for the bare physical trace (which os.ReadDir +// already enumerated) while virtual paths keep validating that their backing +// trace exists. +func vsCopilotDiscoveredMatch(root, path string) (multiSessionMatch, bool) { + if match, ok := vsCopilotClassifyPath(root, path, false); ok { + return match, true + } + root = filepath.Clean(root) + path = filepath.Clean(path) + if _, _, ok := splitVisualStudioCopilotVirtualPath(path); ok { + return multiSessionMatch{}, false + } + if !visualStudioCopilotTraceUnderRoot(root, path, false) { + return multiSessionMatch{}, false + } + return multiSessionMatch{ + Path: path, + Container: path, + ProjectHint: "visualstudio", + }, true +} + +func discoverVisualStudioCopilotSessionFilesUnderRoot( + vsRoot string, +) []DiscoveredFile { + if vsRoot == "" { + return nil + } + entries, err := os.ReadDir(vsRoot) + if err != nil { + return nil + } + files := discoverVisualStudioCopilotSessionFiles(vsRoot, entries) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files +} + +func vsCopilotWatchRoots(roots []string) []WatchRoot { + out := make([]WatchRoot, 0, len(roots)) + for _, root := range roots { + out = append(out, WatchRoot{ + Path: root, + Recursive: false, + IncludeGlobs: []string{"*_VSGitHubCopilot_traces.jsonl"}, + DebounceKey: string(AgentVSCopilot) + ":traces:" + root, + }) + } + return out +} + +// vsCopilotClassifyPath maps a stored or changed path to its trace container and +// conversation. A virtual path always requires its backing trace to exist; a +// bare trace path relaxes the regular-file check under allowMissing so a deleted +// trace still classifies for changed-path tombstones. +func vsCopilotClassifyPath( + root, path string, allowMissing bool, +) (multiSessionMatch, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if tracePath, conversationID, ok := + splitVisualStudioCopilotVirtualPath(path); ok { + if !visualStudioCopilotTraceUnderRoot(root, tracePath, true) { + return multiSessionMatch{}, false + } + return multiSessionMatch{ + Path: path, + Container: tracePath, + MemberID: conversationID, + ProjectHint: "visualstudio", + }, true + } + if visualStudioCopilotTraceUnderRoot(root, path, !allowMissing) { + return multiSessionMatch{ + Path: path, + Container: path, + ProjectHint: "visualstudio", + }, true + } + return multiSessionMatch{}, false +} + +func vsCopilotFindMember(root, rawID string) (multiSessionMatch, bool) { + path := findVisualStudioCopilotSourceFile(root, rawID) + if path == "" { + return multiSessionMatch{}, false + } + return vsCopilotClassifyPath(root, path, false) +} + +// findVisualStudioCopilotSourceFile locates a trace file by conversation UUID +// and returns a conversation-scoped # virtual path. +func findVisualStudioCopilotSourceFile(root, rawID string) string { + if root == "" || !IsValidSessionID(rawID) { + return "" + } + return findVisualStudioCopilotTraceSourceFile(root, rawID) +} + +func vsCopilotFingerprintSource( + src multiSessionSource, +) (SourceFingerprint, error) { + size, mtime, err := VisualStudioCopilotTraceFingerprintStrict(src.Container) + if err != nil { + return SourceFingerprint{}, err + } + hash, err := hashJSONLSourceFile(src.Container) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Size: size, + MTimeNS: mtime, + Hash: hash, + }, nil +} + +func vsCopilotParseMember( + src multiSessionSource, req ParseRequest, +) (*ParseResult, error) { + project := firstNonEmptyJSONLString(req.Source.ProjectHint, "visualstudio") + sess, msgs, err := parseVisualStudioCopilotConversation( + src.Container, src.MemberID, project, req.Machine, + ) + if err != nil { + return nil, err + } + if sess == nil { + return nil, nil + } + return &ParseResult{Session: *sess, Messages: msgs}, nil +} + +func vsCopilotParseContainer( + src multiSessionSource, req ParseRequest, +) ([]ParseResult, error) { + ids, err := VisualStudioCopilotFileConversationIDs(src.Container) + if err != nil { + return nil, err + } + project := firstNonEmptyJSONLString(req.Source.ProjectHint, "visualstudio") + results := make([]ParseResult, 0, len(ids)) + for _, id := range ids { + sess, msgs, err := parseVisualStudioCopilotConversation( + src.Container, id, project, req.Machine, + ) + if err != nil { + return nil, err + } + if sess == nil { + continue + } + results = append(results, ParseResult{Session: *sess, Messages: msgs}) + } + return results, nil +} + +// splitVisualStudioCopilotVirtualPath splits a # +// virtual source path into its physical trace file and conversation ID. It +// builds on the provider-neutral ParseVirtualSourcePath splitter and adds the +// Visual Studio Copilot validation: the container must name a trace file and the +// source ID must be a valid conversation ID. It returns ok=false for a plain +// trace-file path. +func splitVisualStudioCopilotVirtualPath( + sourcePath string, +) (tracePath, conversationID string, ok bool) { + tracePath, conversationID, ok = ParseVirtualSourcePath(sourcePath) + if !ok { + return "", "", false + } + if !IsVisualStudioCopilotTraceFile(tracePath) || + !IsValidSessionID(conversationID) { + return "", "", false + } + return tracePath, conversationID, true +} + +func visualStudioCopilotTraceUnderRoot( + root, path string, + requireRegular bool, +) bool { + rel, ok := relUnder(root, path) + if !ok || strings.Contains(filepath.ToSlash(rel), "/") { + return false + } + if !IsVisualStudioCopilotTraceFile(path) { + return false + } + return !requireRegular || IsRegularFile(path) +} + +func visualStudioCopilotProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + IncrementalAppend: CapabilityNotApplicable, + MultiSessionSource: CapabilitySupported, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilitySupported, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/visualstudio_copilot_test.go b/internal/parser/visualstudio_copilot_test.go index aeb1f2ab1..768d1bac7 100644 --- a/internal/parser/visualstudio_copilot_test.go +++ b/internal/parser/visualstudio_copilot_test.go @@ -36,7 +36,7 @@ func TestDiscoverVisualStudioCopilotSessions(t *testing.T) { []byte("{}\n"), 0o644, )) - files := DiscoverVisualStudioCopilotSessions(tracesDir) + files := discoverVisualStudioCopilotTestSessions(t, tracesDir) require.Len(t, files, 1) assert.Equal(t, tracePath+"#"+conversationID, files[0].Path) @@ -55,7 +55,7 @@ func TestDiscoverVisualStudioCopilotSessions_IgnoresParentDirs(t *testing.T) { "20260612T194439_257709a3_VSGitHubCopilot_traces.jsonl", ), []byte("{}\n"), 0o644)) - files := DiscoverVisualStudioCopilotSessions(root) + files := discoverVisualStudioCopilotTestSessions(t, root) assert.Empty(t, files) } @@ -80,7 +80,7 @@ func TestDiscoverVisualStudioCopilotSessions_DeduplicatesConversationTraceFiles( require.NoError(t, os.WriteFile(oldTrace, []byte(data), 0o644)) require.NoError(t, os.WriteFile(newTrace, []byte(data), 0o644)) - files := DiscoverVisualStudioCopilotSessions(root) + files := discoverVisualStudioCopilotTestSessions(t, root) require.Len(t, files, 1) assert.Equal(t, newTrace+"#"+conversationID, files[0].Path) @@ -100,7 +100,7 @@ func TestParseVisualStudioCopilotSession_MalformedTraceLineReturnsError(t *testi }) + "\n" + `{"resourceSpans":[` + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -159,7 +159,7 @@ func TestDiscoverVisualStudioCopilotSessions_EmitsWorkItemPerConversation(t *tes require.NoError(t, os.WriteFile(oldTrace, []byte(oldData), 0o644)) require.NoError(t, os.WriteFile(newTrace, []byte(newData), 0o644)) - files := DiscoverVisualStudioCopilotSessions(dir) + files := discoverVisualStudioCopilotTestSessions(t, dir) got := map[string]string{} for _, f := range files { @@ -185,7 +185,7 @@ func TestDiscoverVisualStudioCopilotSessions_SampleFixturesEnumerateBothConversa t.Skipf("sample dir not available: %v", err) } - files := DiscoverVisualStudioCopilotSessions(sampleDir) + files := discoverVisualStudioCopilotTestSessions(t, sampleDir) got := map[string]struct{}{} for _, f := range files { @@ -228,7 +228,7 @@ func TestParseVisualStudioCopilotConversation_PropagatesSiblingDirReadError(t *t require.NoError(t, os.Chmod(dir, 0o100)) t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) - _, _, err := ParseVisualStudioCopilotConversation( + _, _, err := parseVisualStudioCopilotTestConversation(t, tracePath, conversationID, "visualstudio", "local", ) require.Error(t, err, @@ -325,7 +325,7 @@ func TestParseVisualStudioCopilotConversation_PropagatesReadError(t *testing.T) ) require.NoError(t, os.Mkdir(dir, 0o755)) - sess, msgs, err := ParseVisualStudioCopilotConversation( + sess, msgs, err := parseVisualStudioCopilotTestConversation(t, dir, "4a8f63f6-7626-4416-a874-fc7bd2c3f005", "visualstudio", "local", ) @@ -349,7 +349,7 @@ func TestDiscoverVisualStudioCopilotSessions_EnqueuesUnreadableTraceFile(t *test ) require.NoError(t, os.Symlink(target, link)) - files := DiscoverVisualStudioCopilotSessions(root) + files := discoverVisualStudioCopilotTestSessions(t, root) require.Len(t, files, 1) assert.Equal(t, link, files[0].Path, @@ -394,7 +394,7 @@ func TestParseVisualStudioCopilotSession_IgnoresNonTraceFiles(t *testing.T) { }` require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -431,7 +431,7 @@ func TestParseVisualStudioCopilotTraceSession(t *testing.T) { }, "\n") + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -475,7 +475,7 @@ func TestParseVisualStudioCopilotTraceSession_GetFileResult(t *testing.T) { }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -521,7 +521,7 @@ func TestParseVisualStudioCopilotTraceSession_InvokeOnlyFirstMessage(t *testing. }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -553,7 +553,7 @@ func TestParseVisualStudioCopilotTraceSession_ChatPromptFirstMessage(t *testing. }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -593,7 +593,7 @@ func TestParseVisualStudioCopilotTraceSession_PreservesPromptMarkdown(t *testing }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -637,7 +637,7 @@ func TestParseVisualStudioCopilotTraceSession_CombinesConversationTraceFiles(t * require.NoError(t, os.WriteFile(path, []byte(firstData), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(secondData), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -687,7 +687,7 @@ func TestParseVisualStudioCopilotTraceSession_PropagatesSiblingReadError(t *test ) require.NoError(t, os.Symlink(target, sibling)) - _, _, err := ParseVisualStudioCopilotSession( + _, _, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) require.Error(t, err, @@ -710,7 +710,7 @@ func TestParseVisualStudioCopilotTraceSession_MalformedTraceLineErrors(t *testin }) + "\n" + `{"resourceSpans":` + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - _, _, err := ParseVisualStudioCopilotSession( + _, _, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) require.Error(t, err, @@ -746,7 +746,7 @@ func TestParseVisualStudioCopilotTraceSession_DeduplicatesChatOutputAcrossFiles( require.NoError(t, os.WriteFile(path, []byte(chatSpan), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(chatSpan), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -805,7 +805,7 @@ func TestParseVisualStudioCopilotTraceSession_PrefersCompleteChatOutputAcrossFil require.NoError(t, os.WriteFile(path, []byte(partial), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(complete), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -865,7 +865,7 @@ func TestParseVisualStudioCopilotTraceSession_PrefersCompleteChatUsageForVisible require.NoError(t, os.WriteFile(path, []byte(richEarlier), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(leanerLater), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -913,7 +913,7 @@ func TestParseVisualStudioCopilotTraceSession_DeduplicatesToolSpanAcrossFiles(t require.NoError(t, os.WriteFile(path, []byte(partial), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(complete), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -970,7 +970,7 @@ func TestParseVisualStudioCopilotTraceSession_PreservesOrderWhenDedupingToolSpan ) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1010,7 +1010,7 @@ func TestParseVisualStudioCopilotTraceSession_ChatOutputMessages(t *testing.T) { }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1071,7 +1071,7 @@ func TestParseVisualStudioCopilotTraceSession_CountsUsageForToolOnlyChatTurn(t * }) + "\n" require.NoError(t, os.WriteFile(path, []byte(chatSpan+toolSpan), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1130,7 +1130,7 @@ func TestParseVisualStudioCopilotTraceSession_DoesNotDoubleCountTextPlusToolUsag }) + "\n" require.NoError(t, os.WriteFile(path, []byte(chatSpan+toolSpan), 0o644)) - sess, _, err := ParseVisualStudioCopilotSession( + sess, _, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1181,7 +1181,7 @@ func TestParseVisualStudioCopilotTraceSession_PrefersCompleteToolOnlyChatUsage(t require.NoError(t, os.WriteFile(path, []byte(chatSpan("200", "10")), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(chatSpan("500", "42")+toolSpan), 0o644)) - sess, _, err := ParseVisualStudioCopilotSession( + sess, _, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1214,7 +1214,7 @@ func TestParseVisualStudioCopilotTraceSession_ChatUsage(t *testing.T) { }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1262,7 +1262,7 @@ func TestParseVisualStudioCopilotTraceSession_StandardToolInputs(t *testing.T) { }, "\n") + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - _, msgs, err := ParseVisualStudioCopilotSession( + _, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1313,7 +1313,7 @@ func TestParseVisualStudioCopilotTraceSession_UsesSiblingPromptSpan(t *testing.T require.NoError(t, os.WriteFile(path, []byte(primaryData), 0o644)) require.NoError(t, os.WriteFile(sibling, []byte(siblingData), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1386,7 +1386,7 @@ func TestParseVisualStudioCopilotTraceSession_DeduplicatesPromptAndToolSpans(t * }, "\n") + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1430,7 +1430,7 @@ func TestParseVisualStudioCopilotTraceSession_ChatSummaryFallback(t *testing.T) }) + "\n" require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) - sess, msgs, err := ParseVisualStudioCopilotSession( + sess, msgs, err := parseVisualStudioCopilotTestSession(t, path, "visualstudio", "local", ) @@ -1473,7 +1473,7 @@ func TestParseVisualStudioCopilotConversation_ParsesEachConversationIndependentl require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) // The prompt conversation parses with its user message. - promptSess, _, err := ParseVisualStudioCopilotConversation( + promptSess, _, err := parseVisualStudioCopilotTestConversation(t, path, promptID, "visualstudio", "local", ) require.NoError(t, err) @@ -1486,7 +1486,7 @@ func TestParseVisualStudioCopilotConversation_ParsesEachConversationIndependentl // The ambient conversation in the same file is not dropped; it // parses on its own with its invoke_agent turn. - ambientSess, ambientMsgs, err := ParseVisualStudioCopilotConversation( + ambientSess, ambientMsgs, err := parseVisualStudioCopilotTestConversation(t, path, ambientID, "visualstudio", "local", ) require.NoError(t, err) @@ -1517,13 +1517,13 @@ func TestFindVisualStudioCopilotSourceFile(t *testing.T) { []byte(traceLine+"\n"), 0o644)) assert.Equal(t, VisualStudioCopilotVirtualPath(newTrace, uuid), - FindVisualStudioCopilotSourceFile(tracesDir, uuid), + findVisualStudioCopilotTestSourceFile(t, tracesDir, uuid), "source lookup must return a conversation-scoped virtual path so a "+ "single-session resync does not reparse the whole trace file") assert.Equal(t, "", - FindVisualStudioCopilotSourceFile(dir, uuid)) + findVisualStudioCopilotTestSourceFile(t, dir, uuid)) assert.Equal(t, "", - FindVisualStudioCopilotSourceFile(tracesDir, "../etc/passwd")) + findVisualStudioCopilotTestSourceFile(t, tracesDir, "../etc/passwd")) } // TestWriteVisualStudioCopilotConversationJSONL verifies that exporting one diff --git a/internal/parser/vscode_copilot.go b/internal/parser/vscode_copilot.go index c93274b87..84beb8b3f 100644 --- a/internal/parser/vscode_copilot.go +++ b/internal/parser/vscode_copilot.go @@ -115,10 +115,10 @@ type vscodeCopilotWorkspace struct { Workspace string `json:"workspace"` } -// ParseVSCodeCopilotSession parses a VSCode Copilot chat -// session file (.json or .jsonl). Returns (nil, nil, nil) -// if the file is empty or contains no meaningful content. -func ParseVSCodeCopilotSession( +// parseSession parses a VSCode Copilot chat session file (.json or .jsonl). +// Returns (nil, nil, nil) if the file is empty or contains no meaningful +// content. +func (p *vscodeCopilotProvider) parseSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/vscode_copilot_provider.go b/internal/parser/vscode_copilot_provider.go new file mode 100644 index 000000000..dbfb7727e --- /dev/null +++ b/internal/parser/vscode_copilot_provider.go @@ -0,0 +1,654 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +var _ Provider = (*vscodeCopilotProvider)(nil) + +type vscodeCopilotProviderFactory struct { + def AgentDef +} + +func newVSCodeCopilotProviderFactory(def AgentDef) ProviderFactory { + return vscodeCopilotProviderFactory{def: cloneAgentDef(def)} +} + +func (f vscodeCopilotProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f vscodeCopilotProviderFactory) Capabilities() Capabilities { + return vscodeCopilotProviderCapabilities() +} + +func (f vscodeCopilotProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &vscodeCopilotProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: vscodeCopilotProviderCapabilities(), + Config: cfg, + }, + sources: newVSCodeCopilotSourceSet(cfg.Roots), + } +} + +type vscodeCopilotProvider struct { + ProviderBase + sources vscodeCopilotSourceSet +} + +func (p *vscodeCopilotProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *vscodeCopilotProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *vscodeCopilotProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *vscodeCopilotProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *vscodeCopilotProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *vscodeCopilotProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + path, project, ok := p.sources.pathFromSource(req.Source) + if !ok { + return ParseOutcome{}, fmt.Errorf("vscode copilot source path unavailable") + } + if req.Source.ProjectHint != "" { + project = req.Source.ProjectHint + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := p.parseSession(path, project, machine) + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + SkipReason: SkipNoSession, + }, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + sess.File.Size = req.Fingerprint.Size + sess.File.Mtime = req.Fingerprint.MTimeNS + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + UsageEvents: sess.UsageEvents, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + }, nil +} + +type vscodeCopilotSource struct { + Root string + Path string + Project string +} + +type vscodeCopilotSourceSet struct { + roots []string +} + +func newVSCodeCopilotSourceSet(roots []string) vscodeCopilotSourceSet { + return vscodeCopilotSourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s vscodeCopilotSourceSet) Discover(ctx context.Context) ([]SourceRef, error) { + var sources []SourceRef + seen := make(map[string]struct{}) + for _, root := range s.roots { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, file := range s.discoverSessionFiles(root) { + source, ok := s.sourceRef(root, file.Path) + if !ok { + continue + } + source.ProjectHint = file.Project + addJSONLSource(source, &sources, seen) + } + } + sortJSONLSources(sources) + return sources, nil +} + +// discoverSessionFiles traverses the VSCode workspaceStorage directory to find +// chatSessions/*.json and *.jsonl files. When both formats exist for the same +// session UUID, the .jsonl file takes priority. It also checks +// globalStorage/emptyWindowChatSessions and transferredChatSessions. The root +// should point to e.g. +// +// ~/Library/Application Support/Code/User (macOS) +// ~/.config/Code/User (Linux) +func (s vscodeCopilotSourceSet) discoverSessionFiles( + vscodeUserDir string, +) []DiscoveredFile { + if vscodeUserDir == "" { + return nil + } + + var files []DiscoveredFile + + // 1. Scan workspaceStorage//chatSessions/*.{json,jsonl} + wsDir := filepath.Join(vscodeUserDir, "workspaceStorage") + hashDirs, err := os.ReadDir(wsDir) + if err == nil { + for _, entry := range hashDirs { + if !entry.IsDir() { + continue + } + + hashPath := filepath.Join(wsDir, entry.Name()) + chatDir := filepath.Join(hashPath, "chatSessions") + sessionFiles, err := os.ReadDir(chatDir) + if err != nil { + continue + } + + // Read workspace.json to get project name + project := ReadVSCodeWorkspaceManifest(hashPath) + if project == "" { + project = "unknown" + } + + files = append(files, + discoverVSCodeSessionFiles( + chatDir, sessionFiles, project, + AgentVSCodeCopilot, + )..., + ) + } + } + + // 2. Scan globalStorage/emptyWindowChatSessions/*.{json,jsonl} + for _, subdir := range []string{ + "globalStorage/emptyWindowChatSessions", + "globalStorage/transferredChatSessions", + } { + globalDir := filepath.Join(vscodeUserDir, subdir) + globalFiles, err := os.ReadDir(globalDir) + if err != nil { + continue + } + files = append(files, + discoverVSCodeSessionFiles( + globalDir, globalFiles, "empty-window", + AgentVSCodeCopilot, + )..., + ) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files +} + +func (s vscodeCopilotSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)*2) + for _, root := range s.roots { + workspace := filepath.Join(root, "workspaceStorage") + roots = append(roots, WatchRoot{ + Path: workspace, + Recursive: true, + IncludeGlobs: []string{"*.json", "*.jsonl"}, + DebounceKey: string(AgentVSCodeCopilot) + ":workspace:" + workspace, + }) + global := filepath.Join(root, "globalStorage") + roots = append(roots, WatchRoot{ + Path: global, + Recursive: true, + IncludeGlobs: []string{"*.json", "*.jsonl"}, + DebounceKey: string(AgentVSCodeCopilot) + ":global:" + global, + }) + } + return WatchPlan{Roots: roots}, nil +} + +func (s vscodeCopilotSourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, root := range s.roots { + sources := s.sourcesForWorkspaceManifest(root, req.Path) + if len(sources) > 0 { + return sources, nil + } + source, ok := s.sourceRefForChangedPath(root, req) + if ok { + return []SourceRef{source}, nil + } + } + return nil, nil +} + +func (s vscodeCopilotSourceSet) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + if err := ctx.Err(); err != nil { + return SourceRef{}, false, err + } + for _, path := range []string{req.StoredFilePath, req.FingerprintKey} { + if path == "" { + continue + } + for _, root := range s.roots { + if source, ok := s.sourceRef(root, path); ok { + return source, true, nil + } + } + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + path := s.findSourceFile(root, req.RawSessionID) + if path == "" { + continue + } + if source, ok := s.sourceRef(root, path); ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +// findSourceFile locates a VSCode Copilot session file by UUID (.jsonl +// preferred over .json) across workspaceStorage and the global session dirs. +func (s vscodeCopilotSourceSet) findSourceFile( + vscodeUserDir, rawID string, +) string { + if vscodeUserDir == "" || !IsValidSessionID(rawID) { + return "" + } + + // Search through workspaceStorage + wsDir := filepath.Join(vscodeUserDir, "workspaceStorage") + hashDirs, err := os.ReadDir(wsDir) + if err == nil { + for _, entry := range hashDirs { + if !entry.IsDir() { + continue + } + base := filepath.Join( + wsDir, entry.Name(), "chatSessions", + ) + // Prefer .jsonl + for _, ext := range []string{".jsonl", ".json"} { + candidate := filepath.Join( + base, rawID+ext, + ) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + } + } + + // Check global dirs + for _, subdir := range []string{ + "globalStorage/emptyWindowChatSessions", + "globalStorage/transferredChatSessions", + } { + base := filepath.Join(vscodeUserDir, subdir) + for _, ext := range []string{".jsonl", ".json"} { + candidate := filepath.Join(base, rawID+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + } + + return "" +} + +func (s vscodeCopilotSourceSet) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + path, _, ok := s.pathFromSource(source) + if !ok { + return SourceFingerprint{}, fmt.Errorf("vscode copilot source path unavailable") + } + info, err := os.Stat(path) + if err != nil { + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", path, err) + } + if info.IsDir() { + return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", path) + } + fingerprint := SourceFingerprint{ + Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path), + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + } + workspacePath := s.workspaceManifestForSource(path) + if workspacePath != "" { + if workspaceInfo, err := os.Stat(workspacePath); err == nil { + fingerprint.Size += workspaceInfo.Size() + if mtime := workspaceInfo.ModTime().UnixNano(); mtime > fingerprint.MTimeNS { + fingerprint.MTimeNS = mtime + } + } + } + fingerprint.Hash, err = vscodeCopilotSourceHash(path, workspacePath) + if err != nil { + return SourceFingerprint{}, err + } + return fingerprint, nil +} + +func (s vscodeCopilotSourceSet) pathFromSource(source SourceRef) (string, string, bool) { + switch src := source.Opaque.(type) { + case vscodeCopilotSource: + return src.Path, src.Project, src.Path != "" + case *vscodeCopilotSource: + if src != nil && src.Path != "" { + return src.Path, src.Project, true + } + } + for _, candidate := range []string{source.DisplayPath, source.FingerprintKey, source.Key} { + for _, root := range s.roots { + if ref, ok := s.sourceRef(root, candidate); ok { + src := ref.Opaque.(vscodeCopilotSource) + return src.Path, src.Project, true + } + } + } + return "", "", false +} + +func (s vscodeCopilotSourceSet) sourceRef(root, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, ok := relUnder(root, path) + if !ok { + return SourceRef{}, false + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) == 4 && + parts[0] == "workspaceStorage" && + parts[2] == "chatSessions" && + isVSCodeCopilotSessionPath(parts[3]) { + if promoted := vscodeCopilotPreferredExistingPath(path); promoted != "" { + path = promoted + } + if !IsRegularFile(path) { + return SourceRef{}, false + } + hashDir := filepath.Join(root, "workspaceStorage", parts[1]) + project := ReadVSCodeWorkspaceManifest(hashDir) + if project == "" { + project = "unknown" + } + return s.newSourceRef(root, path, project), true + } + if len(parts) == 3 && + parts[0] == "globalStorage" && + (parts[1] == "emptyWindowChatSessions" || + parts[1] == "transferredChatSessions") && + isVSCodeCopilotSessionPath(parts[2]) { + if promoted := vscodeCopilotPreferredExistingPath(path); promoted != "" { + path = promoted + } + if !IsRegularFile(path) { + return SourceRef{}, false + } + return s.newSourceRef(root, path, "empty-window"), true + } + return SourceRef{}, false +} + +func (s vscodeCopilotSourceSet) sourceRefForChangedPath( + root string, + req ChangedPathRequest, +) (SourceRef, bool) { + path := req.Path + if req.EventKind != "remove" && vscodeCopilotJSONLPreferredOver(path) { + return SourceRef{}, false + } + if source, ok := s.sourceRef(root, path); ok { + return source, true + } + return s.syntheticSourceRef(root, path) +} + +func (s vscodeCopilotSourceSet) syntheticSourceRef( + root, path string, +) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, ok := relUnder(root, path) + if !ok { + return SourceRef{}, false + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) == 4 && + parts[0] == "workspaceStorage" && + parts[2] == "chatSessions" && + isVSCodeCopilotSessionPath(parts[3]) { + if promoted := vscodeCopilotPreferredExistingPath(path); promoted != "" { + path = promoted + } + hashDir := filepath.Join(root, "workspaceStorage", parts[1]) + project := ReadVSCodeWorkspaceManifest(hashDir) + if project == "" { + project = "unknown" + } + return s.newSourceRef(root, path, project), true + } + if len(parts) == 3 && + parts[0] == "globalStorage" && + (parts[1] == "emptyWindowChatSessions" || + parts[1] == "transferredChatSessions") && + isVSCodeCopilotSessionPath(parts[2]) { + if promoted := vscodeCopilotPreferredExistingPath(path); promoted != "" { + path = promoted + } + return s.newSourceRef(root, path, "empty-window"), true + } + return SourceRef{}, false +} + +func (s vscodeCopilotSourceSet) sourcesForWorkspaceManifest( + root, path string, +) []SourceRef { + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, ok := relUnder(root, path) + if !ok { + return nil + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) != 3 || + parts[0] != "workspaceStorage" || + parts[2] != "workspace.json" { + return nil + } + hashDir := filepath.Join(root, "workspaceStorage", parts[1]) + chatDir := filepath.Join(hashDir, "chatSessions") + entries, err := os.ReadDir(chatDir) + if err != nil { + return nil + } + project := ReadVSCodeWorkspaceManifest(hashDir) + if project == "" { + project = "unknown" + } + files := discoverVSCodeSessionFiles( + chatDir, entries, project, AgentVSCodeCopilot, + ) + sources := make([]SourceRef, 0, len(files)) + seen := make(map[string]struct{}, len(files)) + for _, file := range files { + source, ok := s.sourceRef(root, file.Path) + if !ok { + continue + } + source.ProjectHint = file.Project + addJSONLSource(source, &sources, seen) + } + sortJSONLSources(sources) + return sources +} + +func (s vscodeCopilotSourceSet) workspaceManifestForSource(path string) string { + for _, root := range s.roots { + root = filepath.Clean(root) + rel, ok := relUnder(root, path) + if !ok { + continue + } + parts := strings.Split(filepath.ToSlash(rel), "/") + if len(parts) == 4 && + parts[0] == "workspaceStorage" && + parts[2] == "chatSessions" && + isVSCodeCopilotSessionPath(parts[3]) { + workspacePath := filepath.Join( + root, + "workspaceStorage", + parts[1], + "workspace.json", + ) + if IsRegularFile(workspacePath) { + return workspacePath + } + } + } + return "" +} + +func (s vscodeCopilotSourceSet) newSourceRef(root, path, project string) SourceRef { + return SourceRef{ + Provider: AgentVSCodeCopilot, + Key: path, + DisplayPath: path, + FingerprintKey: path, + ProjectHint: project, + Opaque: vscodeCopilotSource{ + Root: root, + Path: path, + Project: project, + }, + } +} + +func isVSCodeCopilotSessionPath(name string) bool { + return strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".jsonl") +} + +func vscodeCopilotPreferredExistingPath(path string) string { + if base, ok := strings.CutSuffix(path, ".json"); ok { + candidate := base + ".jsonl" + if IsRegularFile(candidate) { + return candidate + } + } + if IsRegularFile(path) { + return path + } + if base, ok := strings.CutSuffix(path, ".jsonl"); ok { + candidate := base + ".json" + if IsRegularFile(candidate) { + return candidate + } + } + return "" +} + +func vscodeCopilotJSONLPreferredOver(path string) bool { + base, ok := strings.CutSuffix(path, ".json") + if !ok { + return false + } + return IsRegularFile(base + ".jsonl") +} + +func vscodeCopilotSourceHash(path, workspacePath string) (string, error) { + hash, err := hashJSONLSourceFile(path) + if err != nil { + return "", err + } + if workspacePath == "" { + return hash, nil + } + workspaceHash, err := hashJSONLSourceFile(workspacePath) + if err != nil { + return "", err + } + h := sha256.New() + _, _ = h.Write([]byte("chat\x00" + hash + "\x00workspace\x00" + workspaceHash)) + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +func vscodeCopilotProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + IncrementalAppend: CapabilityNotApplicable, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilityNotApplicable, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + Thinking: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/vscode_copilot_test.go b/internal/parser/vscode_copilot_test.go index bf73482ac..e7fbd3782 100644 --- a/internal/parser/vscode_copilot_test.go +++ b/internal/parser/vscode_copilot_test.go @@ -127,7 +127,7 @@ func TestParseVSCodeCopilotSession(t *testing.T) { path, []byte(tt.json), 0644, )) - sess, msgs, err := ParseVSCodeCopilotSession( + sess, msgs, err := parseVSCodeCopilotTestSession(t, path, "testproject", "local", ) require.NoError(t, err) @@ -166,7 +166,7 @@ func TestParseVSCodeCopilotSession(t *testing.T) { } func TestParseVSCodeCopilotSession_NonExistent(t *testing.T) { - sess, msgs, err := ParseVSCodeCopilotSession( + sess, msgs, err := parseVSCodeCopilotTestSession(t, "/nonexistent/path.json", "proj", "local", ) require.NoError(t, err, "expected nil error") @@ -196,7 +196,7 @@ func TestParseVSCodeCopilotSession_MixedTextAndTools(t *testing.T) { path := filepath.Join(dir, "test.json") require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - _, msgs, err := ParseVSCodeCopilotSession(path, "proj", "local") + _, msgs, err := parseVSCodeCopilotTestSession(t, path, "proj", "local") require.NoError(t, err) // Find assistant message @@ -242,7 +242,7 @@ func TestParseVSCodeCopilotSession_TerminalToolData(t *testing.T) { path := filepath.Join(dir, "test.json") require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - _, msgs, err := ParseVSCodeCopilotSession(path, "proj", "local") + _, msgs, err := parseVSCodeCopilotTestSession(t, path, "proj", "local") require.NoError(t, err) var assistant *ParsedMessage @@ -328,7 +328,7 @@ func TestDiscoverVSCodeCopilotSessions(t *testing.T) { globalPath := filepath.Join(globalDir, "global-sess.json") require.NoError(t, os.WriteFile(globalPath, []byte(sessionJSON), 0644)) - files := DiscoverVSCodeCopilotSessions(root) + files := discoverVSCodeCopilotTestSessions(t, root) require.Len(t, files, 2) @@ -518,7 +518,7 @@ func TestParseVSCodeCopilotSession_JSONL(t *testing.T) { path, []byte(content), 0644, )) - sess, msgs, err := ParseVSCodeCopilotSession( + sess, msgs, err := parseVSCodeCopilotTestSession(t, path, "testproject", "local", ) require.NoError(t, err) @@ -722,7 +722,7 @@ func TestDiscoverVSCodeCopilot_JSONLDedup(t *testing.T) { []byte(sessionJSON), 0644, )) - files := DiscoverVSCodeCopilotSessions(root) + files := discoverVSCodeCopilotTestSessions(t, root) // Should get 3 files: dup1.jsonl, only-jsonl.jsonl, only-json.json if !assert.Len(t, files, 3, "expected 3 files") { @@ -767,7 +767,7 @@ func TestFindVSCodeCopilotSourceFile(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := FindVSCodeCopilotSourceFile( + got := findVSCodeCopilotTestSourceFile(t, tt.dir, tt.id, ) assert.Equal(t, tt.want, got) @@ -823,7 +823,7 @@ func TestParseVSCodeCopilotSession_TokenUsage(t *testing.T) { path := filepath.Join(dir, "usage.json") require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644)) - sess, _, err := ParseVSCodeCopilotSession(path, "proj", "local") + sess, _, err := parseVSCodeCopilotTestSession(t, path, "proj", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -871,7 +871,7 @@ func TestParseVSCodeCopilotSession_TokenUsageModelFallback(t *testing.T) { path := filepath.Join(dir, "usage2.json") require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644)) - sess, _, err := ParseVSCodeCopilotSession(path, "proj", "local") + sess, _, err := parseVSCodeCopilotTestSession(t, path, "proj", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -898,7 +898,7 @@ func TestParseVSCodeCopilotSession_NoTokenUsage(t *testing.T) { path := filepath.Join(dir, "nousage.json") require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644)) - sess, _, err := ParseVSCodeCopilotSession(path, "proj", "local") + sess, _, err := parseVSCodeCopilotTestSession(t, path, "proj", "local") require.NoError(t, err) require.NotNil(t, sess) diff --git a/internal/service/direct.go b/internal/service/direct.go index 5809a91b6..2cc253603 100644 --- a/internal/service/direct.go +++ b/internal/service/direct.go @@ -311,7 +311,7 @@ func (b *directBackend) Sync( // conversation lives on in a sibling. The single-session path keeps the // conversation scope and follows it across sibling trace files. if _, _, ok := - parser.ParseVisualStudioCopilotVirtualPath(storedPath); ok { + parser.SplitVisualStudioCopilotVirtualPath(storedPath); ok { if err := b.engine.SyncSingleSessionContext( ctx, in.ID, ); err != nil { diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 3cb7596bd..c0abcbb1a 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -368,7 +368,7 @@ func IsVisualStudioCopilotSkipPath(path string) bool { if parser.IsVisualStudioCopilotTraceFile(path) { return true } - _, _, ok := parser.ParseVisualStudioCopilotVirtualPath(path) + _, _, ok := parser.SplitVisualStudioCopilotVirtualPath(path) return ok } @@ -981,60 +981,6 @@ func (e *Engine) classifyOnePath( // shapes, so the legacy block was removed when Claude was folded // onto its provider. - // VSCode Copilot: /workspaceStorage//chatSessions/.{json,jsonl} - // or: /globalStorage/emptyWindowChatSessions/.{json,jsonl} - for _, vscDir := range e.agentDirs[parser.AgentVSCodeCopilot] { - if vscDir == "" { - continue - } - if rel, ok := isUnder(vscDir, path); ok { - parts := strings.Split(rel, sep) - // workspaceStorage//chatSessions/.{json,jsonl} - if len(parts) == 4 && - parts[0] == "workspaceStorage" && - parts[2] == "chatSessions" && - (strings.HasSuffix(parts[3], ".json") || - strings.HasSuffix(parts[3], ".jsonl")) { - if vscodeJSONLSiblingExists(path) { - continue - } - hashDir := filepath.Join( - vscDir, "workspaceStorage", parts[1], - ) - project := parser.ReadVSCodeWorkspaceManifest(hashDir) - if project == "" { - project = "unknown" - } - return parser.DiscoveredFile{ - Path: path, - Project: project, - Agent: parser.AgentVSCodeCopilot, - }, true - } - // globalStorage/emptyWindowChatSessions/.{json,jsonl} - // globalStorage/transferredChatSessions/.{json,jsonl} - if len(parts) == 3 && - parts[0] == "globalStorage" && - (parts[1] == "emptyWindowChatSessions" || parts[1] == "transferredChatSessions") && - (strings.HasSuffix(parts[2], ".json") || - strings.HasSuffix(parts[2], ".jsonl")) { - if vscodeJSONLSiblingExists(path) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: "empty-window", - Agent: parser.AgentVSCodeCopilot, - }, true - } - } - } - - // Visual Studio Copilot: /*_VSGitHubCopilot_traces.jsonl - if df, ok := e.classifyVisualStudioCopilotPath(path, sep); ok { - return df, true - } - if df, ok := e.classifyAiderPath(path); ok { return df, true } @@ -1141,37 +1087,6 @@ func (e *Engine) classifyOnePath( return parser.DiscoveredFile{}, false } -// classifyVisualStudioCopilotPath matches a top-level Visual Studio Copilot -// trace file (/*_VSGitHubCopilot_traces.jsonl) under a configured -// trace directory. Trace files live directly in the directory, so nested -// paths are rejected. Split out of classifyOnePath to keep that function -// within NilAway's per-function size limit. -func (e *Engine) classifyVisualStudioCopilotPath( - path, sep string, -) (parser.DiscoveredFile, bool) { - if !parser.IsVisualStudioCopilotTraceFile(path) { - return parser.DiscoveredFile{}, false - } - for _, vsDir := range e.agentDirs[parser.AgentVSCopilot] { - if vsDir == "" { - continue - } - rel, ok := isUnder(vsDir, path) - if !ok { - continue - } - if strings.Contains(rel, sep) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: "visualstudio", - Agent: parser.AgentVSCopilot, - }, true - } - return parser.DiscoveredFile{}, false -} - // classifyAiderPath handles Aider's rootless chat-history layout: // // /.../.aider.chat.history.md @@ -1427,18 +1342,6 @@ func (e *Engine) classifyShelleySQLitePath( return parser.DiscoveredFile{}, false } -// vscodeJSONLSiblingExists returns true when path is a .json -// file and a .jsonl sibling exists for the same UUID. This -// mirrors the dedup logic in DiscoverVSCodeCopilotSessions. -func vscodeJSONLSiblingExists(path string) bool { - base, ok := strings.CutSuffix(path, ".json") - if !ok { - return false - } - _, err := os.Stat(base + ".jsonl") - return err == nil -} - // resyncTempSuffix is appended to the original DB path to // form the temp database path during resync. const resyncTempSuffix = "-resync" @@ -3725,8 +3628,6 @@ func (e *Engine) processFile( statPath = dbPath } else if dbPath, _, ok := parser.ParseShelleyVirtualPath(file.Path); ok { statPath = dbPath - } else if tracePath, _, ok := parser.ParseVisualStudioCopilotVirtualPath(file.Path); ok { - statPath = tracePath } else if historyPath, _, ok := parser.ParseAiderVirtualPath(file.Path); ok { // aider stores "#"; stat the physical file // so SyncSingleSession (live watcher / on-demand re-sync) works. @@ -3789,10 +3690,6 @@ func (e *Engine) processFile( switch file.Agent { case parser.AgentReasonix: res = e.processReasonix(file, info) - case parser.AgentVSCodeCopilot: - res = e.processVSCodeCopilot(file, info) - case parser.AgentVSCopilot: - res = e.processVisualStudioCopilot(file, info) case parser.AgentKiro: res = e.processKiro(file, info) case parser.AgentKiroIDE: @@ -4375,7 +4272,7 @@ func (e *Engine) shouldCacheSkip( return false } if _, _, ok := - parser.ParseVisualStudioCopilotVirtualPath(file.Path); ok { + parser.SplitVisualStudioCopilotVirtualPath(file.Path); ok { return false } } @@ -5432,112 +5329,6 @@ func reasonixEffectiveInfo(path string, info os.FileInfo) os.FileInfo { return fakeSnapshotInfo{fSize: size, fMtime: mtime} } -func (e *Engine) processVSCodeCopilot( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseVSCodeCopilotSession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - { - Session: *sess, - Messages: msgs, - UsageEvents: sess.UsageEvents, - }, - }, - } -} - -func (e *Engine) processVisualStudioCopilot( - file parser.DiscoveredFile, _ os.FileInfo, -) processResult { - // Resolve the physical trace path first. Discovery emits one - // # work item per conversation; a watcher event - // or single-session resync may instead pass a real trace file, which can - // hold spans for several conversations. - tracePath := file.Path - var conversationIDs []string - if resolved, conversationID, ok := - parser.ParseVisualStudioCopilotVirtualPath(file.Path); ok { - tracePath = resolved - conversationIDs = []string{conversationID} - } - - // Skip on a fingerprint spanning every sibling trace file: a - // conversation's transcript is rebuilt from all of them, so a change to any - // sibling must defeat the skip even when the representative trace file is - // unchanged. The primary-file stat alone would let a single-session resync - // or watch fallback leave a session stale. - size, mtime, err := parser.VisualStudioCopilotTraceFingerprintStrict( - tracePath, - ) - if err != nil { - return processResult{err: err, noCacheSkip: true} - } - if e.shouldSkipByPath( - file.Path, fakeSnapshotInfo{fSize: size, fMtime: mtime}, - ) { - return processResult{skip: true} - } - - // A real trace file can hold spans for several conversations, so enumerate - // them and emit each independently. - if conversationIDs == nil { - ids, err := parser.VisualStudioCopilotFileConversationIDs(file.Path) - if err != nil { - return processResult{err: err, noCacheSkip: true} - } - conversationIDs = ids - } - - hash, hashErr := ComputeFileHash(tracePath) - - var results []parser.ParseResult - for _, conversationID := range conversationIDs { - sess, msgs, err := parser.ParseVisualStudioCopilotConversation( - tracePath, conversationID, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err, noCacheSkip: true} - } - if sess == nil { - continue - } - if hashErr == nil { - sess.File.Hash = hash - } - results = append(results, parser.ParseResult{ - Session: *sess, Messages: msgs, - }) - } - - // forceReplace mirrors the other multi-session-per-source agents - // (Zed, Kiro): each conversation's messages are fully re-derived from - // all of its spans on every parse, so existing rows must be replaced - // rather than appended. - return processResult{ - results: results, - forceReplace: true, - } -} - func (e *Engine) processZed( file parser.DiscoveredFile, info os.FileInfo, ) processResult { diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 3cf7b8ab8..875e031ea 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -7104,6 +7104,143 @@ func TestSyncPathsVSCodeCopilotJSONLPriority(t *testing.T) { assert.Equal(t, 0, len(page.Sessions), "expected 0 sessions (.json skipped), got %d", len(page.Sessions)) } +func TestSyncPathsVSCodeCopilotWorkspaceMetadataRefreshesProject(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir := t.TempDir() + vscDir := filepath.Join(dir, "vscode") + hashDir := filepath.Join(vscDir, "workspaceStorage", "abc123") + chatDir := filepath.Join(hashDir, "chatSessions") + workspacePath := filepath.Join(hashDir, "workspace.json") + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentVSCodeCopilot: {vscDir}, + }, + Machine: "local", + }) + + writeWorkspace := func(name string) { + t.Helper() + dbtest.WriteTestFile(t, workspacePath, fmt.Appendf(nil, + `{"folder":"file:///Users/alice/code/%s"}`, + name, + )) + } + + uuid := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" + session := fmt.Sprintf( + `{"version":1,"sessionId":"%s",`+ + `"creationDate":1704103200000,`+ + `"lastMessageDate":1704103260000,`+ + `"requests":[{"requestId":"r1",`+ + `"message":{"text":"hello"},`+ + `"response":[{"value":"hi"}],`+ + `"timestamp":1704103200000}]}`, + uuid, + ) + jsonlPath := filepath.Join(chatDir, uuid+".jsonl") + + writeWorkspace("one") + dbtest.WriteTestFile( + t, jsonlPath, + []byte(`{"kind":0,"v":`+session+`}`), + ) + + engine.SyncPaths([]string{jsonlPath}) + assertSessionState( + t, database, "vscode-copilot:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "one", sess.Project) + }, + ) + + info, err := os.Stat(jsonlPath) + require.NoError(t, err, "stat vscode copilot session") + engine.InjectSkipCache(map[string]int64{ + jsonlPath: info.ModTime().UnixNano(), + }) + + writeWorkspace("two") + engine.SyncPaths([]string{workspacePath}) + assertSessionState( + t, database, "vscode-copilot:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "two", sess.Project) + }, + ) + + writeWorkspace("three") + engine.SyncPaths([]string{jsonlPath, workspacePath}) + assertSessionState( + t, database, "vscode-copilot:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "three", sess.Project) + }, + ) +} + +func TestSyncPathsVSCodeCopilotPersistsUsageEvents(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir := t.TempDir() + vscDir := filepath.Join(dir, "vscode") + chatDir := filepath.Join( + vscDir, "workspaceStorage", "abc123", + "chatSessions", + ) + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentVSCodeCopilot: {vscDir}, + }, + Machine: "local", + }) + + uuid := "cccccccc-dddd-eeee-ffff-000000000000" + session := fmt.Sprintf( + `{"version":3,"sessionId":"%s",`+ + `"creationDate":1704103200000,`+ + `"lastMessageDate":1704103260000,`+ + `"requests":[{"requestId":"r1",`+ + `"message":{"text":"hello"},`+ + `"response":[{"value":"hi"}],`+ + `"timestamp":1704103200000,`+ + `"modelId":"copilot/claude-opus-4.8",`+ + `"result":{"metadata":{`+ + `"promptTokens":12,`+ + `"outputTokens":3,`+ + `"resolvedModel":"claude-opus-4-8"}}}]}`, + uuid, + ) + jsonPath := filepath.Join(chatDir, uuid+".json") + dbtest.WriteTestFile(t, jsonPath, []byte(session)) + + engine.SyncPaths([]string{jsonPath}) + + ctx := context.Background() + sessionID := "vscode-copilot:" + uuid + events, err := database.GetUsageEvents(ctx, sessionID) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "vscode-copilot", events[0].Source) + assert.Equal(t, "claude-opus-4-8", events[0].Model) + assert.Equal(t, 12, events[0].InputTokens) + assert.Equal(t, 3, events[0].OutputTokens) + + require.NoError(t, engine.SyncSingleSession(sessionID)) + events, err = database.GetUsageEvents(ctx, sessionID) + require.NoError(t, err) + require.Len(t, events, 1) + assert.Equal(t, "claude-opus-4-8", events[0].Model) +} + func TestPiSessionIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/internal/sync/parsediff.go b/internal/sync/parsediff.go index a3e5300e7..ae73e84d6 100644 --- a/internal/sync/parsediff.go +++ b/internal/sync/parsediff.go @@ -445,7 +445,7 @@ func isOpenCodeFamilyProviderVirtualSource(path string) bool { // shared trace file, and the "#runIdx" suffix aider appends to its shared // history file. func stripVirtualSourceSuffix(path string) string { - if tracePath, _, ok := parser.ParseVisualStudioCopilotVirtualPath(path); ok { + if tracePath, _, ok := parser.SplitVisualStudioCopilotVirtualPath(path); ok { return tracePath } if historyPath, _, ok := parser.ParseAiderVirtualPath(path); ok { diff --git a/internal/sync/visualstudio_copilot_integration_test.go b/internal/sync/visualstudio_copilot_integration_test.go index c2b3113ed..bb3fc545d 100644 --- a/internal/sync/visualstudio_copilot_integration_test.go +++ b/internal/sync/visualstudio_copilot_integration_test.go @@ -246,8 +246,8 @@ func TestFindSourceFileVisualStudioCopilotReturnsVirtualPath(t *testing.T) { } // TestSyncSingleSessionContextVisualStudioCopilotPreservesProject verifies that -// a single-session re-sync keeps the session's visualstudio project rather than -// overwriting it with an empty string. +// a single-session re-sync keeps the stored project rather than overwriting it +// with the provider's default project. func TestSyncSingleSessionContextVisualStudioCopilotPreservesProject(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") @@ -271,6 +271,8 @@ func TestSyncSingleSessionContextVisualStudioCopilotPreservesProject(t *testing. require.NoError(t, err) require.NotNil(t, before) require.Equal(t, "visualstudio", before.Project) + before.Project = "stored-solution" + require.NoError(t, database.UpsertSession(*before)) require.NoError(t, engine.SyncSingleSessionContext( context.Background(), sessionID, @@ -279,8 +281,8 @@ func TestSyncSingleSessionContextVisualStudioCopilotPreservesProject(t *testing. after, err := database.GetSession(context.Background(), sessionID) require.NoError(t, err) require.NotNil(t, after) - assert.Equal(t, "visualstudio", after.Project, - "single-session re-sync must preserve the visualstudio project") + assert.Equal(t, "stored-solution", after.Project, + "single-session re-sync must preserve the stored project") } // TestSyncEngineVisualStudioCopilotUnreadableSiblingBlocksPartialSession