Files
kenn-io--agentsview/internal/parser/visualstudio_copilot_provider.go
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

682 lines
20 KiB
Go

package parser
import (
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
)
// Visual Studio Copilot normally stores conversations inside shared trace
// files (*_VSGitHubCopilot_traces.jsonl), and newer Visual Studio versions can
// also write extensionless conversation files under .vs/*/copilot-chat/*/sessions.
// 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 legacy trace
// whose conversation IDs could not be read, so read failures are surfaced 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
// <traceFile>#<conversationID> 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 visualStudioCopilotVS2026SessionUnderRoot(root, path) {
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 {
vsRoot = filepath.Clean(vsRoot)
if vsRoot == "" {
return nil
}
bestByConversation := map[string]visualStudioCopilotCandidate{}
filesByConversation := map[string]DiscoveredFile{}
filesByPath := map[string]DiscoveredFile{}
entries, err := os.ReadDir(vsRoot)
if err == nil {
for _, file := range discoverVisualStudioCopilotSessionFiles(vsRoot, entries) {
if !vsCopilotRememberVirtualDiscoveredFile(
file,
bestByConversation,
filesByConversation,
) {
filesByPath[file.Path] = file
}
}
}
for _, file := range discoverVisualStudioCopilotVS2026SessionFiles(vsRoot) {
vsCopilotRememberVirtualDiscoveredFile(
file,
bestByConversation,
filesByConversation,
)
}
files := make([]DiscoveredFile, 0, len(filesByConversation)+len(filesByPath))
for _, file := range filesByConversation {
files = append(files, file)
}
for _, file := range filesByPath {
files = append(files, file)
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return files
}
func vsCopilotRememberVirtualDiscoveredFile(
file DiscoveredFile,
bestByConversation map[string]visualStudioCopilotCandidate,
filesByConversation map[string]DiscoveredFile,
) bool {
path, conversationID, ok := splitVisualStudioCopilotVirtualPath(file.Path)
if !ok {
return false
}
mtime := time.Time{}
if info, err := os.Stat(path); err == nil {
mtime = info.ModTime()
}
current := bestByConversation[conversationID]
if !visualStudioCopilotCandidateWins(path, mtime, current) {
return true
}
bestByConversation[conversationID] = visualStudioCopilotCandidate{
path: path,
mtime: mtime,
}
filesByConversation[conversationID] = file
return true
}
func discoverVisualStudioCopilotVS2026SessionFiles(
root string,
) []DiscoveredFile {
root = filepath.Clean(root)
switch visualStudioCopilotVS2026RootKind(root) {
case visualStudioCopilotVS2026SessionsRoot:
return discoverVisualStudioCopilotVS2026SessionFilesInDirectory(root)
case visualStudioCopilotVS2026ThreadRoot:
sessionsRoot, ok := visualStudioCopilotChildDir(root, "sessions")
if !ok {
return nil
}
return discoverVisualStudioCopilotVS2026SessionFilesInDirectory(sessionsRoot)
case visualStudioCopilotVS2026CopilotChatRoot:
return discoverVisualStudioCopilotVS2026SessionFilesInCopilotChatRoot(root)
case visualStudioCopilotVS2026VSRoot:
return discoverVisualStudioCopilotVS2026SessionFilesInVSRoot(root)
default:
vsRoot, ok := visualStudioCopilotChildDir(root, ".vs")
if !ok {
return nil
}
return discoverVisualStudioCopilotVS2026SessionFilesInVSRoot(vsRoot)
}
}
func discoverVisualStudioCopilotVS2026SessionFilesInVSRoot(
vsRoot string,
) []DiscoveredFile {
solutions, err := os.ReadDir(vsRoot)
if err != nil {
return nil
}
out := []DiscoveredFile{}
for _, solution := range solutions {
if !isDirOrSymlink(solution, vsRoot) {
continue
}
copilotChatRoot, ok := visualStudioCopilotChildDir(
filepath.Join(vsRoot, solution.Name()),
"copilot-chat",
)
if !ok {
continue
}
out = append(
out,
discoverVisualStudioCopilotVS2026SessionFilesInCopilotChatRoot(
copilotChatRoot,
)...,
)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
func discoverVisualStudioCopilotVS2026SessionFilesInCopilotChatRoot(
copilotChatRoot string,
) []DiscoveredFile {
threads, err := os.ReadDir(copilotChatRoot)
if err != nil {
return nil
}
out := []DiscoveredFile{}
for _, thread := range threads {
if !isDirOrSymlink(thread, copilotChatRoot) {
continue
}
sessionsRoot, ok := visualStudioCopilotChildDir(
filepath.Join(copilotChatRoot, thread.Name()),
"sessions",
)
if !ok {
continue
}
out = append(
out,
discoverVisualStudioCopilotVS2026SessionFilesInDirectory(
sessionsRoot,
)...,
)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
func discoverVisualStudioCopilotVS2026SessionFilesInDirectory(
dir string,
) []DiscoveredFile {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
out := make([]DiscoveredFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !isVisualStudioCopilotVS2026SessionFileName(name) {
continue
}
path := filepath.Join(dir, name)
if !isVisualStudioCopilotVS2026SessionPath(path) {
continue
}
out = append(out, DiscoveredFile{
Path: VisualStudioCopilotVirtualPath(path, name),
Project: "visualstudio",
Agent: AgentVSCopilot,
})
}
return out
}
func visualStudioCopilotChildDir(parent, name string) (string, bool) {
entries, err := os.ReadDir(parent)
if err != nil {
return "", false
}
for _, entry := range entries {
if isDirOrSymlink(entry, parent) && entry.Name() == name {
return filepath.Join(parent, entry.Name()), true
}
}
for _, entry := range entries {
if isDirOrSymlink(entry, parent) && strings.EqualFold(entry.Name(), name) {
return filepath.Join(parent, entry.Name()), true
}
}
return "", false
}
func vsCopilotWatchRoots(roots []string) []WatchRoot {
out := make([]WatchRoot, 0, len(roots)*2)
for _, root := range roots {
root = filepath.Clean(root)
if root == "" || root == "." {
continue
}
includeSessionRoots := true
switch visualStudioCopilotVS2026RootKind(root) {
case visualStudioCopilotVS2026SessionsRoot:
out = append(out, vsCopilotVS2026SessionWatchRoot(root))
includeSessionRoots = false
case visualStudioCopilotVS2026VSRoot,
visualStudioCopilotVS2026CopilotChatRoot,
visualStudioCopilotVS2026ThreadRoot:
out = append(out, WatchRoot{
Path: root,
Recursive: true,
IncludeGlobs: []string{"*"},
DebounceKey: string(AgentVSCopilot) + ":sessions:" + root,
})
default:
out = append(out, WatchRoot{
Path: root,
Recursive: false,
IncludeGlobs: []string{"*_VSGitHubCopilot_traces.jsonl"},
DebounceKey: string(AgentVSCopilot) + ":traces:" + root,
})
if vsRoot, ok := visualStudioCopilotChildDir(root, ".vs"); ok {
out = append(out, WatchRoot{
Path: vsRoot,
Recursive: true,
IncludeGlobs: []string{"*"},
DebounceKey: string(AgentVSCopilot) + ":sessions:" + vsRoot,
})
}
}
if includeSessionRoots {
for _, sessionsRoot := range visualStudioCopilotVS2026SessionRoots(root) {
out = append(out, vsCopilotVS2026SessionWatchRoot(sessionsRoot))
}
}
}
return out
}
func vsCopilotVS2026SessionWatchRoot(root string) WatchRoot {
return WatchRoot{
Path: root,
Recursive: false,
IncludeGlobs: []string{"*"},
DebounceKey: string(AgentVSCopilot) + ":sessions:" + root,
}
}
func visualStudioCopilotVS2026SessionRoots(root string) []string {
root = filepath.Clean(root)
var roots []string
switch visualStudioCopilotVS2026RootKind(root) {
case visualStudioCopilotVS2026SessionsRoot:
roots = append(roots, root)
case visualStudioCopilotVS2026ThreadRoot:
if sessionsRoot, ok := visualStudioCopilotChildDir(root, "sessions"); ok {
roots = append(roots, sessionsRoot)
}
case visualStudioCopilotVS2026CopilotChatRoot:
roots = visualStudioCopilotVS2026SessionRootsInCopilotChatRoot(root)
case visualStudioCopilotVS2026VSRoot:
roots = visualStudioCopilotVS2026SessionRootsInVSRoot(root)
default:
if vsRoot, ok := visualStudioCopilotChildDir(root, ".vs"); ok {
roots = visualStudioCopilotVS2026SessionRootsInVSRoot(vsRoot)
}
}
sort.Strings(roots)
return slices.Compact(roots)
}
func visualStudioCopilotVS2026SessionRootsInVSRoot(
vsRoot string,
) []string {
solutions, err := os.ReadDir(vsRoot)
if err != nil {
return nil
}
out := []string{}
for _, solution := range solutions {
if !isDirOrSymlink(solution, vsRoot) {
continue
}
copilotChatRoot, ok := visualStudioCopilotChildDir(
filepath.Join(vsRoot, solution.Name()),
"copilot-chat",
)
if !ok {
continue
}
out = append(
out,
visualStudioCopilotVS2026SessionRootsInCopilotChatRoot(
copilotChatRoot,
)...,
)
}
sort.Strings(out)
return slices.Compact(out)
}
func visualStudioCopilotVS2026SessionRootsInCopilotChatRoot(
copilotChatRoot string,
) []string {
threads, err := os.ReadDir(copilotChatRoot)
if err != nil {
return nil
}
out := []string{}
for _, thread := range threads {
if !isDirOrSymlink(thread, copilotChatRoot) {
continue
}
sessionsRoot, ok := visualStudioCopilotChildDir(
filepath.Join(copilotChatRoot, thread.Name()),
"sessions",
)
if !ok {
continue
}
out = append(out, sessionsRoot)
}
sort.Strings(out)
return slices.Compact(out)
}
// vsCopilotClassifyPath maps a stored or changed path to its trace container and
// conversation. A virtual path normally requires its backing container to exist;
// VS 2026 one-file member tombstones relax that under allowMissing. A bare trace
// path also 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 isVisualStudioCopilotVS2026SessionPath(tracePath) {
conversationID = canonicalVisualStudioCopilotConversationID(
conversationID,
)
if !visualStudioCopilotVS2026SessionUnderRoot(root, tracePath) ||
(!allowMissing && !IsRegularFile(tracePath)) {
return multiSessionMatch{}, false
}
return multiSessionMatch{
Path: VisualStudioCopilotVirtualPath(tracePath, conversationID),
Container: tracePath,
MemberID: conversationID,
ProjectHint: "visualstudio",
}, true
}
if !visualStudioCopilotTraceUnderRoot(root, tracePath, true) {
return multiSessionMatch{}, false
}
return multiSessionMatch{
Path: path,
Container: tracePath,
MemberID: conversationID,
ProjectHint: "visualstudio",
}, true
}
if isVisualStudioCopilotVS2026SessionPath(path) &&
visualStudioCopilotVS2026SessionUnderRoot(root, path) {
conversationID := canonicalVisualStudioCopilotConversationID(
filepath.Base(path),
)
if allowMissing && !IsRegularFile(path) {
return multiSessionMatch{
Path: path,
Container: path,
ProjectHint: "visualstudio",
}, true
}
return multiSessionMatch{
Path: VisualStudioCopilotVirtualPath(path, conversationID),
Container: path,
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 the canonical source for one
// conversation ID, deduplicating across legacy traces and VS 2026 session
// files with the same newest-mtime, then path tie-breaker used by discovery.
func findVisualStudioCopilotSourceFile(root, rawID string) string {
rawID = canonicalVisualStudioCopilotConversationID(rawID)
if root == "" || !IsValidSessionID(rawID) {
return ""
}
for _, file := range discoverVisualStudioCopilotSessionFilesUnderRoot(root) {
_, conversationID, ok := splitVisualStudioCopilotVirtualPath(file.Path)
if ok && sameVisualStudioCopilotConversationID(conversationID, rawID) {
return file.Path
}
}
return ""
}
func visualStudioCopilotVS2026SessionUnderRoot(
root, path string,
) bool {
rel, ok := relUnder(root, path)
if !ok {
return false
}
if !isVisualStudioCopilotVS2026SessionPath(path) {
return false
}
parts := strings.Split(filepath.ToSlash(rel), "/")
switch visualStudioCopilotVS2026RootKind(root) {
case visualStudioCopilotVS2026SessionsRoot:
return len(parts) == 1
case visualStudioCopilotVS2026ThreadRoot:
return len(parts) == 2 && strings.EqualFold(parts[0], "sessions")
case visualStudioCopilotVS2026CopilotChatRoot:
return len(parts) == 3 && strings.EqualFold(parts[1], "sessions")
case visualStudioCopilotVS2026VSRoot:
return len(parts) == 5 &&
strings.EqualFold(parts[1], "copilot-chat") &&
strings.EqualFold(parts[3], "sessions")
default:
return len(parts) == 6 &&
strings.EqualFold(parts[0], ".vs") &&
strings.EqualFold(parts[2], "copilot-chat") &&
strings.EqualFold(parts[4], "sessions")
}
}
type visualStudioCopilotVS2026RootMode int
const (
visualStudioCopilotVS2026ProjectRoot visualStudioCopilotVS2026RootMode = iota
visualStudioCopilotVS2026VSRoot
visualStudioCopilotVS2026CopilotChatRoot
visualStudioCopilotVS2026ThreadRoot
visualStudioCopilotVS2026SessionsRoot
)
func visualStudioCopilotVS2026RootKind(
root string,
) visualStudioCopilotVS2026RootMode {
root = filepath.Clean(root)
switch {
case strings.EqualFold(filepath.Base(root), "sessions") &&
strings.EqualFold(filepath.Base(filepath.Dir(filepath.Dir(root))), "copilot-chat"):
return visualStudioCopilotVS2026SessionsRoot
case strings.EqualFold(filepath.Base(filepath.Dir(root)), "copilot-chat"):
return visualStudioCopilotVS2026ThreadRoot
case strings.EqualFold(filepath.Base(root), "copilot-chat"):
return visualStudioCopilotVS2026CopilotChatRoot
case strings.EqualFold(filepath.Base(root), ".vs"):
return visualStudioCopilotVS2026VSRoot
default:
return visualStudioCopilotVS2026ProjectRoot
}
}
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 <traceFile>#<conversationID>
// 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 path and
// 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 !isVisualStudioCopilotConversationPath(tracePath) ||
!IsValidSessionID(conversationID) {
return "", "", false
}
conversationID = canonicalVisualStudioCopilotConversationID(conversationID)
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: multiSessionContainerSourceCapabilities(
CapabilitySupported,
CapabilitySupported,
),
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
AggregateUsageEvents: CapabilitySupported,
Model: CapabilitySupported,
},
}
}