From cefd6ca27f24f62c25710d1a7d263fac30940716 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 22 Jun 2026 22:08:17 -0400 Subject: [PATCH] feat(parser): migrate openhands provider OpenHands stores each conversation as a directory with metadata and event files, so the provider needs a directory source facade rather than a JSONL file wrapper. This keeps the legacy discovery and dashed/undashed ID lookup behavior while making the composite snapshot fingerprint explicit at the provider boundary. The provider uses the existing OpenHands parser and snapshot helpers so freshness, shallow watch planning, changed-path classification, and normalized parse output stay aligned with the legacy sync path. test(parser): opt openhands into provider shadow OpenHands now has a concrete facade provider on this branch, so its migration mode should enter shadow comparison instead of remaining legacy-only and additive. Earlier provider opt-ins stay inherited and later provider branches own their modes. 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 openhands shadow parity OpenHands is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseOpenHandsSession. The test uses the directory snapshot source shape so the provider fingerprint path and planned data-version behavior stay visible while the branch migrates away from legacy dispatch. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesOpenHandsLegacyParser|TestOpenHandsProvider|TestParseOpenHands|TestDiscoverAndFindOpenHands|TestClassifyOnePath_OpenHands|TestProcessFileOpenHandsUsesSnapshotMtimeForRetryCache' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... refactor(parser): fold openhands into provider Move OpenHands discovery, source lookup, and parse ownership onto the concrete provider and delete the package-level DiscoverOpenHandsSessions, FindOpenHandsSourceFile, and ParseOpenHandsSession free functions. Discovery now walks conversation roots directly in the provider source set, raw-session-ID lookup folds the literal/dash-stripped/normalized matching into sessionDirForID, and parsing runs on a provider receiver method. The provider-neutral snapshot, session-dir predicate, and event parse helpers stay as shared free functions. Make OpenHands provider-authoritative and remove its legacy sync dispatch: the classifyOnePath block, the processFile case arm, the OpenHands snapshot-mtime branch, and processOpenHands are gone. Sync now classifies and processes OpenHands through provider changed-path handling, which preserves the base_state.json/TASKS.json/events companion remap to the session directory and keeps the snapshot mtime driving the skip-retry cache via the provider fingerprint. Drop the OpenHands AgentDef DiscoverFunc/FindSourceFunc hooks, remove the shadow baseline test, exempt the provider file from the shim scan, and add a guard asserting the legacy entrypoints stay deleted. --- internal/parser/openhands.go | 83 +---- internal/parser/openhands_provider.go | 414 +++++++++++++++++++++ internal/parser/openhands_provider_test.go | 209 +++++++++++ internal/parser/openhands_test.go | 59 ++- internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/provider_shim_scan_test.go | 1 - internal/parser/types.go | 18 +- internal/sync/classify_openhands_test.go | 19 +- internal/sync/engine.go | 73 ---- internal/sync/openhands_retry_test.go | 11 +- 11 files changed, 703 insertions(+), 188 deletions(-) create mode 100644 internal/parser/openhands_provider.go create mode 100644 internal/parser/openhands_provider_test.go 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()}, }