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)