From 77acac5f3edc01dccf16277d3f6e432828e91c31 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 21:33:52 -0400 Subject: [PATCH] feat(parser): migrate workbuddy provider WorkBuddy is still JSONL-backed, but its source layout has two valid shapes: project-level session files and nested subagent files. Moving it behind a concrete provider keeps that provider-specific shape explicit while continuing to reuse the shared JSONL filesystem mechanics. The provider preserves legacy discovery and lookup behavior, including symlinked project directories and files, compound subagent raw IDs, deleted-path classification, source fingerprinting, and existing parser normalization for parent/subagent relationships. Validation: go fmt ./...; go test -tags "fts5" ./internal/parser -run TestWorkBuddyProvider -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; make test-short; git diff --check test(parser): document workbuddy subagent discovery WorkBuddy legacy discovery accepts any JSONL filename under a valid parent session's subagents directory, while raw subagent lookup still validates the requested ID. The provider migration intentionally preserves that asymmetry rather than tightening discovery and dropping sources that older code would import. Validation: go fmt ./...; go test -tags "fts5" ./internal/parser -run TestWorkBuddyProviderSourceMethods -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check; make nilaway test(parser): opt workbuddy into provider shadow WorkBuddy now has a concrete facade provider on this branch, so its migration mode should enter the shared shadow-compare harness rather than remaining legacy-only and additive. Lower provider opt-ins stay inherited and later provider branches remain responsible for their own concrete providers. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare workbuddy shadow parity WorkBuddy is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseWorkBuddySession. The test covers both the main session file and nested subagent file shape so parent relationship parity stays visible while the stack migrates provider by provider. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesWorkBuddyLegacyParser|TestWorkBuddyProvider|TestParseWorkBuddy' -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; go test -tags "fts5" ./internal/sync -run TestObserveProviderSourceMatchesWorkBuddyLegacyParser -count=1 refactor(parser): fold workbuddy into provider WorkBuddy already had a concrete provider, but it still depended on exported legacy parser/source functions and legacy sync dispatch. That kept the branch additive and let the old shape remain authoritative.\n\nMove parsing and composite subagent source lookup behind the provider, remove registry callbacks and sync dispatch, and convert the WorkBuddy tests to provider-backed helpers plus a guard that the old entrypoints stay gone.\n\nValidation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestWorkBuddy|TestDiscoverWorkBuddy|TestParseWorkBuddy|TestFindWorkBuddy|TestEngineClassifyWorkBuddy|TestWorkBuddyRegistry' -count=1 -v; go test -tags "fts5" ./internal/parser ./internal/sync ./cmd/agentsview -count=1; go vet ./...; git diff --check fix(parser): preserve workbuddy file hashes WorkBuddy legacy sync stored the transcript content hash for both main sessions and subagent transcripts. The provider migration kept copying Fingerprint.Hash into Session.File.Hash, but the recursive source set did not request hashed fingerprints, so provider-authoritative writes would clear file_hash.\n\nEnable source hashing and make the provider parse test exercise Fingerprint -> Parse for both main and subagent sources.\n\nValidation: go test -tags "fts5" ./internal/parser -run TestWorkBuddyProvider -count=1; go test -tags "fts5" ./internal/parser -count=1; go test -tags "fts5" ./internal/sync -run 'Test.*WorkBuddy' -count=1; go vet ./...; git diff --check fix(parser): thread ctx through workbuddy source lookups --- internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/types.go | 16 +- internal/parser/workbuddy.go | 97 +-------- internal/parser/workbuddy_provider.go | 129 +++++++++++ internal/parser/workbuddy_provider_test.go | 238 +++++++++++++++++++++ internal/parser/workbuddy_test.go | 66 ++++-- internal/sync/engine.go | 75 ------- internal/sync/workbuddy_test.go | 19 +- 9 files changed, 443 insertions(+), 201 deletions(-) create mode 100644 internal/parser/workbuddy_provider.go create mode 100644 internal/parser/workbuddy_provider_test.go diff --git a/internal/parser/provider.go b/internal/parser/provider.go index f8413a11e..c2a56bd04 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -361,6 +361,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newPiProviderFactory(def) case AgentQwen: return newQwenProviderFactory(def) + case AgentWorkBuddy: + return newWorkBuddyProviderFactory(def) case AgentZencoder: return newZencoderProviderFactory(def) default: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 27356b66e..aa1b8e323 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -45,7 +45,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentKiroIDE: ProviderMigrationLegacyOnly, AgentCortex: ProviderMigrationLegacyOnly, AgentHermes: ProviderMigrationLegacyOnly, - AgentWorkBuddy: ProviderMigrationLegacyOnly, + AgentWorkBuddy: ProviderMigrationProviderAuthoritative, AgentForge: ProviderMigrationLegacyOnly, AgentPiebald: ProviderMigrationLegacyOnly, AgentWarp: ProviderMigrationLegacyOnly, diff --git a/internal/parser/types.go b/internal/parser/types.go index 7f83e38b9..3b4fc8997 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -456,15 +456,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindHermesSourceFile, }, { - Type: AgentWorkBuddy, - DisplayName: "WorkBuddy", - EnvVar: "WORKBUDDY_PROJECTS_DIR", - ConfigKey: "workbuddy_project_dirs", - DefaultDirs: []string{".workbuddy/projects"}, - IDPrefix: "workbuddy:", - FileBased: true, - DiscoverFunc: DiscoverWorkBuddySessions, - FindSourceFunc: FindWorkBuddySourceFile, + Type: AgentWorkBuddy, + DisplayName: "WorkBuddy", + EnvVar: "WORKBUDDY_PROJECTS_DIR", + ConfigKey: "workbuddy_project_dirs", + DefaultDirs: []string{".workbuddy/projects"}, + IDPrefix: "workbuddy:", + FileBased: true, }, { Type: AgentForge, diff --git a/internal/parser/workbuddy.go b/internal/parser/workbuddy.go index 135f939f4..847a98393 100644 --- a/internal/parser/workbuddy.go +++ b/internal/parser/workbuddy.go @@ -5,108 +5,13 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" "github.com/tidwall/gjson" ) -func DiscoverWorkBuddySessions(projectsDir string) []DiscoveredFile { - if projectsDir == "" { - return nil - } - - projects, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, projEntry := range projects { - if !isDirOrSymlink(projEntry, projectsDir) { - continue - } - project := projEntry.Name() - projectDir := filepath.Join(projectsDir, project) - entries, err := os.ReadDir(projectDir) - if err != nil { - continue - } - for _, entry := range entries { - name := entry.Name() - if !entry.IsDir() && strings.HasSuffix(name, ".jsonl") { - stem := strings.TrimSuffix(name, ".jsonl") - if IsValidSessionID(stem) { - files = append(files, DiscoveredFile{ - Path: filepath.Join(projectDir, name), - Project: project, - Agent: AgentWorkBuddy, - }) - } - continue - } - if !isDirOrSymlink(entry, projectDir) || !IsValidSessionID(name) { - continue - } - subagentsDir := filepath.Join(projectDir, name, "subagents") - subagents, err := os.ReadDir(subagentsDir) - if err != nil { - continue - } - for _, sub := range subagents { - if sub.IsDir() || !strings.HasSuffix(sub.Name(), ".jsonl") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(subagentsDir, sub.Name()), - Project: project, - Agent: AgentWorkBuddy, - }) - } - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -func FindWorkBuddySourceFile(projectsDir, rawID string) string { - if projectsDir == "" { - return "" - } - rawID = strings.TrimPrefix(rawID, "workbuddy:") - sessionID, subagentID, hasSubagent := strings.Cut(rawID, ":subagent:") - if !IsValidSessionID(sessionID) { - return "" - } - if hasSubagent && !IsValidSessionID(subagentID) { - return "" - } - - projects, err := os.ReadDir(projectsDir) - if err != nil { - return "" - } - for _, projEntry := range projects { - if !isDirOrSymlink(projEntry, projectsDir) { - continue - } - projectDir := filepath.Join(projectsDir, projEntry.Name()) - candidate := filepath.Join(projectDir, sessionID+".jsonl") - if hasSubagent { - candidate = filepath.Join(projectDir, sessionID, "subagents", subagentID+".jsonl") - } - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "" -} - -func ParseWorkBuddySession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) { +func parseWorkBuddySession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) if err != nil { return nil, nil, fmt.Errorf("stat %s: %w", path, err) diff --git a/internal/parser/workbuddy_provider.go b/internal/parser/workbuddy_provider.go new file mode 100644 index 000000000..d88ca5593 --- /dev/null +++ b/internal/parser/workbuddy_provider.go @@ -0,0 +1,129 @@ +package parser + +import ( + "context" + "path/filepath" + "strings" +) + +// WorkBuddy stores each session as a JSONL file in a project directory, with +// subagent transcripts nested under a "subagents" subdirectory. It is a +// directory-of-files provider: discovery, watching, change classification, +// lookup, and fingerprinting come from JSONLSourceSet, and the ParseFile option +// makes that source set a full SourceSet so it rides the generic factory. +func newWorkBuddyProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + workBuddyProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newWorkBuddySourceSet(cfg.Roots) }, + ) +} + +func newWorkBuddySourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentWorkBuddy, roots, + withRecursive(), + withSymlinkFollowing(), + withContentHashing(), + withIncludePath(isWorkBuddySourcePath), + withProjectHint(workBuddyProjectHintFromPath), + withSessionIDFromPath(workBuddySessionIDFromPath), + withLookupIDValid(isWorkBuddyLookupID), + withParseFile(workBuddyParseFile), + ) +} + +func workBuddyParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseWorkBuddySession(path, req.Source.ProjectHint, req.Machine) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil +} + +func isWorkBuddySourcePath(root, path string) bool { + parts, ok := workBuddyPathParts(root, path) + if !ok { + return false + } + switch len(parts) { + case 2: + stem, ok := strings.CutSuffix(parts[1], ".jsonl") + return ok && IsValidSessionID(stem) + case 4: + return IsValidSessionID(parts[1]) && + parts[2] == "subagents" && + strings.HasSuffix(parts[3], ".jsonl") + default: + return false + } +} + +func workBuddyProjectHintFromPath(root, path string) string { + parts, ok := workBuddyPathParts(root, path) + if !ok || len(parts) < 2 { + return "" + } + return parts[0] +} + +func workBuddySessionIDFromPath(root, path string) string { + if !isWorkBuddySourcePath(root, path) { + return "" + } + parts, _ := workBuddyPathParts(root, path) + stem := strings.TrimSuffix(filepath.Base(path), ".jsonl") + if len(parts) == 4 { + return parts[1] + ":subagent:" + stem + } + return stem +} + +func isWorkBuddyLookupID(rawID string) bool { + if rawID == "" { + return false + } + sessionID, subagentID, hasSubagent := strings.Cut(rawID, ":subagent:") + if !IsValidSessionID(sessionID) { + return false + } + return !hasSubagent || IsValidSessionID(subagentID) +} + +func workBuddyPathParts(root, path string) ([]string, bool) { + rel, err := filepath.Rel(root, path) + if err != nil { + return nil, false + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return nil, false + } + } + return parts, true +} + +func workBuddyProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Cwd: CapabilitySupported, + Relationships: CapabilitySupported, + Subagents: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + MalformedLineCount: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/workbuddy_provider_test.go b/internal/parser/workbuddy_provider_test.go new file mode 100644 index 000000000..c411801c1 --- /dev/null +++ b/internal/parser/workbuddy_provider_test.go @@ -0,0 +1,238 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkBuddyProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentWorkBuddy) + require.True(t, ok) + require.NotNil(t, factory) + + caps := factory.Capabilities() + assert.Equal(t, CapabilitySupported, caps.Source.DiscoverSources) + assert.Equal(t, CapabilitySupported, caps.Source.WatchSources) + assert.Equal(t, CapabilitySupported, caps.Source.ClassifyChangedPath) + assert.Equal(t, CapabilitySupported, caps.Source.FindSource) + assert.Equal(t, CapabilitySupported, caps.Source.CompositeFingerprint) + assert.Equal(t, CapabilitySupported, caps.Content.FirstMessage) + assert.Equal(t, CapabilitySupported, caps.Content.Cwd) + assert.Equal(t, CapabilitySupported, caps.Content.Relationships) + assert.Equal(t, CapabilitySupported, caps.Content.Subagents) + assert.Equal(t, CapabilitySupported, caps.Content.ToolCalls) + assert.Equal(t, CapabilitySupported, caps.Content.ToolResults) + assert.Equal(t, CapabilitySupported, caps.Content.PerMessageTokenUsage) + assert.Equal(t, CapabilitySupported, caps.Content.Model) + assert.Equal(t, CapabilitySupported, caps.Content.MalformedLineCount) + + provider, ok := NewProvider(AgentWorkBuddy, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestWorkBuddyProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sessionID := "11111111-1111-4111-8111-111111111111" + subagentID := "agent-123" + projectDir := filepath.Join(root, "proj") + sourcePath := filepath.Join(projectDir, sessionID+".jsonl") + subagentPath := filepath.Join( + projectDir, sessionID, "subagents", subagentID+".jsonl", + ) + nonIDSubagentPath := filepath.Join( + projectDir, sessionID, "subagents", "2025.01.01.jsonl", + ) + writeSourceFile(t, sourcePath, workBuddyProviderFixture("hello")) + writeSourceFile(t, subagentPath, workBuddyProviderFixture("sub task")) + writeSourceFile(t, nonIDSubagentPath, workBuddyProviderFixture("dated sub task")) + writeSourceFile(t, filepath.Join(projectDir, "2025.01.01.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(projectDir, sessionID, "tool-results", "tool_123.txt"), "{}\n") + writeSourceFile(t, filepath.Join(root, sessionID+".jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(projectDir, sessionID, "subagents", "nested", "deep.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentWorkBuddy, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 3) + assert.Equal( + t, + []string{sourcePath, nonIDSubagentPath, subagentPath}, + sourceDisplayPaths(discovered), + ) + assert.Equal(t, []string{"proj", "proj", "proj"}, sourceProjects(discovered)) + + 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.True(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"*.jsonl"}, plan.Roots[0].IncludeGlobs) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~workbuddy:" + sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, sourcePath, fingerprint.Key) + assert.NotZero(t, fingerprint.Size) + assert.NotZero(t, fingerprint.MTimeNS) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionID + ":subagent:" + subagentID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, subagentPath, found.DisplayPath) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionID + ":subagent:../agent-123", + }) + require.NoError(t, err) + assert.False(t, ok) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionID + ":subagent:2025.01.01", + }) + require.NoError(t, err) + assert.False(t, ok) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: subagentPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, subagentPath, found.DisplayPath) + + require.NoError(t, os.Remove(subagentPath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: subagentPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, subagentPath, changed[0].DisplayPath) +} + +func TestWorkBuddyProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + targetDir := t.TempDir() + sessionID := "11111111-1111-4111-8111-111111111111" + linkDir := filepath.Join(root, "proj") + sourcePath := filepath.Join(linkDir, sessionID+".jsonl") + writeSourceFile(t, filepath.Join(targetDir, sessionID+".jsonl"), workBuddyProviderFixture("hello")) + if err := os.Symlink(targetDir, linkDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentWorkBuddy, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~workbuddy:" + sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestWorkBuddyProviderParseMainAndSubagent(t *testing.T) { + root := t.TempDir() + sessionID := "11111111-1111-4111-8111-111111111111" + subagentID := "agent-123" + sourcePath := filepath.Join(root, "proj", sessionID+".jsonl") + subagentPath := filepath.Join(root, "proj", sessionID, "subagents", subagentID+".jsonl") + mainContent := workBuddyProviderFixture("hello") + subContent := workBuddyProviderFixture("sub task") + writeSourceFile(t, sourcePath, mainContent) + writeSourceFile(t, subagentPath, subContent) + + provider, ok := NewProvider(AgentWorkBuddy, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 2) + + mainFingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + mainOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: mainFingerprint, + }) + require.NoError(t, err) + require.True(t, mainOutcome.ResultSetComplete) + require.Len(t, mainOutcome.Results, 1) + mainResult := mainOutcome.Results[0] + assert.Equal(t, DataVersionCurrent, mainResult.DataVersion) + assert.Equal(t, "workbuddy:"+sessionID, mainResult.Result.Session.ID) + assert.Equal(t, "devbox", mainResult.Result.Session.Machine) + assert.Equal(t, + fmt.Sprintf("%x", sha256.Sum256([]byte(mainContent))), + mainResult.Result.Session.File.Hash, + ) + assert.Len(t, mainResult.Result.Messages, 3) + assert.Equal(t, "hello", mainResult.Result.Session.FirstMessage) + assert.True(t, mainResult.Result.Session.HasTotalOutputTokens) + + subFingerprint, err := provider.Fingerprint(context.Background(), sources[1]) + require.NoError(t, err) + subOutcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[1], + Fingerprint: subFingerprint, + }) + require.NoError(t, err) + require.True(t, subOutcome.ResultSetComplete) + require.Len(t, subOutcome.Results, 1) + subResult := subOutcome.Results[0] + assert.Equal(t, DataVersionCurrent, subResult.DataVersion) + assert.Equal( + t, + "workbuddy:"+sessionID+":subagent:"+subagentID, + subResult.Result.Session.ID, + ) + assert.Equal(t, "workbuddy:"+sessionID, subResult.Result.Session.ParentSessionID) + assert.Equal(t, RelSubagent, subResult.Result.Session.RelationshipType) + assert.Equal(t, + fmt.Sprintf("%x", sha256.Sum256([]byte(subContent))), + subResult.Result.Session.File.Hash, + ) +} + +func workBuddyProviderFixture(firstMessage string) string { + return fmt.Sprintf( + `{"id":"u1","timestamp":1778749186168,"type":"message","role":"user","content":[{"type":"input_text","text":%q}],"cwd":"/tmp/cwd-project"} +{"id":"a1","timestamp":1778749187168,"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}],"providerData":{"model":"gpt-5.5","usage":{"inputTokens":20,"outputTokens":4,"cacheReadInputTokens":5}}} +{"id":"fc1","timestamp":1778749188168,"type":"function_call","name":"Bash","callId":"call_1","arguments":"{\"command\":\"pwd\"}"} +`, firstMessage) +} diff --git a/internal/parser/workbuddy_test.go b/internal/parser/workbuddy_test.go index d42024d78..19890e17c 100644 --- a/internal/parser/workbuddy_test.go +++ b/internal/parser/workbuddy_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "fmt" "os" "path/filepath" @@ -11,6 +12,47 @@ import ( "github.com/tidwall/gjson" ) +func parseWorkBuddyTestSession( + t testing.TB, + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return parseWorkBuddySession(path, project, machine) +} + +func discoverWorkBuddyTestSessions(t testing.TB, root string) []DiscoveredFile { + t.Helper() + provider, ok := NewProvider(AgentWorkBuddy, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + + files := make([]DiscoveredFile, 0, len(sources)) + for _, source := range sources { + files = append(files, DiscoveredFile{ + Path: source.DisplayPath, + Project: source.ProjectHint, + Agent: source.Provider, + }) + } + return files +} + +func findWorkBuddyTestSourceFile(t testing.TB, root, rawID string) string { + t.Helper() + provider, ok := NewProvider(AgentWorkBuddy, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, found, err := provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: rawID}, + ) + require.NoError(t, err) + if !found { + return "" + } + return source.DisplayPath +} + func TestDiscoverWorkBuddySessions(t *testing.T) { root := t.TempDir() mainPath := filepath.Join(root, "proj", "11111111-1111-4111-8111-111111111111.jsonl") @@ -21,7 +63,7 @@ func TestDiscoverWorkBuddySessions(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644), "WriteFile(%q)", path) } - files := DiscoverWorkBuddySessions(root) + files := discoverWorkBuddyTestSessions(t, root) require.Len(t, files, 2) assert.Equal(t, mainPath, files[0].Path) assert.Equal(t, "proj", files[0].Project) @@ -44,7 +86,7 @@ func TestParseWorkBuddySession(t *testing.T) { `, cwd, cwd) require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, msgs, err := ParseWorkBuddySession(path, "proj", "local") + sess, msgs, err := parseWorkBuddyTestSession(t, path, "proj", "local") require.NoError(t, err) require.NotNil(t, sess, "session nil") assert.Equal(t, "workbuddy:11111111-1111-4111-8111-111111111111", sess.ID) @@ -76,7 +118,7 @@ func TestParseWorkBuddySessionDoesNotDoubleCountOpenAICachedTokens(t *testing.T) ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, msgs, err := ParseWorkBuddySession(path, "proj", "local") + sess, msgs, err := parseWorkBuddyTestSession(t, path, "proj", "local") require.NoError(t, err) require.NotNil(t, sess, "session nil") require.Len(t, msgs, 1) @@ -101,7 +143,7 @@ func TestParseWorkBuddySessionUsesCwdProjectAndFileSessionID(t *testing.T) { `, cwd) require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, _, err := ParseWorkBuddySession(path, "stored-project", "local") + sess, _, err := parseWorkBuddyTestSession(t, path, "stored-project", "local") require.NoError(t, err) assert.Equal(t, "workbuddy:22222222-2222-4222-8222-222222222222", sess.ID) assert.Equal(t, "cwd_project", sess.Project) @@ -114,7 +156,7 @@ func TestParseWorkBuddySessionNormalizesWindowsCwdProject(t *testing.T) { ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, _, err := ParseWorkBuddySession(path, "stored", "local") + sess, _, err := parseWorkBuddyTestSession(t, path, "stored", "local") require.NoError(t, err) assert.Equal(t, "report_builder", sess.Project) } @@ -126,7 +168,7 @@ func TestParseWorkBuddySessionFallsBackToDiscoveredProjectWhenCwdHasNoProject(t ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, _, err := ParseWorkBuddySession(path, "discovered-proj", "local") + sess, _, err := parseWorkBuddyTestSession(t, path, "discovered-proj", "local") require.NoError(t, err) assert.Equal(t, "discovered-proj", sess.Project) } @@ -138,7 +180,7 @@ func TestParseWorkBuddySessionOmitsAbsentTokenUsageKeys(t *testing.T) { ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - _, msgs, err := ParseWorkBuddySession(path, "proj", "local") + _, msgs, err := parseWorkBuddyTestSession(t, path, "proj", "local") require.NoError(t, err) require.Len(t, msgs, 1) m := msgs[0] @@ -160,7 +202,7 @@ func TestParseWorkBuddySubagentSession(t *testing.T) { ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, _, err := ParseWorkBuddySession(path, "proj", "local") + sess, _, err := parseWorkBuddyTestSession(t, path, "proj", "local") require.NoError(t, err) assert.Equal(t, "workbuddy:11111111-1111-4111-8111-111111111111:subagent:agent-123", sess.ID) assert.Equal(t, "workbuddy:11111111-1111-4111-8111-111111111111", sess.ParentSessionID) @@ -172,7 +214,7 @@ func TestFindWorkBuddySourceFile(t *testing.T) { path := filepath.Join(root, "proj", "11111111-1111-4111-8111-111111111111.jsonl") require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - got := FindWorkBuddySourceFile(root, "workbuddy:11111111-1111-4111-8111-111111111111") + got := findWorkBuddyTestSourceFile(t, root, "workbuddy:11111111-1111-4111-8111-111111111111") assert.Equal(t, path, got) } @@ -182,7 +224,7 @@ func TestFindWorkBuddySourceFileRejectsInvalidSubagentID(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - got := FindWorkBuddySourceFile(root, "workbuddy:11111111-1111-4111-8111-111111111111:subagent:../agent-123") + got := findWorkBuddyTestSourceFile(t, root, "workbuddy:11111111-1111-4111-8111-111111111111:subagent:../agent-123") assert.Empty(t, got, "want empty path") } @@ -193,7 +235,7 @@ func TestParseWorkBuddyProjectNamedSubagentsIsNotSubagent(t *testing.T) { ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, _, err := ParseWorkBuddySession(path, "subagents", "local") + sess, _, err := parseWorkBuddyTestSession(t, path, "subagents", "local") require.NoError(t, err) assert.Equal(t, "workbuddy:11111111-1111-4111-8111-111111111111", sess.ID) assert.Empty(t, sess.ParentSessionID) @@ -207,7 +249,7 @@ func TestParseWorkBuddySessionDecodesObjectToolResultText(t *testing.T) { ` require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - _, msgs, err := ParseWorkBuddySession(path, "proj", "local") + _, msgs, err := parseWorkBuddyTestSession(t, path, "proj", "local") require.NoError(t, err) require.Len(t, msgs, 1) require.Len(t, msgs[0].ToolResults, 1) diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 27dfce404..e6f4830bb 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1260,38 +1260,6 @@ func (e *Engine) classifyOnePath( } } - // WorkBuddy: //.jsonl - // or: ///subagents/*.jsonl - for _, workBuddyDir := range e.agentDirs[parser.AgentWorkBuddy] { - if workBuddyDir == "" { - continue - } - if rel, ok := isUnder(workBuddyDir, path); ok { - if !strings.HasSuffix(path, ".jsonl") { - continue - } - parts := strings.Split(rel, sep) - if len(parts) == 2 { - stem := strings.TrimSuffix(parts[1], ".jsonl") - if !parser.IsValidSessionID(stem) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parts[0], - Agent: parser.AgentWorkBuddy, - }, true - } - if len(parts) == 4 && parts[2] == "subagents" { - return parser.DiscoveredFile{ - Path: path, - Project: parts[0], - Agent: parser.AgentWorkBuddy, - }, true - } - } - } - // VSCode Copilot: /workspaceStorage//chatSessions/.{json,jsonl} // or: /globalStorage/emptyWindowChatSessions/.{json,jsonl} for _, vscDir := range e.agentDirs[parser.AgentVSCodeCopilot] { @@ -4496,8 +4464,6 @@ func (e *Engine) processFile( res = e.processCortex(file, info) case parser.AgentHermes: res = e.processHermes(file, info) - case parser.AgentWorkBuddy: - res = e.processWorkBuddy(file, info) case parser.AgentVibe: res = e.processVibe(file, info) case parser.AgentPositron: @@ -6607,35 +6573,6 @@ func (e *Engine) processHermes( } } -func (e *Engine) processWorkBuddy( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseWorkBuddySession( - 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}, - }, - } -} - func (e *Engine) processVibe( file parser.DiscoveredFile, info os.FileInfo, ) processResult { @@ -9467,18 +9404,6 @@ func (e *Engine) SyncSingleSessionContext( file.Project = sess.Project } } - case parser.AgentWorkBuddy: - for _, workBuddyDir := range e.agentDirs[parser.AgentWorkBuddy] { - rel, ok := isUnder(workBuddyDir, path) - if !ok { - continue - } - parts := strings.Split(rel, string(filepath.Separator)) - if len(parts) == 2 || len(parts) == 4 && parts[2] == "subagents" { - file.Project = parts[0] - break - } - } } res := e.processFile(ctx, file) diff --git a/internal/sync/workbuddy_test.go b/internal/sync/workbuddy_test.go index 2b5ac9c79..18f0e312d 100644 --- a/internal/sync/workbuddy_test.go +++ b/internal/sync/workbuddy_test.go @@ -34,20 +34,22 @@ func TestEngineClassifyWorkBuddyPaths(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) } - got, ok := engine.classifyOnePath(mainPath, nil) - require.True(t, ok, "main path did not classify") + files := engine.classifyPaths([]string{mainPath}) + require.Len(t, files, 1, "main path did not classify") + got := files[0] assert.Equal(t, mainPath, got.Path) assert.Equal(t, "proj", got.Project) assert.Equal(t, parser.AgentWorkBuddy, got.Agent) - got, ok = engine.classifyOnePath(subPath, nil) - require.True(t, ok, "subagent path did not classify") + files = engine.classifyPaths([]string{subPath}) + require.Len(t, files, 1, "subagent path did not classify") + got = files[0] assert.Equal(t, subPath, got.Path) assert.Equal(t, "proj", got.Project) assert.Equal(t, parser.AgentWorkBuddy, got.Agent) - got, ok = engine.classifyOnePath(toolPath, nil) - assert.False(t, ok, "tool result classified as %+v", got) + files = engine.classifyPaths([]string{toolPath}) + assert.Empty(t, files, "tool result classified as %+v", files) } func TestEngineClassifyWorkBuddyProjectNamedSubagentsAsMainSession(t *testing.T) { @@ -64,8 +66,9 @@ func TestEngineClassifyWorkBuddyProjectNamedSubagentsAsMainSession(t *testing.T) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - got, ok := engine.classifyOnePath(path, nil) - require.True(t, ok, "path did not classify") + files := engine.classifyPaths([]string{path}) + require.Len(t, files, 1, "path did not classify") + got := files[0] assert.Equal(t, path, got.Path) assert.Equal(t, "subagents", got.Project) assert.Equal(t, parser.AgentWorkBuddy, got.Agent)