From 04048af5df004dd93f6496a871ccecc6ec17f810 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sun, 21 Jun 2026 13:52:00 -0400 Subject: [PATCH 1/3] feat(parser): migrate claw providers OpenClaw and QClaw share a Claw-style source layout where each agent directory owns a sessions folder and active JSONL files compete with archived JSONL variants for the same logical session. Moving them behind concrete provider facades keeps that active-over-archive and newest-archive policy explicit without broadening the generic JSONL source helpers around variable archive suffixes. The providers preserve colon-delimited agent/session lookup, selected-source change classification, symlinked agent directories, stale stored-path remapping, source fingerprinting, and existing parse normalization. fix(parser): promote claw archives on removal Claw providers choose a single source per logical session, so live-sync removal events need to account for source promotion. When an active file or newest archive disappears, another archive may become the selected source even though the changed path is no longer the source to parse. This keeps write events strict about the selected path, while remove and rename-style missing-path events can remap a valid stale Claw path to the newly selected source for the same raw session ID. test(parser): opt openclaw qclaw into provider shadow OpenClaw and QClaw now have concrete facade providers on this branch, so their migration modes should enter shadow comparison rather than staying legacy-only and additive. Earlier provider opt-ins remain inherited; later provider branches still own their own 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 claw shadow parity OpenClaw and QClaw are shadow-compared on this branch, so add source-level migration coverage that compares provider observation with their legacy parsers. The paired test follows the shared provider implementation and keeps the agent/session raw ID shape and planned data-version behavior visible during review. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesClawLegacyParsers|Test(OpenClaw|QClaw)Provider|TestParse(OpenClaw|QClaw)' -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 claw providers into provider OpenClaw and QClaw should no longer keep exported discover/find/parse entrypoints beside the provider facade. Folding discovery, raw-ID lookup, archive selection, and parsing into the concrete providers makes this branch a real migration instead of another shim around the legacy path. The sync engine now relies on provider changed-path handling for this family, so the provider migration mode can become authoritative and the shadow-only comparison test is removed. Validation: go test -tags "fts5" ./internal/parser -run 'TestClawProvidersOwnLegacyEntrypoints|TestOpenClaw|TestQClaw|TestClawProvider|TestParseOpenClaw|TestParseQClaw|TestDiscoverOpenClaw|TestDiscoverQClaw|TestFindOpenClaw|TestFindQClaw' -count=1 -v; go test -tags "fts5" ./internal/sync -run 'TestEngine_ClassifyPathsQClaw|TestProviderMigration|TestObserveProvider|TestProviderProcess' -count=1 -v; go fmt ./...; go test -tags "fts5" ./internal/parser ./internal/sync ./cmd/agentsview -count=1; go vet ./...; git diff --check --- internal/parser/claw_provider.go | 682 ++++++++++++++++++++++++++ internal/parser/claw_provider_test.go | 345 +++++++++++++ internal/parser/discovery.go | 408 --------------- internal/parser/openclaw.go | 4 +- internal/parser/openclaw_test.go | 99 ++-- internal/parser/provider.go | 4 + internal/parser/provider_migration.go | 4 +- internal/parser/qclaw.go | 4 +- internal/parser/qclaw_test.go | 97 ++-- internal/parser/types.go | 22 +- internal/sync/engine.go | 144 ------ 11 files changed, 1183 insertions(+), 630 deletions(-) create mode 100644 internal/parser/claw_provider.go create mode 100644 internal/parser/claw_provider_test.go diff --git a/internal/parser/claw_provider.go b/internal/parser/claw_provider.go new file mode 100644 index 000000000..bdd3a256f --- /dev/null +++ b/internal/parser/claw_provider.go @@ -0,0 +1,682 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +var ( + _ Provider = (*openClawProvider)(nil) + _ Provider = (*qClawProvider)(nil) +) + +type clawProviderSpec struct { + agent AgentType + sessionFile func(string) bool + sessionID func(string) string +} + +type openClawProviderFactory struct { + def AgentDef +} + +func newOpenClawProviderFactory(def AgentDef) ProviderFactory { + return openClawProviderFactory{def: cloneAgentDef(def)} +} + +func (f openClawProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f openClawProviderFactory) Capabilities() Capabilities { + return openClawProviderCapabilities() +} + +func (f openClawProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &openClawProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: openClawProviderCapabilities(), + Config: cfg, + }, + sources: newClawSourceSet(cfg.Roots, openClawProviderSpec()), + } +} + +type openClawProvider struct { + ProviderBase + sources clawSourceSet +} + +func (p *openClawProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *openClawProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *openClawProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *openClawProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *openClawProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *openClawProvider) 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("%s source path unavailable", p.Def.Type) + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := p.parseSession(path, "", machine) + return clawParseOutcome(req, sess, msgs, err) +} + +type qClawProviderFactory struct { + def AgentDef +} + +func newQClawProviderFactory(def AgentDef) ProviderFactory { + return qClawProviderFactory{def: cloneAgentDef(def)} +} + +func (f qClawProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f qClawProviderFactory) Capabilities() Capabilities { + return qClawProviderCapabilities() +} + +func (f qClawProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &qClawProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: qClawProviderCapabilities(), + Config: cfg, + }, + sources: newClawSourceSet(cfg.Roots, qClawProviderSpec()), + } +} + +type qClawProvider struct { + ProviderBase + sources clawSourceSet +} + +func (p *qClawProvider) Discover(ctx context.Context) ([]SourceRef, error) { + return p.sources.Discover(ctx) +} + +func (p *qClawProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *qClawProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + return p.sources.SourcesForChangedPath(ctx, req) +} + +func (p *qClawProvider) FindSource( + ctx context.Context, + req FindSourceRequest, +) (SourceRef, bool, error) { + req = providerFindRequestWithRawSessionID(p.Def, req) + return p.sources.FindSource(ctx, req) +} + +func (p *qClawProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *qClawProvider) 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("%s source path unavailable", p.Def.Type) + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := p.parseSession(path, "", machine) + return clawParseOutcome(req, sess, msgs, err) +} + +type clawSource struct { + Root string + Path string +} + +type clawSourceSet struct { + roots []string + spec clawProviderSpec +} + +func newClawSourceSet(roots []string, spec clawProviderSpec) clawSourceSet { + return clawSourceSet{ + roots: cleanJSONLRoots(roots), + spec: spec, + } +} + +func (s clawSourceSet) 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 _, source := range s.discoverRoot(root) { + key := string(source.Provider) + "\x00" + source.Key + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + sources = append(sources, source) + } + } + sort.Slice(sources, func(i, j int) bool { + if sources[i].DisplayPath != sources[j].DisplayPath { + return sources[i].DisplayPath < sources[j].DisplayPath + } + return sources[i].Key < sources[j].Key + }) + return sources, nil +} + +func (s clawSourceSet) WatchPlan(context.Context) (WatchPlan, error) { + roots := make([]WatchRoot, 0, len(s.roots)) + for _, root := range s.roots { + roots = append(roots, WatchRoot{ + Path: root, + Recursive: true, + IncludeGlobs: []string{"*.jsonl", "*.jsonl.*"}, + DebounceKey: string(s.spec.agent) + ":claw:" + root, + }) + } + return WatchPlan{Roots: roots}, nil +} + +func (s clawSourceSet) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if req.WatchRoot != "" { + for _, root := range s.roots { + if !samePath(root, req.WatchRoot) { + continue + } + source, ok := s.sourceForChangedPathInRoot(root, req) + if !ok { + return nil, nil + } + return []SourceRef{source}, nil + } + return nil, nil + } + source, ok := s.sourceForChangedPath(req) + if !ok { + return nil, nil + } + return []SourceRef{source}, nil +} + +func (s clawSourceSet) 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.sourceForStoredPath(path); ok { + return source, true, nil + } + } + if req.RawSessionID == "" { + return SourceRef{}, false, nil + } + for _, root := range s.roots { + path := s.sourcePathForRawID(root, req.RawSessionID) + if path == "" { + continue + } + if source, ok := s.sourceRef(root, path); ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func (s clawSourceSet) 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("%s source path unavailable", s.spec.agent) + } + 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) + } + return SourceFingerprint{ + Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path), + Size: info.Size(), + MTimeNS: info.ModTime().UnixNano(), + }, nil +} + +func (s clawSourceSet) pathFromSource(source SourceRef) (string, bool) { + switch src := source.Opaque.(type) { + case clawSource: + return src.Path, src.Path != "" + case *clawSource: + 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.(clawSource) + return src.Path, true + } + } + return "", false +} + +func (s clawSourceSet) 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 clawSourceSet) sourceForChangedPath(req ChangedPathRequest) (SourceRef, bool) { + for _, root := range s.roots { + if source, ok := s.sourceForChangedPathInRoot(root, req); ok { + return source, true + } + } + return SourceRef{}, false +} + +func (s clawSourceSet) sourceForChangedPathInRoot( + root string, + req ChangedPathRequest, +) (SourceRef, bool) { + if source, ok := s.sourceForPathInRoot(root, req.Path); ok { + return source, true + } + if !jsonlMissingPathFallbackAllowed(req) { + return SourceRef{}, false + } + return s.sourceForStoredPathInRoot(root, req.Path) +} + +func (s clawSourceSet) sourceForStoredPath(path string) (SourceRef, bool) { + for _, root := range s.roots { + if source, ok := s.sourceForStoredPathInRoot(root, path); ok { + return source, true + } + } + return SourceRef{}, false +} + +func (s clawSourceSet) sourceForStoredPathInRoot( + root string, + path string, +) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rawID, ok := s.rawIDFromPath(root, path) + if !ok { + return SourceRef{}, false + } + best := s.sourcePathForRawID(root, rawID) + if best == "" { + return SourceRef{}, false + } + return s.sourceRef(root, best) +} + +func (s clawSourceSet) sourceForPathInRoot(root string, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + rawID, ok := s.rawIDFromPath(root, path) + if !ok { + return SourceRef{}, false + } + best := s.sourcePathForRawID(root, rawID) + if best == "" || !samePath(best, path) { + return SourceRef{}, false + } + return s.sourceRef(root, best) +} + +func (s clawSourceSet) sourceRef(root string, path string) (SourceRef, bool) { + rawID, ok := s.rawIDFromPath(root, path) + if !ok { + return SourceRef{}, false + } + return SourceRef{ + Provider: s.spec.agent, + Key: path, + DisplayPath: path, + FingerprintKey: path, + ProjectHint: clawAgentIDFromRawID(rawID), + Opaque: clawSource{ + Root: filepath.Clean(root), + Path: filepath.Clean(path), + }, + }, true +} + +func (s clawSourceSet) rawIDFromPath(root string, path string) (string, bool) { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return "", false + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 3 || parts[1] != "sessions" { + return "", false + } + if !IsValidSessionID(parts[0]) || !s.spec.sessionFile(parts[2]) { + return "", false + } + sessionID := s.spec.sessionID(parts[2]) + if !IsValidSessionID(sessionID) { + return "", false + } + return parts[0] + ":" + sessionID, true +} + +func (s clawSourceSet) discoverRoot(root string) []SourceRef { + if root == "" { + return nil + } + + agentEntries, err := os.ReadDir(root) + if err != nil { + return nil + } + + var sources []SourceRef + for _, agentEntry := range agentEntries { + if !isDirOrSymlink(agentEntry, root) { + continue + } + if !IsValidSessionID(agentEntry.Name()) { + continue + } + + sessionsDir := filepath.Join(root, agentEntry.Name(), "sessions") + entries, err := os.ReadDir(sessionsDir) + if err != nil { + continue + } + + best := make(map[string]os.DirEntry) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !s.spec.sessionFile(name) { + continue + } + sessionID := s.spec.sessionID(name) + if !IsValidSessionID(sessionID) { + continue + } + prev, exists := best[sessionID] + if !exists { + best[sessionID] = entry + continue + } + best[sessionID] = s.bestEntry(prev, entry) + } + + for _, entry := range best { + source, ok := s.sourceRef(root, filepath.Join(sessionsDir, entry.Name())) + if ok { + sources = append(sources, source) + } + } + } + return sources +} + +func (s clawSourceSet) sourcePathForRawID(root, rawID string) string { + if root == "" { + return "" + } + agentID, sessionID, ok := strings.Cut(rawID, ":") + if !ok || !IsValidSessionID(agentID) || !IsValidSessionID(sessionID) { + return "" + } + + sessionsDir := filepath.Join(root, agentID, "sessions") + active := filepath.Join(sessionsDir, sessionID+".jsonl") + if _, err := os.Stat(active); err == nil { + return active + } + + entries, err := os.ReadDir(sessionsDir) + if err != nil { + return "" + } + + var best os.DirEntry + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !s.spec.sessionFile(name) { + continue + } + if s.spec.sessionID(name) != sessionID { + continue + } + if best == nil { + best = entry + continue + } + best = s.bestEntry(best, entry) + } + if best == nil { + return "" + } + return filepath.Join(sessionsDir, best.Name()) +} + +func (s clawSourceSet) bestEntry(a, b os.DirEntry) os.DirEntry { + aActive := strings.HasSuffix(a.Name(), ".jsonl") + bActive := strings.HasSuffix(b.Name(), ".jsonl") + if aActive && !bActive { + return a + } + if bActive && !aActive { + return b + } + aTime := clawArchiveTime(a) + bTime := clawArchiveTime(b) + if !aTime.IsZero() && !bTime.IsZero() { + if bTime.After(aTime) { + return b + } + return a + } + if !aTime.IsZero() { + return a + } + if !bTime.IsZero() { + return b + } + ai, errA := a.Info() + bi, errB := b.Info() + if errA == nil && errB == nil && bi.ModTime().After(ai.ModTime()) { + return b + } + return a +} + +func clawArchiveTime(e os.DirEntry) time.Time { + name := e.Name() + idx := strings.Index(name, ".jsonl.") + if idx <= 0 { + return time.Time{} + } + suffix := name[idx+len(".jsonl."):] + _, tsStr, ok := strings.Cut(suffix, ".") + if !ok { + return time.Time{} + } + if tIdx := strings.IndexByte(tsStr, 'T'); tIdx >= 0 { + datePart := tsStr[:tIdx+1] + timePart := tsStr[tIdx+1:] + timePart = strings.Replace(timePart, "-", ":", 1) + timePart = strings.Replace(timePart, "-", ":", 1) + tsStr = datePart + timePart + } + t, err := time.Parse("2006-01-02T15:04:05.000Z", tsStr) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05Z", tsStr) + } + if err != nil { + return time.Time{} + } + return t +} + +func clawParseOutcome( + req ParseRequest, + sess *ParsedSession, + msgs []ParsedMessage, + err error, +) (ParseOutcome, error) { + 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 +} + +func openClawProviderSpec() clawProviderSpec { + return clawProviderSpec{ + agent: AgentOpenClaw, + sessionFile: IsOpenClawSessionFile, + sessionID: OpenClawSessionID, + } +} + +func qClawProviderSpec() clawProviderSpec { + return clawProviderSpec{ + agent: AgentQClaw, + sessionFile: IsQClawSessionFile, + sessionID: QClawSessionID, + } +} + +func clawAgentIDFromRawID(rawID string) string { + agentID, _, ok := strings.Cut(rawID, ":") + if !ok { + return "" + } + return agentID +} + +func openClawProviderCapabilities() Capabilities { + return clawProviderCapabilities() +} + +func qClawProviderCapabilities() Capabilities { + return clawProviderCapabilities() +} + +func clawProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/claw_provider_test.go b/internal/parser/claw_provider_test.go new file mode 100644 index 000000000..0ddcbdb9d --- /dev/null +++ b/internal/parser/claw_provider_test.go @@ -0,0 +1,345 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenClawProviderFactoryReplacesLegacyAdapter(t *testing.T) { + assertClawProviderReplacesLegacyAdapter(t, AgentOpenClaw) +} + +func TestQClawProviderFactoryReplacesLegacyAdapter(t *testing.T) { + assertClawProviderReplacesLegacyAdapter(t, AgentQClaw) +} + +func TestClawProvidersOwnLegacyEntrypoints(t *testing.T) { + for _, tt := range []struct { + parserFile string + providerFile string + symbols []string + calls []string + }{ + { + parserFile: "openclaw.go", + providerFile: "claw_provider.go", + symbols: []string{ + "func DiscoverOpenClawSessions", + "func FindOpenClawSourceFile", + "func ParseOpenClawSession", + }, + calls: []string{ + "DiscoverOpenClawSessions(", + "FindOpenClawSourceFile(", + "ParseOpenClawSession(", + }, + }, + { + parserFile: "qclaw.go", + providerFile: "claw_provider.go", + symbols: []string{ + "func DiscoverQClawSessions", + "func FindQClawSourceFile", + "func ParseQClawSession", + }, + calls: []string{ + "DiscoverQClawSessions(", + "FindQClawSourceFile(", + "ParseQClawSession(", + }, + }, + } { + parserSource, err := os.ReadFile(tt.parserFile) + require.NoError(t, err) + providerSource, err := os.ReadFile(tt.providerFile) + require.NoError(t, err) + + for _, symbol := range tt.symbols { + assert.NotContains(t, string(parserSource), symbol) + } + + providerText := string(providerSource) + for _, call := range tt.calls { + assert.NotContains( + t, + strings.ReplaceAll(providerText, "func "+call, ""), + call, + ) + } + } +} + +func TestOpenClawProviderSourceMethods(t *testing.T) { + spec := openClawProviderTestSpec() + assertClawProviderSourceMethods(t, spec) +} + +func TestQClawProviderSourceMethods(t *testing.T) { + spec := qClawProviderTestSpec() + assertClawProviderSourceMethods(t, spec) +} + +func TestOpenClawProviderDiscoversSymlinkedAgentDirectory(t *testing.T) { + spec := openClawProviderTestSpec() + assertClawProviderDiscoversSymlinkedAgentDirectory(t, spec) +} + +func TestQClawProviderDiscoversSymlinkedAgentDirectory(t *testing.T) { + spec := qClawProviderTestSpec() + assertClawProviderDiscoversSymlinkedAgentDirectory(t, spec) +} + +func TestOpenClawProviderParse(t *testing.T) { + spec := openClawProviderTestSpec() + assertClawProviderParse(t, spec) +} + +func TestQClawProviderParse(t *testing.T) { + spec := qClawProviderTestSpec() + assertClawProviderParse(t, spec) +} + +type clawProviderTestSpec struct { + agent AgentType + prefix string + sessionFile func(string) bool + fixture func(string, string) string +} + +func openClawProviderTestSpec() clawProviderTestSpec { + return clawProviderTestSpec{ + agent: AgentOpenClaw, + prefix: "openclaw", + sessionFile: IsOpenClawSessionFile, + fixture: func(sessionID string, firstMessage string) string { + return clawProviderFixture(sessionID, firstMessage) + }, + } +} + +func qClawProviderTestSpec() clawProviderTestSpec { + return clawProviderTestSpec{ + agent: AgentQClaw, + prefix: "qclaw", + sessionFile: IsQClawSessionFile, + fixture: func(sessionID string, firstMessage string) string { + return clawProviderFixture(sessionID, firstMessage) + }, + } +} + +func assertClawProviderReplacesLegacyAdapter(t *testing.T, agent AgentType) { + t.Helper() + + factory, ok := ProviderFactoryByType(agent) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(agent, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func assertClawProviderSourceMethods(t *testing.T, spec clawProviderTestSpec) { + t.Helper() + + root := t.TempDir() + activePath := filepath.Join(root, "main", "sessions", "abc-123.jsonl") + activeArchivePath := filepath.Join( + root, "main", "sessions", + "abc-123.jsonl.deleted.2026-01-01T00-00-00.000Z", + ) + oldArchivePath := filepath.Join( + root, "main", "sessions", + "def-456.jsonl.deleted.2026-01-01T00-00-00.000Z", + ) + newArchivePath := filepath.Join( + root, "main", "sessions", + "def-456.jsonl.reset.2026-03-01T00-00-00.000Z", + ) + writeSourceFile(t, activePath, spec.fixture("abc-123", "active question")) + writeSourceFile(t, activeArchivePath, spec.fixture("abc-123", "archived active")) + writeSourceFile(t, oldArchivePath, spec.fixture("def-456", "old archive")) + writeSourceFile(t, newArchivePath, spec.fixture("def-456", "new archive")) + writeSourceFile(t, filepath.Join(root, "main", "sessions", "notes.jsonl.tmp"), "{}\n") + writeSourceFile(t, filepath.Join(root, "bad agent", "sessions", "skip.jsonl"), "{}\n") + + provider, ok := NewProvider(spec.agent, 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{"*.jsonl", "*.jsonl.*"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.Equal(t, activePath, discovered[0].DisplayPath) + assert.Equal(t, "main", discovered[0].ProjectHint) + assert.Equal(t, newArchivePath, discovered[1].DisplayPath) + assert.Equal(t, "main", discovered[1].ProjectHint) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~" + spec.prefix + ":main:abc-123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, activePath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "main:def-456", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, newArchivePath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: activeArchivePath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, activePath, found.DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, activePath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.Positive(t, fingerprint.MTimeNS) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: newArchivePath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, newArchivePath, changed[0].DisplayPath) + + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: activeArchivePath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + assert.Empty(t, changed) + + require.NoError(t, os.Remove(activePath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: activePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, activeArchivePath, changed[0].DisplayPath) + + require.NoError(t, os.Remove(newArchivePath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: newArchivePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, oldArchivePath, changed[0].DisplayPath) + + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: oldArchivePath, + EventKind: "write", + WatchRoot: filepath.Join(root, "..", "other-root"), + }, + ) + require.NoError(t, err) + assert.Empty(t, changed) + + assert.True(t, spec.sessionFile(filepath.Base(activeArchivePath))) +} + +func assertClawProviderDiscoversSymlinkedAgentDirectory( + t *testing.T, + spec clawProviderTestSpec, +) { + t.Helper() + + root := t.TempDir() + targetRoot := t.TempDir() + targetAgent := filepath.Join(targetRoot, "main") + sourceAgent := filepath.Join(root, "main") + sourcePath := filepath.Join(sourceAgent, "sessions", "abc-123.jsonl") + writeSourceFile( + t, + filepath.Join(targetAgent, "sessions", "abc-123.jsonl"), + spec.fixture("abc-123", "from symlink"), + ) + if err := os.Symlink(targetAgent, sourceAgent); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(spec.agent, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~" + spec.prefix + ":main:abc-123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func assertClawProviderParse(t *testing.T, spec clawProviderTestSpec) { + t.Helper() + + root := t.TempDir() + sourcePath := filepath.Join(root, "main", "sessions", "abc-123.jsonl") + writeSourceFile(t, sourcePath, spec.fixture("abc-123", "provider question")) + + provider, ok := NewProvider(spec.agent, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, spec.prefix+":main:abc-123", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "project", outcome.Results[0].Result.Session.Project) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func clawProviderFixture(sessionID string, firstMessage string) string { + return `{"type":"session","version":3,"id":"` + sessionID + `","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}` + "\n" + + `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"` + firstMessage + `"}],"timestamp":"2026-02-25T10:00:01Z"}}` + "\n" + + `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"Done."}],"timestamp":"2026-02-25T10:00:02Z"}}` + "\n" +} diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 1cf207076..2c03183cf 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -1806,414 +1806,6 @@ func visualStudioCopilotTraceContains( return false } -// DiscoverOpenClawSessions finds all JSONL session files under the -// OpenClaw agents directory. The directory structure is: -// //sessions/.jsonl -// -// When both active (.jsonl) and archived (.jsonl.deleted.*, -// .jsonl.full.bak, .jsonl.reset.*) files exist for the same -// logical session ID, only one file is returned per session: -// the active .jsonl file is preferred; if absent, the newest -// archived file (by filename, which embeds a timestamp, or by -// file mtime as a fallback) is chosen. -func DiscoverOpenClawSessions(agentsDir string) []DiscoveredFile { - if agentsDir == "" { - return nil - } - - // Each agent has its own subdirectory. - agentEntries, err := os.ReadDir(agentsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, agentEntry := range agentEntries { - if !isDirOrSymlink(agentEntry, agentsDir) { - continue - } - if !IsValidSessionID(agentEntry.Name()) { - continue - } - - sessionsDir := filepath.Join( - agentsDir, agentEntry.Name(), "sessions", - ) - entries, err := os.ReadDir(sessionsDir) - if err != nil { - continue - } - - // Deduplicate by logical session ID within each - // agent's sessions directory. - best := make(map[string]os.DirEntry) // sessionID -> best entry - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !IsOpenClawSessionFile(name) { - continue - } - sid := OpenClawSessionID(name) - prev, exists := best[sid] - if !exists { - best[sid] = entry - continue - } - best[sid] = bestOpenClawEntry(prev, entry) - } - - for _, entry := range best { - files = append(files, DiscoveredFile{ - Path: filepath.Join( - sessionsDir, entry.Name(), - ), - Agent: AgentOpenClaw, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// bestOpenClawEntry returns the preferred entry when two files -// share the same logical session ID. Active .jsonl files always -// win. Among archived files, the one with the newest embedded -// timestamp wins; when no timestamp is parseable, mtime is used. -func bestOpenClawEntry(a, b os.DirEntry) os.DirEntry { - aActive := strings.HasSuffix(a.Name(), ".jsonl") - bActive := strings.HasSuffix(b.Name(), ".jsonl") - if aActive && !bActive { - return a - } - if bActive && !aActive { - return b - } - aTime := openClawArchiveTime(a) - bTime := openClawArchiveTime(b) - if !aTime.IsZero() && !bTime.IsZero() { - if bTime.After(aTime) { - return b - } - return a - } - if !aTime.IsZero() { - return a - } - if !bTime.IsZero() { - return b - } - ai, errA := a.Info() - bi, errB := b.Info() - if errA == nil && errB == nil && - bi.ModTime().After(ai.ModTime()) { - return b - } - return a -} - -// openClawArchiveTime extracts the timestamp embedded in an -// OpenClaw archive filename suffix (e.g. ".deleted.2026-02-19T08-59-24.951Z"). -func openClawArchiveTime(e os.DirEntry) time.Time { - name := e.Name() - idx := strings.Index(name, ".jsonl.") - if idx <= 0 { - return time.Time{} - } - suffix := name[idx+len(".jsonl."):] - // suffix is e.g. "deleted.2026-02-19T08-59-24.951Z" or "full.bak" - _, tsStr, ok := strings.Cut(suffix, ".") - if !ok { - return time.Time{} - } - // Convert dash-separated time back to colons: 08-59-24 → 08:59:24 - if tIdx := strings.IndexByte(tsStr, 'T'); tIdx >= 0 { - datePart := tsStr[:tIdx+1] - timePart := tsStr[tIdx+1:] - // Only replace first two dashes in time portion (hh-mm-ss) - timePart = strings.Replace(timePart, "-", ":", 1) - timePart = strings.Replace(timePart, "-", ":", 1) - tsStr = datePart + timePart - } - t, err := time.Parse("2006-01-02T15:04:05.000Z", tsStr) - if err != nil { - t, err = time.Parse("2006-01-02T15:04:05Z", tsStr) - } - if err != nil { - return time.Time{} - } - return t -} - -// FindOpenClawSourceFile locates an OpenClaw session file by its -// raw ID (without the "openclaw:" prefix). The raw ID has the -// format ":", which directly maps to the -// file at //sessions/.jsonl. -// -// If the active .jsonl file does not exist (archive-only session), -// the sessions directory is scanned for any archived file whose -// logical session ID matches. When multiple archived files match, -// the best candidate (newest by filename timestamp) is returned. -func FindOpenClawSourceFile(agentsDir, rawID string) string { - if agentsDir == "" { - return "" - } - - // Split "agentId:sessionId" into its two parts. - agentID, sessionID, ok := strings.Cut(rawID, ":") - if !ok || !IsValidSessionID(agentID) || - !IsValidSessionID(sessionID) { - return "" - } - - sessionsDir := filepath.Join( - agentsDir, agentID, "sessions", - ) - - // Fast path: the active .jsonl file exists. - active := filepath.Join(sessionsDir, sessionID+".jsonl") - if _, err := os.Stat(active); err == nil { - return active - } - - // Slow path: scan for archived files matching this session. - entries, err := os.ReadDir(sessionsDir) - if err != nil { - return "" - } - - var best os.DirEntry - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !IsOpenClawSessionFile(name) { - continue - } - if OpenClawSessionID(name) != sessionID { - continue - } - if best == nil { - best = entry - continue - } - best = bestOpenClawEntry(best, entry) - } - if best != nil { - return filepath.Join(sessionsDir, best.Name()) - } - return "" -} - -// DiscoverQClawSessions finds all JSONL session files under the -// QClaw agents directory. The directory structure is: -// //sessions/.jsonl -// -// When both active (.jsonl) and archived (.jsonl.deleted.*, -// .jsonl.full.bak, .jsonl.reset.*) files exist for the same -// logical session ID, only one file is returned per session: -// the active .jsonl file is preferred; if absent, the newest -// archived file (by filename, which embeds a timestamp, or by -// file mtime as a fallback) is chosen. -func DiscoverQClawSessions(agentsDir string) []DiscoveredFile { - if agentsDir == "" { - return nil - } - - // Each agent has its own subdirectory. - agentEntries, err := os.ReadDir(agentsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, agentEntry := range agentEntries { - if !isDirOrSymlink(agentEntry, agentsDir) { - continue - } - if !IsValidSessionID(agentEntry.Name()) { - continue - } - - sessionsDir := filepath.Join( - agentsDir, agentEntry.Name(), "sessions", - ) - entries, err := os.ReadDir(sessionsDir) - if err != nil { - continue - } - - // Deduplicate by logical session ID within each - // agent's sessions directory. - best := make(map[string]os.DirEntry) // sessionID -> best entry - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !IsQClawSessionFile(name) { - continue - } - sid := QClawSessionID(name) - prev, exists := best[sid] - if !exists { - best[sid] = entry - continue - } - best[sid] = bestQClawEntry(prev, entry) - } - - for _, entry := range best { - files = append(files, DiscoveredFile{ - Path: filepath.Join( - sessionsDir, entry.Name(), - ), - Agent: AgentQClaw, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// bestQClawEntry returns the preferred entry when two files -// share the same logical session ID. Active .jsonl files always -// win. Among archived files, the one with the newest embedded -// timestamp wins; when no timestamp is parseable, mtime is used. -func bestQClawEntry(a, b os.DirEntry) os.DirEntry { - aActive := strings.HasSuffix(a.Name(), ".jsonl") - bActive := strings.HasSuffix(b.Name(), ".jsonl") - if aActive && !bActive { - return a - } - if bActive && !aActive { - return b - } - aTime := qClawArchiveTime(a) - bTime := qClawArchiveTime(b) - if !aTime.IsZero() && !bTime.IsZero() { - if bTime.After(aTime) { - return b - } - return a - } - if !aTime.IsZero() { - return a - } - if !bTime.IsZero() { - return b - } - ai, errA := a.Info() - bi, errB := b.Info() - if errA == nil && errB == nil && - bi.ModTime().After(ai.ModTime()) { - return b - } - return a -} - -// qClawArchiveTime extracts the timestamp embedded in an -// QClaw archive filename suffix (e.g. ".deleted.2026-02-19T08-59-24.951Z"). -func qClawArchiveTime(e os.DirEntry) time.Time { - name := e.Name() - idx := strings.Index(name, ".jsonl.") - if idx <= 0 { - return time.Time{} - } - suffix := name[idx+len(".jsonl."):] - // suffix is e.g. "deleted.2026-02-19T08-59-24.951Z" or "full.bak" - _, tsStr, ok := strings.Cut(suffix, ".") - if !ok { - return time.Time{} - } - // Convert dash-separated time back to colons: 08-59-24 → 08:59:24 - if tIdx := strings.IndexByte(tsStr, 'T'); tIdx >= 0 { - datePart := tsStr[:tIdx+1] - timePart := tsStr[tIdx+1:] - // Only replace first two dashes in time portion (hh-mm-ss) - timePart = strings.Replace(timePart, "-", ":", 1) - timePart = strings.Replace(timePart, "-", ":", 1) - tsStr = datePart + timePart - } - t, err := time.Parse("2006-01-02T15:04:05.000Z", tsStr) - if err != nil { - t, err = time.Parse("2006-01-02T15:04:05Z", tsStr) - } - if err != nil { - return time.Time{} - } - return t -} - -// FindQClawSourceFile locates a QClaw session file by its -// raw ID (without the "qclaw:" prefix). The raw ID has the -// format ":", which directly maps to the -// file at //sessions/.jsonl. -// -// If the active .jsonl file does not exist (archive-only session), -// the sessions directory is scanned for any archived file whose -// logical session ID matches. When multiple archived files match, -// the best candidate (newest by filename timestamp) is returned. -func FindQClawSourceFile(agentsDir, rawID string) string { - if agentsDir == "" { - return "" - } - - // Split "agentId:sessionId" into its two parts. - agentID, sessionID, ok := strings.Cut(rawID, ":") - if !ok || !IsValidSessionID(agentID) || - !IsValidSessionID(sessionID) { - return "" - } - - sessionsDir := filepath.Join( - agentsDir, agentID, "sessions", - ) - - // Fast path: the active .jsonl file exists. - active := filepath.Join(sessionsDir, sessionID+".jsonl") - if _, err := os.Stat(active); err == nil { - return active - } - - // Slow path: scan for archived files matching this session. - entries, err := os.ReadDir(sessionsDir) - if err != nil { - return "" - } - - var best os.DirEntry - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !IsQClawSessionFile(name) { - continue - } - if QClawSessionID(name) != sessionID { - continue - } - if best == nil { - best = entry - continue - } - best = bestQClawEntry(best, entry) - } - if best != nil { - return filepath.Join(sessionsDir, best.Name()) - } - return "" -} - // extractIflowBaseSessionID extracts the base session ID from an iFlow // session ID. Fork IDs are formatted as -, so we // remove the child UUID suffix to get the base session ID for file lookup. diff --git a/internal/parser/openclaw.go b/internal/parser/openclaw.go index 045b3aeb4..9eee3dc9e 100644 --- a/internal/parser/openclaw.go +++ b/internal/parser/openclaw.go @@ -13,10 +13,10 @@ import ( "github.com/tidwall/gjson" ) -// ParseOpenClawSession parses an OpenClaw JSONL session file. +// parseSession parses an OpenClaw JSONL session file. // OpenClaw stores messages in a JSONL format with a session header // line, message entries, compaction summaries, and metadata events. -func ParseOpenClawSession( +func (p *openClawProvider) parseSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/openclaw_test.go b/internal/parser/openclaw_test.go index 0daaf0b83..e64cd9e4c 100644 --- a/internal/parser/openclaw_test.go +++ b/internal/parser/openclaw_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -31,6 +32,44 @@ func writeOpenClawTestFile( return path, root } +func parseOpenClawSessionForTest( + t *testing.T, + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + provider, ok := NewProvider(AgentOpenClaw, ProviderConfig{ + Roots: []string{filepath.Dir(filepath.Dir(filepath.Dir(path)))}, + Machine: machine, + }) + require.True(t, ok) + claw, ok := provider.(*openClawProvider) + require.True(t, ok) + return claw.parseSession(path, project, machine) +} + +func discoverOpenClawSessionsForTest(t *testing.T, root string) []SourceRef { + t.Helper() + provider, ok := NewProvider(AgentOpenClaw, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + return sources +} + +func findOpenClawSourceForTest(t *testing.T, root, rawID string) string { + t.Helper() + provider, ok := NewProvider(AgentOpenClaw, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, found, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + if !found { + return "" + } + return source.DisplayPath +} + func TestParseOpenClawSession_Basic(t *testing.T) { path, _ := writeOpenClawTestFile(t, "main", `{"type":"session","version":3,"id":"abc-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}`, @@ -39,7 +78,7 @@ func TestParseOpenClawSession_Basic(t *testing.T) { `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"I'm doing well, thanks!"}],"timestamp":"2026-02-25T10:00:02Z"}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test-machine") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test-machine") require.NoError(t, err) require.NotNil(t, sess, "expected session, got nil") @@ -61,7 +100,7 @@ func TestParseOpenClawSession_Thinking(t *testing.T) { `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me consider..."},{"type":"text","text":"Here is my response."}],"timestamp":"2026-02-25T10:00:02Z"}}`, ) - _, msgs, err := ParseOpenClawSession(path, "", "test") + _, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") assert.True(t, msgs[1].HasThinking, "expected HasThinking=true for assistant message") @@ -76,7 +115,7 @@ func TestParseOpenClawSession_ToolResult(t *testing.T) { `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"The hosts file contains localhost."}],"timestamp":"2026-02-25T10:00:04Z"}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 4, len(msgs), "expected 4 messages, got %d") // Assistant with tool_use @@ -114,7 +153,7 @@ func TestParseOpenClawSession_RealToolCallFormat(t *testing.T) { `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"call_2","name":"read","arguments":{"path":"/etc/hosts"}}],"timestamp":"2026-02-25T10:00:04Z"}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.NotNil(t, sess) require.Equal(t, 4, len(msgs), @@ -163,7 +202,7 @@ func TestParseOpenClawSession_OrphanToolResult(t *testing.T) { `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-02-25T10:00:04Z"}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) // 3 messages: user, assistant (tool_use), assistant (text). // The orphan toolResult is skipped entirely. @@ -180,7 +219,7 @@ func TestParseOpenClawSession_EmptyFile(t *testing.T) { `{"type":"session","version":3,"id":"empty","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, ) - sess, _, err := ParseOpenClawSession(path, "", "test") + sess, _, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) assert.Nil(t, sess, "expected nil session for file with no messages") } @@ -196,7 +235,7 @@ func TestParseOpenClawSession_AssistantUsage(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":91,"cacheRead":0,"cacheWrite":9612,"totalTokens":9706,"cost":{"input":0.000009,"output":0.001365,"cacheRead":0,"cacheWrite":0.036045,"total":0.037419}}}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -239,7 +278,7 @@ func TestParseOpenClawSession_AssistantUsageWithoutCost(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"hi back"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"input":42,"output":17,"cacheRead":0,"cacheWrite":0,"totalTokens":59}}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -263,7 +302,7 @@ func TestParseOpenClawSession_PartialUsage(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"output":17}}}`, ) - _, msgs, err := ParseOpenClawSession(path, "", "test") + _, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -286,7 +325,7 @@ func TestParseOpenClawSession_NoUsage(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z"}}`, ) - _, msgs, err := ParseOpenClawSession(path, "", "test") + _, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -303,7 +342,7 @@ func TestParseOpenClawSession_Compaction(t *testing.T) { `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"Continuing..."}],"timestamp":"2026-02-25T10:00:03Z"}}`, ) - sess, msgs, err := ParseOpenClawSession(path, "", "test") + sess, msgs, err := parseOpenClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.False(t, sess == nil, "expected session, got nil") // Compaction should be skipped, only messages remain. @@ -322,9 +361,9 @@ func TestParseOpenClawSession_AgentIDInSessionID(t *testing.T) { `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`, ) - sessA, _, err := ParseOpenClawSession(pathA, "", "test") + sessA, _, err := parseOpenClawSessionForTest(t, pathA, "", "test") require.NoError(t, err) - sessB, _, err := ParseOpenClawSession(pathB, "", "test") + sessB, _, err := parseOpenClawSessionForTest(t, pathB, "", "test") require.NoError(t, err) assert.NotEqualf(t, sessB.ID, sessA.ID, @@ -373,9 +412,9 @@ func TestBestOpenClawEntry_CrossSuffix(t *testing.T) { )) } - files := DiscoverOpenClawSessions(root) + files := discoverOpenClawSessionsForTest(t, root) require.Equal(t, 1, len(files), "expected 1 (deduplicated), got %d") - assert.Equal(t, newer, filepath.Base(files[0].Path), "expected %q, got %q") + assert.Equal(t, newer, filepath.Base(files[0].DisplayPath), "expected %q, got %q") } func TestDiscoverOpenClawSessions(t *testing.T) { @@ -394,10 +433,10 @@ func TestDiscoverOpenClawSessions(t *testing.T) { require.NoError(t, os.MkdirAll(claudeSessions, 0755)) require.NoError(t, os.WriteFile(filepath.Join(claudeSessions, "sess2.jsonl"), []byte("{}"), 0644)) - files := DiscoverOpenClawSessions(root) + files := discoverOpenClawSessionsForTest(t, root) require.Equal(t, 2, len(files), "expected 2 session files, got %d") for _, f := range files { - assert.Equal(t, AgentOpenClaw, f.Agent, "expected agent openclaw, got %s") + assert.Equal(t, AgentOpenClaw, f.Provider, "expected agent openclaw, got %s") } } @@ -418,12 +457,12 @@ func TestDiscoverOpenClawSessions_DeduplicatesArchived(t *testing.T) { )) } - files := DiscoverOpenClawSessions(root) + files := discoverOpenClawSessionsForTest(t, root) require.Equal(t, 1, len(files), "expected 1 file (deduplicated), got %d") // Active file should win. - assert.Truef(t, strings.HasSuffix(files[0].Path, "abc.jsonl"), + assert.Truef(t, strings.HasSuffix(files[0].DisplayPath, "abc.jsonl"), "expected active .jsonl to win, got %s", - filepath.Base(files[0].Path)) + filepath.Base(files[0].DisplayPath)) } func TestDiscoverOpenClawSessions_ArchiveOnlyPicksNewest(t *testing.T) { @@ -442,10 +481,10 @@ func TestDiscoverOpenClawSessions_ArchiveOnlyPicksNewest(t *testing.T) { )) } - files := DiscoverOpenClawSessions(root) + files := discoverOpenClawSessionsForTest(t, root) require.Equal(t, 1, len(files), "expected 1 file (deduplicated), got %d") want := "xyz.jsonl.deleted.2026-03-01T00-00-00.000Z" - assert.Equal(t, want, filepath.Base(files[0].Path), "expected newest archive") + assert.Equal(t, want, filepath.Base(files[0].DisplayPath), "expected newest archive") } func TestDiscoverOpenClawSessions_DifferentSessionsNotDeduped(t *testing.T) { @@ -464,7 +503,7 @@ func TestDiscoverOpenClawSessions_DifferentSessionsNotDeduped(t *testing.T) { )) } - files := DiscoverOpenClawSessions(root) + files := discoverOpenClawSessionsForTest(t, root) require.Len(t, files, 2, "expected 2 files (different sessions)") } @@ -476,19 +515,19 @@ func TestFindOpenClawSourceFile(t *testing.T) { require.NoError(t, os.WriteFile(target, []byte("{}"), 0644)) // Raw ID is now "agentId:sessionId". - found := FindOpenClawSourceFile(root, "main:abc-123") + found := findOpenClawSourceForTest(t, root, "main:abc-123") assert.Equal(t, target, found, "expected %s, got %s") // Non-existent session. - notFound := FindOpenClawSourceFile(root, "main:nonexistent") + notFound := findOpenClawSourceForTest(t, root, "main:nonexistent") assert.Equal(t, "", notFound, "expected empty string, got %s") // Non-existent agent. - notFound2 := FindOpenClawSourceFile(root, "other:abc-123") + notFound2 := findOpenClawSourceForTest(t, root, "other:abc-123") assert.Equal(t, "", notFound2, "expected empty string, got %s") // Invalid format (no colon separator). - notFound3 := FindOpenClawSourceFile(root, "abc-123") + notFound3 := findOpenClawSourceForTest(t, root, "abc-123") assert.Equal(t, "", notFound3, "expected empty string for bare ID, got %s") } @@ -504,7 +543,7 @@ func TestFindOpenClawSourceFile_ArchiveOnly(t *testing.T) { []byte("{}"), 0644, )) - found := FindOpenClawSourceFile(root, "main:def-456") + found := findOpenClawSourceForTest(t, root, "main:def-456") want := filepath.Join(sessDir, archived) assert.Equal(t, want, found, "expected %s, got %s") } @@ -523,7 +562,7 @@ func TestFindOpenClawSourceFile_PrefersActiveOverArchive(t *testing.T) { []byte("{}"), 0644, )) - found := FindOpenClawSourceFile(root, "main:ghi-789") + found := findOpenClawSourceForTest(t, root, "main:ghi-789") assert.Equal(t, active, found, "expected active file %s, got %s") } @@ -542,7 +581,7 @@ func TestFindOpenClawSourceFile_ArchiveOnlyNewest(t *testing.T) { )) } - found := FindOpenClawSourceFile(root, "main:jkl") + found := findOpenClawSourceForTest(t, root, "main:jkl") want := filepath.Join(sessDir, newest) assert.Equal(t, want, found, "expected newest archive %s, got %s") } diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 8e4ce8a7e..260cf1312 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -361,10 +361,14 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newGptmeProviderFactory(def) case AgentKimi: return newKimiProviderFactory(def) + case AgentOpenClaw: + return newOpenClawProviderFactory(def) case AgentOMP, AgentPi: return newPiProviderFactory(def) case AgentQwenPaw: return newQwenPawProviderFactory(def) + case AgentQClaw: + return newQClawProviderFactory(def) case AgentWorkBuddy: return newWorkBuddyProviderFactory(def) case AgentQwen: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 8f77430e2..050fcf3f5 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -36,8 +36,8 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentQwen: ProviderMigrationProviderAuthoritative, AgentCommandCode: ProviderMigrationProviderAuthoritative, AgentDeepSeekTUI: ProviderMigrationProviderAuthoritative, - AgentOpenClaw: ProviderMigrationLegacyOnly, - AgentQClaw: ProviderMigrationLegacyOnly, + AgentOpenClaw: ProviderMigrationProviderAuthoritative, + AgentQClaw: ProviderMigrationProviderAuthoritative, AgentKimi: ProviderMigrationProviderAuthoritative, AgentClaudeAI: ProviderMigrationLegacyOnly, AgentChatGPT: ProviderMigrationLegacyOnly, diff --git a/internal/parser/qclaw.go b/internal/parser/qclaw.go index 350bd9f06..86a305b7f 100644 --- a/internal/parser/qclaw.go +++ b/internal/parser/qclaw.go @@ -11,10 +11,10 @@ import ( "github.com/tidwall/gjson" ) -// ParseQClawSession parses a QClaw JSONL session file. +// parseSession parses a QClaw JSONL session file. // QClaw stores messages in a JSONL format with a session header // line, message entries, compaction summaries, and metadata events. -func ParseQClawSession( +func (p *qClawProvider) parseSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/qclaw_test.go b/internal/parser/qclaw_test.go index 5a5825b74..ec4147cf2 100644 --- a/internal/parser/qclaw_test.go +++ b/internal/parser/qclaw_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -31,6 +32,44 @@ func writeQClawTestFile( return path, root } +func parseQClawSessionForTest( + t *testing.T, + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + provider, ok := NewProvider(AgentQClaw, ProviderConfig{ + Roots: []string{filepath.Dir(filepath.Dir(filepath.Dir(path)))}, + Machine: machine, + }) + require.True(t, ok) + claw, ok := provider.(*qClawProvider) + require.True(t, ok) + return claw.parseSession(path, project, machine) +} + +func discoverQClawSessionsForTest(t *testing.T, root string) []SourceRef { + t.Helper() + provider, ok := NewProvider(AgentQClaw, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + return sources +} + +func findQClawSourceForTest(t *testing.T, root, rawID string) string { + t.Helper() + provider, ok := NewProvider(AgentQClaw, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, found, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: rawID, + }) + require.NoError(t, err) + if !found { + return "" + } + return source.DisplayPath +} + func TestParseQClawSession_Basic(t *testing.T) { path, _ := writeQClawTestFile(t, "main", `{"type":"session","version":3,"id":"abc-123","timestamp":"2026-02-25T10:00:00Z","cwd":"/home/user/project"}`, @@ -39,7 +78,7 @@ func TestParseQClawSession_Basic(t *testing.T) { `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"I'm doing well, thanks!"}],"timestamp":"2026-02-25T10:00:02Z"}}`, ) - sess, msgs, err := ParseQClawSession(path, "", "test-machine") + sess, msgs, err := parseQClawSessionForTest(t, path, "", "test-machine") require.NoError(t, err) require.NotNil(t, sess, "expected session, got nil") @@ -61,7 +100,7 @@ func TestParseQClawSession_Thinking(t *testing.T) { `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me consider..."},{"type":"text","text":"Here is my response."}],"timestamp":"2026-02-25T10:00:02Z"}}`, ) - _, msgs, err := ParseQClawSession(path, "", "test") + _, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") assert.True(t, msgs[1].HasThinking, "expected HasThinking=true for assistant message") @@ -76,7 +115,7 @@ func TestParseQClawSession_ToolResult(t *testing.T) { `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"The hosts file contains localhost."}],"timestamp":"2026-02-25T10:00:04Z"}}`, ) - sess, msgs, err := ParseQClawSession(path, "", "test") + sess, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 4, len(msgs), "expected 4 messages, got %d") // Assistant with tool_use @@ -108,7 +147,7 @@ func TestParseQClawSession_OrphanToolResult(t *testing.T) { `{"type":"message","id":"m4","timestamp":"2026-02-25T10:00:04Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-02-25T10:00:04Z"}}`, ) - sess, msgs, err := ParseQClawSession(path, "", "test") + sess, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) // 3 messages: user, assistant (tool_use), assistant (text). // The orphan toolResult is skipped entirely. @@ -125,7 +164,7 @@ func TestParseQClawSession_EmptyFile(t *testing.T) { `{"type":"session","version":3,"id":"empty","timestamp":"2026-02-25T10:00:00Z","cwd":"/tmp"}`, ) - sess, _, err := ParseQClawSession(path, "", "test") + sess, _, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) assert.Nil(t, sess, "expected nil session for file with no messages") } @@ -141,7 +180,7 @@ func TestParseQClawSession_AssistantUsage(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":91,"cacheRead":0,"cacheWrite":9612,"totalTokens":9706,"cost":{"input":0.000009,"output":0.001365,"cacheRead":0,"cacheWrite":0.036045,"total":0.037419}}}}`, ) - sess, msgs, err := ParseQClawSession(path, "", "test") + sess, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -184,7 +223,7 @@ func TestParseQClawSession_AssistantUsageWithoutCost(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"hi back"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"input":42,"output":17,"cacheRead":0,"cacheWrite":0,"totalTokens":59}}}`, ) - sess, msgs, err := ParseQClawSession(path, "", "test") + sess, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -208,7 +247,7 @@ func TestParseQClawSession_PartialUsage(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z","provider":"anthropic","model":"claude-haiku-4-5","usage":{"output":17}}}`, ) - _, msgs, err := ParseQClawSession(path, "", "test") + _, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -231,7 +270,7 @@ func TestParseQClawSession_NoUsage(t *testing.T) { `{"type":"message","id":"a1","timestamp":"2026-04-30T12:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"reply"}],"timestamp":"2026-04-30T12:00:02Z"}}`, ) - _, msgs, err := ParseQClawSession(path, "", "test") + _, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.Equal(t, 2, len(msgs), "expected 2 messages, got %d") @@ -248,7 +287,7 @@ func TestParseQClawSession_Compaction(t *testing.T) { `{"type":"message","id":"m2","timestamp":"2026-02-25T10:00:03Z","message":{"role":"assistant","content":[{"type":"text","text":"Continuing..."}],"timestamp":"2026-02-25T10:00:03Z"}}`, ) - sess, msgs, err := ParseQClawSession(path, "", "test") + sess, msgs, err := parseQClawSessionForTest(t, path, "", "test") require.NoError(t, err) require.False(t, sess == nil, "expected session, got nil") // Compaction should be skipped, only messages remain. @@ -267,9 +306,9 @@ func TestParseQClawSession_AgentIDInSessionID(t *testing.T) { `{"type":"message","id":"m1","timestamp":"2026-02-25T10:00:01Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}],"timestamp":"2026-02-25T10:00:01Z"}}`, ) - sessA, _, err := ParseQClawSession(pathA, "", "test") + sessA, _, err := parseQClawSessionForTest(t, pathA, "", "test") require.NoError(t, err) - sessB, _, err := ParseQClawSession(pathB, "", "test") + sessB, _, err := parseQClawSessionForTest(t, pathB, "", "test") require.NoError(t, err) assert.NotEqualf(t, sessB.ID, sessA.ID, @@ -318,9 +357,9 @@ func TestBestQClawEntry_CrossSuffix(t *testing.T) { )) } - files := DiscoverQClawSessions(root) + files := discoverQClawSessionsForTest(t, root) require.Equal(t, 1, len(files), "expected 1 (deduplicated), got %d") - assert.Equal(t, newer, filepath.Base(files[0].Path), "expected %q, got %q") + assert.Equal(t, newer, filepath.Base(files[0].DisplayPath), "expected %q, got %q") } func TestDiscoverQClawSessions(t *testing.T) { @@ -339,10 +378,10 @@ func TestDiscoverQClawSessions(t *testing.T) { require.NoError(t, os.MkdirAll(claudeSessions, 0755)) require.NoError(t, os.WriteFile(filepath.Join(claudeSessions, "sess2.jsonl"), []byte("{}"), 0644)) - files := DiscoverQClawSessions(root) + files := discoverQClawSessionsForTest(t, root) require.Equal(t, 2, len(files), "expected 2 session files, got %d") for _, f := range files { - assert.Equal(t, AgentQClaw, f.Agent, "expected agent qclaw, got %s") + assert.Equal(t, AgentQClaw, f.Provider, "expected agent qclaw, got %s") } } @@ -363,12 +402,12 @@ func TestDiscoverQClawSessions_DeduplicatesArchived(t *testing.T) { )) } - files := DiscoverQClawSessions(root) + files := discoverQClawSessionsForTest(t, root) require.Equal(t, 1, len(files), "expected 1 file (deduplicated), got %d") // Active file should win. - assert.Truef(t, strings.HasSuffix(files[0].Path, "abc.jsonl"), + assert.Truef(t, strings.HasSuffix(files[0].DisplayPath, "abc.jsonl"), "expected active .jsonl to win, got %s", - filepath.Base(files[0].Path)) + filepath.Base(files[0].DisplayPath)) } func TestDiscoverQClawSessions_ArchiveOnlyPicksNewest(t *testing.T) { @@ -387,10 +426,10 @@ func TestDiscoverQClawSessions_ArchiveOnlyPicksNewest(t *testing.T) { )) } - files := DiscoverQClawSessions(root) + files := discoverQClawSessionsForTest(t, root) require.Equal(t, 1, len(files), "expected 1 file (deduplicated), got %d") want := "xyz.jsonl.deleted.2026-03-01T00-00-00.000Z" - assert.Equal(t, want, filepath.Base(files[0].Path), "expected newest archive") + assert.Equal(t, want, filepath.Base(files[0].DisplayPath), "expected newest archive") } func TestDiscoverQClawSessions_DifferentSessionsNotDeduped(t *testing.T) { @@ -409,7 +448,7 @@ func TestDiscoverQClawSessions_DifferentSessionsNotDeduped(t *testing.T) { )) } - files := DiscoverQClawSessions(root) + files := discoverQClawSessionsForTest(t, root) require.Len(t, files, 2, "expected 2 files (different sessions)") } @@ -421,19 +460,19 @@ func TestFindQClawSourceFile(t *testing.T) { require.NoError(t, os.WriteFile(target, []byte("{}"), 0644)) // Raw ID is now "agentId:sessionId". - found := FindQClawSourceFile(root, "main:abc-123") + found := findQClawSourceForTest(t, root, "main:abc-123") assert.Equal(t, target, found, "expected %s, got %s") // Non-existent session. - notFound := FindQClawSourceFile(root, "main:nonexistent") + notFound := findQClawSourceForTest(t, root, "main:nonexistent") assert.Equal(t, "", notFound, "expected empty string, got %s") // Non-existent agent. - notFound2 := FindQClawSourceFile(root, "other:abc-123") + notFound2 := findQClawSourceForTest(t, root, "other:abc-123") assert.Equal(t, "", notFound2, "expected empty string, got %s") // Invalid format (no colon separator). - notFound3 := FindQClawSourceFile(root, "abc-123") + notFound3 := findQClawSourceForTest(t, root, "abc-123") assert.Equal(t, "", notFound3, "expected empty string for bare ID, got %s") } @@ -449,7 +488,7 @@ func TestFindQClawSourceFile_ArchiveOnly(t *testing.T) { []byte("{}"), 0644, )) - found := FindQClawSourceFile(root, "main:def-456") + found := findQClawSourceForTest(t, root, "main:def-456") want := filepath.Join(sessDir, archived) assert.Equal(t, want, found, "expected %s, got %s") } @@ -468,7 +507,7 @@ func TestFindQClawSourceFile_PrefersActiveOverArchive(t *testing.T) { []byte("{}"), 0644, )) - found := FindQClawSourceFile(root, "main:ghi-789") + found := findQClawSourceForTest(t, root, "main:ghi-789") assert.Equal(t, active, found, "expected active file %s, got %s") } @@ -487,7 +526,7 @@ func TestFindQClawSourceFile_ArchiveOnlyNewest(t *testing.T) { )) } - found := FindQClawSourceFile(root, "main:jkl") + found := findQClawSourceForTest(t, root, "main:jkl") want := filepath.Join(sessDir, newest) assert.Equal(t, want, found, "expected newest archive %s, got %s") } diff --git a/internal/parser/types.go b/internal/parser/types.go index 699ef997a..fc108ca0c 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -367,21 +367,17 @@ var Registry = []AgentDef{ ".openclaw/agents", ".kimi_openclaw/agents", }, - IDPrefix: "openclaw:", - FileBased: true, - DiscoverFunc: DiscoverOpenClawSessions, - FindSourceFunc: FindOpenClawSourceFile, + IDPrefix: "openclaw:", + FileBased: true, }, { - Type: AgentQClaw, - DisplayName: "QClaw", - EnvVar: "QCLAW_DIR", - ConfigKey: "qclaw_dirs", - DefaultDirs: []string{".qclaw/agents"}, - IDPrefix: "qclaw:", - FileBased: true, - DiscoverFunc: DiscoverQClawSessions, - FindSourceFunc: FindQClawSourceFile, + Type: AgentQClaw, + DisplayName: "QClaw", + EnvVar: "QCLAW_DIR", + ConfigKey: "qclaw_dirs", + DefaultDirs: []string{".qclaw/agents"}, + IDPrefix: "qclaw:", + FileBased: true, }, { Type: AgentKimi, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 3128a245a..fcc61a8ec 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1274,47 +1274,6 @@ func (e *Engine) classifyOnePath( return df, true } - // OpenClaw: //sessions/.jsonl - // or: //sessions/.jsonl. - for _, ocDir := range e.agentDirs[parser.AgentOpenClaw] { - if ocDir == "" { - continue - } - if rel, ok := isUnder(ocDir, path); ok { - parts := strings.Split(rel, sep) - // Expect: /sessions/ - if len(parts) != 3 || parts[1] != "sessions" { - continue - } - if !parser.IsValidSessionID(parts[0]) { - continue - } - if !parser.IsOpenClawSessionFile(parts[2]) { - continue - } - if !strings.HasSuffix(parts[2], ".jsonl") { - sid := parser.OpenClawSessionID(parts[2]) - active := filepath.Join( - ocDir, parts[0], "sessions", - sid+".jsonl", - ) - if _, err := os.Stat(active); err == nil { - continue - } - best := parser.FindOpenClawSourceFile( - ocDir, parts[0]+":"+sid, - ) - if best != path { - continue - } - } - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentOpenClaw, - }, true - } - } - // Kiro CLI legacy: /.jsonl for _, kiroDir := range e.agentDirs[parser.AgentKiro] { if kiroDir == "" { @@ -1331,47 +1290,6 @@ func (e *Engine) classifyOnePath( } } - // QClaw: //sessions/.jsonl - // or: //sessions/.jsonl. - for _, qcDir := range e.agentDirs[parser.AgentQClaw] { - if qcDir == "" { - continue - } - if rel, ok := isUnder(qcDir, path); ok { - parts := strings.Split(rel, sep) - // Expect: /sessions/ - if len(parts) != 3 || parts[1] != "sessions" { - continue - } - if !parser.IsValidSessionID(parts[0]) { - continue - } - if !parser.IsQClawSessionFile(parts[2]) { - continue - } - if !strings.HasSuffix(parts[2], ".jsonl") { - sid := parser.QClawSessionID(parts[2]) - active := filepath.Join( - qcDir, parts[0], "sessions", - sid+".jsonl", - ) - if _, err := os.Stat(active); err == nil { - continue - } - best := parser.FindQClawSourceFile( - qcDir, parts[0]+":"+sid, - ) - if best != path { - continue - } - } - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentQClaw, - }, true - } - } - // Antigravity IDE: /conversations/.db (+ -wal, -shm). // annotations/.pbtxt and brain//* sidecar events are // handled in classifyPaths via classifyAntigravitySidecarPath, @@ -4564,10 +4482,6 @@ func (e *Engine) processFile( res = e.processVSCodeCopilot(file, info) case parser.AgentVSCopilot: res = e.processVisualStudioCopilot(file, info) - case parser.AgentOpenClaw: - res = e.processOpenClaw(file, info) - case parser.AgentQClaw: - res = e.processQClaw(file, info) case parser.AgentKiro: res = e.processKiro(file, info) case parser.AgentKiroIDE: @@ -6193,64 +6107,6 @@ func (e *Engine) processVSCodeCopilot( } } -func (e *Engine) processOpenClaw( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseOpenClawSession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - -func (e *Engine) processQClaw( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseQClawSession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - func (e *Engine) processVisualStudioCopilot( file parser.DiscoveredFile, _ os.FileInfo, ) processResult { From a8d793d563117ef085385ab473d441d51cab2bb1 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 25 Jun 2026 19:18:11 -0400 Subject: [PATCH 2/3] refactor(parser): adopt exported source-set API in claw providers Update openclaw and qclaw provider call sites to the exported source-set framework API. --- internal/parser/claw_provider.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/parser/claw_provider.go b/internal/parser/claw_provider.go index bdd3a256f..1f3ed2b4a 100644 --- a/internal/parser/claw_provider.go +++ b/internal/parser/claw_provider.go @@ -73,7 +73,7 @@ func (p *openClawProvider) FindSource( ctx context.Context, req FindSourceRequest, ) (SourceRef, bool, error) { - req = providerFindRequestWithRawSessionID(p.Def, req) + req = ProviderFindRequestWithRawSessionID(p.Def, req) return p.sources.FindSource(ctx, req) } @@ -152,7 +152,7 @@ func (p *qClawProvider) FindSource( ctx context.Context, req FindSourceRequest, ) (SourceRef, bool, error) { - req = providerFindRequestWithRawSessionID(p.Def, req) + req = ProviderFindRequestWithRawSessionID(p.Def, req) return p.sources.FindSource(ctx, req) } From ebe3172c1f8d657819876fc8e243e22fb32bd0b7 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Sat, 27 Jun 2026 08:25:47 -0400 Subject: [PATCH 3/3] fix(parser): restore Claw content hashing in provider fingerprint clawSourceSet.Fingerprint built a SourceFingerprint without a Hash, so clawParseOutcome's guarded assignment left Session.File.Hash empty for both OpenClaw and QClaw. The legacy processOpenClaw/processQClaw paths always computed a full-file hash via ComputeFileHash and persisted file_hash, and the full-parse write overwrites file_hash unconditionally, so the migration cleared existing hashes to NULL on resync. Compute the content hash in Fingerprint via hashJSONLSourceFile. --- internal/parser/claw_provider.go | 8 ++++++++ internal/parser/claw_provider_test.go | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/internal/parser/claw_provider.go b/internal/parser/claw_provider.go index 1f3ed2b4a..ca2879c1f 100644 --- a/internal/parser/claw_provider.go +++ b/internal/parser/claw_provider.go @@ -309,10 +309,18 @@ func (s clawSourceSet) Fingerprint( if info.IsDir() { return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", path) } + // Legacy processOpenClaw/processQClaw persisted a full-file content hash + // (file_hash). Without it here the parse outcome leaves Session.File.Hash + // empty and a resync clears the stored hash to NULL. + 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 } diff --git a/internal/parser/claw_provider_test.go b/internal/parser/claw_provider_test.go index 0ddcbdb9d..79eb4119e 100644 --- a/internal/parser/claw_provider_test.go +++ b/internal/parser/claw_provider_test.go @@ -220,6 +220,17 @@ func assertClawProviderSourceMethods(t *testing.T, spec clawProviderTestSpec) { assert.Equal(t, activePath, fingerprint.Key) assert.Positive(t, fingerprint.Size) assert.Positive(t, fingerprint.MTimeNS) + // The legacy processOpenClaw/processQClaw path persisted a content hash; + // the provider fingerprint must too, or a resync clears stored file_hash. + assert.NotEmpty(t, fingerprint.Hash) + + parsed, err := provider.Parse(context.Background(), ParseRequest{ + Source: found, + Fingerprint: fingerprint, + }) + require.NoError(t, err) + require.Len(t, parsed.Results, 1) + assert.Equal(t, fingerprint.Hash, parsed.Results[0].Result.Session.File.Hash) changed, err := provider.SourcesForChangedPath( context.Background(),