From a73e4d10a9ea92455f5d88cc902eabbe080d4065 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 21:33:25 -0400 Subject: [PATCH] feat(parser): migrate qwenpaw provider QwenPaw stores rewritten JSON session snapshots under workspace-scoped sessions directories, with a second one-level subdirectory namespace for console sessions. Moving it behind a concrete provider keeps those raw ID shapes explicit while continuing to use the shared file-source mechanics where they fit. The provider preserves workspace project hints, root and console source lookup, hidden/deeper layout rejection, symlinked workspace discovery, content-hash fingerprinting, and force-replace parse semantics for rewritten session files. fix(parser): prune qwenpaw source traversal QwenPaw legacy discovery only walks valid workspace directories, the sessions directory, and one real non-hidden namespace below sessions. The provider migration reused the generic recursive JSONL walker, which preserved emitted source filtering but still allowed traversal and event classification through deeper or symlinked session namespaces. Add a shared traversal predicate to the JSONL source helper so providers can keep discovery and changed-path classification aligned when their source layouts are narrower than an unbounded recursive scan. test(parser): opt qwenpaw into provider shadow QwenPaw now has a concrete facade provider on this branch, so its migration mode should enter shadow comparison instead of remaining legacy-only and additive. Earlier provider opt-ins stay inherited and later branches own their modes. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare qwenpaw shadow parity QwenPaw is shadow-compared on this branch, so add source-level migration coverage that compares provider observation with ParseQwenPawSession. The test covers both root session files and console subdirectory session files so the path-derived workspace/session IDs and planned data-version behavior stay visible during review. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesQwenPawLegacyParser|TestQwenPawProvider|TestParseQwenPaw|TestSyncSingleSession_QwenPaw|TestWriteBatchQwenPaw' -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 qwenpaw into provider Move QwenPaw parse and source-lookup ownership onto the concrete qwenPawProvider and delete the package-level legacy entrypoints (DiscoverQwenPawSessions, FindQwenPawSourceFile, ParseQwenPawSession) plus their unexported discovery/traversal helpers. ParseQwenPawSession becomes the provider parseSession method, and the rawID resolution from FindQwenPawSourceFile moves onto the provider as sourceFileForRawID with its traversal guard. The provider's existing JSONLSourceSet already reproduces workspace/sessions/console discovery, symlink handling, and hidden-subdir pruning, so the legacy DiscoverQwenPawSessions free function is dropped entirely. Route QwenPaw sync classification and processing through the provider-neutral runtime by removing its legacy engine dispatch: the classifyOnePath workspace/sessions block, the processFile case arm, and the processQwenPaw method. The provider's SourcesForChangedPath and forceReplace-on-parse capability preserve changed-path remapping and the full-rewrite write semantics the legacy path provided. Make QwenPaw provider-authoritative in the migration manifest, drop its AgentDef DiscoverFunc/FindSourceFunc hooks, remove it from the pending shim scan list, and replace the shadow-baseline test with provider API coverage plus a guard that the legacy symbols stay gone. To preserve single-session resync parity, FindSource now resolves a DB-stored file_path that points outside any configured QWENPAW_DIR by synthesizing a source from the path's implicit //sessions/ layout, recovering the workspace as ProjectHint so a reparse keeps the canonical qwenpaw:: ID instead of orphaning it under an empty workspace. fix(parser): thread ctx through qwenpaw source lookups --- internal/parser/jsonl_source_set_test.go | 446 +++++++++++++++++++++ internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/provider_shim_scan_test.go | 1 - internal/parser/qwenpaw.go | 186 +-------- internal/parser/qwenpaw_provider.go | 290 ++++++++++++++ internal/parser/qwenpaw_provider_test.go | 305 ++++++++++++++ internal/parser/qwenpaw_test.go | 141 +++++-- internal/parser/types.go | 16 +- internal/sync/engine.go | 78 ---- internal/sync/engine_test.go | 12 +- 11 files changed, 1161 insertions(+), 318 deletions(-) create mode 100644 internal/parser/jsonl_source_set_test.go create mode 100644 internal/parser/qwenpaw_provider.go create mode 100644 internal/parser/qwenpaw_provider_test.go diff --git a/internal/parser/jsonl_source_set_test.go b/internal/parser/jsonl_source_set_test.go new file mode 100644 index 000000000..e2a8ab813 --- /dev/null +++ b/internal/parser/jsonl_source_set_test.go @@ -0,0 +1,446 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONLSourceSetDiscoverRecursiveStableSources(t *testing.T) { + root := t.TempDir() + writeSourceFile(t, filepath.Join(root, "b.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "a.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", "c.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", "ignored.txt"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", "upper.JSONL"), "{}\n") + + roots := []string{root} + sources := newJSONLSourceSet(AgentCodex, roots, + withRecursive(), + withKey(func(root, path string) string { + return mustRelSlash(t, root, path) + }), + withProjectHint(func(root, path string) string { + rel := mustRelSlash(t, root, filepath.Dir(path)) + if rel == "." { + return "" + } + return rel + }), + ) + roots[0] = filepath.Join(root, "mutated") + + discovered, err := sources.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 3) + + assert.Equal(t, []string{ + "a.jsonl", + "b.jsonl", + "nested/c.jsonl", + }, sourceKeys(discovered)) + assert.Equal(t, []string{"", "", "nested"}, sourceProjects(discovered)) + for _, source := range discovered { + assert.Equal(t, AgentCodex, source.Provider) + assert.Equal(t, source.DisplayPath, source.FingerprintKey) + assert.NotEmpty(t, source.DisplayPath) + assert.IsType(t, JSONLSource{}, source.Opaque) + } +} + +func TestJSONLSourceSetShallowDiscoveryAndFilters(t *testing.T) { + root := t.TempDir() + writeSourceFile(t, filepath.Join(root, "keep.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "keep.ndjson"), "{}\n") + writeSourceFile(t, filepath.Join(root, "drop.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", "skip.jsonl"), "{}\n") + + sources := newJSONLSourceSet(AgentGptme, []string{root}, + withExtensions(".jsonl", ".ndjson"), + withInclude(func(path string, _ os.FileInfo) bool { + return filepath.Base(path) != "drop.jsonl" + }), + ) + + discovered, err := sources.Discover(context.Background()) + require.NoError(t, err) + + assert.Equal(t, []string{ + filepath.Join(root, "keep.jsonl"), + filepath.Join(root, "keep.ndjson"), + }, sourceDisplayPaths(discovered)) +} + +func TestJSONLSourceSetWatchChangedPathFindAndFingerprint(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "nested", "session-1.jsonl") + content := "{\"role\":\"user\"}\n" + writeSourceFile(t, path, content) + writeSourceFile(t, filepath.Join(root, "nested", "notes.txt"), "{}\n") + + sources := newJSONLSourceSet(AgentCodex, []string{root}, + withRecursive(), + withContentHashing(), + ) + + plan, err := sources.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"}, plan.Roots[0].IncludeGlobs) + assert.NotEmpty(t, plan.Roots[0].DebounceKey) + + changed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: path, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, path, changed[0].Key) + assert.Equal(t, path, changed[0].DisplayPath) + assert.Equal(t, path, changed[0].FingerprintKey) + + ignored, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "nested", "notes.txt"), + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, ignored) + + outside, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(t.TempDir(), "session-1.jsonl"), + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, outside) + + found, ok, err := sources.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: path, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, path, found.DisplayPath) + + foundByID, ok, err := sources.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "session-1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, found.DisplayPath, foundByID.DisplayPath) + + withoutOpaque := found + withoutOpaque.Opaque = nil + fingerprint, err := sources.Fingerprint(context.Background(), withoutOpaque) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, path, fingerprint.Key) + assert.Equal(t, info.Size(), fingerprint.Size) + assert.Equal(t, info.ModTime().UnixNano(), fingerprint.MTimeNS) + assert.Equal(t, fmt.Sprintf("%x", sha256.Sum256([]byte(content))), fingerprint.Hash) +} + +func TestJSONLSourceSetFindSourceUsesFingerprintKey(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "nested", "session-1.jsonl") + writeSourceFile(t, path, "{}\n") + + defaultSources := newJSONLSourceSet( + AgentCodex, []string{root}, withRecursive(), + ) + found, ok, err := defaultSources.FindSource( + context.Background(), + FindSourceRequest{FingerprintKey: path}, + ) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, path, found.DisplayPath) + + customSources := newJSONLSourceSet(AgentCodex, []string{root}, + withRecursive(), + withFingerprintKey(func(root, path string) string { + return "fingerprint:" + mustRelSlash(t, root, path) + }), + ) + found, ok, err = customSources.FindSource( + context.Background(), + FindSourceRequest{FingerprintKey: "fingerprint:nested/session-1.jsonl"}, + ) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, path, found.DisplayPath) + assert.Equal(t, "fingerprint:nested/session-1.jsonl", found.FingerprintKey) +} + +func TestJSONLSourceSetChangedPathClassifiesDeletedFiles(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "nested", "deleted.jsonl") + sources := newJSONLSourceSet(AgentCodex, []string{root}, + withRecursive(), + ) + + changed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: path, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, path, changed[0].Key) + assert.Equal(t, path, changed[0].DisplayPath) + assert.Equal(t, path, changed[0].FingerprintKey) + assert.Equal(t, "nested/deleted.jsonl", changed[0].Opaque.(JSONLSource).RelPath) + + shallowPath := filepath.Join(root, "nested", "ignored.jsonl") + shallowSources := newJSONLSourceSet(AgentCodex, []string{root}) + changed, err = shallowSources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: shallowPath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func TestJSONLSourceSetChangedPathRejectsExistingNonRegularPath(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "nested", "not-a-source.jsonl") + require.NoError(t, os.MkdirAll(path, 0o755)) + + sources := newJSONLSourceSet(AgentCodex, []string{root}, + withRecursive(), + ) + + changed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: path, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func TestJSONLSourceSetChangedPathUsesPathOnlyFilterForDeletedFiles(t *testing.T) { + root := t.TempDir() + sources := newJSONLSourceSet(AgentCodex, []string{root}, + withRecursive(), + withIncludePath(func(root, path string) bool { + return filepath.Base(path) == "events.jsonl" + }), + ) + + ignored, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "session", "notes.jsonl"), + EventKind: "remove", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, ignored) + + changed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "session", "events.jsonl"), + EventKind: "remove", + WatchRoot: root, + }, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, filepath.Join(root, "session", "events.jsonl"), changed[0].DisplayPath) +} + +func TestJSONLSourceSetDescendPathPrunesSources(t *testing.T) { + root := t.TempDir() + keepPath := filepath.Join(root, "keep", "session.jsonl") + skipPath := filepath.Join(root, "skip", "session.jsonl") + writeSourceFile(t, keepPath, "{}\n") + writeSourceFile(t, skipPath, "{}\n") + + sources := newJSONLSourceSet(AgentCodex, []string{root}, + withRecursive(), + withDescendPath(func(root, path string) bool { + return filepath.Base(path) != "skip" + }), + ) + + discovered, err := sources.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, keepPath, discovered[0].DisplayPath) + + changed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: skipPath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + assert.Empty(t, changed) + + removed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: filepath.Join(root, "skip", "removed.jsonl"), + EventKind: "remove", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, removed) +} + +func TestJSONLSourceSetDuplicateKeysKeepFirstConfiguredRoot(t *testing.T) { + firstRoot := t.TempDir() + secondRoot := t.TempDir() + firstPath := filepath.Join(firstRoot, "session.jsonl") + secondPath := filepath.Join(secondRoot, "session.jsonl") + writeSourceFile(t, firstPath, "{}\n") + writeSourceFile(t, secondPath, "{}\n") + + sources := newJSONLSourceSet(AgentCodex, []string{firstRoot, secondRoot}, + withKey(func(_, path string) string { + return filepath.Base(path) + }), + ) + + discovered, err := sources.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, firstPath, discovered[0].DisplayPath) + + found, ok, err := sources.FindSource( + context.Background(), + FindSourceRequest{StoredFilePath: secondPath}, + ) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, firstPath, found.DisplayPath) + + changed, err := sources.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: secondPath, + EventKind: "write", + WatchRoot: secondRoot, + }, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func TestJSONLSourceSetFindSourceNormalizesRawSessionID(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "session-1.jsonl") + writeSourceFile(t, path, "{}\n") + + // LookupIDValid rejects the raw, un-normalized form, so a lookup only + // succeeds when RawSessionIDForLookup runs before the validity gate and + // before the SessionIDFromPath comparison in the discovery loop. The + // on-disk session ID is "session-1" (base name without extension), which + // the raw "raw:session-1" only matches once normalized. + rejectsRaw := func(rawID string) bool { + return rawID != "" && !strings.HasPrefix(rawID, "raw:") + } + + normalizing := newJSONLSourceSet(AgentCodex, []string{root}, + withRawSessionIDForLookup(func(rawID string) string { + return strings.TrimPrefix(rawID, "raw:") + }), + withLookupIDValid(rejectsRaw), + ) + + found, ok, err := normalizing.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "raw:session-1"}, + ) + require.NoError(t, err) + require.True(t, ok, "normalized raw session ID must resolve its source") + assert.Equal(t, path, found.DisplayPath) + + // Without the normalizer the identical request is gated out: the raw form + // fails LookupIDValid and never matches the on-disk session ID. This locks + // in that the normalization step is what enables both checks. + unnormalized := newJSONLSourceSet(AgentCodex, []string{root}, + withLookupIDValid(rejectsRaw), + ) + + _, ok, err = unnormalized.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "raw:session-1"}, + ) + require.NoError(t, err) + assert.False(t, ok, "un-normalized raw session ID must not resolve") +} + +func TestJSONLSourceSetMissingRootAndInvalidLookupAreNoops(t *testing.T) { + root := t.TempDir() + sources := newJSONLSourceSet(AgentCodex, []string{ + filepath.Join(root, "missing"), + }) + + discovered, err := sources.Discover(context.Background()) + require.NoError(t, err) + assert.Empty(t, discovered) + + found, ok, err := sources.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "../session", + }) + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, found) +} + +func writeSourceFile(t *testing.T, path, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +func mustRelSlash(t *testing.T, root, path string) string { + t.Helper() + + rel, err := filepath.Rel(root, path) + require.NoError(t, err) + return filepath.ToSlash(rel) +} + +func sourceKeys(sources []SourceRef) []string { + keys := make([]string, 0, len(sources)) + for _, source := range sources { + keys = append(keys, source.Key) + } + return keys +} + +func sourceProjects(sources []SourceRef) []string { + projects := make([]string, 0, len(sources)) + for _, source := range sources { + projects = append(projects, source.ProjectHint) + } + return projects +} + +func sourceDisplayPaths(sources []SourceRef) []string { + paths := make([]string, 0, len(sources)) + for _, source := range sources { + paths = append(paths, source.DisplayPath) + } + return paths +} diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 7cb2eb6c8..db5842909 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -369,6 +369,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newQClawProviderFactory(def) case AgentQwen: return newQwenProviderFactory(def) + case AgentQwenPaw: + return newQwenPawProviderFactory(def) case AgentWorkBuddy: return newWorkBuddyProviderFactory(def) case AgentZencoder: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 40eae111c..050fcf3f5 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -54,7 +54,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentAntigravityCLI: ProviderMigrationLegacyOnly, AgentVibe: ProviderMigrationLegacyOnly, AgentZed: ProviderMigrationLegacyOnly, - AgentQwenPaw: ProviderMigrationLegacyOnly, + AgentQwenPaw: ProviderMigrationProviderAuthoritative, AgentGptme: ProviderMigrationProviderAuthoritative, AgentShelley: ProviderMigrationLegacyOnly, AgentAider: ProviderMigrationLegacyOnly, diff --git a/internal/parser/provider_shim_scan_test.go b/internal/parser/provider_shim_scan_test.go index 19023bf20..6d369e1ff 100644 --- a/internal/parser/provider_shim_scan_test.go +++ b/internal/parser/provider_shim_scan_test.go @@ -57,7 +57,6 @@ var pendingShimProviderFiles = map[string]bool{ "opencode_provider.go": true, "openhands_provider.go": true, "positron_provider.go": true, - "qwenpaw_provider.go": true, "shelley_provider.go": true, "vibe_provider.go": true, "visualstudio_copilot_provider.go": true, diff --git a/internal/parser/qwenpaw.go b/internal/parser/qwenpaw.go index 1b94aa7e3..c7bce53a8 100644 --- a/internal/parser/qwenpaw.go +++ b/internal/parser/qwenpaw.go @@ -7,190 +7,12 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" "github.com/tidwall/gjson" ) -// DiscoverQwenPawSessions walks //sessions/*.json and -// //sessions/console/*.json. Each QwenPaw runtime -// hosts multiple agent workspaces (e.g. "default", "fund_manager") -// and each workspace persists one JSON file per active session under -// sessions/. Hidden subdirectories (e.g. ".weixin-legacy") and the -// legacy dialog/*.jsonl layout are skipped. -func DiscoverQwenPawSessions(root string) []DiscoveredFile { - if root == "" { - return nil - } - workspaceEntries, err := os.ReadDir(root) - if err != nil { - return nil - } - var files []DiscoveredFile - for _, wsEntry := range workspaceEntries { - if !isDirOrSymlink(wsEntry, root) { - continue - } - workspace := wsEntry.Name() - if !IsValidQwenPawIDPart(workspace) { - continue - } - files = append(files, - discoverQwenPawSessionsDir( - filepath.Join(root, workspace, "sessions"), - workspace, - )..., - ) - } - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// discoverQwenPawSessionsDir collects *.json from a sessions/ root -// and one level of non-hidden subdirectories (e.g. console/). -func discoverQwenPawSessionsDir( - sessionsDir, workspace string, -) []DiscoveredFile { - entries, err := os.ReadDir(sessionsDir) - if err != nil { - return nil - } - var files []DiscoveredFile - for _, entry := range entries { - if entry.IsDir() { - name := entry.Name() - if strings.HasPrefix(name, ".") || !IsValidQwenPawIDPart(name) { - continue - } - subDir := filepath.Join(sessionsDir, name) - files = append(files, - discoverQwenPawSessionsFiles(subDir, workspace)..., - ) - continue - } - stem, ok := strings.CutSuffix(entry.Name(), ".json") - if !ok || !IsValidQwenPawIDPart(stem) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(sessionsDir, entry.Name()), - Project: workspace, - Agent: AgentQwenPaw, - }) - } - return files -} - -// discoverQwenPawSessionsFiles collects *.json from a single -// directory without recursing further. -func discoverQwenPawSessionsFiles( - dir, workspace string, -) []DiscoveredFile { - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - var files []DiscoveredFile - for _, entry := range entries { - if entry.IsDir() { - continue - } - stem, ok := strings.CutSuffix(entry.Name(), ".json") - if !ok || !IsValidQwenPawIDPart(stem) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(dir, entry.Name()), - Project: workspace, - Agent: AgentQwenPaw, - }) - } - return files -} - -// FindQwenPawSourceFile resolves a rawID to a sessions JSON file. -// -// Raw ID shapes: -// -// - qwenpaw:: -> //sessions/.json -// - qwenpaw::: -> //sessions//.json -// -// The subdir segment disambiguates the sessions/console/ layout -// from the sessions/ root so two files with the same stem cannot -// collide. -// -// Returns "" when the rawID is malformed, references a traversal -// component (".", ".."), escapes the resolved sessions directory, -// or the file does not exist. -func FindQwenPawSourceFile(root, rawID string) string { - if root == "" { - return "" - } - workspace, rest, ok := strings.Cut(rawID, ":") - if !ok { - return "" - } - if !IsValidQwenPawIDPart(workspace) { - return "" - } - var candidate string - if subdir, stem, found := strings.Cut(rest, ":"); found { - if !IsValidQwenPawIDPart(subdir) || - !IsValidQwenPawIDPart(stem) { - return "" - } - candidate = filepath.Join( - root, workspace, "sessions", subdir, stem+".json", - ) - } else { - if !IsValidQwenPawIDPart(rest) { - return "" - } - candidate = filepath.Join( - root, workspace, "sessions", rest+".json", - ) - } - if !isUnderQwenPawRoot(root, candidate) { - return "" - } - if _, err := os.Stat(candidate); err == nil { - return candidate - } - return "" -} - -// isUnderQwenPawRoot reports whether candidate resolves to a path -// inside //sessions/. Both sides are cleaned and -// converted to absolute form so that "." / ".." segments in the -// candidate cannot escape the QwenPaw root. -func isUnderQwenPawRoot(root, candidate string) bool { - absRoot, err := filepath.Abs(filepath.Clean(root)) - if err != nil { - return false - } - absCand, err := filepath.Abs(filepath.Clean(candidate)) - if err != nil { - return false - } - rel, err := filepath.Rel(absRoot, absCand) - if err != nil { - return false - } - rel = filepath.ToSlash(rel) - if rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { - return false - } - parts := strings.Split(rel, "/") - if len(parts) < 2 || parts[1] != "sessions" { - return false - } - return true -} - // IsValidQwenPawIDPart accepts workspace names and session file // stems. QwenPaw emits channel-scoped filenames containing dots, // at-signs, and double dashes (e.g. "@im.wechat_wechat--..."), @@ -202,8 +24,8 @@ func isUnderQwenPawRoot(root, candidate string) bool { // part is joined into a session ID: // // - ":" joins ID parts in qwenpawSessionID. A stem "foo:bar" would -// produce qwenpaw::foo:bar, which FindQwenPawSourceFile -// reparses as the sessions/foo/bar.json subdir layout. +// produce qwenpaw::foo:bar, which source lookup reparses +// as the sessions/foo/bar.json subdir layout. // - "~" is the remote-host separator (see StripHostPrefix). A part // containing it would be split off as a bogus host prefix. // - "?", "#", and "%" are URL delimiters. Session IDs are @@ -261,7 +83,7 @@ func qwenpawSessionID(path, project, stem string) (string, error) { return "qwenpaw:" + project + ":" + parent + ":" + stem, nil } -// ParseQwenPawSession parses a QwenPaw sessions/.json file. +// parseSession parses a QwenPaw sessions/.json file. // // The on-disk shape is: // @@ -286,7 +108,7 @@ func qwenpawSessionID(path, project, stem string) (string, error) { // of Anthropic's user-side tool_result). They map to RoleUser + // IsSystem so they remain distinguishable from real user turns // without inflating UserMessageCount. -func ParseQwenPawSession( +func parseQwenPawSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { raw, err := os.ReadFile(path) diff --git a/internal/parser/qwenpaw_provider.go b/internal/parser/qwenpaw_provider.go new file mode 100644 index 000000000..e2be6c4a6 --- /dev/null +++ b/internal/parser/qwenpaw_provider.go @@ -0,0 +1,290 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +// QwenPaw stores each session as a JSON file under +// //sessions/[/].json. It is a +// directory-of-files provider: discovery, watching, change classification, and +// fingerprinting come from JSONLSourceSet. The ParseFile option makes that +// source set a full SourceSet so it rides the generic factory. Its colon-joined +// raw IDs are resolved by reconstruction (RawSessionIDSourceFiles), and a +// DB-recorded path outside the configured roots is honored via +// StoredPathFallbackRoot; ForceReplace mirrors the wholesale-rewrite parse +// outcome. +func newQwenPawProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + qwenPawProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newQwenPawSourceSet(cfg.Roots) }, + ) +} + +func newQwenPawSourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentQwenPaw, roots, + withRecursive(), + withExtensions(".json"), + withContentHashing(), + withSymlinkFollowing(), + withDescendPath(qwenPawDescendPath), + withIncludePath(isQwenPawSourcePath), + withProjectHint(qwenPawProjectHintFromPath), + withSessionIDFromPath(qwenPawSessionIDFromPath), + withRawSessionIDSourceFiles(qwenPawRawSessionIDSourceFiles), + withStoredPathFallbackRoot(qwenPawStoredPathRoot), + withParseFile(qwenPawParseFile), + withForceReplace(), + ) +} + +func qwenPawParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseQwenPawSession(path, req.Source.ProjectHint, req.Machine) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, nil + } + if req.Fingerprint.Hash != "" { + sess.File.Hash = req.Fingerprint.Hash + } + return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil +} + +// qwenPawRawSessionIDSourceFiles reconstructs the sessions JSON path from a +// colon-joined raw ID across each configured root. The filename-stem discovery +// scan cannot match these IDs because they contain colons. +func qwenPawRawSessionIDSourceFiles(roots []string, rawID string) []string { + var candidates []string + for _, root := range roots { + if path := qwenPawSourceFileForRawID(root, rawID); path != "" { + candidates = append(candidates, path) + } + } + return candidates +} + +// qwenPawStoredPathRoot synthesizes the configured root for a stored qwenpaw +// source path that is not under any current root. It locates the implicit +// //sessions/ layout, validates the path is a real qwenpaw +// source shape, and confirms the file still exists so a stale DB row does not +// resolve to a missing file. +func qwenPawStoredPathRoot(storedPath string) (string, bool) { + path := filepath.Clean(storedPath) + root, ok := qwenPawImplicitRoot(path) + if !ok || !isQwenPawSourcePath(root, path) { + return "", false + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return "", false + } + return root, true +} + +// qwenPawImplicitRoot derives the QwenPaw root implied by a source path of the +// form //sessions/[/].json. The root is the +// grandparent of the sessions/ directory. Returns false when the path has no +// sessions/ segment in the expected position. +func qwenPawImplicitRoot(path string) (string, bool) { + // path = //sessions/.json + // or //sessions//.json + sessionsDir := filepath.Dir(path) + if filepath.Base(sessionsDir) != "sessions" { + // Allow one subdir level (e.g. console/). + sessionsDir = filepath.Dir(sessionsDir) + if filepath.Base(sessionsDir) != "sessions" { + return "", false + } + } + workspaceDir := filepath.Dir(sessionsDir) + root := filepath.Dir(workspaceDir) + if root == "" || root == "." { + return "", false + } + return root, true +} + +// qwenPawSourceFileForRawID resolves a rawID to a sessions JSON file under root. +// +// Raw ID shapes: +// +// - : -> //sessions/.json +// - :: -> //sessions//.json +// +// The subdir segment disambiguates the sessions/console/ layout from the +// sessions/ root so two files with the same stem cannot collide. +// +// Returns "" when the rawID is malformed, references a traversal component +// (".", ".."), escapes the resolved sessions directory, or the file does +// not exist. +func qwenPawSourceFileForRawID(root, rawID string) string { + if root == "" { + return "" + } + workspace, rest, ok := strings.Cut(rawID, ":") + if !ok || !IsValidQwenPawIDPart(workspace) { + return "" + } + var candidate string + if subdir, stem, found := strings.Cut(rest, ":"); found { + if !IsValidQwenPawIDPart(subdir) || + !IsValidQwenPawIDPart(stem) { + return "" + } + candidate = filepath.Join( + root, workspace, "sessions", subdir, stem+".json", + ) + } else { + if !IsValidQwenPawIDPart(rest) { + return "" + } + candidate = filepath.Join( + root, workspace, "sessions", rest+".json", + ) + } + if !qwenPawCandidateUnderRoot(root, candidate) { + return "" + } + if _, err := os.Stat(candidate); err == nil { + return candidate + } + return "" +} + +// qwenPawCandidateUnderRoot reports whether candidate resolves to a path +// inside //sessions/. Both sides are cleaned and converted +// to absolute form so that "." / ".." segments in the candidate cannot +// escape the QwenPaw root. +func qwenPawCandidateUnderRoot(root, candidate string) bool { + absRoot, err := filepath.Abs(filepath.Clean(root)) + if err != nil { + return false + } + absCand, err := filepath.Abs(filepath.Clean(candidate)) + if err != nil { + return false + } + rel, err := filepath.Rel(absRoot, absCand) + if err != nil { + return false + } + rel = filepath.ToSlash(rel) + if rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return false + } + parts := strings.Split(rel, "/") + if len(parts) < 2 || parts[1] != "sessions" { + return false + } + return true +} + +func isQwenPawSourcePath(root, path string) bool { + parts, ok := qwenPawSourcePathParts(root, path) + return ok && qwenPawSourcePathPartsValid(parts) +} + +func qwenPawSourcePathPartsValid(parts []string) bool { + if len(parts) < 3 || parts[1] != "sessions" { + return false + } + workspace := parts[0] + stem, ok := strings.CutSuffix(parts[len(parts)-1], ".json") + if !ok || !IsValidQwenPawIDPart(workspace) || + !IsValidQwenPawIDPart(stem) { + return false + } + switch len(parts) { + case 3: + return true + case 4: + subdir := parts[2] + return !strings.HasPrefix(subdir, ".") && + IsValidQwenPawIDPart(subdir) + default: + return false + } +} + +func qwenPawProjectHintFromPath(root, path string) string { + parts, ok := qwenPawSourcePathParts(root, path) + if !ok || len(parts) < 3 { + return "" + } + return parts[0] +} + +func qwenPawSessionIDFromPath(root, path string) string { + parts, ok := qwenPawSourcePathParts(root, path) + if !ok || !qwenPawSourcePathPartsValid(parts) { + return "" + } + stem := strings.TrimSuffix(parts[len(parts)-1], ".json") + if len(parts) == 4 { + return parts[0] + ":" + parts[2] + ":" + stem + } + return parts[0] + ":" + stem +} + +func qwenPawSourcePathParts(root, path string) ([]string, bool) { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return nil, false + } + parts := strings.Split(rel, string(filepath.Separator)) + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return nil, false + } + } + return parts, true +} + +func qwenPawDescendPath(root, path string) bool { + parts, ok := qwenPawSourcePathParts(root, path) + if !ok { + return false + } + switch len(parts) { + case 1: + return IsValidQwenPawIDPart(parts[0]) + case 2: + return IsValidQwenPawIDPart(parts[0]) && parts[1] == "sessions" + case 3: + subdir := parts[2] + if parts[1] != "sessions" || + !IsValidQwenPawIDPart(parts[0]) || + strings.HasPrefix(subdir, ".") || + !IsValidQwenPawIDPart(subdir) { + return false + } + info, err := os.Lstat(path) + if err != nil { + return true + } + return info.Mode()&os.ModeSymlink == 0 + default: + return false + } +} + +func qwenPawProviderCapabilities() Capabilities { + source := jsonlFileProviderSourceCapabilities() + source.ForceReplaceOnParse = CapabilitySupported + return Capabilities{ + Source: source, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + MalformedLineCount: CapabilitySupported, + }, + } +} diff --git a/internal/parser/qwenpaw_provider_test.go b/internal/parser/qwenpaw_provider_test.go new file mode 100644 index 000000000..279989c13 --- /dev/null +++ b/internal/parser/qwenpaw_provider_test.go @@ -0,0 +1,305 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQwenPawProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentQwenPaw) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentQwenPaw, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestQwenPawProviderSourceMethods(t *testing.T) { + root := t.TempDir() + rootPath := qwenPawProviderWriteSession( + t, root, "default", "", "root_1", "root question", + ) + consolePath := qwenPawProviderWriteSession( + t, root, "default", "console", "console_1", "console question", + ) + qwenPawProviderWriteSession( + t, root, "default", ".weixin-legacy", "hidden_1", "hidden", + ) + deepDir := filepath.Join(root, "default", "sessions", "console", "nested") + require.NoError(t, os.MkdirAll(deepDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(deepDir, "deep.json"), + []byte(qwenPawProviderFixture("deep")), + 0o644, + )) + require.NoError(t, os.MkdirAll(filepath.Join(root, "default", "dialog"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(root, "default", "dialog", "legacy.jsonl"), + []byte("{}\n"), + 0o644, + )) + + provider, ok := NewProvider(AgentQwenPaw, 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{"*.json"}, plan.Roots[0].IncludeGlobs) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.ElementsMatch(t, []string{rootPath, consolePath}, []string{ + discovered[0].DisplayPath, + discovered[1].DisplayPath, + }) + for _, source := range discovered { + assert.Equal(t, AgentQwenPaw, source.Provider) + assert.Equal(t, "default", source.ProjectHint) + assert.Equal(t, source.DisplayPath, source.FingerprintKey) + } + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~qwenpaw:default:root_1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, rootPath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "default:console:console_1", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, consolePath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: rootPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, rootPath, found.DisplayPath) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, rootPath, fingerprint.Key) + assert.Positive(t, fingerprint.Size) + assert.Positive(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: rootPath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, rootPath, changed[0].DisplayPath) + + require.NoError(t, os.Remove(consolePath)) + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: consolePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, consolePath, changed[0].DisplayPath) + + changed, err = provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: rootPath, + EventKind: "write", + WatchRoot: filepath.Join(root, "..", "other-root"), + }, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func TestQwenPawProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := qwenPawProviderWriteSession( + t, root, "default", "console", "console_1", "provider question", + ) + provider, ok := NewProvider(AgentQwenPaw, 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.True(t, outcome.ForceReplace) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "qwenpaw:default:console:console_1", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "default", 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) +} + +// TestQwenPawProviderFindSourceResolvesStoredPathOutsideRoots locks in the +// single-session resync parity: when the DB-stored file_path points outside +// any configured QWENPAW_DIR, FindSource must still resolve it from the path's +// implicit //sessions/ layout and recover the workspace as +// ProjectHint, so a reparse keeps the canonical qwenpaw:: ID +// instead of orphaning it under an empty workspace. +func TestQwenPawProviderFindSourceResolvesStoredPathOutsideRoots(t *testing.T) { + storedRoot := t.TempDir() + storedPath := qwenPawProviderWriteSession( + t, storedRoot, "my_ws", "", "default_1", "outside question", + ) + + // Provider is configured with an unrelated root, so the in-root lookup + // cannot match the stored path. + provider, ok := NewProvider(AgentQwenPaw, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: storedPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, storedPath, found.DisplayPath) + assert.Equal(t, "my_ws", found.ProjectHint) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: found, + Machine: "devbox", + }) + require.NoError(t, err) + require.Len(t, outcome.Results, 1) + assert.Equal(t, "qwenpaw:my_ws:default_1", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "my_ws", outcome.Results[0].Result.Session.Project) + + // A stored path that is not a valid qwenpaw source shape stays unresolved. + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: filepath.Join(storedRoot, "loose.json"), + }) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestQwenPawProviderDiscoversSymlinkedWorkspace(t *testing.T) { + root := t.TempDir() + targetRoot := t.TempDir() + qwenPawProviderWriteSession( + t, targetRoot, "default", "", "root_1", "from symlink", + ) + sourceWorkspace := filepath.Join(root, "default") + targetWorkspace := filepath.Join(targetRoot, "default") + sourcePath := filepath.Join(sourceWorkspace, "sessions", "root_1.json") + if err := os.Symlink(targetWorkspace, sourceWorkspace); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentQwenPaw, 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) + assert.Equal(t, "default", discovered[0].ProjectHint) +} + +func TestQwenPawProviderPrunesSymlinkedSessionNamespaces(t *testing.T) { + root := t.TempDir() + sourcePath := qwenPawProviderWriteSession( + t, root, "default", "", "root_1", "root question", + ) + targetDir := filepath.Join(t.TempDir(), "console-target") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(targetDir, "linked_1.json"), + []byte(qwenPawProviderFixture("linked question")), + 0o644, + )) + linkedDir := filepath.Join(root, "default", "sessions", "linked") + if err := os.Symlink(targetDir, linkedDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + linkedPath := filepath.Join(linkedDir, "linked_1.json") + + provider, ok := NewProvider(AgentQwenPaw, 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) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: linkedPath, + EventKind: "write", + WatchRoot: root, + }, + ) + require.NoError(t, err) + assert.Empty(t, changed) +} + +func qwenPawProviderWriteSession( + t *testing.T, + root string, + workspace string, + subdir string, + stem string, + firstMessage string, +) string { + t.Helper() + parts := []string{root, workspace, "sessions"} + if subdir != "" { + parts = append(parts, subdir) + } + dir := filepath.Join(parts...) + require.NoError(t, os.MkdirAll(dir, 0o755)) + path := filepath.Join(dir, stem+".json") + require.NoError(t, os.WriteFile( + path, + []byte(qwenPawProviderFixture(firstMessage)), + 0o644, + )) + return path +} + +func qwenPawProviderFixture(firstMessage string) string { + return `{"agent":{"memory":{"content":[` + + `[{"id":"u1","name":"user","role":"user","content":[{"type":"text","text":"` + firstMessage + `"}],"metadata":{},"timestamp":"2026-04-19 22:37:34.004"},[]],` + + `[{"id":"a1","name":"Friday","role":"assistant","content":[{"type":"text","text":"Done."}],"metadata":{},"timestamp":"2026-04-19 22:37:35.123"},[]]` + + `]}}}` +} diff --git a/internal/parser/qwenpaw_test.go b/internal/parser/qwenpaw_test.go index 400a5785c..f0f6b3c8e 100644 --- a/internal/parser/qwenpaw_test.go +++ b/internal/parser/qwenpaw_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "runtime" @@ -12,6 +13,68 @@ import ( "github.com/stretchr/testify/require" ) +// newQwenPawTestProvider builds a concrete qwenPawProvider for the given +// roots so package tests can exercise the folded parse, discovery, and +// source-lookup behavior directly through provider methods. +func newQwenPawTestProvider(t *testing.T, roots ...string) Provider { + t.Helper() + provider, ok := NewProvider(AgentQwenPaw, ProviderConfig{ + Roots: roots, + Machine: "local", + }) + require.True(t, ok) + return provider +} + +// parseQwenPawTestSession parses a QwenPaw session JSON file at path with +// the given workspace project, replacing the removed package-level +// ParseQwenPawSession entrypoint with the provider-owned parse method. +func parseQwenPawTestSession( + t *testing.T, path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return parseQwenPawSession(path, project, machine) +} + +// discoverQwenPawTestSessions discovers QwenPaw sessions under root through +// the provider, returning the legacy DiscoveredFile shape (path + project) +// the tests assert against. +func discoverQwenPawTestSessions( + t *testing.T, root string, +) []DiscoveredFile { + t.Helper() + if root == "" { + return nil + } + provider := newQwenPawTestProvider(t, root) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + if len(sources) == 0 { + return nil + } + files := make([]DiscoveredFile, 0, len(sources)) + for _, source := range sources { + files = append(files, DiscoveredFile{ + Path: source.DisplayPath, + Project: source.ProjectHint, + Agent: AgentQwenPaw, + }) + } + return files +} + +// findQwenPawTestSourceFile resolves a raw QwenPaw ID to a session file +// through the provider, replacing the removed FindQwenPawSourceFile. +func findQwenPawTestSourceFile( + t *testing.T, root, rawID string, +) string { + t.Helper() + if root == "" { + return "" + } + return qwenPawSourceFileForRawID(root, rawID) +} + // skipColonFilenamesOnWindows skips tests that must create files or // directories whose names contain ":". That character is illegal in // Windows filenames (reserved for NTFS alternate data streams), so the @@ -90,7 +153,7 @@ func TestParseQwenPawSession_BasicUserAssistant(t *testing.T) { `{"id":"abc1","name":"Friday","role":"assistant","content":[{"type":"text","text":"你好,有什么可以帮你的?"}],"metadata":{},"timestamp":"2026-04-19 22:37:35.123"}`, }, ) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) @@ -124,7 +187,7 @@ func TestParseQwenPawSession_ThinkingExtracted(t *testing.T) { `{"id":"a1","name":"Friday","role":"assistant","content":[{"type":"thinking","thinking":"我应该先思考一下"},{"type":"text","text":"答案"}],"metadata":{},"timestamp":"2026-04-19 22:37:35.000"}`, }, ) - _, msgs, err := ParseQwenPawSession(path, "default", "local") + _, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.Equal(t, 2, len(msgs)) @@ -142,7 +205,7 @@ func TestParseQwenPawSession_ToolUseAndResult(t *testing.T) { `{"id":"a2","name":"Friday","role":"assistant","content":[{"type":"text","text":"文件内容是 file contents here"}],"metadata":{},"timestamp":"2026-04-19 22:37:37.000"}`, }, ) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) require.Equal(t, 4, len(msgs)) @@ -171,7 +234,7 @@ func TestParseQwenPawSession_MultipleToolUsesInOneMessage(t *testing.T) { `{"id":"a1","name":"Friday","role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"read_file","input":{"file_path":"a"}},{"type":"tool_use","id":"call_2","name":"read_file","input":{"file_path":"b"}}],"metadata":{},"timestamp":"2026-04-19 22:37:35.000"}`, }, ) - _, msgs, err := ParseQwenPawSession(path, "default", "local") + _, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.Equal(t, 1, len(msgs)) require.Equal(t, 2, len(msgs[0].ToolCalls)) @@ -185,7 +248,7 @@ func TestParseQwenPawSession_EmptyContentArray(t *testing.T) { `{"id":"a1","name":"Friday","role":"assistant","content":[],"metadata":{},"timestamp":"2026-04-19 22:37:35.000"}`, }, ) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) assertMessageCount(t, sess.MessageCount, 1) @@ -199,7 +262,7 @@ func TestParseQwenPawSession_MissingTimestamp(t *testing.T) { `{"id":"u1","name":"user","role":"user","content":[{"type":"text","text":"hi"}],"metadata":{}}`, }, ) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) assertZeroTimestamp(t, sess.StartedAt, "StartedAt") @@ -209,7 +272,7 @@ func TestParseQwenPawSession_MissingTimestamp(t *testing.T) { } func TestParseQwenPawSession_NonexistentFile(t *testing.T) { - _, _, err := ParseQwenPawSession( + _, _, err := parseQwenPawTestSession(t, "/nonexistent/default/sessions/foo.json", "default", "local", ) @@ -224,7 +287,7 @@ func TestParseQwenPawSession_FileMtimeIsNanoseconds(t *testing.T) { ) info, err := os.Stat(path) require.NoError(t, err) - sess, _, err := ParseQwenPawSession(path, "default", "local") + sess, _, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, info.ModTime().UnixNano(), sess.File.Mtime, @@ -238,7 +301,7 @@ func TestParseQwenPawSession_SystemTextMessage(t *testing.T) { `{"id":"s1","name":"system","role":"system","content":[{"type":"text","text":"system notice"}],"metadata":{},"timestamp":"2026-04-19 22:37:35.000"}`, }, ) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) require.Equal(t, 2, len(msgs)) @@ -255,7 +318,7 @@ func TestParseQwenPawSession_MalformedJsonReturnsError(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("{not valid json"), 0o644)) - _, _, err := ParseQwenPawSession(path, "default", "local") + _, _, err := parseQwenPawTestSession(t, path, "default", "local") require.Error(t, err) assert.Contains(t, err.Error(), "malformed JSON", "error must come from the ValidBytes guard, not content-missing") @@ -272,7 +335,7 @@ func TestParseQwenPawSession_RejectsColonInIDParts(t *testing.T) { path := filepath.Join(dir, "ok.json") require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - _, _, err := ParseQwenPawSession(path, "ws:bad", "local") + _, _, err := parseQwenPawTestSession(t, path, "ws:bad", "local") require.Error(t, err) assert.Contains(t, err.Error(), "invalid workspace") }) @@ -285,7 +348,7 @@ func TestParseQwenPawSession_RejectsColonInIDParts(t *testing.T) { path := filepath.Join(dir, "ok.json") require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - _, _, err := ParseQwenPawSession(path, "default", "local") + _, _, err := parseQwenPawTestSession(t, path, "default", "local") require.Error(t, err) assert.Contains(t, err.Error(), "invalid subdir") }) @@ -298,7 +361,7 @@ func TestParseQwenPawSession_EmptyContentArrayTopLevel(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte(`{"agent":{"memory":{"content":[]}}}`), 0o644)) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) assertMessageCount(t, sess.MessageCount, 0) @@ -316,7 +379,7 @@ func TestParseQwenPawSession_SkipsNonMessageEntries(t *testing.T) { path := filepath.Join(dir, "mixed.json") require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) - sess, msgs, err := ParseQwenPawSession(path, "default", "local") + sess, msgs, err := parseQwenPawTestSession(t, path, "default", "local") require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, 1, sess.MalformedLines) @@ -341,7 +404,7 @@ func TestDiscoverQwenPawSessions(t *testing.T) { []byte(`{"agent":{"memory":{"content":[]}}}`), 0o644, )) - files := DiscoverQwenPawSessions(root) + files := discoverQwenPawTestSessions(t, root) require.Equal(t, 3, len(files)) for _, f := range files { assert.Equal(t, AgentQwenPaw, f.Agent) @@ -363,7 +426,7 @@ func TestDiscoverQwenPawSessions_IncludesConsoleSubdir(t *testing.T) { []byte(`{"agent":{"memory":{"content":[]}}}`), 0o644, )) - files := DiscoverQwenPawSessions(root) + files := discoverQwenPawTestSessions(t, root) require.Equal(t, 1, len(files)) assert.Equal(t, "default", files[0].Project) } @@ -377,7 +440,7 @@ func TestDiscoverQwenPawSessions_SkipsHiddenSubdirs(t *testing.T) { []byte(`{"agent":{"memory":{"content":[]}}}`), 0o644, )) - files := DiscoverQwenPawSessions(root) + files := discoverQwenPawTestSessions(t, root) assert.Nil(t, files) } @@ -404,13 +467,13 @@ func TestDiscoverQwenPawSessions_FiltersNonSessionFiles(t *testing.T) { []byte("{}\n"), 0o644, )) - files := DiscoverQwenPawSessions(root) + files := discoverQwenPawTestSessions(t, root) require.Equal(t, 1, len(files)) } func TestDiscoverQwenPawSessions_EmptyAndMissing(t *testing.T) { - assert.Nil(t, DiscoverQwenPawSessions("")) - assert.Nil(t, DiscoverQwenPawSessions("/nonexistent")) + assert.Nil(t, discoverQwenPawTestSessions(t, "")) + assert.Nil(t, discoverQwenPawTestSessions(t, "/nonexistent")) } func TestDiscoverQwenPawSessions_SkipsFilesAtRoot(t *testing.T) { @@ -419,7 +482,7 @@ func TestDiscoverQwenPawSessions_SkipsFilesAtRoot(t *testing.T) { filepath.Join(root, "loose.json"), []byte("{}\n"), 0o644, )) - files := DiscoverQwenPawSessions(root) + files := discoverQwenPawTestSessions(t, root) assert.Nil(t, files) } @@ -448,11 +511,11 @@ func TestDiscoverQwenPawSessions_RejectsColliding(t *testing.T) { require.NoError(t, os.WriteFile( filepath.Join(colonSubDir, "ok.json"), content, 0o644)) - files := DiscoverQwenPawSessions(root) + files := discoverQwenPawTestSessions(t, root) require.Len(t, files, 1) assert.Equal(t, subFile, files[0].Path) - sess, _, err := ParseQwenPawSession( + sess, _, err := parseQwenPawTestSession(t, files[0].Path, files[0].Project, "local") require.NoError(t, err) assert.Equal(t, "qwenpaw:default:foo:bar", sess.ID) @@ -467,17 +530,17 @@ func TestFindQwenPawSourceFile(t *testing.T) { []byte(`{"agent":{"memory":{"content":[]}}}`), 0o644)) assert.Equal(t, path, - FindQwenPawSourceFile(root, "default:default_1776607601691")) + findQwenPawTestSourceFile(t, root, "default:default_1776607601691")) assert.Equal(t, "", - FindQwenPawSourceFile(root, "default:does_not_exist")) + findQwenPawTestSourceFile(t, root, "default:does_not_exist")) assert.Equal(t, "", - FindQwenPawSourceFile(root, "ghost:default_1")) + findQwenPawTestSourceFile(t, root, "ghost:default_1")) assert.Equal(t, "", - FindQwenPawSourceFile(root, "invalid")) + findQwenPawTestSourceFile(t, root, "invalid")) assert.Equal(t, "", - FindQwenPawSourceFile(root, "bad/workspace:default_1")) + findQwenPawTestSourceFile(t, root, "bad/workspace:default_1")) assert.Equal(t, "", - FindQwenPawSourceFile("", "default:default_1")) + findQwenPawTestSourceFile(t, "", "default:default_1")) } func TestFindQwenPawSourceFile_ConsoleSubdir(t *testing.T) { @@ -490,7 +553,7 @@ func TestFindQwenPawSourceFile_ConsoleSubdir(t *testing.T) { // Subdir is encoded in the raw ID so the lookup is unambiguous. assert.Equal(t, path, - FindQwenPawSourceFile(root, + findQwenPawTestSourceFile(t, root, "default:console:default_1781268804068")) } @@ -513,9 +576,9 @@ func TestParseQwenPawSession_IDsDifferBySubdir(t *testing.T) { `,[]]]}}}`), 0o644)) } - rootSess, _, err := ParseQwenPawSession(rootPath, "default", "local") + rootSess, _, err := parseQwenPawTestSession(t, rootPath, "default", "local") require.NoError(t, err) - consoleSess, _, err := ParseQwenPawSession(consolePath, "default", "local") + consoleSess, _, err := parseQwenPawTestSession(t, consolePath, "default", "local") require.NoError(t, err) assert.Equal(t, "qwenpaw:default:foo", rootSess.ID, @@ -539,9 +602,9 @@ func TestFindQwenPawSourceFile_RootAndConsoleResolveIndependently(t *testing.T) // Each lookup returns the unique file that matches the encoded // layout — no precedence ambiguity, no silent overwrite. assert.Equal(t, rootPath, - FindQwenPawSourceFile(root, "default:same")) + findQwenPawTestSourceFile(t, root, "default:same")) assert.Equal(t, consolePath, - FindQwenPawSourceFile(root, "default:console:same")) + findQwenPawTestSourceFile(t, root, "default:console:same")) } func TestIsValidQwenPawIDPart(t *testing.T) { @@ -576,7 +639,7 @@ func TestFindQwenPawSourceFile_AcceptsWeirdFilenames(t *testing.T) { []byte(`{"agent":{"memory":{"content":[]}}}`), 0o644)) assert.Equal(t, path, - FindQwenPawSourceFile(root, "default:"+weird)) + findQwenPawTestSourceFile(t, root, "default:"+weird)) } func TestFindQwenPawSourceFile_RejectsTraversal(t *testing.T) { @@ -596,7 +659,7 @@ func TestFindQwenPawSourceFile_RejectsTraversal(t *testing.T) { ".:default_1", "default:.", } { - assert.Equal(t, "", FindQwenPawSourceFile(root, rawID), + assert.Equal(t, "", findQwenPawTestSourceFile(t, root, rawID), "rawID %q must not resolve", rawID) } for _, bad := range []string{".", ".."} { @@ -624,7 +687,7 @@ func TestFindQwenPawSourceFile_RejectsColonInStem(t *testing.T) { // The shared raw ID resolves to the subdir file, not the rejected // root-level foo:bar.json. assert.Equal(t, subFile, - FindQwenPawSourceFile(root, "default:foo:bar")) + findQwenPawTestSourceFile(t, root, "default:foo:bar")) } // TestQwenPawFixtures exercises the checked-in testdata/qwenpaw tree @@ -632,14 +695,14 @@ func TestFindQwenPawSourceFile_RejectsColonInStem(t *testing.T) { // produce its expected canonical ID. This guards against malformed or // drifting fixtures that would otherwise pass CI unnoticed. func TestQwenPawFixtures(t *testing.T) { - files := DiscoverQwenPawSessions(filepath.Join("testdata", "qwenpaw")) + files := discoverQwenPawTestSessions(t, filepath.Join("testdata", "qwenpaw")) require.Len(t, files, 5) sessByID := make(map[string]*ParsedSession, len(files)) msgsByID := make(map[string][]ParsedMessage, len(files)) for _, f := range files { assert.Equal(t, AgentQwenPaw, f.Agent) - sess, msgs, err := ParseQwenPawSession(f.Path, f.Project, "local") + sess, msgs, err := parseQwenPawTestSession(t, f.Path, f.Project, "local") require.NoErrorf(t, err, "parse %s", f.Path) require.NotNil(t, sess) sessByID[sess.ID] = sess diff --git a/internal/parser/types.go b/internal/parser/types.go index 96182fa1a..90cf2a81c 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -549,15 +549,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindAntigravityCLISourceFile, }, { - Type: AgentQwenPaw, - DisplayName: "QwenPaw", - EnvVar: "QWENPAW_DIR", - ConfigKey: "qwenpaw_dirs", - DefaultDirs: []string{".copaw/workspaces"}, - IDPrefix: "qwenpaw:", - FileBased: true, - DiscoverFunc: DiscoverQwenPawSessions, - FindSourceFunc: FindQwenPawSourceFile, + Type: AgentQwenPaw, + DisplayName: "QwenPaw", + EnvVar: "QWENPAW_DIR", + ConfigKey: "qwenpaw_dirs", + DefaultDirs: []string{".copaw/workspaces"}, + IDPrefix: "qwenpaw:", + FileBased: true, }, { Type: AgentGptme, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 6540fbb6a..3326e5544 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1188,44 +1188,6 @@ func (e *Engine) classifyOnePath( } } - // QwenPaw: //sessions/.json - // or //sessions//.json - for _, qwenpawDir := range e.agentDirs[parser.AgentQwenPaw] { - if qwenpawDir == "" { - continue - } - if rel, ok := isUnder(qwenpawDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) < 3 || parts[1] != "sessions" { - continue - } - if !parser.IsValidQwenPawIDPart(parts[0]) { - continue - } - var stem string - switch { - case len(parts) == 3: - stem = parts[2] - case len(parts) == 4 && !strings.HasPrefix(parts[2], "."): - if !parser.IsValidQwenPawIDPart(parts[2]) { - continue - } - stem = parts[3] - default: - continue - } - sessionID, ok := strings.CutSuffix(stem, ".json") - if !ok || !parser.IsValidQwenPawIDPart(sessionID) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parts[0], - Agent: parser.AgentQwenPaw, - }, true - } - } - // VSCode Copilot: /workspaceStorage//chatSessions/.{json,jsonl} // or: /globalStorage/emptyWindowChatSessions/.{json,jsonl} for _, vscDir := range e.agentDirs[parser.AgentVSCodeCopilot] { @@ -4364,8 +4326,6 @@ func (e *Engine) processFile( res = e.processAntigravity(file, info) case parser.AgentAntigravityCLI: res = e.processAntigravityCLI(file, info) - case parser.AgentQwenPaw: - res = e.processQwenPaw(file, info) case parser.AgentAider: res = e.processAider(file, info) default: @@ -5985,43 +5945,6 @@ func (e *Engine) processVisualStudioCopilot( } } -func (e *Engine) processQwenPaw( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseQwenPawSession( - 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 - } - - // forceReplace: QwenPaw's _atomic_write_json rewrites the entire - // sessions/.json on every save, and ParseQwenPawSession - // assigns Ordinal by position in agent.memory.content. If that - // array is compacted, summarized, or reordered — common in - // agent-memory frameworks — ordinals shift, and the append-only - // writeMessages path would silently keep stale rows. Treat every - // re-parse as a full rewrite, matching OpenCode / Antigravity. - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - forceReplace: true, - } -} - func (e *Engine) processZed( file parser.DiscoveredFile, info os.FileInfo, ) processResult { @@ -9127,7 +9050,6 @@ func (e *Engine) SyncSingleSessionContext( // Fallback when the stored file_path points outside any // currently configured QWENPAW_DIR (e.g. the root was // removed, or the session was synced from a custom path). - // Without this, ParseQwenPawSession would build // "qwenpaw::" and orphan the requested // "qwenpaw::" row. Prefer the DB-stored // Project as the authoritative record; parse the workspace diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index 52ffe1a1f..f92ba9e6c 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -1106,14 +1106,10 @@ func TestWriteBatchQwenPawReplacesMessages(t *testing.T) { // case where a QwenPaw session's stored DB file_path points outside // any currently configured QWENPAW_DIR (e.g. the root was removed or // the session was synced from a custom path). FindSourceFile still -// returns the stored path, but the workspace derivation loop in -// SyncSingleSessionContext finds no matching configured root, leaves -// file.Project empty, and ParseQwenPawSession then emits a brand-new -// qwenpaw:: session — orphaning the requested -// qwenpaw:: row. -// -// The fix falls back to the DB-stored Project (consistent with the -// Claude / Iflow / Hermes resync paths). +// returns the stored path, and the provider must resolve the workspace +// from that path's implicit //sessions/ layout rather +// than emitting a brand-new qwenpaw:: session that orphans the +// requested qwenpaw:: row. func TestSyncSingleSession_QwenPawPreservesWorkspaceFromDB(t *testing.T) { database := openTestDB(t)