Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/parser/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/parser/provider_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
76 changes: 2 additions & 74 deletions internal/parser/qwen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <projectsDir>/<encoded-project>/chats/<session-id>.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
Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions internal/parser/qwen_provider.go
Original file line number Diff line number Diff line change
@@ -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,
},
}
}
150 changes: 150 additions & 0 deletions internal/parser/qwen_provider_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading