From 89342a2fa205ba3ec304590c610b9cd141f32d54 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 20:59:33 -0400 Subject: [PATCH] feat(parser): migrate copilot ide providers VS Code Copilot and Visual Studio Copilot both needed concrete providers because their source identity is richer than a plain parser callback. VS Code needs workspace and global chat discovery with .jsonl preference, while Visual Studio needs virtual per-conversation trace sources with sibling-aware freshness. The providers preserve raw and full ID lookup, watch classification, source hashing, VS Code project hints, Visual Studio physical trace fan-out, strict composite trace fingerprints, force-replace parse semantics, and parser output normalization. fix(parser): classify copilot ide source changes The Copilot IDE providers advertised changed-path classification, but the initial migration only accepted source paths that still existed. That dropped deletion and metadata-only events before the sync layer could make a refresh or removal decision. Classify syntactically valid removed VS Code chat files and Visual Studio trace files, fan workspace.json changes out to current workspace chat sessions, and cover Visual Studio physical trace fan-out with multiple conversations. fix(parser): include vscode workspace metadata freshness VS Code Copilot project names come from workspace.json, so classifying manifest writes is not enough if the source fingerprint still only reflects the chat transcript. An unchanged chat file could skip the parse that refreshes Session.Project. Fold workspace.json size, mtime, and content hash into workspace chat fingerprints while leaving global chat fingerprints unchanged, and cover metadata-only freshness in the provider tests. fix(sync): refresh vscode copilot workspace metadata VS Code Copilot was provider-aware for workspace.json freshness, but this stack still runs legacy sync writes. Without mirroring that freshness in the legacy process path, metadata-only workspace renames could be classified but then skipped against the unchanged chat transcript. Move the Copilot IDE providers into shadow compare on their migration branch, preserve .jsonl priority during provider changed-path classification, and store composite workspace freshness for VS Code Copilot sessions while both shapes run. Validation: go test -tags "fts5" ./internal/sync -run 'TestSyncPathsVSCodeCopilot(JSONLPriority|WorkspaceMetadataRefreshesProject)' -count=1; go test -tags "fts5" ./internal/parser -run 'Test(VSCodeCopilotProvider|VisualStudioCopilotProvider|ProviderMigrationModes)' -count=1; go test -tags "fts5" ./internal/sync -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare copilot ide shadow parity VS Code Copilot and Visual Studio Copilot are already opted into shadow comparison on this branch, but provider method tests alone do not prove the migration path still matches the legacy parser output consumed by sync. Cover the workspace-backed VS Code JSONL source and Visual Studio virtual trace source through ObserveProviderSource so reviewers can see provider observation, data-version planning, and legacy parser parity in one place. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatches(VSCodeCopilot|VisualStudioCopilot)LegacyParser|TestCopilotIDEProvider|Test(VSCodeCopilotProvider|VisualStudioCopilotProvider)' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/...; git diff --check refactor(parser): fold copilot IDE providers Move VSCode Copilot and Visual Studio Copilot source discovery, lookup, and parse ownership onto their concrete providers and delete the seven legacy package-level free functions: DiscoverVSCodeCopilotSessions, FindVSCodeCopilotSourceFile, ParseVSCodeCopilotSession, DiscoverVisualStudioCopilotSessions, FindVisualStudioCopilotSourceFile, ParseVisualStudioCopilotConversation, and ParseVisualStudioCopilotVirtualPath. VSCode Copilot: discoverSessionFiles and findSourceFile become source-set helpers, parseSession becomes a provider method, and the shared discoverVSCodeSessionFiles helper stays in discovery.go. Visual Studio Copilot: discoverSessionFiles and findSourceFile become source-set helpers (over the retained findVisualStudioCopilotTraceSourceFile and discoverVisualStudioCopilotSessionFiles helpers), and parseConversation becomes a provider method. The virtual-path resolution is reproduced on the provider via the provider-neutral ParseVirtualSourcePath helper plus the trace-file and conversation-ID predicates (splitVisualStudioCopilotVirtualPath), replacing the deleted ParseVisualStudioCopilotVirtualPath. External callers (session export, direct service, parsediff, engine skip-path checks) use the new exported SplitVisualStudioCopilotVirtualPath, which wraps the same neutral splitter. The provider's discovery now surfaces an unreadable physical trace file as a source so the read failure is reported instead of being dropped. Make both providers provider-authoritative and drop their legacy sync dispatch: the classifyOnePath VSCode block, classifyVisualStudioCopilotPath and its call, the processFile case arms, processVSCodeCopilot and its vscodeCopilot* helpers, processVisualStudioCopilot, the vscodeJSONLSiblingExists helper, and the now-dead legacy-preamble references to these agents. Drop the AgentDef DiscoverFunc/FindSourceFunc hooks for both, remove both provider files from the pending shim scan list, and replace the shadow-baseline test with provider API coverage plus a guard asserting the legacy entrypoints stay gone. Re-home the shared writeProviderShadowSourceFile test helper into provider_shadow_test.go so the sync test package builds. fix(parser): preserve copilot provider metadata Provider-authoritative Copilot sync consumes ParseResult side channels, not only fields stored on ParsedSession. VS Code Copilot was parsing aggregate token usage but returning an empty ParseResult.UsageEvents slice, so a provider resync could erase usage rows. Visual Studio Copilot single-session resyncs carry the stored project through Source.ProjectHint. Honoring that hint prevents the provider default from overwriting preserved project metadata, while VS Code now also carries the composite fingerprint size and mtime alongside the hash. Validation: go test -tags "fts5" ./internal/parser -run 'Test(VSCodeCopilotProviderSourceMethods|VisualStudioCopilotProviderSourceMethods)' -count=1; go test -tags "fts5" ./internal/sync -run 'TestSyncPathsVSCodeCopilotPersistsUsageEvents|TestSyncSingleSessionContextVisualStudioCopilotPreservesProject' -count=1; go test -tags "fts5" ./internal/parser -run 'Test.*Copilot.*Provider|TestParseVSCodeCopilotSession_TokenUsage|TestParseVisualStudioCopilot' -count=1; go test -tags "fts5" ./internal/sync -run 'Test.*(VSCodeCopilot|VisualStudioCopilot).*' -count=1; go vet ./...; git diff --check test(parser): guard visual studio copilot session fold The Copilot IDE fold deleted ParseVisualStudioCopilotSession along with the other Visual Studio Copilot legacy entrypoints, but the regression guard did not name that symbol. Adding it prevents a future shim from reappearing unnoticed. Validation: go test -tags "fts5" ./internal/parser -run 'TestCopilotIDEProvidersOwnLegacyEntrypoints|Test(VSCodeCopilotProviderSourceMethods|VisualStudioCopilotProviderSourceMethods)' -count=1; git diff --check --- cmd/agentsview/session_export.go | 2 +- internal/parser/copilot_ide_provider_test.go | 416 +++++++++++ .../parser/copilot_ide_test_helpers_test.go | 113 +++ internal/parser/discovery.go | 146 +--- internal/parser/provider.go | 4 + internal/parser/provider_migration.go | 4 +- internal/parser/provider_shim_scan_test.go | 32 +- internal/parser/types.go | 10 +- internal/parser/visualstudio_copilot.go | 68 +- .../parser/visualstudio_copilot_provider.go | 274 ++++++++ internal/parser/visualstudio_copilot_test.go | 74 +- internal/parser/vscode_copilot.go | 8 +- internal/parser/vscode_copilot_provider.go | 654 ++++++++++++++++++ internal/parser/vscode_copilot_test.go | 22 +- internal/service/direct.go | 2 +- internal/sync/engine.go | 213 +----- internal/sync/engine_integration_test.go | 137 ++++ internal/sync/parsediff.go | 2 +- .../visualstudio_copilot_integration_test.go | 10 +- 19 files changed, 1699 insertions(+), 492 deletions(-) create mode 100644 internal/parser/copilot_ide_provider_test.go create mode 100644 internal/parser/copilot_ide_test_helpers_test.go create mode 100644 internal/parser/visualstudio_copilot_provider.go create mode 100644 internal/parser/vscode_copilot_provider.go 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