From 6ac3df00fdbd822cbecce61f637c292d8664a131 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Mon, 22 Jun 2026 22:16:25 -0400 Subject: [PATCH] feat(parser): migrate hermes provider Hermes can represent a configured root as either individual transcript files or as a state.db archive that fans out into multiple sessions. Moving it behind a concrete provider makes that source choice explicit instead of leaving archive behavior inside the legacy adapter path.\n\nThe provider preserves transcript discovery and lookup while treating state.db as a multi-session, force-replace source. Its fingerprint covers the archive database plus sibling transcripts so transcript-quality changes can refresh the archive source that ParseHermesArchive reads. fix(parser): preserve hermes archive event coverage Hermes archive discovery can normalize a configured sessions directory or direct state.db path into a sibling archive source, but the watch plan and changed-path classifier still assumed the configured root was the only event root. That left state.db updates and removed primary files invisible to provider-path sync. Normalize archive watch roots, map delete and rename-style events syntactically when primary files are gone, and cover archive-parent, sessions-directory, and direct-state roots. This lets Hermes enter shadow comparison as an actual migration branch. Validation: go test -tags "fts5" ./internal/parser -run 'Test(HermesProvider|ProviderMigrationModes)' -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check fix(parser): watch hermes archive roots syntactically Hermes archive configs can point at the archive parent, its sessions directory, or the state.db file before the sibling archive components have been created. Watch planning needs to treat those shapes as archive roots from their paths, not from startup-time existence checks, otherwise late-created metadata or transcripts are invisible until a full sync. The transcript watch root is now retained for archive-shaped roots even when sessions/ is not present yet, while ordinary transcript-only roots keep their recursive file watch. Validation: go test -tags "fts5" ./internal/parser -run 'TestHermesProvider' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; git diff --check fix(parser): feed hermes archive roots to runtime watcher Hermes provider watch planning now knows how to follow archive-shaped roots, but the actual serve-time watcher still reads registry watch resolvers. Without a matching Hermes resolver there, the default .hermes/sessions config can miss sibling state.db creation or updates in live sync. Expose Hermes shallow archive-parent watch roots through the registry while keeping transcript roots recursive, and add shadow parity coverage so this branch remains a migration rather than an additive provider implementation. Validation: go test -tags "fts5" ./cmd/agentsview ./internal/parser ./internal/sync -run 'TestCollectWatchRootsHermesSessionsWatchesStateDBParent|TestHermesProvider|TestParseHermes|TestProviderMigrationModes|TestObserveProviderSourceMatchesHermesLegacyParser' -count=1; go test -tags "fts5" ./cmd/agentsview ./internal/parser ./internal/sync -count=1; go fmt ./...; go vet ./...; ./custom-gcl run --config .golangci.nilaway.yml ./cmd/agentsview/... ./internal/parser/... ./internal/sync/...; git diff --check fix(sync): classify hermes archive watcher events Roborev jobs 2715 and 2716 caught that Hermes archive watch roots were subscribed but the legacy SyncPaths classifier still ignored sibling state.db events. That meant live sync could wait for a periodic full sync even though the watcher saw the change. Map configured Hermes archive roots, state.db events, and direct archive transcript events back to the state.db source that processHermes already parses, while preserving transcript-only root classification for standalone Hermes session files. Validation: go test -tags "fts5" ./internal/sync -run TestSyncPathsHermesStateDBEventRefreshesArchive -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -run 'Test(HermesProvider|ObserveProviderSourceMatchesHermesLegacyParser|SyncPathsHermesStateDBEventRefreshesArchive)' -count=1; go fmt ./...; go vet ./...; git diff --check fix(sync): include hermes transcripts in archive skips Roborev job 2803 caught that Hermes transcript watcher events could still be suppressed by state.db-only skip metadata after being routed to the archive source. In mixed state-db/transcript archives, state.db can be unchanged while a sibling transcript is new or updated. Use archive-effective size and mtime for state.db skip checks by folding direct transcript files from the sibling sessions directory into the snapshot, and add a regression where a transcript event refreshes an already-indexed archive. Validation: go test -tags "fts5" ./internal/sync -run 'TestSyncPathsHermes(ArchiveTranscriptEventRefreshesArchive|StateDBEventRefreshesArchive)' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync -run 'Test(HermesProvider|ObserveProviderSourceMatchesHermesLegacyParser|SyncPathsHermes)' -count=1; go fmt ./...; go vet ./...; git diff --check fix(sync): use aggregate hermes archive fingerprints Hermes archive freshness needs the state.db sync path to compare the same aggregate fingerprint it persists. Discovering through the public Hermes session lister reselected state.db and missed sibling transcripts, so state.db events could avoid real skip-cache parity.\n\nEnumerate direct transcript files for the archive snapshot and stamp archive parse results with the aggregate state.db fingerprint before writing. This keeps unchanged archive syncs comparable while still refreshing when sibling transcripts change.\n\nValidation: go test -tags "fts5" ./internal/parser ./internal/sync; go vet ./...; make nilaway fix(sync): apply hermes archive fingerprints consistently Hermes archive refresh paths need to compare and persist the same aggregate fingerprint for state.db plus sibling transcripts. Otherwise cached parse skips and single-session refreshes can fall back to raw state.db metadata and miss transcript-only archive changes. Use the aggregate archive file info before generic skip-cache checks and share the archive parse-and-stamp helper between full archive processing and single-session refreshes. The regression coverage now persists the metadata, checks unchanged archive skips, and covers transcript discovery/removal behavior. Validation: go test -tags "fts5" ./internal/sync -run 'TestHermesArchive|TestProcessFileHermes|TestProcessHermesArchive|TestSyncSingleHermesArchive' -count=1; go test -tags "fts5" ./internal/parser ./internal/sync; go vet ./...; make nilaway refactor(parser): fold hermes into provider Move Hermes source discovery, lookup, and parse ownership onto the concrete hermesProvider and delete the package-level DiscoverHermesSessions, FindHermesSourceFile, ParseHermesArchive, and ParseHermesSession free functions. Discovery and find-source bodies now live as provider-owned helpers (discoverHermesSessions, findHermesSourceFile); parse, archive parse, the state-db reader, and the transcript-archive fallback become hermesProvider methods (parseSession, parseArchive, parseStateDB, parseTranscriptArchive). Reproduce Hermes archive behavior on the provider. The provider's archive Parse now stamps every state.db session with the state.db path plus the aggregate (state.db + direct transcripts) size and mtime, replacing the engine's stampHermesArchiveResults/hermesArchiveEffectiveInfo so a transcript-only change still refreshes the archive's stored freshness. The new provider helpers hermesArchiveEffectiveFileInfo and hermesArchiveTranscriptFiles mirror the legacy engine aggregation (every .jsonl and session_*.json directly under the sessions directory, no dedup). The existing composite archive Fingerprint and archive watch/ classify source-set methods already carried the rest. Make Hermes provider-authoritative and drop its legacy sync dispatch: remove classifyHermesPath (and its hermesSyncArchivePaths, hermesSyncDirExists, hermesSyncTranscriptPath helpers), the processFile hermesArchiveEffectiveInfo stat hook and case arm, processHermes, parseHermesArchive, stampHermesArchiveResults, hermesArchiveEffectiveInfo, hermesArchiveTranscriptFiles, hermesArchiveSourcePaths, and the syncSingleHermesArchive special-case plus its method. Single-session resync of an archive now falls through to the generic provider path, which reparses the whole archive (ForceReplace) the same way a full sync does. Drop the Hermes AgentDef DiscoverFunc/FindSourceFunc hooks (the provider-owned WatchRootsFunc/ShallowWatchRootsFunc stay), remove hermes_provider.go from the pending shim scan list, replace the shadow-baseline test with provider-API coverage plus a guard that the legacy entrypoints stay gone, and route the package and engine archive tests through provider methods and the provider-authoritative processFile/ SyncPaths paths. Add internal/sync/provider_shadow_support_test.go defining the shared writeProviderShadowSourceFile test helper that the remaining vibe shadow test still references, which was orphaned by a predecessor commit. test(sync): drop unused shadow source-file helper The hermes fold left writeProviderShadowSourceFile in a dedicated test support file, but every shadow test writes its fixtures inline, so the helper has no callers and trips the unused linter. Remove the dead scaffolding. --- cmd/agentsview/main_test.go | 23 + internal/parser/hermes.go | 47 +- internal/parser/hermes_provider.go | 690 +++++++++++++++++++++ internal/parser/hermes_provider_test.go | 472 ++++++++++++++ internal/parser/hermes_test.go | 106 +++- 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/engine.go | 104 ---- internal/sync/hermes_archive_test.go | 318 ++++++++++ internal/sync/hermes_integration_test.go | 152 +++++ 12 files changed, 1775 insertions(+), 160 deletions(-) create mode 100644 internal/parser/hermes_provider.go create mode 100644 internal/parser/hermes_provider_test.go create mode 100644 internal/sync/hermes_archive_test.go create mode 100644 internal/sync/hermes_integration_test.go diff --git a/cmd/agentsview/main_test.go b/cmd/agentsview/main_test.go index b44b2e60b..27e1eb708 100644 --- a/cmd/agentsview/main_test.go +++ b/cmd/agentsview/main_test.go @@ -569,6 +569,29 @@ func TestStartRemoteHostSync_NilEmitterSafe(t *testing.T) { <-exited } +func TestCollectWatchRootsHermesSessionsWatchesStateDBParent(t *testing.T) { + root := t.TempDir() + sessionsDir := filepath.Join(root, "sessions") + require.NoError(t, os.Mkdir(sessionsDir, 0o755), "mkdir sessions") + + cfg := config.Config{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentHermes: {sessionsDir}, + }, + } + + roots, unwatchedDirs := collectWatchRoots(cfg) + + require.Empty(t, unwatchedDirs, "unwatched dirs before watcher setup") + require.Len(t, roots, 2) + assert.Equal(t, root, roots[0].root) + assert.True(t, roots[0].shallow) + assert.Equal(t, []string{sessionsDir}, roots[0].dirs) + assert.Equal(t, sessionsDir, roots[1].root) + assert.False(t, roots[1].shallow) + assert.Equal(t, []string{sessionsDir}, roots[1].dirs) +} + func TestResyncCoversSignals(t *testing.T) { tests := []struct { name string diff --git a/internal/parser/hermes.go b/internal/parser/hermes.go index 7c2d42989..819a25dd5 100644 --- a/internal/parser/hermes.go +++ b/internal/parser/hermes.go @@ -54,17 +54,19 @@ type hermesStateMessage struct { codexMessageItems string } -// ParseHermesArchive parses a Hermes root directory. If a state.db is -// present, it uses that database for session metadata and usage while -// selecting the richest available message stream. Without state.db it -// falls back to the transcript-file parser. -func ParseHermesArchive(root, project, machine string) ([]ParseResult, error) { +// parseArchive parses a Hermes root directory. If a state.db is present, it +// uses that database for session metadata and usage while selecting the richest +// available message stream. Without state.db it falls back to the +// transcript-file parser. It owns the archive on-disk shape (state.db plus the +// sessions transcript directory) for the Hermes provider; the package-level +// entrypoint was folded onto the provider. +func (p *hermesProvider) parseArchive(root, project, machine string) ([]ParseResult, error) { stateDB, sessionsDir, ok := hermesStatePaths(root) if !ok { - return parseHermesTranscriptArchive(root, project, machine) + return p.parseTranscriptArchive(root, project, machine) } - results, err := parseHermesStateDB( + results, err := p.parseStateDB( stateDB, sessionsDir, project, machine, ) if err == nil { @@ -74,12 +76,12 @@ func ParseHermesArchive(root, project, machine string) ([]ParseResult, error) { "hermes: state db parse failed for %s: %v; falling back to transcripts", stateDB, err, ) - return parseHermesTranscriptArchive( + return p.parseTranscriptArchive( sessionsDir, project, machine, ) } -func parseHermesTranscriptArchive( +func (p *hermesProvider) parseTranscriptArchive( root, project, machine string, ) ([]ParseResult, error) { var results []ParseResult @@ -88,7 +90,7 @@ func parseHermesTranscriptArchive( if project != "" { fileProject = project } - sess, msgs, err := ParseHermesSession( + sess, msgs, err := p.parseSession( file.Path, fileProject, machine, ) if err != nil { @@ -103,7 +105,9 @@ func parseHermesTranscriptArchive( return results, nil } -// ParseHermesSession parses a Hermes Agent JSONL session file. +// parseSession parses a Hermes Agent session file. It owns the on-disk shape +// (flat JSONL transcripts plus session_*.json snapshots) for the Hermes +// provider; the package-level entrypoint was folded onto the provider. // // Hermes stores sessions as flat JSONL files in ~/.hermes/sessions/ // with filenames like 20260403_153620_5a3e2ff1.jsonl. @@ -114,7 +118,7 @@ func parseHermesTranscriptArchive( // - Assistant messages: {"role":"assistant", "content":"...", "reasoning":"...", // "finish_reason":"tool_calls|stop", "tool_calls":[...], "timestamp":"..."} // - Tool results: {"role":"tool", "content":"...", "tool_call_id":"...", "timestamp":"..."} -func ParseHermesSession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) { +func (p *hermesProvider) parseSession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) { if strings.HasSuffix(path, ".json") { return parseHermesJSONSession(path, project, machine) } @@ -546,7 +550,7 @@ func hermesStatePaths(root string) (stateDB, sessionsDir string, ok bool) { return "", "", false } -func parseHermesStateDB( +func (p *hermesProvider) parseStateDB( stateDB, sessionsDir, project, machine string, ) ([]ParseResult, error) { conn, err := sql.Open("sqlite3", "file:"+stateDB+"?mode=ro") @@ -580,7 +584,7 @@ func parseHermesStateDB( if _, ok := seen[rawID]; ok { continue } - sess, msgs, err := ParseHermesSession( + sess, msgs, err := p.parseSession( file.Path, file.Project, machine, ) if err != nil { @@ -1043,10 +1047,11 @@ func HermesSessionID(name string) string { return name } -// DiscoverHermesSessions finds Hermes session sources. When a sibling -// state.db exists, it prefers that archive root; otherwise it returns -// transcript files from the sessions directory. -func DiscoverHermesSessions(sessionsDir string) []DiscoveredFile { +// discoverHermesSessions finds Hermes session sources under root. When a +// sibling state.db exists, it prefers that archive root; otherwise it returns +// transcript files from the sessions directory. It is the provider-owned +// discovery body folded off the package-level entrypoint. +func discoverHermesSessions(sessionsDir string) []DiscoveredFile { if sessionsDir == "" { return nil } @@ -1112,8 +1117,10 @@ func discoverHermesTranscriptFiles(sessionsDir string) []DiscoveredFile { return files } -// FindHermesSourceFile finds a Hermes session file by session ID. -func FindHermesSourceFile(sessionsDir, sessionID string) string { +// findHermesSourceFile finds a Hermes transcript file by session ID under +// sessionsDir. It is the provider-owned find-source body folded off the +// package-level entrypoint. +func findHermesSourceFile(sessionsDir, sessionID string) string { if !IsValidSessionID(sessionID) { return "" } diff --git a/internal/parser/hermes_provider.go b/internal/parser/hermes_provider.go new file mode 100644 index 000000000..9496a8666 --- /dev/null +++ b/internal/parser/hermes_provider.go @@ -0,0 +1,690 @@ +package parser + +import ( + "context" + "crypto/sha256" + "database/sql" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +var _ Provider = (*hermesProvider)(nil) + +type hermesProviderFactory struct { + def AgentDef +} + +func newHermesProviderFactory(def AgentDef) ProviderFactory { + return hermesProviderFactory{def: cloneAgentDef(def)} +} + +func (f hermesProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f hermesProviderFactory) Capabilities() Capabilities { + return hermesProviderCapabilities() +} + +func (f hermesProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &hermesProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: hermesProviderCapabilities(), + Config: cfg, + }, + sources: newHermesSourceSet(cfg.Roots), + } +} + +type hermesProvider struct { + ProviderBase + sources hermesSourceSet +} + +func (p *hermesProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *hermesProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *hermesProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *hermesProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *hermesProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *hermesProvider) 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("hermes source path unavailable") + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + if filepath.Base(path) == "state.db" { + results, err := p.parseArchive(path, req.Source.ProjectHint, machine) + if err != nil { + return ParseOutcome{}, err + } + // Mirror the legacy engine's stampHermesArchiveResults: every archive + // session's stored file identity is the state.db path with the + // aggregate (state.db plus transcripts) size and mtime, so a + // transcript-only change still refreshes the archive's freshness. + size, mtime := hermesArchiveEffectiveFileInfo(path) + out := make([]ParseResultOutcome, 0, len(results)) + for i := range results { + results[i].Session.File.Path = path + results[i].Session.File.Size = size + results[i].Session.File.Mtime = mtime + out = append(out, ParseResultOutcome{ + Result: results[i], + DataVersion: DataVersionCurrent, + }) + } + return ParseOutcome{ + Results: out, + ResultSetComplete: true, + ForceReplace: true, + }, nil + } + + sess, msgs, err := p.parseSession(path, req.Source.ProjectHint, 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 hermesSource struct { + Root string + Path string +} + +type hermesSourceSet struct { + roots []string +} + +func newHermesSourceSet(roots []string) hermesSourceSet { + return hermesSourceSet{roots: cleanJSONLRoots(roots)} +} + +func (s hermesSourceSet) Discover(ctx context.Context) ([]SourceRef, error) { + var sources []SourceRef + seen := make(map[string]struct{}) + for _, root := range s.roots { + if err := ctx.Err(); err != nil { + return nil, err + } + for _, file := range discoverHermesSessions(root) { + source, ok := s.sourceRef(root, file.Path) + if !ok { + continue + } + addJSONLSource(source, &sources, seen) + } + } + sortJSONLSources(sources) + return sources, nil +} + +func (s hermesSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)) + for _, root := range s.roots { + roots = append(roots, hermesWatchRoots(root)...) + } + return WatchPlan{Roots: roots}, nil +} + +func (s hermesSourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + allowMissing := jsonlMissingPathFallbackAllowed(req) + if req.WatchRoot != "" { + watchRoot := filepath.Clean(req.WatchRoot) + for _, root := range s.roots { + if !hermesWatchRootMatches(root, watchRoot) { + continue + } + source, ok := s.sourceForChangedPath(root, req.Path, allowMissing) + if ok { + return []SourceRef{source}, nil + } + } + return nil, nil + } + for _, root := range s.roots { + source, ok := s.sourceForChangedPath(root, req.Path, allowMissing) + if ok { + return []SourceRef{source}, nil + } + } + return nil, nil +} + +func (s hermesSourceSet) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + if err := ctx.Err(); err != nil { + return SourceRef{}, false, err + } + for _, path := range []string{req.StoredFilePath, req.FingerprintKey} { + if path == "" { + continue + } + for _, root := range s.roots { + if source, ok := s.sourceForPath(root, path); ok { + return source, true, nil + } + } + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + if stateDB, _, ok := hermesStatePaths(root); ok && + IsValidSessionID(req.RawSessionID) { + found, err := hermesStateDBHasSession(stateDB, req.RawSessionID) + if err != nil { + return SourceRef{}, false, err + } + if !found { + continue + } + if source, ok := s.sourceRef(root, stateDB); ok { + return source, true, nil + } + } + transcriptRoot := hermesTranscriptRoot(root) + path := findHermesSourceFile(transcriptRoot, req.RawSessionID) + if path == "" { + continue + } + if source, ok := s.sourceRef(root, path); ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func hermesStateDBHasSession(stateDB string, rawID string) (bool, error) { + conn, err := sql.Open("sqlite3", "file:"+stateDB+"?mode=ro") + if err != nil { + return false, fmt.Errorf("open hermes state db: %w", err) + } + defer conn.Close() + + var found int + err = conn.QueryRow( + "SELECT 1 FROM sessions WHERE id = ? LIMIT 1", + rawID, + ).Scan(&found) + if err == nil { + return true, nil + } + if err == sql.ErrNoRows { + return false, nil + } + return false, fmt.Errorf("query hermes session %s: %w", rawID, err) +} + +func (s hermesSourceSet) 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("hermes source path unavailable") + } + if filepath.Base(path) == "state.db" { + return hermesArchiveFingerprint(source, path) + } + 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) + } + hash, err := hashJSONLSourceFile(path) + if err != nil { + return SourceFingerprint{}, err + } + return SourceFingerprint{ + Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path), + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + Hash: hash, + }, nil +} + +func (s hermesSourceSet) pathFromSource(source SourceRef) (string, bool) { + switch src := source.Opaque.(type) { + case hermesSource: + return src.Path, src.Path != "" + case *hermesSource: + if src != nil && src.Path != "" { + return src.Path, true + } + } + for _, candidate := range []string{ + source.DisplayPath, + source.FingerprintKey, + source.Key, + } { + for _, root := range s.roots { + if ref, ok := s.sourceForPath(root, candidate); ok { + src := ref.Opaque.(hermesSource) + return src.Path, true + } + } + } + return "", false +} + +func (s hermesSourceSet) sourceForPath(root, path string) (SourceRef, bool) { + return s.sourceForChangedPath(root, path, false) +} + +func (s hermesSourceSet) sourceForChangedPath( + root, + path string, + allowMissing bool, +) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if stateDB, sessionsDir, ok := hermesStatePaths(root); ok { + if samePath(path, stateDB) || hermesPathInTranscriptDir(sessionsDir, path) { + return hermesArchiveSourceRef(root, stateDB) + } + return SourceRef{}, false + } + if allowMissing { + if stateDB, sessionsDir, ok := hermesArchivePathsForEvent(root, path); ok && + (samePath(path, stateDB) || hermesPathInTranscriptDir(sessionsDir, path)) { + return hermesArchiveSourceRef(root, stateDB) + } + transcriptRoot := hermesTranscriptRoot(root) + if hermesPathInTranscriptDir(transcriptRoot, path) { + return hermesTranscriptSourceRef(root, path) + } + } + return s.sourceRef(root, path) +} + +func (s hermesSourceSet) sourceRef(root, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if stateDB, _, ok := hermesStatePaths(root); ok && samePath(path, stateDB) { + return hermesArchiveSourceRef(root, stateDB) + } + transcriptRoot := hermesTranscriptRoot(root) + if !hermesPathInTranscriptDir(transcriptRoot, path) || !IsRegularFile(path) { + return SourceRef{}, false + } + return hermesTranscriptSourceRef(root, path) +} + +func hermesArchiveSourceRef(root, stateDB string) (SourceRef, bool) { + root = filepath.Clean(root) + stateDB = filepath.Clean(stateDB) + return SourceRef{ + Provider: AgentHermes, + Key: stateDB, + DisplayPath: stateDB, + FingerprintKey: stateDB, + Opaque: hermesSource{ + Root: root, + Path: stateDB, + }, + }, true +} + +func hermesTranscriptSourceRef(root, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + return SourceRef{ + Provider: AgentHermes, + Key: path, + DisplayPath: path, + FingerprintKey: path, + Opaque: hermesSource{ + Root: root, + Path: path, + }, + }, true +} + +func hermesWatchRoots(root string) []WatchRoot { + root = filepath.Clean(root) + if stateDB, sessionsDir, ok := hermesArchiveRootPaths(root); ok { + watchRoots := []WatchRoot{{ + Path: filepath.Dir(stateDB), + Recursive: false, + IncludeGlobs: []string{"state.db"}, + DebounceKey: string(AgentHermes) + ":archive:" + root, + }} + watchRoots = append(watchRoots, WatchRoot{ + Path: sessionsDir, + Recursive: true, + IncludeGlobs: []string{"*.jsonl", "session_*.json"}, + DebounceKey: string(AgentHermes) + ":sessions:" + root, + }) + return watchRoots + } + return []WatchRoot{{ + Path: root, + Recursive: true, + IncludeGlobs: []string{"state.db", "*.jsonl", "session_*.json"}, + DebounceKey: string(AgentHermes) + ":sessions:" + root, + }} +} + +func ResolveHermesWatchRoots(root string) []string { + root = filepath.Clean(root) + if _, sessionsDir, ok := hermesArchiveRootPaths(root); ok { + return []string{sessionsDir} + } + return []string{root} +} + +func ResolveHermesShallowWatchRoots(root string) []string { + root = filepath.Clean(root) + if stateDB, _, ok := hermesArchiveRootPaths(root); ok { + return []string{filepath.Dir(stateDB)} + } + return nil +} + +func hermesWatchRootMatches(root, watchRoot string) bool { + root = filepath.Clean(root) + watchRoot = filepath.Clean(watchRoot) + if samePath(root, watchRoot) { + return true + } + if stateDB, sessionsDir, ok := hermesArchiveRootPaths(root); ok { + return samePath(watchRoot, filepath.Dir(stateDB)) || + samePath(watchRoot, sessionsDir) + } + switch filepath.Base(root) { + case "state.db": + return samePath(watchRoot, filepath.Dir(root)) || + samePath(watchRoot, filepath.Join(filepath.Dir(root), "sessions")) + case "sessions": + return samePath(watchRoot, filepath.Dir(root)) + default: + return samePath(watchRoot, filepath.Join(root, "sessions")) + } +} + +func hermesArchivePathsForEvent(root, path string) (stateDB, sessionsDir string, ok bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + switch { + case filepath.Base(root) == "state.db": + stateDB = root + sessionsDir = filepath.Join(filepath.Dir(root), "sessions") + case filepath.Base(root) == "sessions": + stateDB = filepath.Join(filepath.Dir(root), "state.db") + sessionsDir = root + case samePath(path, filepath.Join(root, "state.db")) || + IsRegularFile(filepath.Join(root, "state.db")): + stateDB = filepath.Join(root, "state.db") + sessionsDir = filepath.Join(root, "sessions") + default: + return "", "", false + } + return stateDB, sessionsDir, true +} + +func hermesArchiveRootPaths(root string) (stateDB, sessionsDir string, ok bool) { + root = filepath.Clean(root) + if stateDB, sessionsDir, ok := hermesStatePaths(root); ok { + return stateDB, sessionsDir, true + } + switch filepath.Base(root) { + case "state.db": + return root, filepath.Join(filepath.Dir(root), "sessions"), true + case "sessions": + return filepath.Join(filepath.Dir(root), "state.db"), root, true + default: + stateDB = filepath.Join(root, "state.db") + sessionsDir = filepath.Join(root, "sessions") + if IsRegularFile(stateDB) { + return stateDB, sessionsDir, true + } + if info, err := os.Stat(sessionsDir); err == nil && info.IsDir() { + return stateDB, sessionsDir, true + } + return "", "", false + } +} + +func hermesTranscriptRoot(root string) string { + root = filepath.Clean(root) + if _, sessionsDir, ok := hermesStatePaths(root); ok { + return sessionsDir + } + childSessions := filepath.Join(root, "sessions") + if info, err := os.Stat(childSessions); err == nil && info.IsDir() { + return childSessions + } + return root +} + +func hermesPathInTranscriptDir(dir, path string) bool { + dir = filepath.Clean(dir) + path = filepath.Clean(path) + if !samePath(filepath.Dir(path), dir) { + return false + } + name := filepath.Base(path) + if strings.HasSuffix(name, ".jsonl") { + return true + } + return strings.HasSuffix(name, ".json") && strings.HasPrefix(name, "session_") +} + +func hermesArchiveFingerprint(source SourceRef, stateDB string) (SourceFingerprint, error) { + stateInfo, err := os.Stat(stateDB) + if err != nil { + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", stateDB, err) + } + if stateInfo.IsDir() { + return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", stateDB) + } + fingerprint := SourceFingerprint{ + Key: firstNonEmptyJSONLString( + source.FingerprintKey, + source.Key, + stateDB, + ), + Size: stateInfo.Size(), + MTimeNS: stateInfo.ModTime().UnixNano(), + } + h := sha256.New() + if err := addHermesFingerprintPart(h, "state", stateDB, stateInfo); err != nil { + return SourceFingerprint{}, err + } + _, sessionsDir, _ := hermesStatePaths(stateDB) + for _, file := range discoverHermesTranscriptFiles(sessionsDir) { + info, err := os.Stat(file.Path) + if err != nil { + return SourceFingerprint{}, fmt.Errorf("stat %s: %w", file.Path, err) + } + fingerprint.Size += info.Size() + if mtime := info.ModTime().UnixNano(); mtime > fingerprint.MTimeNS { + fingerprint.MTimeNS = mtime + } + if err := addHermesFingerprintPart(h, "transcript", file.Path, info); err != nil { + return SourceFingerprint{}, err + } + } + fingerprint.Hash = fmt.Sprintf("%x", h.Sum(nil)) + return fingerprint, nil +} + +// hermesArchiveEffectiveFileInfo returns the aggregate size and mtime of a +// Hermes archive: the state.db plus every transcript file in its sessions +// directory. It reproduces the legacy engine's hermesArchiveEffectiveInfo so a +// transcript-only change shifts the stored archive freshness even though the +// state.db itself is unchanged. The transcript set matches the legacy +// hermesArchiveTranscriptFiles: every .jsonl and session_*.json file directly +// under the sessions directory, without the .jsonl/.json dedup used elsewhere. +func hermesArchiveEffectiveFileInfo(stateDB string) (int64, int64) { + info, err := os.Stat(stateDB) + if err != nil { + return 0, 0 + } + size := info.Size() + mtime := info.ModTime().UnixNano() + _, sessionsDir, ok := hermesStatePaths(stateDB) + if !ok { + return size, mtime + } + for _, path := range hermesArchiveTranscriptFiles(sessionsDir) { + fileInfo, err := os.Stat(path) + if err != nil || fileInfo == nil || fileInfo.IsDir() { + continue + } + size += fileInfo.Size() + if fileMtime := fileInfo.ModTime().UnixNano(); fileMtime > mtime { + mtime = fileMtime + } + } + return size, mtime +} + +// hermesArchiveTranscriptFiles lists every .jsonl and session_*.json file +// directly under sessionsDir, sorted by path. It mirrors the legacy engine +// helper of the same name so the provider's effective-info aggregation matches +// historical behavior exactly. +func hermesArchiveTranscriptFiles(sessionsDir string) []string { + if sessionsDir == "" { + return nil + } + entries, err := os.ReadDir(sessionsDir) + if err != nil { + return nil + } + paths := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, ".jsonl") || + strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".json") { + paths = append(paths, filepath.Join(sessionsDir, name)) + } + } + sort.Strings(paths) + return paths +} + +func addHermesFingerprintPart( + h hash.Hash, + label string, + path string, + info os.FileInfo, +) error { + if _, err := fmt.Fprintf( + h, + "%s\x00%s\x00%d\x00%d\x00", + label, + path, + info.Size(), + info.ModTime().UnixNano(), + ); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("hash %s: %w", path, err) + } + return nil +} + +func hermesProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + MultiSessionSource: CapabilitySupported, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilitySupported, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Relationships: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + AggregateUsageEvents: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/hermes_provider_test.go b/internal/parser/hermes_provider_test.go new file mode 100644 index 000000000..1beec4f7d --- /dev/null +++ b/internal/parser/hermes_provider_test.go @@ -0,0 +1,472 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHermesProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentHermes) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentHermes, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestHermesProviderTranscriptSourceMethods(t *testing.T) { + root := t.TempDir() + jsonlPath := filepath.Join(root, "child.jsonl") + jsonPath := filepath.Join(root, "session_jsononly.json") + writeSourceFile(t, jsonlPath, hermesProviderJSONLFixture("jsonl question")) + writeSourceFile(t, jsonPath, hermesProviderJSONFixture("json question")) + writeSourceFile(t, filepath.Join(root, "scratch.json"), "{}\n") + + provider, ok := NewProvider(AgentHermes, 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.True(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"state.db", "*.jsonl", "session_*.json"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.ElementsMatch(t, []string{jsonlPath, jsonPath}, []string{ + discovered[0].DisplayPath, + discovered[1].DisplayPath, + }) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "remote~hermes:child", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, jsonlPath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "jsononly", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, jsonPath, found.DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, jsonPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.Positive(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: jsonlPath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, jsonlPath, changed[0].DisplayPath) + + require.NoError(t, os.Remove(jsonlPath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: jsonlPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, jsonlPath, changed[0].DisplayPath) + + ignored, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "scratch.json"), + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, ignored) +} + +func TestHermesProviderStateDBSourceMethods(t *testing.T) { + root := t.TempDir() + sessionsDir := filepath.Join(root, "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) + createHermesStateDB(t, root) + transcriptPath := filepath.Join(sessionsDir, "session_child.json") + writeSourceFile(t, transcriptPath, hermesProviderJSONFixture("transcript question")) + stateDB := filepath.Join(root, "state.db") + + provider, ok := NewProvider(AgentHermes, 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, 2) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"state.db"}, plan.Roots[0].IncludeGlobs) + assert.Equal(t, sessionsDir, plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.Equal(t, []string{"*.jsonl", "session_*.json"}, plan.Roots[1].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, stateDB, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "remote~hermes:child", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, stateDB, found.DisplayPath) + + stateInfo, err := os.Stat(stateDB) + require.NoError(t, err) + transcriptInfo, err := os.Stat(transcriptPath) + require.NoError(t, err) + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, stateDB, fingerprint.Key) + assert.Equal(t, stateInfo.Size()+transcriptInfo.Size(), fingerprint.Size) + assert.Equal( + t, + max(stateInfo.ModTime().UnixNano(), transcriptInfo.ModTime().UnixNano()), + fingerprint.MTimeNS, + ) + assert.NotEmpty(t, fingerprint.Hash) + + for _, tc := range []struct { + name string + path string + }{ + {name: "state db", path: stateDB}, + {name: "archive transcript", path: transcriptPath}, + } { + t.Run(tc.name, func(t *testing.T) { + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: tc.path, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + }) + } + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: stateDB, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + + require.NoError(t, os.Remove(transcriptPath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: transcriptPath, EventKind: "remove", WatchRoot: sessionsDir}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + + require.NoError(t, os.Remove(stateDB)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: stateDB, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) +} + +func TestHermesProviderArchiveWatchRoots(t *testing.T) { + root := t.TempDir() + sessionsDir := filepath.Join(root, "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) + createHermesStateDB(t, root) + stateDB := filepath.Join(root, "state.db") + + for _, tc := range []struct { + name string + configRoot string + }{ + {name: "archive parent", configRoot: root}, + {name: "sessions directory", configRoot: sessionsDir}, + {name: "state db file", configRoot: stateDB}, + } { + t.Run(tc.name, func(t *testing.T) { + provider, ok := NewProvider(AgentHermes, ProviderConfig{ + Roots: []string{tc.configRoot}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 2) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"state.db"}, plan.Roots[0].IncludeGlobs) + assert.Equal(t, sessionsDir, plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.Equal(t, []string{"*.jsonl", "session_*.json"}, plan.Roots[1].IncludeGlobs) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: stateDB, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + }) + } +} + +func TestHermesProviderArchiveWatchRootsBeforeArchiveComplete(t *testing.T) { + t.Run("state db exists before sessions directory", func(t *testing.T) { + root := t.TempDir() + createHermesStateDB(t, root) + stateDB := filepath.Join(root, "state.db") + sessionsDir := filepath.Join(root, "sessions") + + provider, ok := NewProvider(AgentHermes, 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, 2) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"state.db"}, plan.Roots[0].IncludeGlobs) + assert.Equal(t, sessionsDir, plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.Equal(t, []string{"*.jsonl", "session_*.json"}, plan.Roots[1].IncludeGlobs) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: stateDB, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + }) + + t.Run("direct state db root before file exists", func(t *testing.T) { + root := t.TempDir() + stateDB := filepath.Join(root, "state.db") + sessionsDir := filepath.Join(root, "sessions") + + provider, ok := NewProvider(AgentHermes, ProviderConfig{ + Roots: []string{stateDB}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 2) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"state.db"}, plan.Roots[0].IncludeGlobs) + assert.Equal(t, sessionsDir, plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.Equal(t, []string{"*.jsonl", "session_*.json"}, plan.Roots[1].IncludeGlobs) + + createHermesStateDB(t, root) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: stateDB, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + }) + + t.Run("sessions directory root before state db exists", func(t *testing.T) { + root := t.TempDir() + stateDB := filepath.Join(root, "state.db") + sessionsDir := filepath.Join(root, "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) + + provider, ok := NewProvider(AgentHermes, ProviderConfig{ + Roots: []string{sessionsDir}, + Machine: "devbox", + }) + require.True(t, ok) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 2) + assert.Equal(t, root, plan.Roots[0].Path) + assert.False(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"state.db"}, plan.Roots[0].IncludeGlobs) + assert.Equal(t, sessionsDir, plan.Roots[1].Path) + assert.True(t, plan.Roots[1].Recursive) + assert.Equal(t, []string{"*.jsonl", "session_*.json"}, plan.Roots[1].IncludeGlobs) + + createHermesStateDB(t, root) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: stateDB, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, stateDB, changed[0].DisplayPath) + }) +} + +func TestHermesProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "child.jsonl") + writeSourceFile(t, sourcePath, hermesProviderJSONLFixture("parse question")) + + provider, ok := NewProvider(AgentHermes, 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.False(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "hermes:child", result.Result.Session.ID) + assert.Equal(t, AgentHermes, result.Result.Session.Agent) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, sourcePath, result.Result.Session.File.Path) + assert.Equal(t, "abc123", result.Result.Session.File.Hash) + assert.Equal(t, "parse question", result.Result.Session.FirstMessage) + assert.Len(t, result.Result.Messages, 2) +} + +func TestHermesProviderParseStateDB(t *testing.T) { + root := t.TempDir() + sessionsDir := filepath.Join(root, "sessions") + require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) + createHermesStateDB(t, root) + transcriptPath := filepath.Join(sessionsDir, "session_child.json") + writeSourceFile( + t, + transcriptPath, + hermesProviderJSONFixture("archive transcript"), + ) + stateDB := filepath.Join(root, "state.db") + + provider, ok := NewProvider(AgentHermes, 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: stateDB, Hash: "archive-hash"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.True(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Equal(t, "hermes:child", result.Result.Session.ID) + assert.Equal(t, "hermes:parent", result.Result.Session.ParentSessionID) + assert.Equal(t, RelContinuation, result.Result.Session.RelationshipType) + assert.Equal(t, "Child Session", result.Result.Session.SessionName) + assert.Equal(t, "hermes-state-db", result.Result.Session.SourceVersion) + assert.Equal(t, "devbox", result.Result.Session.Machine) + require.Len(t, result.Result.UsageEvents, 1) + assert.Len(t, result.Result.Messages, 2) + + // The provider reproduces the legacy engine's stampHermesArchiveResults: + // every archive session's stored file identity is the state.db path with + // the aggregate (state.db plus transcripts) size and mtime, so a + // transcript-only change still refreshes the archive's freshness. + stateInfo, err := os.Stat(stateDB) + require.NoError(t, err) + transcriptInfo, err := os.Stat(transcriptPath) + require.NoError(t, err) + assert.Equal(t, stateDB, result.Result.Session.File.Path) + assert.Equal( + t, + stateInfo.Size()+transcriptInfo.Size(), + result.Result.Session.File.Size, + ) + assert.Equal( + t, + max(stateInfo.ModTime().UnixNano(), transcriptInfo.ModTime().UnixNano()), + result.Result.Session.File.Mtime, + ) +} + +func TestHermesProviderFindSourceDoesNotReturnStateDBForMissingRawID(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "sessions"), 0o755)) + createHermesStateDB(t, root) + + provider, ok := NewProvider(AgentHermes, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "missing-valid-id", + }) + + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, source) +} + +func hermesProviderJSONLFixture(firstMessage string) string { + return `{"role":"session_meta","platform":"cli","timestamp":"2026-05-14T10:00:00.000000"}` + "\n" + + `{"role":"user","content":"` + firstMessage + `","timestamp":"2026-05-14T10:01:00.000000"}` + "\n" + + `{"role":"assistant","content":"Done.","timestamp":"2026-05-14T10:02:00.000000"}` + "\n" +} + +func hermesProviderJSONFixture(firstMessage string) string { + return `{ + "platform":"cli", + "session_start":"2026-05-14T10:00:00Z", + "last_updated":"2026-05-14T10:02:00Z", + "messages":[ + {"role":"user","content":"` + firstMessage + `","timestamp":"2026-05-14T10:01:00Z"}, + {"role":"assistant","content":"Done.","timestamp":"2026-05-14T10:02:00Z"} + ] + }` +} diff --git a/internal/parser/hermes_test.go b/internal/parser/hermes_test.go index feae3e089..c1bd94ea4 100644 --- a/internal/parser/hermes_test.go +++ b/internal/parser/hermes_test.go @@ -13,6 +13,57 @@ import ( "github.com/stretchr/testify/require" ) +// newHermesTestProvider builds a concrete hermesProvider for the given roots so +// package tests can exercise the folded parse, discovery, and source-lookup +// behavior directly through provider methods. +func newHermesTestProvider(t *testing.T, roots ...string) *hermesProvider { + t.Helper() + provider, ok := NewProvider(AgentHermes, ProviderConfig{ + Roots: roots, + Machine: "local", + }) + require.True(t, ok) + hp, ok := provider.(*hermesProvider) + require.True(t, ok) + return hp +} + +// parseHermesTestSession parses a Hermes transcript at path through the +// provider-owned parse method, replacing the removed package-level +// ParseHermesSession entrypoint. +func parseHermesTestSession( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return newHermesTestProvider(t).parseSession(path, project, machine) +} + +// parseHermesTestArchive parses a Hermes archive root through the provider-owned +// archive method, replacing the removed package-level ParseHermesArchive +// entrypoint. +func parseHermesTestArchive( + t *testing.T, root, project, machine string, +) ([]ParseResult, error) { + t.Helper() + return newHermesTestProvider(t).parseArchive(root, project, machine) +} + +// discoverHermesTestSessions discovers Hermes sources under root through the +// provider source set, replacing the removed package-level +// DiscoverHermesSessions entrypoint. +func discoverHermesTestSessions(t *testing.T, root string) []DiscoveredFile { + t.Helper() + return discoverHermesSessions(root) +} + +// findHermesTestSourceFile resolves a Hermes session ID to a transcript path +// through the provider source set, replacing the removed package-level +// FindHermesSourceFile entrypoint. +func findHermesTestSourceFile(t *testing.T, sessionsDir, sessionID string) string { + t.Helper() + return findHermesSourceFile(sessionsDir, sessionID) +} + func runHermesJSONLTest( t *testing.T, filename, content string, ) (*ParsedSession, []ParsedMessage) { @@ -21,8 +72,8 @@ func runHermesJSONLTest( filename = "20260403_153620_5a3e2ff1.jsonl" } path := createTestFile(t, filename, content) - sess, msgs, err := ParseHermesSession( - path, "", "local", + sess, msgs, err := parseHermesTestSession( + t, path, "", "local", ) require.NoError(t, err) return sess, msgs @@ -36,8 +87,8 @@ func runHermesJSONTest( filename = "session_20260403_153620_5a3e2ff1.json" } path := createTestFile(t, filename, content) - sess, msgs, err := ParseHermesSession( - path, "", "local", + sess, msgs, err := parseHermesTestSession( + t, path, "", "local", ) require.NoError(t, err) return sess, msgs @@ -135,7 +186,7 @@ func TestParseHermesArchive_StateDBMetadataUsageAndTranscriptChoice( 0o644, )) - results, err := ParseHermesArchive(root, "", "local") + results, err := parseHermesTestArchive(t, root, "", "local") require.NoError(t, err) require.Len(t, results, 1) @@ -183,7 +234,7 @@ func TestParseHermesArchive_FallsBackToTranscriptsWhenStateDBUnreadable( 0o644, )) - results, err := ParseHermesArchive(root, "override-project", "local") + results, err := parseHermesTestArchive(t, root, "override-project", "local") require.NoError(t, err) require.Len(t, results, 1) @@ -211,7 +262,7 @@ func TestParseHermesArchive_UsesStateMessagesWhenJSONLIsLowerQuality( 0o644, )) - results, err := ParseHermesArchive(root, "", "local") + results, err := parseHermesTestArchive(t, root, "", "local") require.NoError(t, err) require.Len(t, results, 1) @@ -240,7 +291,7 @@ func TestParseHermesArchiveIncludesTranscriptsMissingFromStateDB( 0o644, )) - results, err := ParseHermesArchive(root, "", "local") + results, err := parseHermesTestArchive(t, root, "", "local") require.NoError(t, err) require.Len(t, results, 2) @@ -408,7 +459,7 @@ func TestDiscoverHermesSessionsFindsTranscriptOnlyRoot( path := filepath.Join(sessionsDir, "session_child.json") require.NoError(t, os.WriteFile(path, []byte(`{"messages":[]}`), 0o644)) - files := DiscoverHermesSessions(root) + files := discoverHermesTestSessions(t, root) require.Len(t, files, 1) assert.Equal(t, path, files[0].Path) } @@ -530,8 +581,8 @@ func TestParseHermesSession_JSONL_ExplicitProject(t *testing.T) { path := createTestFile( t, "20260403_153620_abc.jsonl", content, ) - sess, _, err := ParseHermesSession( - path, "my-project", "local", + sess, _, err := parseHermesTestSession( + t, path, "my-project", "local", ) require.NoError(t, err) require.NotNil(t, sess) @@ -638,8 +689,8 @@ func TestParseHermesSession_JSONL_FirstMessageTruncation(t *testing.T) { func TestParseHermesSession_JSONL_Errors(t *testing.T) { t.Run("missing file", func(t *testing.T) { - _, _, err := ParseHermesSession( - "/nonexistent/file.jsonl", "", "local", + _, _, err := parseHermesTestSession( + t, "/nonexistent/file.jsonl", "", "local", ) assert.Error(t, err) }) @@ -767,8 +818,8 @@ func TestParseHermesSession_JSON_MessageTimestampsExtendBounds( func TestParseHermesSession_JSON_Errors(t *testing.T) { t.Run("missing file", func(t *testing.T) { - _, _, err := ParseHermesSession( - "/nonexistent/file.json", "", "local", + _, _, err := parseHermesTestSession( + t, "/nonexistent/file.json", "", "local", ) assert.Error(t, err) }) @@ -777,7 +828,7 @@ func TestParseHermesSession_JSON_Errors(t *testing.T) { path := createTestFile( t, "session_bad.json", `"just a string"`, ) - _, _, err := ParseHermesSession(path, "", "local") + _, _, err := parseHermesTestSession(t, path, "", "local") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid JSON") }) @@ -1025,7 +1076,7 @@ func TestDiscoverHermesSessions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() setupFileSystem(t, dir, tt.files) - files := DiscoverHermesSessions(dir) + files := discoverHermesTestSessions(t, dir) assertDiscoveredFiles( t, files, tt.wantFiles, AgentHermes, ) @@ -1033,13 +1084,13 @@ func TestDiscoverHermesSessions(t *testing.T) { } t.Run("empty string dir", func(t *testing.T) { - files := DiscoverHermesSessions("") + files := discoverHermesTestSessions(t, "") assert.Nil(t, files) }) t.Run("nonexistent dir", func(t *testing.T) { - files := DiscoverHermesSessions( - filepath.Join(t.TempDir(), "nope"), + files := discoverHermesTestSessions( + t, filepath.Join(t.TempDir(), "nope"), ) assert.Nil(t, files) }) @@ -1088,7 +1139,7 @@ func TestFindHermesSourceFile(t *testing.T) { dir := t.TempDir() setupFileSystem(t, dir, tt.files) - got := FindHermesSourceFile(dir, tt.sessionID) + got := findHermesTestSourceFile(t, dir, tt.sessionID) want := "" if tt.wantFile != "" { want = filepath.Join(dir, tt.wantFile) @@ -1105,7 +1156,7 @@ func TestFindHermesSourceFile(t *testing.T) { "20260403_aaa.jsonl": "{}", }) for _, id := range []string{"", "../etc/passwd", "a/b", "a b"} { - got := FindHermesSourceFile(dir, id) + got := findHermesTestSourceFile(t, dir, id) if got != "" { t.Errorf( "FindHermesSourceFile(%q) = %q, want empty", @@ -1172,8 +1223,13 @@ func TestHermesRegistryEntry(t *testing.T) { assert.Equal(t, "hermes:", found.IDPrefix) assert.True(t, found.FileBased) assert.Contains(t, found.DefaultDirs, ".hermes/sessions") - assert.NotNil(t, found.DiscoverFunc) - assert.NotNil(t, found.FindSourceFunc) + // Hermes is provider-authoritative: discovery and source lookup live on the + // hermesProvider, not on legacy AgentDef hooks. The watch-root resolvers + // stay because they are provider-owned and consumed by watcher setup. + assert.Nil(t, found.DiscoverFunc) + assert.Nil(t, found.FindSourceFunc) + assert.NotNil(t, found.WatchRootsFunc) + assert.NotNil(t, found.ShallowWatchRootsFunc) } // --- File info --- @@ -1190,7 +1246,7 @@ func TestParseHermesSession_FileInfo(t *testing.T) { info, err := os.Stat(path) require.NoError(t, err) - sess, _, err := ParseHermesSession(path, "", "local") + sess, _, err := parseHermesTestSession(t, path, "", "local") require.NoError(t, err) require.NotNil(t, sess) diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 362d5fc0a..13139754c 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -357,6 +357,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newCursorProviderFactory(def) case AgentDeepSeekTUI: return newDeepSeekTUIProviderFactory(def) + case AgentHermes: + return newHermesProviderFactory(def) case AgentIflow: return newIflowProviderFactory(def) case AgentGptme: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 53629eafc..30e5456ed 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -44,7 +44,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentKiro: ProviderMigrationLegacyOnly, AgentKiroIDE: ProviderMigrationLegacyOnly, AgentCortex: ProviderMigrationProviderAuthoritative, - AgentHermes: ProviderMigrationLegacyOnly, + AgentHermes: ProviderMigrationProviderAuthoritative, AgentWorkBuddy: ProviderMigrationProviderAuthoritative, AgentForge: ProviderMigrationLegacyOnly, AgentPiebald: ProviderMigrationLegacyOnly, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 43d193c2a..d32a031f5 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{ "cowork_provider.go": true, "db_backed_provider.go": true, "gemini_provider.go": true, - "hermes_provider.go": true, "kiro_ide_provider.go": true, "kiro_provider.go": true, "opencode_provider.go": true, diff --git a/internal/parser/types.go b/internal/parser/types.go index 71eddc398..727625279 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -433,15 +433,15 @@ var Registry = []AgentDef{ FileBased: true, }, { - Type: AgentHermes, - DisplayName: "Hermes Agent", - EnvVar: "HERMES_SESSIONS_DIR", - ConfigKey: "hermes_sessions_dirs", - DefaultDirs: []string{".hermes/sessions"}, - IDPrefix: "hermes:", - FileBased: true, - DiscoverFunc: DiscoverHermesSessions, - FindSourceFunc: FindHermesSourceFile, + Type: AgentHermes, + DisplayName: "Hermes Agent", + EnvVar: "HERMES_SESSIONS_DIR", + ConfigKey: "hermes_sessions_dirs", + DefaultDirs: []string{".hermes/sessions"}, + IDPrefix: "hermes:", + FileBased: true, + WatchRootsFunc: ResolveHermesWatchRoots, + ShallowWatchRootsFunc: ResolveHermesShallowWatchRoots, }, { Type: AgentWorkBuddy, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index f3b2a809b..eb29fa5c3 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -4179,8 +4179,6 @@ func (e *Engine) processFile( res = e.processKiro(file, info) case parser.AgentKiroIDE: res = e.processKiroIDE(file, info) - case parser.AgentHermes: - res = e.processHermes(file, info) case parser.AgentPositron: res = e.processPositron(file, info) case parser.AgentZed: @@ -6211,45 +6209,6 @@ func (e *Engine) processKiroIDE( } } -func (e *Engine) processHermes( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - if filepath.Base(file.Path) == "state.db" { - results, err := parser.ParseHermesArchive( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - return processResult{results: results, forceReplace: true} - } - - sess, msgs, err := parser.ParseHermesSession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - // vibeEffectiveInfo returns size/mtime for a Vibe session that account // for the sibling meta.json file: size is the sum of both files, and // mtime is the larger of the two. Returns info unchanged when meta.json @@ -8726,23 +8685,6 @@ func (e *Engine) SyncSingleSessionContext( } return err } - if def.Type == parser.AgentHermes { - hermesProject := "" - if sess, _ := e.db.GetSession(ctx, sessionID); sess != nil && - sess.Project != "" && !parser.NeedsProjectReparse(sess.Project) { - hermesProject = sess.Project - } - ok, err := e.syncSingleHermesArchive( - sessionID, path, hermesProject, - ) - if err != nil { - return err - } - if ok { - return nil - } - } - agent := def.Type // Clear skip cache so explicit re-sync always processes @@ -8930,52 +8872,6 @@ func (e *Engine) SyncSingleSessionContext( return nil } -func (e *Engine) syncSingleHermesArchive( - sessionID, path, project string, -) (bool, error) { - stateDB := "" - if filepath.Base(path) == "state.db" { - stateDB = path - } else if filepath.Base(filepath.Dir(path)) == "sessions" { - candidate := filepath.Join( - filepath.Dir(filepath.Dir(path)), "state.db", - ) - if parser.IsRegularFile(candidate) { - stateDB = candidate - } - } - if stateDB == "" { - return false, nil - } - - results, err := parser.ParseHermesArchive( - stateDB, project, e.machine, - ) - if err != nil { - return true, err - } - for _, pr := range results { - if pr.Session.ID != sessionID { - continue - } - if err := e.writeSessionFull(pendingWrite{ - sess: pr.Session, - msgs: pr.Messages, - usageEvents: pr.UsageEvents, - }); err != nil && !isIntentionalSessionSkip(err) && - !errors.Is(err, errSessionPreserved) { - return true, fmt.Errorf( - "write session %s: %w", pr.Session.ID, err, - ) - } - return true, nil - } - return true, fmt.Errorf( - "session %s not found in Hermes archive %s", - sessionID, stateDB, - ) -} - func (e *Engine) applyWorktreeMappingToSingleSession( sessionID string, ) error { diff --git a/internal/sync/hermes_archive_test.go b/internal/sync/hermes_archive_test.go new file mode 100644 index 000000000..58026330c --- /dev/null +++ b/internal/sync/hermes_archive_test.go @@ -0,0 +1,318 @@ +package sync + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/parser" +) + +// hermesArchiveAggregateFileInfo mirrors the legacy engine helper +// hermesArchiveEffectiveInfo for test assertions: the aggregate size and mtime +// of the state.db plus every transcript directly under its sessions directory. +// The Hermes provider now owns this aggregation; this helper only computes the +// expected values the engine must persist. +func hermesArchiveAggregateFileInfo(t *testing.T, stateDB string) (int64, int64) { + t.Helper() + info, err := os.Stat(stateDB) + require.NoError(t, err) + size := info.Size() + mtime := info.ModTime().UnixNano() + sessionsDir := filepath.Join(filepath.Dir(stateDB), "sessions") + entries, err := os.ReadDir(sessionsDir) + if err != nil { + return size, mtime + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + isJSONL := filepath.Ext(name) == ".jsonl" + isSessionJSON := filepath.Ext(name) == ".json" && + len(name) >= len("session_") && name[:len("session_")] == "session_" + if !isJSONL && !isSessionJSON { + continue + } + fileInfo, err := os.Stat(filepath.Join(sessionsDir, name)) + if err != nil || fileInfo.IsDir() { + continue + } + size += fileInfo.Size() + if fileMtime := fileInfo.ModTime().UnixNano(); fileMtime > mtime { + mtime = fileMtime + } + } + return size, mtime +} + +// TestHermesProviderFingerprintAggregatesDirectTranscripts confirms the +// provider-owned archive fingerprint folds the size and mtime of transcripts +// living directly under the sessions directory into the state.db's freshness +// identity, replacing the engine's removed hermesArchiveEffectiveInfo. +func TestHermesProviderFingerprintAggregatesDirectTranscripts(t *testing.T) { + root := t.TempDir() + stateDB := writeHermesArchiveStateDB(t, root) + transcriptPath := filepath.Join(root, "sessions", "extra.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile(transcriptPath, []byte("{}\n{}\n"), 0o644)) + + transcriptTime := time.Now().Add(2 * time.Second).Truncate(time.Second) + require.NoError(t, os.Chtimes(transcriptPath, transcriptTime, transcriptTime)) + + wantSize, wantMtime := hermesArchiveAggregateFileInfo(t, stateDB) + + provider, ok := parser.NewProvider(parser.AgentHermes, parser.ProviderConfig{ + Roots: []string{filepath.Join(root, "sessions")}, + Machine: "local", + }) + 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) + assert.Equal(t, wantSize, fingerprint.Size) + assert.Equal(t, wantMtime, fingerprint.MTimeNS) +} + +// TestHermesProviderFingerprintChangesWhenTranscriptRemoved confirms the +// archive fingerprint shrinks back to the state.db's own size when a direct +// transcript is removed, replacing the engine's removed effective-info logic. +func TestHermesProviderFingerprintChangesWhenTranscriptRemoved(t *testing.T) { + root := t.TempDir() + stateDB := writeHermesArchiveStateDB(t, root) + transcriptPath := filepath.Join(root, "sessions", "extra.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile(transcriptPath, []byte("{}\n{}\n"), 0o644)) + + provider, ok := parser.NewProvider(parser.AgentHermes, parser.ProviderConfig{ + Roots: []string{filepath.Join(root, "sessions")}, + Machine: "local", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + before, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + require.NoError(t, os.Remove(transcriptPath)) + after, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + stateInfo, err := os.Stat(stateDB) + require.NoError(t, err) + assert.NotEqual(t, before.Size, after.Size) + assert.Equal(t, stateInfo.Size(), after.Size) +} + +// TestProcessFileHermesArchiveSkipCacheUsesAggregateMtime confirms the +// provider-authoritative processFile path keys the skip cache on the aggregate +// archive mtime (state.db plus direct transcripts), so a cached entry stamped +// with that mtime short-circuits a reparse. +func TestProcessFileHermesArchiveSkipCacheUsesAggregateMtime(t *testing.T) { + root := t.TempDir() + stateDB := writeHermesArchiveStateDB(t, root) + transcriptPath := filepath.Join(root, "sessions", "extra.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile(transcriptPath, []byte("{}\n"), 0o644)) + transcriptTime := time.Now().Add(2 * time.Second).Truncate(time.Second) + require.NoError(t, os.Chtimes(transcriptPath, transcriptTime, transcriptTime)) + + _, wantMtime := hermesArchiveAggregateFileInfo(t, stateDB) + + engine := NewEngine(dbtest.OpenTestDB(t), EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentHermes: {filepath.Join(root, "sessions")}, + }, + Machine: "local", + }) + engine.InjectSkipCache(map[string]int64{ + stateDB: wantMtime, + }) + + res := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: stateDB, + Agent: parser.AgentHermes, + }) + + require.NoError(t, res.err) + assert.True(t, res.skip) + assert.True(t, res.cacheSkip) + assert.Equal(t, wantMtime, res.mtime) +} + +// TestProcessFileHermesArchivePersistsAggregateFingerprint confirms the +// provider-authoritative processFile path stamps every archive session with the +// state.db path and the aggregate size and mtime, and that a second pass skips +// once the file info is persisted. This replaces the removed +// processHermes-based assertions. +func TestProcessFileHermesArchivePersistsAggregateFingerprint(t *testing.T) { + root := t.TempDir() + stateDB := writeHermesArchiveStateDB(t, root) + transcriptPath := filepath.Join(root, "sessions", "extra.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile( + transcriptPath, + []byte( + `{"role":"session_meta","platform":"cli","timestamp":"2026-05-14T10:00:00.000000"}`+"\n"+ + `{"role":"user","content":"new transcript","timestamp":"2026-05-14T10:01:00.000000"}`+"\n", + ), + 0o644, + )) + + wantSize, wantMtime := hermesArchiveAggregateFileInfo(t, stateDB) + database := dbtest.OpenTestDB(t) + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentHermes: {filepath.Join(root, "sessions")}, + }, + Machine: "local", + }) + + res := engine.processFile(context.Background(), parser.DiscoveredFile{ + Path: stateDB, + Agent: parser.AgentHermes, + }) + + require.NoError(t, res.err) + require.NotEmpty(t, res.results) + for _, result := range res.results { + assert.Equal(t, stateDB, result.Session.File.Path) + assert.Equal(t, wantSize, result.Session.File.Size) + assert.Equal(t, wantMtime, result.Session.File.Mtime) + } + + pending := make([]pendingWrite, 0, len(res.results)) + for _, result := range res.results { + pending = append(pending, pendingWrite{ + sess: result.Session, + msgs: result.Messages, + usageEvents: result.UsageEvents, + }) + } + written, _, failed := engine.writeBatch(pending, syncWriteDefault, true) + require.Equal(t, 0, failed) + require.NotZero(t, written) + + storedSize, storedMtime, ok := database.GetFileInfoByPath(stateDB) + require.True(t, ok) + assert.Equal(t, wantSize, storedSize) + assert.Equal(t, wantMtime, storedMtime) +} + +// TestSyncPathsHermesArchiveTranscriptPersistsAggregateFingerprint confirms that +// syncing a transcript path inside an archive routes through the provider, which +// reparses the whole archive and persists the aggregate file info under the +// state.db path. This replaces the removed syncSingleHermesArchive coverage. +func TestSyncPathsHermesArchiveTranscriptPersistsAggregateFingerprint(t *testing.T) { + root := t.TempDir() + stateDB := writeHermesArchiveStateDB(t, root) + transcriptPath := filepath.Join(root, "sessions", "extra.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile( + transcriptPath, + []byte( + `{"role":"session_meta","platform":"cli","timestamp":"2026-05-14T10:00:00.000000"}`+"\n"+ + `{"role":"user","content":"new transcript","timestamp":"2026-05-14T10:01:00.000000"}`+"\n", + ), + 0o644, + )) + + wantSize, wantMtime := hermesArchiveAggregateFileInfo(t, stateDB) + database := dbtest.OpenTestDB(t) + engine := NewEngine(database, EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentHermes: {filepath.Join(root, "sessions")}, + }, + Machine: "local", + }) + + engine.SyncPaths([]string{transcriptPath}) + + storedSize, storedMtime, found := database.GetFileInfoByPath(stateDB) + require.True(t, found) + assert.Equal(t, wantSize, storedSize) + assert.Equal(t, wantMtime, storedMtime) +} + +func writeHermesArchiveStateDB(t *testing.T, root string) string { + t.Helper() + stateDB := filepath.Join(root, "state.db") + conn, err := sql.Open("sqlite3", stateDB) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + _, err = conn.Exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0 + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT + ); + INSERT INTO sessions ( + id, source, model, started_at, ended_at, message_count + ) VALUES ( + 'child', 'discord', 'gpt-5.4', 1778767200.0, 1778767800.0, 1 + ); + INSERT INTO messages ( + session_id, role, content, timestamp + ) VALUES ( + 'child', 'user', 'state db message', 1778767210.0 + ); + `) + require.NoError(t, err) + return stateDB +} diff --git a/internal/sync/hermes_integration_test.go b/internal/sync/hermes_integration_test.go new file mode 100644 index 000000000..439d6e0b7 --- /dev/null +++ b/internal/sync/hermes_integration_test.go @@ -0,0 +1,152 @@ +package sync_test + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/parser" + "go.kenn.io/agentsview/internal/sync" +) + +func TestSyncPathsHermesStateDBEventRefreshesArchive(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + root := t.TempDir() + stateDB := writeHermesSyncStateDB(t, root) + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentHermes: {filepath.Join(root, "sessions")}, + }, + Machine: "local", + }) + + engine.SyncPaths([]string{stateDB}) + + assertSessionState(t, database, "hermes:child", func(sess *db.Session) { + assert.Equal(t, string(parser.AgentHermes), sess.Agent) + assert.Equal(t, "hermes-discord", sess.Project) + require.NotNil(t, sess.DisplayName) + assert.Equal(t, "Child Session", *sess.DisplayName) + }) +} + +func TestSyncPathsHermesArchiveTranscriptEventRefreshesArchive(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + root := t.TempDir() + stateDB := writeHermesSyncStateDB(t, root) + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{ + parser.AgentHermes: {filepath.Join(root, "sessions")}, + }, + Machine: "local", + }) + engine.SyncPaths([]string{stateDB}) + assertSessionState(t, database, "hermes:child", nil) + + transcriptPath := filepath.Join(root, "sessions", "extra.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile( + transcriptPath, + []byte( + `{"role":"session_meta","platform":"cli","timestamp":"2026-05-14T10:00:00.000000"}`+"\n"+ + `{"role":"user","content":"new transcript","timestamp":"2026-05-14T10:01:00.000000"}`+"\n"+ + `{"role":"assistant","content":"Done.","timestamp":"2026-05-14T10:02:00.000000"}`+"\n", + ), + 0o644, + )) + + engine.SyncPaths([]string{transcriptPath}) + + assertSessionState(t, database, "hermes:extra", func(sess *db.Session) { + require.NotNil(t, sess.FirstMessage) + assert.Equal(t, "new transcript", *sess.FirstMessage) + }) +} + +func writeHermesSyncStateDB(t *testing.T, root string) string { + t.Helper() + stateDB := filepath.Join(root, "state.db") + conn, err := sql.Open("sqlite3", stateDB) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + _, err = conn.Exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0 + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT + ); + INSERT INTO sessions ( + id, source, model, parent_session_id, started_at, ended_at, + message_count, input_tokens, output_tokens, cache_read_tokens, + cache_write_tokens, reasoning_tokens, estimated_cost_usd, + cost_status, cost_source, title, api_call_count + ) VALUES ( + 'child', 'discord', 'gpt-5.4', 'parent', + 1778767200.0, 1778767800.0, 1, 300, 70, 20, 5, 9, + 0.123, 'estimated', 'hermes', 'Child Session', 4 + ); + INSERT INTO messages ( + session_id, role, content, timestamp + ) VALUES ( + 'child', 'user', 'state db only has one message', 1778767210.0 + ); + `) + require.NoError(t, err) + return stateDB +}