diff --git a/internal/parser/claw_provider.go b/internal/parser/claw_provider.go new file mode 100644 index 000000000..ca2879c1f --- /dev/null +++ b/internal/parser/claw_provider.go @@ -0,0 +1,690 @@ +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) + } + // 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 +} + +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..79eb4119e --- /dev/null +++ b/internal/parser/claw_provider_test.go @@ -0,0 +1,356 @@ +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) + // 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(), + 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 {