diff --git a/internal/parser/openhands.go b/internal/parser/openhands.go index 0945e015b..c2644ad6c 100644 --- a/internal/parser/openhands.go +++ b/internal/parser/openhands.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" @@ -20,82 +19,6 @@ const ( openHandsObservationEvent = "ObservationEvent" ) -// DiscoverOpenHandsSessions finds OpenHands CLI conversation -// directories under ~/.openhands/conversations. -func DiscoverOpenHandsSessions( - conversationsDir string, -) []DiscoveredFile { - entries, err := os.ReadDir(conversationsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range entries { - if !entry.IsDir() || !IsValidSessionID(entry.Name()) { - continue - } - sessionDir := filepath.Join( - conversationsDir, entry.Name(), - ) - if !isOpenHandsSessionDir(sessionDir) { - continue - } - files = append(files, DiscoveredFile{ - Path: sessionDir, - Agent: AgentOpenHands, - }) - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindOpenHandsSourceFile locates an OpenHands conversation -// directory by its raw session ID. -func FindOpenHandsSourceFile( - conversationsDir, rawID string, -) string { - if conversationsDir == "" || !IsValidSessionID(rawID) { - return "" - } - - candidates := []string{rawID} - stripped := strings.ReplaceAll(rawID, "-", "") - if stripped != rawID { - candidates = append(candidates, stripped) - } - - for _, cand := range candidates { - sessionDir := filepath.Join(conversationsDir, cand) - if isOpenHandsSessionDir(sessionDir) { - return sessionDir - } - } - - entries, err := os.ReadDir(conversationsDir) - if err != nil { - return "" - } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - sessionDir := filepath.Join( - conversationsDir, entry.Name(), - ) - if !isOpenHandsSessionDir(sessionDir) { - continue - } - if normalizeOpenHandsSessionID(entry.Name()) == normalizeOpenHandsSessionID(rawID) { - return sessionDir - } - } - return "" -} - // OpenHandsSnapshot computes synthetic file metadata for an // OpenHands conversation directory by hashing the relevant // metadata of base_state.json, TASKS.json, and events/*.json. @@ -184,9 +107,9 @@ func OpenHandsSnapshot(path string) (FileInfo, error) { }, nil } -// ParseOpenHandsSession parses a single OpenHands CLI -// conversation directory into a session and messages. -func ParseOpenHandsSession( +// parseSession parses a single OpenHands CLI conversation +// directory into a session and messages. +func (p *openHandsProvider) parseSession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { sessionDir, err := normalizeOpenHandsSessionPath(path) diff --git a/internal/parser/openhands_provider.go b/internal/parser/openhands_provider.go new file mode 100644 index 000000000..04f38ba7f --- /dev/null +++ b/internal/parser/openhands_provider.go @@ -0,0 +1,414 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +var _ Provider = (*openHandsProvider)(nil) + +type openHandsProviderFactory struct { + def AgentDef +} + +func newOpenHandsProviderFactory(def AgentDef) ProviderFactory { + return openHandsProviderFactory{def: cloneAgentDef(def)} +} + +func (f openHandsProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f openHandsProviderFactory) Capabilities() Capabilities { + return openHandsProviderCapabilities() +} + +func (f openHandsProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &openHandsProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: openHandsProviderCapabilities(), + Config: cfg, + }, + sources: newOpenHandsSourceSet(cfg.Roots), + } +} + +type openHandsProvider struct { + ProviderBase + sources openHandsSourceSet +} + +func (p *openHandsProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *openHandsProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *openHandsProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *openHandsProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *openHandsProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *openHandsProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + path, ok := p.sources.pathFromSource(req.Source) + if !ok { + return ParseOutcome{}, fmt.Errorf("openhands 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 req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return ParseOutcome{ + Results: []ParseResultOutcome{{ + Result: ParseResult{ + Session: *sess, + Messages: msgs, + }, + DataVersion: DataVersionCurrent, + }}, + ResultSetComplete: true, + }, nil +} + +type openHandsSource struct { + Root string + Path string +} + +type openHandsSourceSet struct { + roots []string +} + +func newOpenHandsSourceSet(roots []string) openHandsSourceSet { + return openHandsSourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s openHandsSourceSet) 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 + } + entries, err := os.ReadDir(root) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() || !IsValidSessionID(entry.Name()) { + continue + } + sessionDir := filepath.Join(root, entry.Name()) + source, ok := s.sourceRef(root, sessionDir) + if !ok { + continue + } + addJSONLSource(source, &sources, seen) + } + } + sortJSONLSources(sources) + return sources, nil +} + +func (s openHandsSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)) + for _, root := range s.roots { + roots = append(roots, WatchRoot{ + Path: root, + Recursive: false, + DebounceKey: string(AgentOpenHands) + ":dir:" + root, + }) + } + return WatchPlan{Roots: roots}, nil +} + +func (s openHandsSourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if req.WatchRoot != "" { + root := filepath.Clean(req.WatchRoot) + if !s.hasRoot(root) { + return nil, nil + } + source, ok := s.sourceForPathInRoot(root, req.Path) + if !ok { + return nil, nil + } + return []SourceRef{source}, nil + } + for _, root := range s.roots { + source, ok := s.sourceForPathInRoot(root, req.Path) + if ok { + return []SourceRef{source}, nil + } + } + return nil, nil +} + +func (s openHandsSourceSet) 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 + } + if source, ok := s.sourceForPath(path); ok { + return source, true, nil + } + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + path := s.sessionDirForID(root, req.RawSessionID) + if path == "" { + continue + } + if source, ok := s.sourceRef(root, path); ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +// sessionDirForID locates an OpenHands conversation directory under +// root by its raw session ID. It first tries the raw ID and its +// dash-stripped form as literal directory names, then falls back to +// matching any session directory whose normalized ID equals the +// normalized raw ID. +func (s openHandsSourceSet) sessionDirForID(root, rawID string) string { + if root == "" || !IsValidSessionID(rawID) { + return "" + } + + candidates := []string{rawID} + stripped := strings.ReplaceAll(rawID, "-", "") + if stripped != rawID { + candidates = append(candidates, stripped) + } + for _, cand := range candidates { + sessionDir := filepath.Join(root, cand) + if isOpenHandsSessionDir(sessionDir) { + return sessionDir + } + } + + entries, err := os.ReadDir(root) + if err != nil { + return "" + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + sessionDir := filepath.Join(root, entry.Name()) + if !isOpenHandsSessionDir(sessionDir) { + continue + } + if normalizeOpenHandsSessionID(entry.Name()) == + normalizeOpenHandsSessionID(rawID) { + return sessionDir + } + } + return "" +} + +func (s openHandsSourceSet) 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("openhands source path unavailable") + } + snapshot, err := OpenHandsSnapshot(path) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path), + Size: snapshot.Size, + MTimeNS: snapshot.Mtime, + Hash: snapshot.Hash, + }, nil +} + +func (s openHandsSourceSet) pathFromSource(source SourceRef) (string, bool) { + switch src := source.Opaque.(type) { + case openHandsSource: + return src.Path, src.Path != "" + case *openHandsSource: + if src != nil && src.Path != "" { + return src.Path, true + } + } + for _, candidate := range []string{ + source.DisplayPath, + source.FingerprintKey, + source.Key, + } { + if ref, ok := s.sourceForPath(candidate); ok { + src := ref.Opaque.(openHandsSource) + return src.Path, true + } + } + return "", false +} + +func (s openHandsSourceSet) sourceForPath(path string) (SourceRef, bool) { + for _, root := range s.roots { + if source, ok := s.sourceForPathInRoot(root, path); ok { + return source, true + } + } + return SourceRef{}, false +} + +func (s openHandsSourceSet) sourceForPathInRoot( + root string, + path string, +) (SourceRef, bool) { + sessionDir, ok := openHandsSessionDirForPath(root, path) + if !ok { + return SourceRef{}, false + } + return s.sourceRef(root, sessionDir) +} + +func (s openHandsSourceSet) sourceRef(root, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if !isOpenHandsSessionDir(path) { + return SourceRef{}, false + } + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || + strings.Contains(rel, string(filepath.Separator)) { + return SourceRef{}, false + } + if !IsValidSessionID(rel) { + return SourceRef{}, false + } + return SourceRef{ + Provider: AgentOpenHands, + Key: path, + DisplayPath: path, + FingerprintKey: path, + Opaque: openHandsSource{ + Root: root, + Path: path, + }, + }, true +} + +func (s openHandsSourceSet) hasRoot(root string) bool { + for _, configured := range s.roots { + if samePath(root, configured) { + return true + } + } + return false +} + +func openHandsSessionDirForPath(root, path string) (string, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." || rel == ".." || + strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 0 || !IsValidSessionID(parts[0]) { + return "", false + } + switch len(parts) { + case 1: + case 2: + if parts[1] != "base_state.json" && parts[1] != "TASKS.json" { + return "", false + } + case 3: + if parts[1] != "events" || filepath.Ext(parts[2]) != ".json" { + return "", false + } + default: + return "", false + } + return filepath.Join(root, parts[0]), true +} + +func openHandsProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilityNotApplicable, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Cwd: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/openhands_provider_test.go b/internal/parser/openhands_provider_test.go new file mode 100644 index 000000000..5e07f6ba1 --- /dev/null +++ b/internal/parser/openhands_provider_test.go @@ -0,0 +1,209 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenHandsProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentOpenHands) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentOpenHands, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestOpenHandsProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sessionID := "086c7ecf-6cb7-46b6-9fbc-b900358d1247" + dirName := "086c7ecf6cb746b69fbcb900358d1247" + sessionDir := openHandsProviderWriteSession( + t, root, dirName, sessionID, "provider question", + ) + openHandsProviderWriteInvalidSession(t, root, "missing-events") + writeSourceFile(t, filepath.Join(root, "notes.txt"), "{}\n") + + provider, ok := NewProvider(AgentOpenHands, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.NotEmpty(t, plan.Roots[0].DebounceKey) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, AgentOpenHands, discovered[0].Provider) + assert.Equal(t, sessionDir, discovered[0].Key) + assert.Equal(t, sessionDir, discovered[0].DisplayPath) + assert.Equal(t, sessionDir, discovered[0].FingerprintKey) + assert.Empty(t, discovered[0].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "remote~openhands:" + sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionDir, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: dirName, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionDir, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: sessionDir, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionDir, found.DisplayPath) + + snapshot, err := OpenHandsSnapshot(sessionDir) + require.NoError(t, err) + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, sessionDir, fingerprint.Key) + assert.Equal(t, snapshot.Size, fingerprint.Size) + assert.Equal(t, snapshot.Mtime, fingerprint.MTimeNS) + assert.Equal(t, snapshot.Hash, fingerprint.Hash) + + for _, changedPath := range []string{ + sessionDir, + filepath.Join(sessionDir, "base_state.json"), + filepath.Join(sessionDir, "TASKS.json"), + filepath.Join(sessionDir, "events", "event-00000-user.json"), + } { + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: changedPath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1, changedPath) + assert.Equal(t, sessionDir, changed[0].DisplayPath) + } + + ignored, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(sessionDir, "events", "notes.txt"), + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, ignored) + + wrongRoot, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: sessionDir, + EventKind: "write", + WatchRoot: filepath.Join(root, "..", "other-root"), + }, + ) + require.NoError(t, err) + assert.Empty(t, wrongRoot) +} + +func TestOpenHandsProviderParse(t *testing.T) { + root := t.TempDir() + sessionID := "086c7ecf-6cb7-46b6-9fbc-b900358d1247" + sessionDir := openHandsProviderWriteSession( + t, root, "086c7ecf6cb746b69fbcb900358d1247", sessionID, "parse question", + ) + provider, ok := NewProvider(AgentOpenHands, 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) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.False(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "openhands:"+sessionID, result.Result.Session.ID) + assert.Equal(t, AgentOpenHands, result.Result.Session.Agent) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, sessionDir, result.Result.Session.File.Path) + assert.Equal(t, fingerprint.Hash, result.Result.Session.File.Hash) + assert.Equal(t, "parse question", result.Result.Session.FirstMessage) + assert.Len(t, result.Result.Messages, 1) +} + +func openHandsProviderWriteSession( + t *testing.T, + root string, + dirName string, + sessionID string, + firstMessage string, +) string { + t.Helper() + sessionDir := filepath.Join(root, dirName) + eventsDir := filepath.Join(sessionDir, "events") + require.NoError(t, os.MkdirAll(eventsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(sessionDir, "base_state.json"), + []byte(`{"id":"`+sessionID+`","agent":{"llm":{"model":"test-model"}}}`), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(sessionDir, "TASKS.json"), + []byte(`[]`), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(eventsDir, "event-00000-user.json"), + []byte(`{ + "id":"e0", + "timestamp":"2026-04-02T15:25:40.706887", + "source":"user", + "llm_message":{"role":"user","content":[{"type":"text","text":"`+firstMessage+`"}]}, + "kind":"MessageEvent" + }`), + 0o644, + )) + return sessionDir +} + +func openHandsProviderWriteInvalidSession( + t *testing.T, + root string, + dirName string, +) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, dirName), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(root, dirName, "base_state.json"), + []byte(`{}`), + 0o644, + )) +} diff --git a/internal/parser/openhands_test.go b/internal/parser/openhands_test.go index 4d8b4bc6f..3e6109d69 100644 --- a/internal/parser/openhands_test.go +++ b/internal/parser/openhands_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "encoding/json" "os" "path/filepath" @@ -25,19 +26,30 @@ func TestDiscoverAndFindOpenHandsSessions(t *testing.T) { 0o644, )) - files := DiscoverOpenHandsSessions(root) - require.Len(t, files, 1) - assert.Equal(t, sessionDir, files[0].Path) - assert.Equal(t, AgentOpenHands, files[0].Agent) + provider, ok := NewProvider(AgentOpenHands, ProviderConfig{ + Roots: []string{root}, + }) + require.True(t, ok) - assert.Equal( - t, sessionDir, - FindOpenHandsSourceFile(root, sessionID), - ) - assert.Equal( - t, sessionDir, - FindOpenHandsSourceFile(root, dirName), - ) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, sessionDir, sources[0].DisplayPath) + assert.Equal(t, AgentOpenHands, sources[0].Provider) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionDir, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: dirName, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sessionDir, found.DisplayPath) } func TestParseOpenHandsSession(t *testing.T) { @@ -116,10 +128,27 @@ func TestParseOpenHandsSession(t *testing.T) { )) } - sess, msgs, err := ParseOpenHandsSession( - sessionDir, "local", - ) + provider, ok := NewProvider(AgentOpenHands, ProviderConfig{ + Roots: []string{root}, + Machine: "local", + }) + require.True(t, ok) + source, found, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: sessionDir, + }) require.NoError(t, err) + require.True(t, found) + fingerprint, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + + sess := &outcome.Results[0].Result.Session + msgs := outcome.Results[0].Result.Messages require.NotNil(t, sess) require.Len(t, msgs, 4) diff --git a/internal/parser/provider.go b/internal/parser/provider.go index db5842909..17a115f04 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -361,6 +361,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newGptmeProviderFactory(def) case AgentKimi: return newKimiProviderFactory(def) + case AgentOpenHands: + return newOpenHandsProviderFactory(def) case AgentOpenClaw: return newOpenClawProviderFactory(def) case AgentOMP, AgentPi: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 050fcf3f5..bc8349059 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -25,7 +25,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentMiMoCode: ProviderMigrationLegacyOnly, AgentOpenCode: ProviderMigrationLegacyOnly, AgentKilo: ProviderMigrationLegacyOnly, - AgentOpenHands: ProviderMigrationLegacyOnly, + AgentOpenHands: ProviderMigrationProviderAuthoritative, AgentCursor: ProviderMigrationLegacyOnly, AgentIflow: ProviderMigrationProviderAuthoritative, AgentAmp: ProviderMigrationProviderAuthoritative, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 6d369e1ff..dedb0bb1c 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -55,7 +55,6 @@ var pendingShimProviderFiles = map[string]bool{ "kiro_ide_provider.go": true, "kiro_provider.go": true, "opencode_provider.go": true, - "openhands_provider.go": true, "positron_provider.go": true, "shelley_provider.go": true, "vibe_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 90cf2a81c..f799784f1 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -209,16 +209,14 @@ var Registry = []AgentDef{ WatchRootsFunc: ResolveKiloWatchRoots, }, { - Type: AgentOpenHands, - DisplayName: "OpenHands CLI", - EnvVar: "OPENHANDS_CONVERSATIONS_DIR", - ConfigKey: "openhands_dirs", - DefaultDirs: []string{".openhands/conversations"}, - IDPrefix: "openhands:", - FileBased: true, - ShallowWatch: true, - DiscoverFunc: DiscoverOpenHandsSessions, - FindSourceFunc: FindOpenHandsSourceFile, + Type: AgentOpenHands, + DisplayName: "OpenHands CLI", + EnvVar: "OPENHANDS_CONVERSATIONS_DIR", + ConfigKey: "openhands_dirs", + DefaultDirs: []string{".openhands/conversations"}, + IDPrefix: "openhands:", + FileBased: true, + ShallowWatch: true, }, { Type: AgentCursor, diff --git a/internal/sync/classify_openhands_test.go b/internal/sync/classify_openhands_test.go index a23e4e563..580319fd0 100644 --- a/internal/sync/classify_openhands_test.go +++ b/internal/sync/classify_openhands_test.go @@ -34,11 +34,15 @@ func TestClassifyOnePath_OpenHands(t *testing.T) { )) eng := &Engine{ + db: openTestDB(t), agentDirs: map[parser.AgentType][]string{ parser.AgentOpenHands: {root}, }, + providerFactories: providerFactoryMap(parser.ProviderFactories()), + providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ + parser.AgentOpenHands: parser.ProviderMigrationProviderAuthoritative, + }, } - geminiMap := make(map[string]map[string]string) tests := []struct { name string @@ -80,12 +84,15 @@ func TestClassifyOnePath_OpenHands(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, ok := eng.classifyOnePath(tt.path, geminiMap) - assert.Equal(t, tt.want, ok) - if ok { - assert.Equal(t, parser.AgentOpenHands, got.Agent) - assert.Equal(t, tt.retPath, got.Path) + files := eng.classifyPaths([]string{tt.path}) + if !tt.want { + assert.Empty(t, files) + return } + require.Len(t, files, 1) + got := files[0] + assert.Equal(t, parser.AgentOpenHands, got.Agent) + assert.Equal(t, tt.retPath, got.Path) }) } } diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 3326e5544..77b4f6511 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1132,38 +1132,6 @@ func (e *Engine) classifyOnePath( } } - // OpenHands CLI: - // //base_state.json - // //TASKS.json - // //events/*.json - for _, openHandsDir := range e.agentDirs[parser.AgentOpenHands] { - if openHandsDir == "" { - continue - } - if rel, ok := isUnder(openHandsDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) < 2 || !parser.IsValidSessionID(parts[0]) { - continue - } - switch { - case len(parts) == 2 && - (parts[1] == "base_state.json" || - parts[1] == "TASKS.json"): - case len(parts) == 3 && - parts[1] == "events" && - strings.HasSuffix(parts[2], ".json"): - default: - continue - } - return parser.DiscoveredFile{ - Path: filepath.Join( - openHandsDir, parts[0], - ), - Agent: parser.AgentOpenHands, - }, true - } - } - // Cursor: // //agent-transcripts/.{txt,jsonl} // //agent-transcripts//.{txt,jsonl} @@ -4235,13 +4203,6 @@ func (e *Engine) processFile( // Capture mtime once from the initial stat so all // downstream cache operations use a consistent value. mtime := info.ModTime().UnixNano() - if file.Agent == parser.AgentOpenHands { - snapshot, err := parser.OpenHandsSnapshot(file.Path) - if err != nil { - return processResult{err: err} - } - mtime = snapshot.Mtime - } if file.Agent == parser.AgentCowork { mtime = parser.CoworkSessionMtime(file.Path, mtime) } @@ -4300,8 +4261,6 @@ func (e *Engine) processFile( res = e.processGemini(file, info) case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode: res = e.processOpenCodeFormat(file.Agent, file, info) - case parser.AgentOpenHands: - res = e.processOpenHands(file, info) case parser.AgentCursor: res = e.processCursor(file, info) case parser.AgentVSCodeCopilot: @@ -6593,38 +6552,6 @@ func (e *Engine) processAntigravityCLI( } } -func (e *Engine) processOpenHands( - file parser.DiscoveredFile, _ os.FileInfo, -) processResult { - snapshot, err := parser.OpenHandsSnapshot(file.Path) - if err != nil { - return processResult{err: err} - } - - fi := fakeSnapshotInfo{ - fSize: snapshot.Size, fMtime: snapshot.Mtime, - } - if e.shouldSkipByPath(file.Path, fi) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseOpenHandsSession( - file.Path, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - func (e *Engine) processCursor( file parser.DiscoveredFile, info os.FileInfo, ) processResult { diff --git a/internal/sync/openhands_retry_test.go b/internal/sync/openhands_retry_test.go index 872d0c091..353611c67 100644 --- a/internal/sync/openhands_retry_test.go +++ b/internal/sync/openhands_retry_test.go @@ -39,8 +39,15 @@ func TestProcessFileOpenHandsUsesSnapshotMtimeForRetryCache(t *testing.T) { oldDirMtime := dirInfo.ModTime() engine := &Engine{ - db: dbtest.OpenTestDB(t), - machine: "local", + db: dbtest.OpenTestDB(t), + machine: "local", + agentDirs: map[parser.AgentType][]string{ + parser.AgentOpenHands: {root}, + }, + providerFactories: providerFactoryMap(parser.ProviderFactories()), + providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{ + parser.AgentOpenHands: parser.ProviderMigrationProviderAuthoritative, + }, skipCache: map[string]int64{sessionDir: oldDirMtime.UnixNano()}, }