diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index fc2b4a28b..e02ded303 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -936,7 +936,7 @@ func discoverVSCodeSessionFiles( files = append(files, DiscoveredFile{ Path: filepath.Join(dir, name), Project: project, - Agent: AgentVSCodeCopilot, + Agent: agent, }) } else if uuid, ok := strings.CutSuffix(name, ".json"); ok { // Skip .json if a .jsonl exists for the same UUID @@ -946,7 +946,7 @@ func discoverVSCodeSessionFiles( files = append(files, DiscoveredFile{ Path: filepath.Join(dir, name), Project: project, - Agent: AgentVSCodeCopilot, + Agent: agent, }) } } diff --git a/internal/parser/positron.go b/internal/parser/positron.go deleted file mode 100644 index 25d3739b4..000000000 --- a/internal/parser/positron.go +++ /dev/null @@ -1,148 +0,0 @@ -package parser - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// ParsePositronSession parses a Positron Assistant chat session -// file. The format is identical to VSCode Copilot sessions. -// Returns (nil, nil, nil) if the file is empty or contains no -// meaningful content. -func ParsePositronSession( - path, project, machine string, -) (*ParsedSession, []ParsedMessage, error) { - info, err := os.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil, nil - } - return nil, nil, fmt.Errorf("stat %s: %w", path, err) - } - - var data []byte - if strings.HasSuffix(path, ".jsonl") { - data, err = reconstructJSONL(path) - } else { - data, err = os.ReadFile(path) - } - if err != nil { - return nil, nil, fmt.Errorf("read %s: %w", path, err) - } - if len(data) == 0 { - return nil, nil, nil - } - - // Reuse VSCode Copilot parsing logic since formats are identical - sess, msgs, err := parseVSCodeCopilotData( - data, path, project, machine, - ) - if err != nil { - return nil, nil, err - } - if sess == nil { - return nil, nil, nil - } - - // Override agent type and ID prefix for Positron - sess.Agent = AgentPositron - sess.ID = "positron:" + sess.ID - - sess.File = FileInfo{ - Path: path, - Size: info.Size(), - Mtime: info.ModTime().UnixNano(), - } - - return sess, msgs, nil -} - -// DiscoverPositronSessions finds all chat session files under the -// Positron User directory. The structure mirrors VSCode: -// /workspaceStorage//chatSessions/.json -func DiscoverPositronSessions(userDir string) []DiscoveredFile { - if userDir == "" { - return nil - } - - var files []DiscoveredFile - - // Scan workspaceStorage//chatSessions/*.{json,jsonl} - wsDir := filepath.Join(userDir, "workspaceStorage") - hashDirs, err := os.ReadDir(wsDir) - if err != nil { - return 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" - } - - for _, f := range sessionFiles { - if f.IsDir() { - continue - } - name := f.Name() - if !strings.HasSuffix(name, ".json") && - !strings.HasSuffix(name, ".jsonl") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(chatDir, name), - Project: project, - Agent: AgentPositron, - }) - } - } - - return files -} - -// FindPositronSourceFile locates a Positron session file by its -// raw ID (prefix already stripped). -func FindPositronSourceFile(userDir, rawID string) string { - if userDir == "" || !IsValidSessionID(rawID) { - return "" - } - - // Search through workspaceStorage - wsDir := filepath.Join(userDir, "workspaceStorage") - hashDirs, err := os.ReadDir(wsDir) - if err != nil { - return "" - } - - for _, entry := range hashDirs { - if !entry.IsDir() { - continue - } - base := filepath.Join( - wsDir, entry.Name(), "chatSessions", - ) - // Prefer .jsonl over .json - for _, ext := range []string{".jsonl", ".json"} { - candidate := filepath.Join(base, rawID+ext) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - } - - return "" -} diff --git a/internal/parser/positron_provider.go b/internal/parser/positron_provider.go new file mode 100644 index 000000000..f982f5e9a --- /dev/null +++ b/internal/parser/positron_provider.go @@ -0,0 +1,574 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +var _ Provider = (*positronProvider)(nil) + +type positronProviderFactory struct { + def AgentDef +} + +func newPositronProviderFactory(def AgentDef) ProviderFactory { + return positronProviderFactory{def: cloneAgentDef(def)} +} + +func (f positronProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f positronProviderFactory) Capabilities() Capabilities { + return positronProviderCapabilities() +} + +func (f positronProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &positronProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: positronProviderCapabilities(), + Config: cfg, + }, + sources: newPositronSourceSet(cfg.Roots), + } +} + +type positronProvider struct { + ProviderBase + sources positronSourceSet +} + +func (p *positronProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *positronProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *positronProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *positronProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *positronProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *positronProvider) 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("positron 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.Size > 0 { + sess.File.Size = req.Fingerprint.Size + } + if req.Fingerprint.MTimeNS > 0 { + sess.File.Mtime = req.Fingerprint.MTimeNS + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + }, nil +} + +// parseSession parses a Positron Assistant chat session file. The format is +// identical to VSCode Copilot sessions. Returns (nil, nil, nil) if the file is +// empty or contains no meaningful content. +func (p *positronProvider) parseSession( + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("stat %s: %w", path, err) + } + + var data []byte + if strings.HasSuffix(path, ".jsonl") { + data, err = reconstructJSONL(path) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return nil, nil, fmt.Errorf("read %s: %w", path, err) + } + if len(data) == 0 { + return nil, nil, nil + } + + // Reuse VSCode Copilot parsing logic since formats are identical. + sess, msgs, err := parseVSCodeCopilotData(data, path, project, machine) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + + // Override agent type and ID prefix for Positron. + sess.Agent = AgentPositron + sess.ID = "positron:" + sess.ID + + sess.File = FileInfo{ + Path: path, + Size: info.Size(), + Mtime: info.ModTime().UnixNano(), + } + + return sess, msgs, nil +} + +type positronSource struct { + Root string + Path string + Project string +} + +type positronSourceSet struct { + roots []string +} + +func newPositronSourceSet(roots []string) positronSourceSet { + return positronSourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s positronSourceSet) 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.discoverSessions(root) { + source, ok := s.sourceRef(root, file.Path) + if !ok { + continue + } + source.ProjectHint = file.Project + addJSONLSource(source, &sources, seen) + } + } + sortJSONLSources(sources) + return sources, nil +} + +// discoverSessions finds all chat session files under a Positron User +// directory. The structure mirrors VSCode: +// /workspaceStorage//chatSessions/.{json,jsonl}. When a +// .jsonl and .json sibling exist for the same UUID, the .jsonl is preferred. +func (s positronSourceSet) discoverSessions(userDir string) []DiscoveredFile { + if userDir == "" { + return nil + } + + var files []DiscoveredFile + + // Scan workspaceStorage//chatSessions/*.{json,jsonl}. + wsDir := filepath.Join(userDir, "workspaceStorage") + hashDirs, err := os.ReadDir(wsDir) + if err != nil { + return 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 + } + + project := positronWorkspaceProject(userDir, entry.Name()) + files = append(files, + discoverVSCodeSessionFiles( + chatDir, sessionFiles, project, AgentPositron, + )..., + ) + } + + return files +} + +// findSourceFile locates a Positron session file by its raw ID (prefix already +// stripped), preferring .jsonl over .json. Returns "" when no matching file +// exists. +func (s positronSourceSet) findSourceFile(userDir, rawID string) string { + if userDir == "" || !IsValidSessionID(rawID) { + return "" + } + + wsDir := filepath.Join(userDir, "workspaceStorage") + hashDirs, err := os.ReadDir(wsDir) + if err != nil { + return "" + } + + for _, entry := range hashDirs { + if !entry.IsDir() { + continue + } + base := filepath.Join(wsDir, entry.Name(), "chatSessions") + // Prefer .jsonl over .json. + for _, ext := range []string{".jsonl", ".json"} { + candidate := filepath.Join(base, rawID+ext) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + } + + return "" +} + +func (s positronSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)) + for _, root := range s.roots { + workspace := filepath.Join(root, "workspaceStorage") + roots = append(roots, WatchRoot{ + Path: workspace, + Recursive: true, + IncludeGlobs: []string{"*.json", "*.jsonl"}, + DebounceKey: string(AgentPositron) + ":workspace:" + workspace, + }) + } + return WatchPlan{Roots: roots}, nil +} + +func (s positronSourceSet) 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 positronSourceSet) 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 +} + +func (s positronSourceSet) 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("positron 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 positronSourceSet) pathFromSource(source SourceRef) (string, string, bool) { + switch src := source.Opaque.(type) { + case positronSource: + return src.Path, src.Project, src.Path != "" + case *positronSource: + 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.(positronSource) + return src.Path, src.Project, true + } + } + } + return "", "", false +} + +func (s positronSourceSet) 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]) { + return SourceRef{}, false + } + if promoted := vscodeCopilotPreferredExistingPath(path); promoted != "" { + path = promoted + } + if !IsRegularFile(path) { + return SourceRef{}, false + } + project := positronWorkspaceProject(root, parts[1]) + return s.newSourceRef(root, path, project), true +} + +func (s positronSourceSet) 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 positronSourceSet) 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]) { + return SourceRef{}, false + } + if promoted := vscodeCopilotPreferredExistingPath(path); promoted != "" { + path = promoted + } + project := positronWorkspaceProject(root, parts[1]) + return s.newSourceRef(root, path, project), true +} + +func (s positronSourceSet) 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 := positronWorkspaceProject(root, parts[1]) + files := discoverVSCodeSessionFiles(chatDir, entries, project, AgentPositron) + 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.Provider = AgentPositron + source.ProjectHint = file.Project + addJSONLSource(source, &sources, seen) + } + sortJSONLSources(sources) + return sources +} + +func (s positronSourceSet) 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 positronSourceSet) newSourceRef(root, path, project string) SourceRef { + return SourceRef{ + Provider: AgentPositron, + Key: path, + DisplayPath: path, + FingerprintKey: path, + ProjectHint: project, + Opaque: positronSource{ + Root: root, + Path: path, + Project: project, + }, + } +} + +func positronWorkspaceProject(root, hash string) string { + hashDir := filepath.Join(root, "workspaceStorage", hash) + project := ReadVSCodeWorkspaceManifest(hashDir) + if project == "" { + project = "unknown" + } + return project +} + +func positronProviderCapabilities() 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/positron_provider_test.go b/internal/parser/positron_provider_test.go new file mode 100644 index 000000000..24ace99e2 --- /dev/null +++ b/internal/parser/positron_provider_test.go @@ -0,0 +1,134 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPositronProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentPositron) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentPositron, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestPositronProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sessionID := "positron-provider" + hashDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + chatDir := filepath.Join(hashDir, "chatSessions") + workspacePath := filepath.Join(hashDir, "workspace.json") + sourcePath := filepath.Join(chatDir, sessionID+".jsonl") + writeSourceFile(t, workspacePath, + `{"folder":"file:///Users/alice/code/positron-app"}`) + writeSourceFile(t, sourcePath, + vscodeCopilotProviderJSONL(sessionID, "Hello Positron")) + + provider, ok := NewProvider(AgentPositron, 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, filepath.Join(root, "workspaceStorage"), plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + assert.Equal(t, "positron-app", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~positron:" + sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "write"}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, sourcePath, 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, "positron:"+sessionID, result.Result.Session.ID) + assert.Equal(t, AgentPositron, result.Result.Session.Agent) + assert.Equal(t, "positron-app", 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, 2) +} + +func TestPositronProviderClassifiesDeletedAndMetadataPaths(t *testing.T) { + root := t.TempDir() + hashDir := filepath.Join(root, "workspaceStorage", "workspace-hash") + chatDir := filepath.Join(hashDir, "chatSessions") + workspacePath := filepath.Join(hashDir, "workspace.json") + sourcePath := filepath.Join(chatDir, "metadata.jsonl") + writeSourceFile(t, workspacePath, + `{"folder":"file:///Users/alice/code/positron-app"}`) + writeSourceFile(t, sourcePath, + vscodeCopilotProviderJSONL("metadata", "Hello metadata")) + + provider, ok := NewProvider(AgentPositron, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + metadataChanged, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: workspacePath, EventKind: "write"}, + ) + require.NoError(t, err) + require.Len(t, metadataChanged, 1) + assert.Equal(t, sourcePath, metadataChanged[0].DisplayPath) + + beforeMetadata, err := provider.Fingerprint(context.Background(), metadataChanged[0]) + require.NoError(t, err) + writeSourceFile(t, workspacePath, + `{"folder":"file:///Users/alice/code/positron-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(sourcePath)) + deleted, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "remove"}, + ) + require.NoError(t, err) + require.Len(t, deleted, 1) + assert.Equal(t, sourcePath, deleted[0].DisplayPath) +} diff --git a/internal/parser/positron_test.go b/internal/parser/positron_test.go index 8a93ffadb..50b51b724 100644 --- a/internal/parser/positron_test.go +++ b/internal/parser/positron_test.go @@ -9,7 +9,11 @@ import ( "github.com/stretchr/testify/require" ) -func TestParsePositronSession(t *testing.T) { +func newPositronTestSourceSet(roots ...string) positronSourceSet { + return newPositronSourceSet(roots) +} + +func TestPositronProviderParseSession(t *testing.T) { // Create a minimal Positron session JSON sessionJSON := `{ "version": 3, @@ -60,10 +64,11 @@ func TestParsePositronSession(t *testing.T) { sessionPath, []byte(sessionJSON), 0644, )) - sess, msgs, err := ParsePositronSession( + p := &positronProvider{} + sess, msgs, err := p.parseSession( sessionPath, "test-project", "test-machine", ) - require.NoError(t, err, "ParsePositronSession failed") + require.NoError(t, err, "parseSession failed") require.NotNil(t, sess, "expected session, got nil") // Verify session metadata @@ -86,7 +91,7 @@ func TestParsePositronSession(t *testing.T) { assert.True(t, msgs[3].HasToolUse, "msgs[3] should have tool use") } -func TestDiscoverPositronSessions(t *testing.T) { +func TestPositronSourceSetDiscoverSessions(t *testing.T) { tmpDir := t.TempDir() // Create directory structure: @@ -106,11 +111,13 @@ func TestDiscoverPositronSessions(t *testing.T) { 0644, )) - // Create session files + // Create session files. The .json file with a .jsonl sibling must be + // deduped so full discovery matches changed-path sync precedence. sessionJSON := `{"version": 3, "requests": []}` for _, name := range []string{ "session-1.json", "session-2.jsonl", + "session-2.json", } { require.NoError(t, os.WriteFile( filepath.Join(chatDir, name), @@ -126,16 +133,20 @@ func TestDiscoverPositronSessions(t *testing.T) { 0644, )) - files := DiscoverPositronSessions(tmpDir) + set := newPositronTestSourceSet(tmpDir) + files := set.discoverSessions(tmpDir) require.Len(t, files, 2) + paths := make([]string, 0, len(files)) for _, f := range files { + paths = append(paths, filepath.Base(f.Path)) assert.Equal(t, AgentPositron, f.Agent) assert.Equal(t, "myproject", f.Project) } + assert.ElementsMatch(t, []string{"session-1.json", "session-2.jsonl"}, paths) } -func TestFindPositronSourceFile(t *testing.T) { +func TestPositronSourceSetFindSourceFile(t *testing.T) { tmpDir := t.TempDir() // Create directory structure @@ -151,11 +162,13 @@ func TestFindPositronSourceFile(t *testing.T) { sessionPath, []byte(`{}`), 0644, )) + set := newPositronTestSourceSet(tmpDir) + // Test finding existing session - found := FindPositronSourceFile(tmpDir, "test-uuid") + found := set.findSourceFile(tmpDir, "test-uuid") assert.Equal(t, sessionPath, found) // Test finding non-existent session - notFound := FindPositronSourceFile(tmpDir, "nonexistent") + notFound := set.findSourceFile(tmpDir, "nonexistent") assert.Empty(t, notFound) } diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 40c404dfa..08cd92622 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -392,6 +392,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newOpenClawProviderFactory(def) case AgentOMP, AgentPi: return newPiProviderFactory(def) + case AgentPositron: + return newPositronProviderFactory(def) case AgentQClaw: return newQClawProviderFactory(def) case AgentQwen: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index dcafa9b8e..a5de13070 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -49,7 +49,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentForge: ProviderMigrationLegacyOnly, AgentPiebald: ProviderMigrationLegacyOnly, AgentWarp: ProviderMigrationLegacyOnly, - AgentPositron: ProviderMigrationLegacyOnly, + AgentPositron: ProviderMigrationProviderAuthoritative, AgentAntigravity: ProviderMigrationLegacyOnly, AgentAntigravityCLI: ProviderMigrationLegacyOnly, AgentVibe: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index ce5b4b66b..9eb94c06f 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -47,22 +47,23 @@ 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, - "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, + "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, + "shelley_provider.go": true, + "vibe_provider.go": true, + "visualstudio_copilot_provider.go": true, + "vscode_copilot_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 ee0773007..c997ebd24 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -474,11 +474,9 @@ var Registry = []AgentDef{ DefaultDirs: []string{ "Library/Application Support/Positron/User", }, - IDPrefix: "positron:", - WatchSubdirs: []string{"workspaceStorage"}, - FileBased: true, - DiscoverFunc: DiscoverPositronSessions, - FindSourceFunc: FindPositronSourceFile, + IDPrefix: "positron:", + WatchSubdirs: []string{"workspaceStorage"}, + FileBased: true, }, { Type: AgentZed, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index c0abcbb1a..940f6f0db 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -2779,12 +2779,33 @@ func (e *Engine) filterFilesByMtime( return out } +// discoveredFileEffectiveMtime returns the freshness timestamp used to filter a +// discovered file against an incremental-sync cutoff. For provider-sourced +// files it consults the provider's Fingerprint so composite/sibling-file +// freshness (for example a Positron session whose workspace.json changed while +// the chat transcript did not) is honored without a per-agent legacy helper. +// Files without a provider source fall back to the legacy mtime computation. func (e *Engine) discoveredFileEffectiveMtime( - ctx context.Context, - file parser.DiscoveredFile, + ctx context.Context, file parser.DiscoveredFile, ) (int64, error) { + // Codex is excluded from the provider-Fingerprint path on purpose. Its + // Fingerprint folds the shared session_index.jsonl mtime into every + // session's freshness (see CodexEffectiveMtime). That shared signal is + // correct for the skip cache but wrong for the incremental-sync cutoff: + // when the index changes, both the live and archived copies of a UUID + // would look fresh, defeating the per-copy mtime discrimination that + // expandCodexProviderDuplicates relies on to preserve a changed archived + // duplicate. Index refreshes are handled separately by the codexIndexRefresh + // pass in filterFilesByMtime, so codex uses its raw per-file mtime here. + if file.Agent == parser.AgentCodex { + return discoveredFileMtime(file) + } + // Only provider-authoritative sources resolve freshness through the + // provider Fingerprint. Shadow-compare files keep the legacy mtime path so + // agent-specific incremental-sync behavior (for example the Codex index + // refresh below) is unchanged while a provider is still shadowed. if file.ProviderSource != nil && file.ProviderProcess { - if mtime, ok, err := e.providerFingerprintMtime(ctx, file); err != nil { + if mtime, ok, err := e.providerSourceMtime(ctx, file); err != nil { return 0, err } else if ok { return mtime, nil @@ -2793,9 +2814,12 @@ func (e *Engine) discoveredFileEffectiveMtime( return discoveredFileMtime(file) } -func (e *Engine) providerFingerprintMtime( - ctx context.Context, - file parser.DiscoveredFile, +// providerSourceMtime resolves a provider-sourced file's effective mtime through +// the owning provider's Fingerprint. The boolean reports whether the provider +// runtime produced a usable timestamp; a false result tells the caller to fall +// back to the legacy mtime path. +func (e *Engine) providerSourceMtime( + ctx context.Context, file parser.DiscoveredFile, ) (int64, bool, error) { if file.ProviderSource == nil { return 0, false, nil @@ -3694,8 +3718,6 @@ func (e *Engine) processFile( res = e.processKiro(file, info) case parser.AgentKiroIDE: res = e.processKiroIDE(file, info) - case parser.AgentPositron: - res = e.processPositron(file, info) case parser.AgentZed: res = e.processZed(file, info) case parser.AgentShelley: @@ -5630,35 +5652,6 @@ func vibeEffectiveInfo(path string, info os.FileInfo) os.FileInfo { return fakeSnapshotInfo{fSize: size, fMtime: mtime} } -func (e *Engine) processPositron( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParsePositronSession( - 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}, - }, - } -} - // aiderFileUnchanged reports whether a physical aider history file is // unchanged since the last sync. Aider sessions are stored under virtual // "#" paths, so the generic shouldSkipByPath (which looks the @@ -5826,6 +5819,8 @@ func (e *Engine) processAntigravityCLI( if sess == nil { return processResult{} } + sess.File.Size = effectiveInfo.Size() + sess.File.Mtime = effectiveInfo.ModTime().UnixNano() hash, err := ComputeFileHash(file.Path) if err == nil { diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index 875e031ea..2064e3567 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -4733,6 +4733,8 @@ func TestSyncAllSinceOpenCodeStoragePicksUpUsagePartUpdate(t *testing.T) { require.NoError(t, os.Chtimes(usagePartPath, future, future), "chtimes usage part") require.NoError(t, os.Chtimes(sessionPath, sessionMtime, sessionMtime), "restore session mtime") + // Composite freshness includes the part file, so the part-only edit is + // fresh relative to the cutoff and re-syncs the updated reply. stats := env.engine.SyncAllSince(context.Background(), cutoff, nil) require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced) @@ -7241,6 +7243,265 @@ func TestSyncPathsVSCodeCopilotPersistsUsageEvents(t *testing.T) { assert.Equal(t, "claude-opus-4-8", events[0].Model) } +func TestSyncPathsPositronJSONLPriority(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir := t.TempDir() + positronDir := filepath.Join(dir, "positron") + chatDir := filepath.Join( + positronDir, "workspaceStorage", "abc123", + "chatSessions", + ) + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentPositron: {positronDir}, + }, + Machine: "local", + }) + + uuid := "cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa" + session := fmt.Sprintf( + `{"version":1,"sessionId":"%s",`+ + `"creationDate":1704103200000,`+ + `"lastMessageDate":1704103260000,`+ + `"requests":[{"requestId":"r1",`+ + `"message":{"text":"hello"},`+ + `"response":[{"value":"hi"}],`+ + `"timestamp":1704103200000}]}`, + uuid, + ) + + jsonPath := filepath.Join(chatDir, uuid+".json") + jsonlPath := filepath.Join(chatDir, uuid+".jsonl") + dbtest.WriteTestFile(t, jsonPath, []byte(session)) + dbtest.WriteTestFile( + t, jsonlPath, + []byte(`{"kind":0,"v":`+session+`}`), + ) + + engine.SyncPaths([]string{jsonPath}) + + page, err := database.ListSessions( + context.Background(), db.SessionFilter{Limit: 10}, + ) + require.NoError(t, err) + assert.Equal(t, 0, len(page.Sessions), "expected 0 sessions (.json skipped), got %d", len(page.Sessions)) +} + +func TestSyncAllPositronJSONLPriority(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir := t.TempDir() + positronDir := filepath.Join(dir, "positron") + hashDir := filepath.Join(positronDir, "workspaceStorage", "abc123") + chatDir := filepath.Join(hashDir, "chatSessions") + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentPositron: {positronDir}, + }, + Machine: "local", + }) + + uuid := "cccccccc-dddd-eeee-ffff-bbbbbbbbbbbb" + jsonSession := fmt.Sprintf( + `{"version":1,"sessionId":"%s",`+ + `"creationDate":1704103200000,`+ + `"lastMessageDate":1704103260000,`+ + `"requests":[{"requestId":"r1",`+ + `"message":{"text":"json fallback"},`+ + `"response":[{"value":"json response"}],`+ + `"timestamp":1704103200000}]}`, + uuid, + ) + jsonlSession := fmt.Sprintf( + `{"version":1,"sessionId":"%s",`+ + `"creationDate":1704103200000,`+ + `"lastMessageDate":1704103260000,`+ + `"requests":[{"requestId":"r1",`+ + `"message":{"text":"jsonl preferred"},`+ + `"response":[{"value":"jsonl response"}],`+ + `"timestamp":1704103200000}]}`, + uuid, + ) + + jsonPath := filepath.Join(chatDir, uuid+".json") + jsonlPath := filepath.Join(chatDir, uuid+".jsonl") + dbtest.WriteTestFile(t, jsonPath, []byte(jsonSession)) + dbtest.WriteTestFile( + t, jsonlPath, + []byte(`{"kind":0,"v":`+jsonlSession+`}`), + ) + + stats := engine.SyncAll(context.Background(), nil) + assert.Equal(t, 1, stats.Synced, "synced = %d, want 1", stats.Synced) + + sess, err := database.GetSession(context.Background(), "positron:"+uuid) + require.NoError(t, err) + require.NotNil(t, sess) + assertSessionMessageCount(t, database, "positron:"+uuid, 2) + msgs := fetchMessages(t, database, "positron:"+uuid) + require.NotEmpty(t, msgs) + assert.Equal(t, "jsonl preferred", msgs[0].Content) +} + +func TestSyncPathsPositronWorkspaceMetadataRefreshesProject(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir := t.TempDir() + positronDir := filepath.Join(dir, "positron") + hashDir := filepath.Join(positronDir, "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.AgentPositron: {positronDir}, + }, + Machine: "local", + }) + + writeWorkspace := func(name string) { + t.Helper() + dbtest.WriteTestFile(t, workspacePath, fmt.Appendf(nil, + `{"folder":"file:///Users/alice/code/%s"}`, + name, + )) + } + + uuid := "dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb" + 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, "positron:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "one", sess.Project) + }, + ) + + info, err := os.Stat(jsonlPath) + require.NoError(t, err, "stat positron session") + engine.InjectSkipCache(map[string]int64{ + jsonlPath: info.ModTime().UnixNano(), + }) + + writeWorkspace("two") + engine.SyncPaths([]string{workspacePath}) + assertSessionState( + t, database, "positron:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "two", sess.Project) + }, + ) + + writeWorkspace("three") + engine.SyncPaths([]string{jsonlPath, workspacePath}) + assertSessionState( + t, database, "positron:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "three", sess.Project) + }, + ) +} + +func TestSyncAllSincePositronWorkspaceMetadataRefreshesProject(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + dir := t.TempDir() + positronDir := filepath.Join(dir, "positron") + hashDir := filepath.Join(positronDir, "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.AgentPositron: {positronDir}, + }, + Machine: "local", + }) + + writeWorkspace := func(name string) { + t.Helper() + dbtest.WriteTestFile(t, workspacePath, fmt.Appendf(nil, + `{"folder":"file:///Users/alice/code/%s"}`, + name, + )) + } + + uuid := "dddddddd-eeee-ffff-aaaa-cccccccccccc" + 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.SyncAll(context.Background(), nil) + assertSessionState( + t, database, "positron:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "one", sess.Project) + }, + ) + + oldTime := time.Now().Add(-48 * time.Hour) + require.NoError(t, os.Chtimes(jsonlPath, oldTime, oldTime), "chtimes session") + require.NoError(t, os.Chtimes(workspacePath, oldTime, oldTime), "chtimes workspace") + cutoff := time.Now().Add(-1 * time.Hour) + + writeWorkspace("two") + stats := engine.SyncAllSince(context.Background(), cutoff, nil) + assert.Equal(t, 1, stats.Synced, "synced = %d, want 1", stats.Synced) + + assertSessionState( + t, database, "positron:"+uuid, + func(sess *db.Session) { + assert.Equal(t, "two", sess.Project) + }, + ) +} + func TestPiSessionIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test")