diff --git a/internal/parser/provider.go b/internal/parser/provider.go index ed3975317..f8413a11e 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -359,6 +359,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newGptmeProviderFactory(def) case AgentOMP, AgentPi: return newPiProviderFactory(def) + case AgentQwen: + return newQwenProviderFactory(def) case AgentZencoder: return newZencoderProviderFactory(def) default: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index eb7ea76d8..27356b66e 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -33,7 +33,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentVSCodeCopilot: ProviderMigrationLegacyOnly, AgentVSCopilot: ProviderMigrationLegacyOnly, AgentPi: ProviderMigrationProviderAuthoritative, - AgentQwen: ProviderMigrationLegacyOnly, + AgentQwen: ProviderMigrationProviderAuthoritative, AgentCommandCode: ProviderMigrationProviderAuthoritative, AgentDeepSeekTUI: ProviderMigrationProviderAuthoritative, AgentOpenClaw: ProviderMigrationLegacyOnly, diff --git a/internal/parser/qwen.go b/internal/parser/qwen.go index 46f39bcc6..5fac715e0 100644 --- a/internal/parser/qwen.go +++ b/internal/parser/qwen.go @@ -5,85 +5,13 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" "github.com/tidwall/gjson" ) -// DiscoverQwenSessions finds Qwen Code chat transcripts under the -// projects root. The directory structure is: -// //chats/.jsonl -func DiscoverQwenSessions(projectsDir string) []DiscoveredFile { - if projectsDir == "" { - return nil - } - - projectEntries, err := os.ReadDir(projectsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range projectEntries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - projectDir := filepath.Join(projectsDir, entry.Name()) - chatsDir := filepath.Join(projectDir, "chats") - chatEntries, err := os.ReadDir(chatsDir) - if err != nil { - continue - } - - project := GetProjectName(entry.Name()) - for _, chat := range chatEntries { - if chat.IsDir() || !strings.HasSuffix(chat.Name(), ".jsonl") { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(chatsDir, chat.Name()), - Project: project, - Agent: AgentQwen, - }) - } - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindQwenSourceFile locates a Qwen session file by its raw session -// ID (without the "qwen:" prefix). -func FindQwenSourceFile(projectsDir, rawID string) string { - if projectsDir == "" || !IsValidSessionID(rawID) { - return "" - } - - projectEntries, err := os.ReadDir(projectsDir) - if err != nil { - return "" - } - for _, entry := range projectEntries { - if !isDirOrSymlink(entry, projectsDir) { - continue - } - - candidate := filepath.Join( - projectsDir, entry.Name(), "chats", rawID+".jsonl", - ) - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "" -} - -// ParseQwenSession parses a Qwen Code JSONL chat transcript. +// parseSession parses a Qwen Code JSONL chat transcript. // // Qwen emits one `type=assistant` line per model output, including // every tool-call iteration in a multi-step turn. Each iteration's @@ -96,7 +24,7 @@ func FindQwenSourceFile(projectsDir, rawID string) string { // aggregating their thinking text and token usage. A trailing run of // tool-call-only entries with no text follow-up is emitted as a single // coalesced assistant message so the data isn't lost. -func ParseQwenSession( +func parseQwenSession( path, project, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/qwen_provider.go b/internal/parser/qwen_provider.go new file mode 100644 index 000000000..00d06714f --- /dev/null +++ b/internal/parser/qwen_provider.go @@ -0,0 +1,94 @@ +package parser + +import ( + "context" + "path/filepath" + "strings" +) + +// Qwen stores each chat as a JSONL transcript under a per-project +// directory. It is a directory-of-files provider: discovery, watching, +// change classification, lookup, and fingerprinting come from +// JSONLSourceSet, and the ParseFile option makes that source set a full +// SourceSet so it rides the generic factory. +func newQwenProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + qwenProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newQwenSourceSet(cfg.Roots) }, + ) +} + +func newQwenSourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentQwen, roots, + withRecursive(), + withSymlinkFollowing(), + withIncludePath(isQwenSourcePath), + withProjectHint(qwenProjectHintFromPath), + withSessionIDFromPath(qwenSessionIDFromPath), + withParseFile(qwenParseFile), + ) +} + +func qwenParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseQwenSession(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 +} + +func isQwenSourcePath(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + parts := strings.Split(rel, string(filepath.Separator)) + return len(parts) == 3 && + parts[0] != "" && parts[0] != "." && parts[0] != ".." && + parts[1] == "chats" && + parts[2] != "" && parts[2] != "." && parts[2] != ".." && + strings.HasSuffix(parts[2], ".jsonl") +} + +func qwenProjectHintFromPath(root, path string) string { + rel, err := filepath.Rel(root, path) + if err != nil { + return "" + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 3 { + return "" + } + return GetProjectName(parts[0]) +} + +func qwenSessionIDFromPath(root, path string) string { + if !isQwenSourcePath(root, path) { + return "" + } + return strings.TrimSuffix(filepath.Base(path), ".jsonl") +} + +func qwenProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Cwd: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + Model: CapabilitySupported, + }, + } +} diff --git a/internal/parser/qwen_provider_test.go b/internal/parser/qwen_provider_test.go new file mode 100644 index 000000000..0e9608eb4 --- /dev/null +++ b/internal/parser/qwen_provider_test.go @@ -0,0 +1,150 @@ +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQwenProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentQwen) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentQwen, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestQwenProviderSourceMethods(t *testing.T) { + root := t.TempDir() + projectDir := filepath.Join(root, "-Users-alice-code-sample-project") + sourcePath := filepath.Join(projectDir, "chats", "session-123.jsonl") + nonIDPath := filepath.Join(projectDir, "chats", "2025.01.01.jsonl") + writeSourceFile(t, sourcePath, qwenProviderFixture("session-123")) + writeSourceFile(t, nonIDPath, qwenProviderFixture("header-session-id")) + writeSourceFile(t, filepath.Join(projectDir, "notes", "skip.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "root-session.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(projectDir, "chats", "nested", "deep.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentQwen, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 2) + assert.Equal(t, []string{nonIDPath, sourcePath}, sourceDisplayPaths(discovered)) + assert.Equal(t, []string{"sample_project", "sample_project"}, sourceProjects(discovered)) + + plan, err := provider.WatchPlan(context.Background()) + require.NoError(t, err) + require.Len(t, plan.Roots, 1) + assert.Equal(t, root, plan.Roots[0].Path) + assert.True(t, plan.Roots[0].Recursive) + assert.Equal(t, []string{"*.jsonl"}, plan.Roots[0].IncludeGlobs) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~qwen:session-123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "2025.01.01", + }) + require.NoError(t, err) + assert.False(t, ok) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: nonIDPath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, nonIDPath, found.DisplayPath) + + require.NoError(t, os.Remove(sourcePath)) + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "remove", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) +} + +func TestQwenProviderDiscoversSymlinkedProjectDirectory(t *testing.T) { + root := t.TempDir() + targetDir := t.TempDir() + sourcePath := filepath.Join(root, "-Users-alice-code-sample-project", "chats", "session-123.jsonl") + targetPath := filepath.Join(targetDir, "chats", "session-123.jsonl") + writeSourceFile(t, targetPath, qwenProviderFixture("session-123")) + if err := os.Symlink(targetDir, filepath.Join(root, "-Users-alice-code-sample-project")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentQwen, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~qwen:session-123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) +} + +func TestQwenProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "-Users-alice-code-sample-project", "chats", "session-123.jsonl") + writeSourceFile(t, sourcePath, qwenProviderFixture("session-123")) + + provider, ok := NewProvider(AgentQwen, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"}, + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + require.Len(t, outcome.Results, 1) + assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion) + assert.Equal(t, "qwen:session-123", outcome.Results[0].Result.Session.ID) + assert.Equal(t, "sample_project", outcome.Results[0].Result.Session.Project) + assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine) + assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func qwenProviderFixture(sessionID string) string { + return strings.Join([]string{ + `{"uuid":"u1","sessionId":"` + sessionID + `","timestamp":"2026-05-05T11:08:38.572Z","type":"user","cwd":"/Users/alice/code/sample-project","message":{"role":"user","parts":[{"text":"Calculate .089 * 7.85788"}]}}`, + `{"uuid":"u2","sessionId":"` + sessionID + `","timestamp":"2026-05-05T11:08:46.529Z","type":"assistant","cwd":"/Users/alice/code/sample-project","model":"qwen","message":{"role":"model","parts":[{"text":"The user wants multiplication.","thought":true},{"text":"0.089 times 7.85788 is 0.69935132"}]},"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":5}}`, + }, "\n") +} diff --git a/internal/parser/qwen_test.go b/internal/parser/qwen_test.go index 5526bf79c..21cab122f 100644 --- a/internal/parser/qwen_test.go +++ b/internal/parser/qwen_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "testing" @@ -10,6 +11,47 @@ import ( "github.com/tidwall/gjson" ) +func parseQwenTestSession( + t testing.TB, + path, project, machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + return parseQwenSession(path, project, machine) +} + +func discoverQwenTestSessions(t testing.TB, root string) []DiscoveredFile { + t.Helper() + provider, ok := NewProvider(AgentQwen, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + + files := make([]DiscoveredFile, 0, len(sources)) + for _, source := range sources { + files = append(files, DiscoveredFile{ + Path: source.DisplayPath, + Project: source.ProjectHint, + Agent: source.Provider, + }) + } + return files +} + +func findQwenTestSourceFile(t testing.TB, root, rawID string) string { + t.Helper() + provider, ok := NewProvider(AgentQwen, ProviderConfig{Roots: []string{root}}) + require.True(t, ok) + source, found, err := provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: rawID}, + ) + require.NoError(t, err) + if !found { + return "" + } + return source.DisplayPath +} + func TestParseQwenSession(t *testing.T) { t.Parallel() @@ -19,7 +61,7 @@ func TestParseQwenSession(t *testing.T) { path := createTestFile(t, "qwen-session.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.NotNil(t, sess) require.Len(t, msgs, 2) @@ -74,7 +116,7 @@ func TestParseQwenSession_CoalescesToolCallOnlyAssistants(t *testing.T) { path := createTestFile(t, "coalesce.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.Len(t, msgs, 2, "expected user + single coalesced assistant turn") require.Equal(t, 2, sess.MessageCount) @@ -141,7 +183,7 @@ func TestParseQwenSession_TrailingToolCallOnlyAssistants(t *testing.T) { path := createTestFile(t, "trail.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.Len(t, msgs, 2, "trailing tool-call run should coalesce into one assistant message") require.Equal(t, 2, sess.MessageCount) @@ -188,7 +230,7 @@ func TestParseQwenSession_ToolUseRoundTrip(t *testing.T) { path := createTestFile(t, "tools.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.Len(t, msgs, 2, "tool-result user entry should fold into the assistant turn") @@ -235,7 +277,7 @@ func TestParseQwenSession_TextWithFunctionCallCoalesces(t *testing.T) { path := createTestFile(t, "interleaved.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.Len(t, msgs, 2, "interleaved text+functionCall must not inflate MessageCount") @@ -269,7 +311,7 @@ func TestParseQwenSession_AbortedNoAssistantResponse(t *testing.T) { path := createTestFile(t, "abort.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.Len(t, msgs, 1) assert.Equal(t, 1, sess.MessageCount) @@ -291,7 +333,7 @@ func TestParseQwenSession_ShortClean(t *testing.T) { path := createTestFile(t, "short.jsonl", content) - sess, msgs, err := ParseQwenSession(path, "", "local") + sess, msgs, err := parseQwenTestSession(t, path, "", "local") require.NoError(t, err) require.Len(t, msgs, 2) assert.Equal(t, 2, sess.MessageCount) @@ -300,7 +342,7 @@ func TestParseQwenSession_ShortClean(t *testing.T) { assert.False(t, msgs[1].HasThinking) } -func TestDiscoverQwenSessions(t *testing.T) { +func TestQwenProviderDiscoverSessions(t *testing.T) { t.Parallel() root := t.TempDir() @@ -311,7 +353,7 @@ func TestDiscoverQwenSessions(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(chatsDir, "b.jsonl"), []byte("{}\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(chatsDir, "notes.txt"), []byte("ignore"), 0o644)) - files := DiscoverQwenSessions(root) + files := discoverQwenTestSessions(t, root) require.Len(t, files, 2) assert.Equal(t, AgentQwen, files[0].Agent) assert.Equal(t, "qwen", files[0].Project) @@ -319,7 +361,7 @@ func TestDiscoverQwenSessions(t *testing.T) { assert.Equal(t, "qwen", files[1].Project) } -func TestFindQwenSourceFile(t *testing.T) { +func TestQwenProviderFindSourceFile(t *testing.T) { t.Parallel() root := t.TempDir() @@ -331,8 +373,8 @@ func TestFindQwenSourceFile(t *testing.T) { want := filepath.Join(chatsDir, sessionID+".jsonl") require.NoError(t, os.WriteFile(want, []byte("{}\n"), 0o644)) - assert.Equal(t, want, FindQwenSourceFile(root, sessionID)) - assert.Empty(t, FindQwenSourceFile(root, "not-a-session-id")) - assert.Empty(t, FindQwenSourceFile(root, "b0a4eadd-cb99-4165-94d9-64cad5a66d99")) - assert.Empty(t, FindQwenSourceFile("", sessionID)) + assert.Equal(t, want, findQwenTestSourceFile(t, root, sessionID)) + assert.Empty(t, findQwenTestSourceFile(t, root, "not-a-session-id")) + assert.Empty(t, findQwenTestSourceFile(t, root, "b0a4eadd-cb99-4165-94d9-64cad5a66d99")) + assert.Empty(t, findQwenTestSourceFile(t, "", sessionID)) } diff --git a/internal/parser/types.go b/internal/parser/types.go index d80571c4e..7f83e38b9 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -332,9 +332,7 @@ var Registry = []AgentDef{ // Sessions live under //chats/.jsonl, // so the projects root must be watched recursively — pinning the // watch to a "chats" subdir of the root catches no events. - FileBased: true, - DiscoverFunc: DiscoverQwenSessions, - FindSourceFunc: FindQwenSourceFile, + FileBased: true, }, { Type: AgentCommandCode, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index ad9038e89..27dfce404 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1346,28 +1346,6 @@ func (e *Engine) classifyOnePath( return df, true } - // Qwen: //chats/.jsonl - for _, qwenDir := range e.agentDirs[parser.AgentQwen] { - if qwenDir == "" { - continue - } - if rel, ok := isUnder(qwenDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 3 || parts[1] != "chats" { - continue - } - sessionID, ok := strings.CutSuffix(parts[2], ".jsonl") - if !ok || !parser.IsValidSessionID(sessionID) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Project: parser.GetProjectName(parts[0]), - Agent: parser.AgentQwen, - }, true - } - } - if df, ok := e.classifyAiderPath(path); ok { return df, true } @@ -4504,8 +4482,6 @@ func (e *Engine) processFile( res = e.processVSCodeCopilot(file, info) case parser.AgentVSCopilot: res = e.processVisualStudioCopilot(file, info) - case parser.AgentQwen: - res = e.processQwen(file, info) case parser.AgentOpenClaw: res = e.processOpenClaw(file, info) case parser.AgentQClaw: @@ -7072,36 +7048,6 @@ func (e *Engine) processCursor( } } -func (e *Engine) processQwen( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseQwenSession( - file.Path, file.Project, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{{ - Session: *sess, - Messages: msgs, - }}, - } -} - func commandCodeEffectiveInfo(path string, info os.FileInfo) os.FileInfo { size := info.Size() mtime := info.ModTime().UnixNano() @@ -9521,11 +9467,6 @@ func (e *Engine) SyncSingleSessionContext( file.Project = sess.Project } } - case parser.AgentQwen: - // path is //chats/.jsonl - file.Project = parser.GetProjectName( - filepath.Base(filepath.Dir(filepath.Dir(path))), - ) case parser.AgentWorkBuddy: for _, workBuddyDir := range e.agentDirs[parser.AgentWorkBuddy] { rel, ok := isUnder(workBuddyDir, path)