From cb536f6e3e50e205a5f9b6c86bd284adb96f02d5 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 21:34:50 -0400 Subject: [PATCH] feat(parser): migrate commandcode and iflow providers Command Code and iFlow both fit the directory JSONL source shape, so moving them together proves the helper against real providers without mixing in nested layouts like Qwen or composite providers like WorkBuddy. The providers keep source discovery, changed-path classification, persisted lookup, fingerprinting, and parse normalization behind concrete facade implementations while preserving the legacy parser functions for current runtime callers. fix(parser): preserve JSONL provider symlink discovery Command Code and iFlow legacy discovery followed symlinked project directories. The migrated providers should keep that behavior so users with linked project roots do not silently lose discovery or raw-session lookup after moving onto the provider facade. test(parser): opt commandcode iflow into provider shadow CommandCode and iFlow now have concrete facade providers on this branch, so keeping them legacy-only would let the migration branch remain additive instead of exercised by the shared provider harness. This makes the migration manifest fail closed for the providers introduced here while leaving unrelated providers for their own stack branches. 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 commandcode iflow shadow parity Command Code and iFlow now opt into shadow comparison, so their provider branch should prove more than provider-local parsing. Add source-level migration tests that run ObserveProviderSource and compare the normalized provider output against the legacy parser functions for both agents. Validation: go fmt ./...; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... refactor(parser): fold commandcode and iflow into provider Move Command Code and iFlow parse ownership onto their concrete providers and delete the package-level discover/find/parse entrypoints plus the legacy sync dispatch for both agents. Both agents become provider-authoritative so runtime sync routes through provider changed-path classification and processProviderFile instead of the removed processCommandCode/processIflow methods. Command Code: - parseSession moves onto the provider; DiscoverCommandCodeSessions, FindCommandCodeSourceFile, and ParseCommandCodeSession are removed. - The provider reproduces the legacy .meta.json companion behavior: WatchPlan includes *.meta.json, SourcesForChangedPath remaps a changed .meta.json back to its .jsonl transcript, the composite Fingerprint folds the companion size, mtime, and content into the freshness identity, and Parse overrides File.Size/File.Mtime with the combined transcript+meta effective info. commandCodeEffectiveInfo stays in the engine for the SourceMtime watcher fallback. iFlow: - parseSession moves onto the provider; DiscoverIflowProjects, FindIflowSourceFile, and ParseIflowSession are removed. - Parse mirrors the legacy sync path: it resolves the project from the recorded cwd and git branch (falling back to GetProjectName of the project directory), applies InferRelationshipTypes to derive continuation/subagent links, and enables source content hashing so File.Hash matches the legacy ComputeFileHash value. Tests move from the deleted free functions to provider API coverage, add guard tests asserting the legacy entrypoints stay gone, drop the shadow comparison test, and remove both provider files from the pending-shim scan list. fix(parser): preserve commandcode file hash parity Command Code needs a composite provider fingerprint so metadata-only edits invalidate freshness, but that value should not replace the persisted transcript content hash. The legacy sync path stored the SHA-256 of the transcript file in file_hash, and changing that semantic would make metadata-only edits look like transcript content changes.\n\nKeep the composite value scoped to SourceFingerprint and recompute Session.File.Hash from the transcript during provider parse. The provider test now exercises Fingerprint -> Parse with a .meta.json companion to prove the two hashes remain distinct.\n\nValidation: go test -tags "fts5" ./internal/parser -run TestCommandCodeProvider -count=1; go test -tags "fts5" ./internal/parser -count=1; go test -tags "fts5" ./internal/sync -run 'Test.*CommandCode|Test.*Iflow' -count=1; go vet ./...; git diff --check fix(parser): thread ctx through commandcode and iflow source lookups --- internal/parser/commandcode.go | 80 +---- internal/parser/commandcode_provider.go | 321 +++++++++++++++++++ internal/parser/commandcode_provider_test.go | 171 ++++++++++ internal/parser/commandcode_test.go | 60 +++- internal/parser/discovery.go | 76 ----- internal/parser/iflow.go | 4 +- internal/parser/iflow_parser_test.go | 25 +- internal/parser/iflow_provider.go | 105 ++++++ internal/parser/iflow_provider_test.go | 147 +++++++++ internal/parser/provider.go | 16 +- internal/parser/provider_capabilities.go | 15 + internal/parser/provider_migration.go | 4 +- internal/parser/provider_shim_scan_test.go | 2 - internal/parser/types.go | 32 +- internal/parser/types_test.go | 7 +- internal/sync/engine.go | 148 --------- internal/sync/engine_test.go | 6 +- internal/sync/iflow_discovery_test.go | 64 ++-- 18 files changed, 912 insertions(+), 371 deletions(-) create mode 100644 internal/parser/commandcode_provider.go create mode 100644 internal/parser/commandcode_provider_test.go create mode 100644 internal/parser/iflow_provider.go create mode 100644 internal/parser/iflow_provider_test.go create mode 100644 internal/parser/provider_capabilities.go diff --git a/internal/parser/commandcode.go b/internal/parser/commandcode.go index 314d13f0b..a952fc937 100644 --- a/internal/parser/commandcode.go +++ b/internal/parser/commandcode.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strconv" "strings" "time" @@ -20,83 +19,8 @@ type commandCodeMeta struct { Cwd string `json:"cwd"` } -// DiscoverCommandCodeSessions finds Command Code transcripts under -// ~/.commandcode/projects//.jsonl. -func DiscoverCommandCodeSessions(projectsDir string) []DiscoveredFile { - if projectsDir == "" { - return nil - } - - projectEntries, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range projectEntries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - projectDir := filepath.Join(projectsDir, entry.Name()) - sessionEntries, err := os.ReadDir(projectDir) - if err != nil { - continue - } - - for _, sessionEntry := range sessionEntries { - if sessionEntry.IsDir() { - continue - } - name := sessionEntry.Name() - if !strings.HasSuffix(name, ".jsonl") || - strings.HasSuffix(name, ".checkpoints.jsonl") || - strings.HasSuffix(name, ".prompts.jsonl") { - continue - } - id := strings.TrimSuffix(name, ".jsonl") - if !IsValidSessionID(id) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(projectDir, name), - Agent: AgentCommandCode, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindCommandCodeSourceFile locates a Command Code transcript by -// its raw session ID (without the "commandcode:" prefix). -func FindCommandCodeSourceFile(projectsDir, rawID string) string { - if projectsDir == "" || !IsValidSessionID(rawID) { - return "" - } - - projectEntries, err := os.ReadDir(projectsDir) - if err != nil { - return "" - } - for _, entry := range projectEntries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - candidate := filepath.Join(projectsDir, entry.Name(), rawID+".jsonl") - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate - } - } - return "" -} - -// ParseCommandCodeSession parses a Command Code JSONL transcript. -func ParseCommandCodeSession( +// parseSession parses a Command Code JSONL transcript. +func (p *commandCodeProvider) parseSession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/commandcode_provider.go b/internal/parser/commandcode_provider.go new file mode 100644 index 000000000..2125a5ed7 --- /dev/null +++ b/internal/parser/commandcode_provider.go @@ -0,0 +1,321 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" +) + +var _ Provider = (*commandCodeProvider)(nil) + +type commandCodeProviderFactory struct { + def AgentDef +} + +func newCommandCodeProviderFactory(def AgentDef) ProviderFactory { + return commandCodeProviderFactory{def: cloneAgentDef(def)} +} + +func (f commandCodeProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f commandCodeProviderFactory) Capabilities() Capabilities { + return commandCodeProviderCapabilities() +} + +func (f commandCodeProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &commandCodeProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: commandCodeProviderCapabilities(), + Config: cfg, + }, + sources: newCommandCodeSourceSet(cfg.Roots), + } +} + +type commandCodeProvider struct { + ProviderBase + sources DirectoryJSONLSourceSet +} + +func (p *commandCodeProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *commandCodeProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + plan, err := p.sources.WatchPlan(ctx) + if err != nil { + return WatchPlan{}, err + } + for i := range plan.Roots { + plan.Roots[i].IncludeGlobs = append( + plan.Roots[i].IncludeGlobs, + "*.meta.json", + ) + } + return plan, nil +} + +func (p *commandCodeProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + sources, err := p.sources.SourcesForChangedPath(ctx, req) + if err != nil || len(sources) > 0 { + return sources, err + } + if source, ok, err := p.sourceForMetaCompanion(ctx, req); err != nil { + return nil, err + } else if ok { + return []SourceRef{source}, nil + } + return nil, nil +} + +func (p *commandCodeProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + return p.sources.FindSource(ctx, providerFindRequestWithRawSessionID(p.Def, req)) +} + +func (p *commandCodeProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + if err := ctx.Err(); err != nil { + return SourceFingerprint{}, err + } + path, ok, err := p.sources.pathFromSource(ctx, source) + if err != nil { + return SourceFingerprint{}, err + } + if !ok { + return SourceFingerprint{}, fmt.Errorf("commandcode 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(), + } + + h := sha256.New() + if err := addCommandCodeFingerprintPart(h, "transcript", path, info); err != nil { + return SourceFingerprint{}, err + } + metaPath := commandCodeMetaCompanionPath(path) + if metaInfo, ok, err := commandCodeCompanionInfo(metaPath); err != nil { + return SourceFingerprint{}, err + } else if ok && metaInfo != nil { + fingerprint.Size += metaInfo.Size() + if mtime := metaInfo.ModTime().UnixNano(); mtime > fingerprint.MTimeNS { + fingerprint.MTimeNS = mtime + } + if err := addCommandCodeFingerprintPart(h, "meta", metaPath, metaInfo); err != nil { + return SourceFingerprint{}, err + } + } + fingerprint.Hash = fmt.Sprintf("%x", h.Sum(nil)) + return fingerprint, nil +} + +func (p *commandCodeProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + path, ok, err := p.sources.pathFromSource(ctx, req.Source) + if err != nil { + return ParseOutcome{}, err + } + if !ok { + return ParseOutcome{}, fmt.Errorf("commandcode source path unavailable") + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := p.parseSession(path, machine) + if err != nil { + return ParseOutcome{}, err + } + if sess == nil { + return ParseOutcome{ + ResultSetComplete: true, + SkipReason: SkipNoSession, + }, nil + } + if hash, err := hashJSONLSourceFile(path); err == nil { + sess.File.Hash = hash + } + // Mirror the legacy effective-info behavior: the transcript's + // freshness identity (size and mtime) includes the .meta.json + // companion so a title-only rename triggers a reparse. + if size, mtime, ok := commandCodeEffectiveFileInfo(path); ok { + sess.File.Size = size + sess.File.Mtime = mtime + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + }, nil +} + +func newCommandCodeSourceSet(roots []string) DirectoryJSONLSourceSet { + return newDirectoryJSONLSourceSet(AgentCommandCode, roots, + withSymlinkFollowing(), + withIncludePath(isCommandCodeSourcePath), + withProjectHint(func(root, path string) string { return "" }), + withSessionIDFromPath(commandCodeSessionIDFromPath), + ) +} + +func (p *commandCodeProvider) sourceForMetaCompanion( + ctx context.Context, + req ChangedPathRequest, +) (SourceRef, bool, error) { + if req.Path == "" { + return SourceRef{}, false, nil + } + path := filepath.Clean(req.Path) + stem, ok := strings.CutSuffix(filepath.Base(path), ".meta.json") + if !ok || !IsValidSessionID(stem) { + return SourceRef{}, false, nil + } + transcriptPath := filepath.Join(filepath.Dir(path), stem+".jsonl") + if _, err := os.Stat(transcriptPath); err != nil { + return SourceRef{}, false, nil + } + source, ok, err := p.sources.sourceForPath(ctx, transcriptPath) + if err != nil { + return SourceRef{}, false, err + } + if !ok { + return SourceRef{}, false, nil + } + if req.WatchRoot != "" { + root := filepath.Clean(req.WatchRoot) + src := source.Opaque.(JSONLSource) + if !samePath(root, src.Root) { + return SourceRef{}, false, nil + } + } + return source, true, nil +} + +func isCommandCodeSourcePath(root, path string) bool { + name := filepath.Base(path) + if !strings.HasSuffix(name, ".jsonl") || + strings.HasSuffix(name, ".checkpoints.jsonl") || + strings.HasSuffix(name, ".prompts.jsonl") { + return false + } + return IsValidSessionID(strings.TrimSuffix(name, ".jsonl")) +} + +func commandCodeSessionIDFromPath(root, path string) string { + name := filepath.Base(path) + if !isCommandCodeSourcePath(root, path) { + return "" + } + return strings.TrimSuffix(name, ".jsonl") +} + +func commandCodeMetaCompanionPath(path string) string { + return strings.TrimSuffix(path, ".jsonl") + ".meta.json" +} + +// commandCodeEffectiveFileInfo returns the combined size and mtime of the +// transcript and its optional .meta.json companion. The bool is false only +// when the transcript itself cannot be stat'd. +func commandCodeEffectiveFileInfo(path string) (int64, int64, bool) { + info, err := os.Stat(path) + if err != nil { + return 0, 0, false + } + size := info.Size() + mtime := info.ModTime().UnixNano() + if metaInfo, ok, err := commandCodeCompanionInfo( + commandCodeMetaCompanionPath(path), + ); err == nil && ok && metaInfo != nil { + size += metaInfo.Size() + if metaMtime := metaInfo.ModTime().UnixNano(); metaMtime > mtime { + mtime = metaMtime + } + } + return size, mtime, true +} + +func commandCodeCompanionInfo(path string) (os.FileInfo, bool, error) { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("stat %s: %w", path, err) + } + if info.IsDir() { + return nil, false, nil + } + return info, true, nil +} + +func addCommandCodeFingerprintPart( + h interface{ Write([]byte) (int, error) }, + label string, + path string, + info os.FileInfo, +) error { + hash, err := hashJSONLSourceFile(path) + if err != nil { + return err + } + _, _ = fmt.Fprintf( + h, + "%s:%s:%d:%d:%s\n", + label, + filepath.Base(path), + info.Size(), + info.ModTime().UnixNano(), + hash, + ) + return nil +} + +func commandCodeProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Cwd: CapabilitySupported, + GitBranch: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + MalformedLineCount: CapabilitySupported, + }, + } +} diff --git a/internal/parser/commandcode_provider_test.go b/internal/parser/commandcode_provider_test.go new file mode 100644 index 000000000..465699ad7 --- /dev/null +++ b/internal/parser/commandcode_provider_test.go @@ -0,0 +1,171 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandCodeProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentCommandCode) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestCommandCodeProviderSourceMethods(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "users-alice-code-sample-project") + sourcePath := filepath.Join(projectDir, "sess_123.jsonl") + writeSourceFile(t, sourcePath, commandCodeProviderFixture()) + writeSourceFile(t, filepath.Join(projectDir, "sess_123.checkpoints.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(projectDir, "sess_123.prompts.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentCommandCode, 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, AgentCommandCode, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + assert.Empty(t, discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~commandcode:sess_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + FingerprintKey: sourcePath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + require.NoError(t, os.Remove(sourcePath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) +} + +func TestCommandCodeProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + realProjectDir := filepath.Join(t.TempDir(), "real-project") + linkProjectDir := filepath.Join(root, "linked-project") + if err := os.Symlink(realProjectDir, linkProjectDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + sourcePath := filepath.Join(linkProjectDir, "sess_123.jsonl") + writeSourceFile(t, filepath.Join(realProjectDir, "sess_123.jsonl"), commandCodeProviderFixture()) + + provider, ok := NewProvider(AgentCommandCode, 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{ + RawSessionID: "sess_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestCommandCodeProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "project", "sess_123.jsonl") + transcript := commandCodeProviderFixture() + writeSourceFile(t, sourcePath, transcript) + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{ + Key: sourcePath, + }, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "commandcode:sess_123", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, + fmt.Sprintf("%x", sha256.Sum256([]byte(transcript))), + outcome.Results[0].Result.Session.File.Hash, + ) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func TestCommandCodeProviderParsePreservesTranscriptFileHash(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "project", "sess_123.jsonl") + transcript := commandCodeProviderFixture() + writeSourceFile(t, sourcePath, transcript) + writeSourceFile(t, commandCodeMetaCompanionPath(sourcePath), `{"title":"Renamed"}`) + + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + fingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + transcriptHash := fmt.Sprintf("%x", sha256.Sum256([]byte(transcript))) + require.NotEqual(t, transcriptHash, fingerprint.Hash, + "fixture must prove metadata participates in freshness separately") + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, transcriptHash, outcome.Results[0].Result.Session.File.Hash) +} + +func commandCodeProviderFixture() string { + return `{"id":"m1","timestamp":"2026-06-01T10:00:00Z","sessionId":"sess_123","role":"user","content":[{"type":"text","text":"Inspect server logs"}],"gitBranch":"feature/command-code","metadata":{"version":2,"cwd":"/Users/alice/code/sample-project"}} +{"id":"m2","timestamp":"2026-06-01T10:00:03Z","sessionId":"sess_123","role":"assistant","content":[{"type":"text","text":"The error is in the startup path."}],"gitBranch":"feature/command-code","metadata":{"version":2}}` +} diff --git a/internal/parser/commandcode_test.go b/internal/parser/commandcode_test.go index f3aa5d0e6..1fce65f91 100644 --- a/internal/parser/commandcode_test.go +++ b/internal/parser/commandcode_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -10,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestDiscoverCommandCodeSessions(t *testing.T) { +func TestCommandCodeProviderDiscoversSessions(t *testing.T) { t.Parallel() root := t.TempDir() @@ -22,13 +23,17 @@ func TestDiscoverCommandCodeSessions(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(projectDir, "sess_a.prompts.jsonl"), []byte("{}\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(projectDir, "notes.txt"), []byte("ignore"), 0o644)) - files := DiscoverCommandCodeSessions(root) - require.Len(t, files, 1) - assert.Equal(t, AgentCommandCode, files[0].Agent) - assert.Equal(t, filepath.Join(projectDir, "sess_a.jsonl"), files[0].Path) + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, AgentCommandCode, sources[0].Provider) + assert.Equal(t, filepath.Join(projectDir, "sess_a.jsonl"), sources[0].DisplayPath) } -func TestFindCommandCodeSourceFile(t *testing.T) { +func TestCommandCodeProviderFindsSourceFile(t *testing.T) { t.Parallel() root := t.TempDir() @@ -37,11 +42,24 @@ func TestFindCommandCodeSourceFile(t *testing.T) { path := filepath.Join(projectDir, "sess_123.jsonl") require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o644)) - assert.Equal(t, path, FindCommandCodeSourceFile(root, "sess_123")) - assert.Empty(t, FindCommandCodeSourceFile(root, "sess_missing")) + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, path, found.DisplayPath) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_missing", + }) + require.NoError(t, err) + assert.False(t, ok) } -func TestParseCommandCodeSession(t *testing.T) { +func TestCommandCodeProviderParsesSession(t *testing.T) { t.Parallel() content := `{"id":"m1","timestamp":"2026-06-01T10:00:00Z","sessionId":"sess_123","role":"user","content":[{"type":"text","text":"Inspect server logs"}],"gitBranch":"feature/command-code","metadata":{"version":2,"cwd":"/Users/alice/code/sample-project"}} @@ -49,13 +67,31 @@ func TestParseCommandCodeSession(t *testing.T) { {"id":"m3","timestamp":"2026-06-01T10:00:02Z","sessionId":"sess_123","role":"tool","content":[{"type":"tool-result","toolCallId":"tc1","toolName":"Read","output":{"type":"text","value":"error: boom"}}],"gitBranch":"feature/command-code","metadata":{"version":2}} {"id":"m4","timestamp":"2026-06-01T10:00:03Z","sessionId":"sess_123","role":"assistant","content":[{"type":"text","text":"The error is in the startup path."}],"gitBranch":"feature/command-code","metadata":{"version":2}}` - path := createTestFile(t, "commandcode.jsonl", content) + root := t.TempDir() + path := filepath.Join(root, "project", "sess_123.jsonl") + writeSourceFile(t, path, content) metaPath := strings.TrimSuffix(path, ".jsonl") + ".meta.json" require.NoError(t, os.WriteFile(metaPath, []byte(`{"title":"Startup investigation"}`), 0o644)) - sess, msgs, err := ParseCommandCodeSession(path, "local") + provider, ok := NewProvider(AgentCommandCode, ProviderConfig{ + Roots: []string{root}, + Machine: "local", + }) + require.True(t, ok) + source, found, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "sess_123", + }) require.NoError(t, err) - require.NotNil(t, sess) + require.True(t, found) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Machine: "local", + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + + sess := outcome.Results[0].Result.Session + msgs := outcome.Results[0].Result.Messages require.Len(t, msgs, 4) assert.Equal(t, "commandcode:sess_123", sess.ID) diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 15b83aed1..0ed066181 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -2309,49 +2309,6 @@ func FindQClawSourceFile(agentsDir, rawID string) string { return "" } -// DiscoverIflowProjects finds all project directories under the -// iFlow projects dir and returns their JSONL session files. -// iFlow stores sessions in .iflow/projects//session-.jsonl -func DiscoverIflowProjects(projectsDir string) []DiscoveredFile { - entries, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range entries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - projDir := filepath.Join(projectsDir, entry.Name()) - sessionFiles, err := os.ReadDir(projDir) - if err != nil { - continue - } - - for _, sf := range sessionFiles { - if sf.IsDir() { - continue - } - name := sf.Name() - if !strings.HasPrefix(name, "session-") || !strings.HasSuffix(name, ".jsonl") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(projDir, name), - Project: entry.Name(), - Agent: AgentIflow, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - // extractIflowBaseSessionID extracts the base session ID from an iFlow // session ID. Fork IDs are formatted as -, so we // remove the child UUID suffix to get the base session ID for file lookup. @@ -2379,39 +2336,6 @@ func extractIflowBaseSessionID(sessionID string) string { return sessionID } -// FindIflowSourceFile finds the original JSONL file for an iFlow -// session ID by searching all project directories. -func FindIflowSourceFile( - projectsDir, sessionID string, -) string { - if !IsValidSessionID(sessionID) { - return "" - } - - // For fork IDs, extract the base session ID to find the source file - baseID := extractIflowBaseSessionID(sessionID) - - entries, err := os.ReadDir(projectsDir) - if err != nil { - return "" - } - - target := "session-" + strings.TrimPrefix(baseID, "iflow:") + ".jsonl" - for _, entry := range entries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - candidate := filepath.Join( - projectsDir, entry.Name(), target, - ) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - - return "" -} - // DiscoverVibeSessions finds all Vibe session files under the given root directory. // Vibe stores sessions in: ~/.vibe/logs/session/session_YYYYMMDD_HHMMSS_uuid/ // Each session directory contains messages.jsonl diff --git a/internal/parser/iflow.go b/internal/parser/iflow.go index eb502871a..f9c08e5cf 100644 --- a/internal/parser/iflow.go +++ b/internal/parser/iflow.go @@ -24,12 +24,12 @@ type dagEntryIflow struct { timestamp time.Time } -// ParseIflowSession parses an iFlow JSONL session file. +// parseSession parses an iFlow JSONL session file. // Returns a single ParseResult. Unlike Claude, iFlow's // uuid/parentUuid DAG represents streaming incremental updates // (sliding-window snapshots), not conversation forks, so fork // splitting is intentionally not applied. -func ParseIflowSession( +func parseIflowSession( path, project, machine string, ) ([]ParseResult, error) { info, err := os.Stat(path) diff --git a/internal/parser/iflow_parser_test.go b/internal/parser/iflow_parser_test.go index 8cf23170e..f78d504d2 100644 --- a/internal/parser/iflow_parser_test.go +++ b/internal/parser/iflow_parser_test.go @@ -10,6 +10,16 @@ import ( "github.com/stretchr/testify/require" ) +// parseIflowSessionForTest exercises the iFlow provider's transcript parsing +// in isolation, passing the project directly so tests can assert parse +// behavior without project-resolution enrichment. +func parseIflowSessionForTest( + t *testing.T, path, project, machine string, +) ([]ParseResult, error) { + t.Helper() + return parseIflowSession(path, project, machine) +} + func TestParseIflowSession(t *testing.T) { tests := []struct { name string @@ -29,7 +39,8 @@ func TestParseIflowSession(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, tt.filename, "test-project", "local", @@ -68,7 +79,8 @@ func TestExtractIflowProjectHints(t *testing.T) { } func TestIflowSystemMessageFiltering(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", @@ -90,7 +102,8 @@ func TestIflowSystemMessageFiltering(t *testing.T) { } func TestIflowToolCallParsing(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", @@ -121,7 +134,8 @@ func TestIflowToolCallParsing(t *testing.T) { } func TestIflowBurstMerge(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", @@ -283,7 +297,8 @@ func TestIflowBurstBoundary(t *testing.T) { } func TestIflowTimestampParsing(t *testing.T) { - results, err := ParseIflowSession( + results, err := parseIflowSessionForTest( + t, "testdata/iflow/session-5de701fc-7454-4858-a249-95cac4fd3b51.jsonl", "test-project", "local", diff --git a/internal/parser/iflow_provider.go b/internal/parser/iflow_provider.go new file mode 100644 index 000000000..7a28b7ef7 --- /dev/null +++ b/internal/parser/iflow_provider.go @@ -0,0 +1,105 @@ +package parser + +import ( + "context" + "path/filepath" + "strings" +) + +// iFlow stores each chat as a JSONL transcript named session-.jsonl in a +// per-project directory. It is a directory-of-files provider: discovery, +// watching, change classification, lookup, and fingerprinting come from +// DirectoryJSONLSourceSet. The ParseFile option makes that source set a full +// SourceSet so it rides the generic factory; RawSessionIDForLookup strips the +// subagent suffix from stored IDs so FindSource still matches the base file. +func newIflowProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + iflowProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newIflowSourceSet(cfg.Roots) }, + ) +} + +func newIflowSourceSet(roots []string) DirectoryJSONLSourceSet { + return newDirectoryJSONLSourceSet(AgentIflow, roots, + withContentHashing(), + withSymlinkFollowing(), + withIncludePath(isIflowSourcePath), + withSessionIDFromPath(iflowSessionIDFromPath), + withRawSessionIDForLookup(extractIflowBaseSessionID), + withParseFile(iflowParseFile), + ) +} + +func iflowParseFile( + ctx context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + project := iflowResolveProject(ctx, req.Source, path) + results, err := parseIflowSession(path, project, req.Machine) + if err != nil { + return nil, nil, err + } + if len(results) == 0 { + return nil, nil, nil + } + // Mirror the legacy sync path: derive continuation/subagent + // relationship types from parent linkage before emitting. + InferRelationshipTypes(results) + if req.Fingerprint.Hash != "" { + for i := range results { + results[i].Session.File.Hash = req.Fingerprint.Hash + } + } + return results, nil, nil +} + +// iflowResolveProject mirrors the legacy sync project resolution for iFlow: +// start from the project directory name, then prefer a canonical project +// derived from the session's recorded cwd and git branch when available. +func iflowResolveProject( + ctx context.Context, + source SourceRef, + path string, +) string { + dirName := firstNonEmptyJSONLString( + source.ProjectHint, + directoryJSONLProjectFromPath(path), + ) + project := GetProjectName(dirName) + + cwd, gitBranch := ExtractIflowProjectHints(path) + if cwd != "" { + if p := ExtractProjectFromCwdWithBranchContext( + ctx, cwd, gitBranch, + ); p != "" { + project = p + } + } + return project +} + +func isIflowSourcePath(root, path string) bool { + name := filepath.Base(path) + return strings.HasPrefix(name, "session-") && + strings.HasSuffix(name, ".jsonl") +} + +func iflowSessionIDFromPath(root, path string) string { + if !isIflowSourcePath(root, path) { + return "" + } + stem := strings.TrimSuffix(filepath.Base(path), ".jsonl") + return strings.TrimPrefix(stem, "session-") +} + +func iflowProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/iflow_provider_test.go b/internal/parser/iflow_provider_test.go new file mode 100644 index 000000000..6bb17fb06 --- /dev/null +++ b/internal/parser/iflow_provider_test.go @@ -0,0 +1,147 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIflowProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentIflow) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentIflow, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestIflowProviderSourceMethods(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "test-project") + rawID := "5de701fc-7454-4858-a249-95cac4fd3b51" + sourcePath := filepath.Join(projectDir, "session-"+rawID+".jsonl") + copyFixtureFile(t, "testdata/iflow/session-"+rawID+".jsonl", sourcePath) + writeSourceFile(t, filepath.Join(projectDir, rawID+".jsonl"), "{}\n") + + provider, ok := NewProvider(AgentIflow, 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, AgentIflow, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + assert.Equal(t, "test-project", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~iflow:" + rawID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + forkID := rawID + "-6f5d8718-7a95-4bb8-965f-faa23246c82d" + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: forkID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + require.NoError(t, os.Remove(sourcePath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) +} + +func TestIflowProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + realProjectDir := filepath.Join(t.TempDir(), "real-project") + linkProjectDir := filepath.Join(root, "linked-project") + if err := os.Symlink(realProjectDir, linkProjectDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + rawID := "5de701fc-7454-4858-a249-95cac4fd3b51" + sourcePath := filepath.Join(linkProjectDir, "session-"+rawID+".jsonl") + copyFixtureFile( + t, + "testdata/iflow/session-"+rawID+".jsonl", + filepath.Join(realProjectDir, "session-"+rawID+".jsonl"), + ) + + provider, ok := NewProvider(AgentIflow, 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) + assert.Equal(t, "linked-project", discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestIflowProviderParse(t *testing.T) { + root := t.TempDir() + project := "test-project" + rawID := "5de701fc-7454-4858-a249-95cac4fd3b51" + sourcePath := filepath.Join(root, project, "session-"+rawID+".jsonl") + copyFixtureFile(t, "testdata/iflow/session-"+rawID+".jsonl", sourcePath) + + provider, ok := NewProvider(AgentIflow, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "iflow:"+rawID, outcome.Results[0].Result.Session.ID) + // The provider mirrors the legacy sync project resolution, deriving the + // canonical project from the session's recorded cwd rather than the raw + // project directory name. + assert.Equal(t, "docker_image_retagger", outcome.Results[0].Result.Session.Project) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash) + assert.Len(t, outcome.Results[0].Result.Messages, 11) +} + +func copyFixtureFile(t *testing.T, src, dst string) { + t.Helper() + + data, err := os.ReadFile(src) + require.NoError(t, err) + writeSourceFile(t, dst, string(data)) +} diff --git a/internal/parser/provider.go b/internal/parser/provider.go index f71a5c242..64745e779 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -339,13 +339,23 @@ func (p *legacyProvider) Parse(context.Context, ParseRequest) (ParseOutcome, err func ProviderFactories() []ProviderFactory { factories := make([]ProviderFactory, 0, len(Registry)) for _, def := range Registry { - factories = append(factories, legacyProviderFactory{ - def: cloneAgentDef(def), - }) + factories = append(factories, providerFactoryForDef(def)) } return factories } +func providerFactoryForDef(def AgentDef) ProviderFactory { + def = cloneAgentDef(def) + switch def.Type { + case AgentCommandCode: + return newCommandCodeProviderFactory(def) + case AgentIflow: + return newIflowProviderFactory(def) + default: + return legacyProviderFactory{def: def} + } +} + // ProviderFactoryByType returns the factory for an agent type. func ProviderFactoryByType(t AgentType) (ProviderFactory, bool) { for _, factory := range ProviderFactories() { diff --git a/internal/parser/provider_capabilities.go b/internal/parser/provider_capabilities.go new file mode 100644 index 000000000..f95270b4f --- /dev/null +++ b/internal/parser/provider_capabilities.go @@ -0,0 +1,15 @@ +package parser + +func jsonlFileProviderSourceCapabilities() SourceCapabilities { + return SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilityNotApplicable, + } +} diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index e213cbf00..439ec5334 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -27,14 +27,14 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentKilo: ProviderMigrationLegacyOnly, AgentOpenHands: ProviderMigrationLegacyOnly, AgentCursor: ProviderMigrationLegacyOnly, - AgentIflow: ProviderMigrationLegacyOnly, + AgentIflow: ProviderMigrationProviderAuthoritative, AgentAmp: ProviderMigrationLegacyOnly, AgentZencoder: ProviderMigrationLegacyOnly, AgentVSCodeCopilot: ProviderMigrationLegacyOnly, AgentVSCopilot: ProviderMigrationLegacyOnly, AgentPi: ProviderMigrationLegacyOnly, AgentQwen: ProviderMigrationLegacyOnly, - AgentCommandCode: ProviderMigrationLegacyOnly, + AgentCommandCode: ProviderMigrationProviderAuthoritative, AgentDeepSeekTUI: ProviderMigrationLegacyOnly, AgentOpenClaw: ProviderMigrationLegacyOnly, AgentQClaw: ProviderMigrationLegacyOnly, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index b0231d1b3..19023bf20 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -46,14 +46,12 @@ var pendingShimProviderFiles = map[string]bool{ "antigravity_provider.go": true, "claude_provider.go": true, "codex_provider.go": true, - "commandcode_provider.go": true, "copilot_provider.go": true, "cowork_provider.go": true, "cursor_provider.go": true, "db_backed_provider.go": true, "gemini_provider.go": true, "hermes_provider.go": true, - "iflow_provider.go": true, "kiro_ide_provider.go": true, "kiro_provider.go": true, "opencode_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 348c1b5d5..59e3010ad 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -254,15 +254,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindZencoderSourceFile, }, { - Type: AgentIflow, - DisplayName: "iFlow", - EnvVar: "IFLOW_DIR", - ConfigKey: "iflow_dirs", - DefaultDirs: []string{".iflow/projects"}, - IDPrefix: "iflow:", - FileBased: true, - DiscoverFunc: DiscoverIflowProjects, - FindSourceFunc: FindIflowSourceFile, + Type: AgentIflow, + DisplayName: "iFlow", + EnvVar: "IFLOW_DIR", + ConfigKey: "iflow_dirs", + DefaultDirs: []string{".iflow/projects"}, + IDPrefix: "iflow:", + FileBased: true, }, { Type: AgentVSCodeCopilot, @@ -347,15 +345,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindQwenSourceFile, }, { - Type: AgentCommandCode, - DisplayName: "Command Code", - EnvVar: "COMMANDCODE_PROJECTS_DIR", - ConfigKey: "commandcode_project_dirs", - DefaultDirs: []string{".commandcode/projects"}, - IDPrefix: "commandcode:", - FileBased: true, - DiscoverFunc: DiscoverCommandCodeSessions, - FindSourceFunc: FindCommandCodeSourceFile, + Type: AgentCommandCode, + DisplayName: "Command Code", + EnvVar: "COMMANDCODE_PROJECTS_DIR", + ConfigKey: "commandcode_project_dirs", + DefaultDirs: []string{".commandcode/projects"}, + IDPrefix: "commandcode:", + FileBased: true, }, { Type: AgentDeepSeekTUI, diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index d62e602c3..3861c296a 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -558,8 +558,11 @@ func TestCommandCodeRegistryEntry(t *testing.T) { def, ok := AgentByType(AgentCommandCode) require.True(t, ok, "AgentCommandCode missing from Registry") require.True(t, def.FileBased, "Command Code FileBased") - require.NotNil(t, def.DiscoverFunc, "Command Code DiscoverFunc") - require.NotNil(t, def.FindSourceFunc, "Command Code FindSourceFunc") + // Command Code is a migrated, provider-authoritative agent: source + // discovery and lookup live on the concrete provider, not on legacy + // AgentDef hooks. + require.Nil(t, def.DiscoverFunc, "Command Code DiscoverFunc") + require.Nil(t, def.FindSourceFunc, "Command Code FindSourceFunc") assert.Equal(t, []string{".commandcode/projects"}, def.DefaultDirs) assert.Equal(t, "commandcode:", def.IDPrefix) } diff --git a/internal/sync/engine.go b/internal/sync/engine.go index eb43cc389..41d009f0f 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1188,27 +1188,6 @@ func (e *Engine) classifyOnePath( } } - // iFlow: //session-.jsonl - for _, iflowDir := range e.agentDirs[parser.AgentIflow] { - if iflowDir == "" { - continue - } - if rel, ok := isUnder(iflowDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 2 { - continue - } - if !strings.HasPrefix(parts[1], "session-") || !strings.HasSuffix(parts[1], ".jsonl") { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parts[0], - Agent: parser.AgentIflow, - }, true - } - } - // Kimi: ///wire.jsonl (legacy) // or ///agents//wire.jsonl (.kimi-code) // Components that cannot round-trip through the ':'-delimited @@ -1492,46 +1471,6 @@ func (e *Engine) classifyOnePath( return df, true } - // Command Code: //.jsonl - for _, commandCodeDir := range e.agentDirs[parser.AgentCommandCode] { - if commandCodeDir == "" { - continue - } - if rel, ok := isUnder(commandCodeDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 2 { - continue - } - if sessionID, ok := strings.CutSuffix(parts[1], ".meta.json"); ok { - if !parser.IsValidSessionID(sessionID) { - continue - } - jsonlPath := filepath.Join(commandCodeDir, parts[0], sessionID+".jsonl") - if _, err := os.Stat(jsonlPath); err != nil { - continue - } - return parser.DiscoveredFile{ - Path: jsonlPath, - Project: parser.NormalizeName(parts[0]), - Agent: parser.AgentCommandCode, - }, true - } - if strings.HasSuffix(parts[1], ".checkpoints.jsonl") || - strings.HasSuffix(parts[1], ".prompts.jsonl") { - continue - } - sessionID, ok := strings.CutSuffix(parts[1], ".jsonl") - if !ok || !parser.IsValidSessionID(sessionID) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parser.NormalizeName(parts[0]), - Agent: parser.AgentCommandCode, - }, true - } - } - // OpenClaw: //sessions/.jsonl // or: //sessions/.jsonl. for _, ocDir := range e.agentDirs[parser.AgentOpenClaw] { @@ -4660,8 +4599,6 @@ func (e *Engine) processFile( res = e.processOpenHands(file, info) case parser.AgentCursor: res = e.processCursor(file, info) - case parser.AgentIflow: - res = e.processIflow(ctx, file, info) case parser.AgentAmp: res = e.processAmp(file, info) case parser.AgentDeepSeekTUI: @@ -4676,8 +4613,6 @@ func (e *Engine) processFile( res = e.processPi(file, info) case parser.AgentQwen: res = e.processQwen(file, info) - case parser.AgentCommandCode: - res = e.processCommandCode(file, info) case parser.AgentOpenClaw: res = e.processOpenClaw(file, info) case parser.AgentQClaw: @@ -7435,39 +7370,6 @@ func (e *Engine) processQwen( } } -func (e *Engine) processCommandCode( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - effectiveInfo := commandCodeEffectiveInfo(file.Path, info) - if e.shouldSkipByPath(file.Path, effectiveInfo) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseCommandCodeSession( - file.Path, 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 - } - sess.File.Size = effectiveInfo.Size() - sess.File.Mtime = effectiveInfo.ModTime().UnixNano() - - return processResult{ - results: []parser.ParseResult{{ - Session: *sess, - Messages: msgs, - }}, - } -} - func commandCodeEffectiveInfo(path string, info os.FileInfo) os.FileInfo { size := info.Size() mtime := info.ModTime().UnixNano() @@ -7506,56 +7408,6 @@ func validateCursorContainment( return nil } -func (e *Engine) processIflow( - ctx context.Context, - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - // Extract session ID from filename: session-.jsonl - sessionID := "iflow:" + strings.TrimPrefix(strings.TrimSuffix(info.Name(), ".jsonl"), "session-") - - if e.shouldSkipFile(sessionID, info) { - sess, _ := e.db.GetSession( - ctx, e.idPrefix+sessionID, - ) - if sess != nil && - sess.Project != "" && - !parser.NeedsProjectReparse(sess.Project) { - return processResult{skip: true} - } - } - - // Determine project name from cwd if possible - project := parser.GetProjectName(file.Project) - cwd, gitBranch := parser.ExtractIflowProjectHints( - file.Path, - ) - if cwd != "" { - if p := parser.ExtractProjectFromCwdWithBranchContext( - ctx, cwd, gitBranch, - ); p != "" { - project = p - } - } - - results, err := parser.ParseIflowSession( - file.Path, project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - for i := range results { - results[i].Session.File.Hash = hash - } - } - - parser.InferRelationshipTypes(results) - - return processResult{results: results} -} - // computeFinalStreak counts trailing consecutive failures // from the end of the tool call list. func computeFinalStreak(calls []signals.ToolCallRow) int { diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index 5bc90c310..edfb00892 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -3144,7 +3144,11 @@ func TestEngine_ClassifyPathsCommandCodeSession(t *testing.T) { require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files) assert.Equal(t, sessionPath, files[0].Path) assert.Equal(t, parser.AgentCommandCode, files[0].Agent) - assert.Equal(t, "users_alice_code_sample_project", files[0].Project) + // Command Code is provider-authoritative: classification attaches a + // provider source and recomputes the project during parse, so the + // classification carries no informational project hint. + assert.Empty(t, files[0].Project) + require.NotNil(t, files[0].ProviderSource) bogus := []string{ filepath.Join(commandCodeDir, "stray.jsonl"), diff --git a/internal/sync/iflow_discovery_test.go b/internal/sync/iflow_discovery_test.go index eb7749d5a..5bf654956 100644 --- a/internal/sync/iflow_discovery_test.go +++ b/internal/sync/iflow_discovery_test.go @@ -1,6 +1,7 @@ package sync import ( + "context" "os" "path/filepath" "testing" @@ -10,7 +11,7 @@ import ( "go.kenn.io/agentsview/internal/parser" ) -func TestDiscoverIflowProjects(t *testing.T) { +func TestIflowProviderDiscoversProjects(t *testing.T) { // Create a temporary directory structure for testing tmpDir := t.TempDir() @@ -40,16 +41,19 @@ func TestDiscoverIflowProjects(t *testing.T) { subDir := filepath.Join(proj1, "subdir") require.NoError(t, os.MkdirAll(subDir, 0o755)) - // Run discovery - files := parser.DiscoverIflowProjects(tmpDir) + provider, ok := parser.NewProvider(parser.AgentIflow, parser.ProviderConfig{ + Roots: []string{tmpDir}, + }) + require.True(t, ok) - // Verify results - assert.Len(t, files, 3) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + assert.Len(t, sources, 3) - // Verify file paths + // Verify source paths paths := make(map[string]bool) - for _, f := range files { - paths[f.Path] = true + for _, s := range sources { + paths[s.DisplayPath] = true } assert.True(t, paths[session1], "session1 not found in results") @@ -57,22 +61,22 @@ func TestDiscoverIflowProjects(t *testing.T) { assert.True(t, paths[session3], "session3 not found in results") assert.False(t, paths[otherFile], "other.txt should not be in results") - // Verify project names + // Verify project hints projects := make(map[string]bool) - for _, f := range files { - projects[f.Project] = true + for _, s := range sources { + projects[s.ProjectHint] = true } assert.True(t, projects["project1"], "project1 not found in projects") assert.True(t, projects["project2"], "project2 not found in projects") - // Verify agent type - for _, f := range files { - assert.Equal(t, parser.AgentType("iflow"), f.Agent) + // Verify provider type + for _, s := range sources { + assert.Equal(t, parser.AgentIflow, s.Provider) } } -func TestFindIflowSourceFile(t *testing.T) { +func TestIflowProviderFindsSourceFile(t *testing.T) { tmpDir := t.TempDir() // Create a project directory @@ -84,23 +88,39 @@ func TestFindIflowSourceFile(t *testing.T) { sessionFile := filepath.Join(proj, "session-"+sessionID+".jsonl") require.NoError(t, os.WriteFile(sessionFile, []byte(`{"test":"data"}`), 0o644)) + provider, ok := parser.NewProvider(parser.AgentIflow, parser.ProviderConfig{ + Roots: []string{tmpDir}, + }) + require.True(t, ok) + // Test finding the file - found := parser.FindIflowSourceFile(tmpDir, sessionID) - assert.Equal(t, sessionFile, found) + found, ok, err := provider.FindSource(context.Background(), parser.FindSourceRequest{ + RawSessionID: sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionFile, found.DisplayPath) // Test finding a non-existent file - notFound := parser.FindIflowSourceFile(tmpDir, "nonexistent") - assert.Empty(t, notFound) + _, ok, err = provider.FindSource(context.Background(), parser.FindSourceRequest{ + RawSessionID: "nonexistent", + }) + require.NoError(t, err) + assert.False(t, ok) // Test finding a fork ID (should extract base session ID) // Fork IDs have format: - - // The file lookup should use only the base UUID + // The source lookup should use only the base UUID baseSessionID := "96e6d875-92eb-40b9-b193-a9ba99f0f709" forkSessionID := baseSessionID + "-12345678-1234-5678-9abc-def012345678" forkSessionFile := filepath.Join(proj, "session-"+baseSessionID+".jsonl") require.NoError(t, os.WriteFile(forkSessionFile, []byte(`{"test":"fork"}`), 0o644)) // Test finding the fork session - should find the base file - foundFork := parser.FindIflowSourceFile(tmpDir, forkSessionID) - assert.Equal(t, forkSessionFile, foundFork, "for fork ID %s", forkSessionID) + foundFork, ok, err := provider.FindSource(context.Background(), parser.FindSourceRequest{ + RawSessionID: forkSessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, forkSessionFile, foundFork.DisplayPath, "for fork ID %s", forkSessionID) }