Skip to content

Commit dba161b

Browse files
feat(parser): migrate qwen provider
Qwen uses a nested project/chats JSONL source shape, so it is a good next provider facade slice after the shallow and directory JSONL migrations. Moving it behind a concrete provider keeps discovery, lookup, fingerprinting, and parse output explicit without introducing another source framework. Legacy discovery accepts any one-level .jsonl file under chats while raw-session lookup still validates the requested ID before matching filename-derived IDs. The provider keeps that asymmetry, symlinked project directory and file behavior, project hints, and existing parser normalization intact. Validation: go fmt ./...; go test -tags "fts5" ./internal/parser -run TestQwenProvider -count=1; go test -tags "fts5" ./internal/parser -count=1; make test-short; go vet ./...; git diff --check test(parser): opt qwen into provider shadow Qwen now has a concrete facade provider on this branch, so its migration mode should enter shadow comparison instead of remaining an additive legacy-only provider. Lower provider opt-ins stay inherited and later branches remain responsible for their own concrete providers. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare qwen shadow parity Qwen is shadow-compared on this branch, so add the source-level migration proof that provider observation matches ParseQwenSession for its nested project/chats layout. This keeps reviewers focused on behavioral parity while later branches continue migrating their own provider shapes. Validation: go test -tags "fts5" ./internal/parser ./internal/sync -run 'TestObserveProviderSourceMatchesQwenLegacyParser|TestQwenProvider|TestParseQwen' -count=1; go fmt ./...; go vet ./...; git diff --check; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... refactor(parser): fold qwen into provider Qwen already had a concrete provider, but the branch still kept exported legacy parser/source functions and legacy sync dispatch. That left the migration additive instead of making the provider shape authoritative.\n\nMove Qwen parsing behind the provider method, remove the registry callbacks and sync processor/classifier, and replace the shadow comparison with provider API coverage plus a guard that the old entrypoints stay gone.\n\nValidation: go test -tags "fts5" ./internal/parser -run 'TestQwen|TestParseQwenSession' -count=1 -v; go test -tags "fts5" ./internal/sync -run 'TestEngine_ClassifyPathsQwenSession|TestProviderMigration|TestObserveProvider|TestSyncSingle.*Qwen|TestQwen' -count=1 -v; go test -tags "fts5" ./internal/parser ./internal/sync ./cmd/agentsview -count=1; go vet ./...; git diff --check fix(parser): thread ctx through qwen source lookups
1 parent 24a7db3 commit dba161b

8 files changed

Lines changed: 306 additions & 151 deletions

File tree

internal/parser/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory {
359359
return newGptmeProviderFactory(def)
360360
case AgentOMP, AgentPi:
361361
return newPiProviderFactory(def)
362+
case AgentQwen:
363+
return newQwenProviderFactory(def)
362364
case AgentZencoder:
363365
return newZencoderProviderFactory(def)
364366
default:

internal/parser/provider_migration.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{
3333
AgentVSCodeCopilot: ProviderMigrationLegacyOnly,
3434
AgentVSCopilot: ProviderMigrationLegacyOnly,
3535
AgentPi: ProviderMigrationProviderAuthoritative,
36-
AgentQwen: ProviderMigrationLegacyOnly,
36+
AgentQwen: ProviderMigrationProviderAuthoritative,
3737
AgentCommandCode: ProviderMigrationProviderAuthoritative,
3838
AgentDeepSeekTUI: ProviderMigrationProviderAuthoritative,
3939
AgentOpenClaw: ProviderMigrationLegacyOnly,

internal/parser/qwen.go

Lines changed: 2 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,85 +5,13 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8-
"sort"
98
"strings"
109
"time"
1110

1211
"github.com/tidwall/gjson"
1312
)
1413

15-
// DiscoverQwenSessions finds Qwen Code chat transcripts under the
16-
// projects root. The directory structure is:
17-
// <projectsDir>/<encoded-project>/chats/<session-id>.jsonl
18-
func DiscoverQwenSessions(projectsDir string) []DiscoveredFile {
19-
if projectsDir == "" {
20-
return nil
21-
}
22-
23-
projectEntries, err := os.ReadDir(projectsDir)
24-
if err != nil {
25-
return nil
26-
}
27-
28-
var files []DiscoveredFile
29-
for _, entry := range projectEntries {
30-
if !isDirOrSymlink(entry, projectsDir) {
31-
continue
32-
}
33-
34-
projectDir := filepath.Join(projectsDir, entry.Name())
35-
chatsDir := filepath.Join(projectDir, "chats")
36-
chatEntries, err := os.ReadDir(chatsDir)
37-
if err != nil {
38-
continue
39-
}
40-
41-
project := GetProjectName(entry.Name())
42-
for _, chat := range chatEntries {
43-
if chat.IsDir() || !strings.HasSuffix(chat.Name(), ".jsonl") {
44-
continue
45-
}
46-
files = append(files, DiscoveredFile{
47-
Path: filepath.Join(chatsDir, chat.Name()),
48-
Project: project,
49-
Agent: AgentQwen,
50-
})
51-
}
52-
}
53-
54-
sort.Slice(files, func(i, j int) bool {
55-
return files[i].Path < files[j].Path
56-
})
57-
return files
58-
}
59-
60-
// FindQwenSourceFile locates a Qwen session file by its raw session
61-
// ID (without the "qwen:" prefix).
62-
func FindQwenSourceFile(projectsDir, rawID string) string {
63-
if projectsDir == "" || !IsValidSessionID(rawID) {
64-
return ""
65-
}
66-
67-
projectEntries, err := os.ReadDir(projectsDir)
68-
if err != nil {
69-
return ""
70-
}
71-
for _, entry := range projectEntries {
72-
if !isDirOrSymlink(entry, projectsDir) {
73-
continue
74-
}
75-
76-
candidate := filepath.Join(
77-
projectsDir, entry.Name(), "chats", rawID+".jsonl",
78-
)
79-
if _, err := os.Stat(candidate); err == nil {
80-
return candidate
81-
}
82-
}
83-
return ""
84-
}
85-
86-
// ParseQwenSession parses a Qwen Code JSONL chat transcript.
14+
// parseSession parses a Qwen Code JSONL chat transcript.
8715
//
8816
// Qwen emits one `type=assistant` line per model output, including
8917
// every tool-call iteration in a multi-step turn. Each iteration's
@@ -96,7 +24,7 @@ func FindQwenSourceFile(projectsDir, rawID string) string {
9624
// aggregating their thinking text and token usage. A trailing run of
9725
// tool-call-only entries with no text follow-up is emitted as a single
9826
// coalesced assistant message so the data isn't lost.
99-
func ParseQwenSession(
27+
func parseQwenSession(
10028
path, project, machine string,
10129
) (*ParsedSession, []ParsedMessage, error) {
10230
info, err := os.Stat(path)

internal/parser/qwen_provider.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package parser
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// Qwen stores each chat as a JSONL transcript under a per-project
10+
// directory. It is a directory-of-files provider: discovery, watching,
11+
// change classification, lookup, and fingerprinting come from
12+
// JSONLSourceSet, and the ParseFile option makes that source set a full
13+
// SourceSet so it rides the generic factory.
14+
func newQwenProviderFactory(def AgentDef) ProviderFactory {
15+
return newSourceSetFactory(
16+
def,
17+
qwenProviderCapabilities(),
18+
func(cfg ProviderConfig) SourceSet { return newQwenSourceSet(cfg.Roots) },
19+
)
20+
}
21+
22+
func newQwenSourceSet(roots []string) JSONLSourceSet {
23+
return newJSONLSourceSet(AgentQwen, roots,
24+
withRecursive(),
25+
withSymlinkFollowing(),
26+
withIncludePath(isQwenSourcePath),
27+
withProjectHint(qwenProjectHintFromPath),
28+
withSessionIDFromPath(qwenSessionIDFromPath),
29+
withParseFile(qwenParseFile),
30+
)
31+
}
32+
33+
func qwenParseFile(
34+
_ context.Context, path string, req ParseRequest,
35+
) ([]ParseResult, []string, error) {
36+
sess, msgs, err := parseQwenSession(path, req.Source.ProjectHint, req.Machine)
37+
if err != nil {
38+
return nil, nil, err
39+
}
40+
if sess == nil {
41+
return nil, nil, nil
42+
}
43+
if req.Fingerprint.Hash != "" {
44+
sess.File.Hash = req.Fingerprint.Hash
45+
}
46+
return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil
47+
}
48+
49+
func isQwenSourcePath(root, path string) bool {
50+
rel, err := filepath.Rel(root, path)
51+
if err != nil {
52+
return false
53+
}
54+
parts := strings.Split(rel, string(filepath.Separator))
55+
return len(parts) == 3 &&
56+
parts[0] != "" && parts[0] != "." && parts[0] != ".." &&
57+
parts[1] == "chats" &&
58+
parts[2] != "" && parts[2] != "." && parts[2] != ".." &&
59+
strings.HasSuffix(parts[2], ".jsonl")
60+
}
61+
62+
func qwenProjectHintFromPath(root, path string) string {
63+
rel, err := filepath.Rel(root, path)
64+
if err != nil {
65+
return ""
66+
}
67+
parts := strings.Split(rel, string(filepath.Separator))
68+
if len(parts) != 3 {
69+
return ""
70+
}
71+
return GetProjectName(parts[0])
72+
}
73+
74+
func qwenSessionIDFromPath(root, path string) string {
75+
if !isQwenSourcePath(root, path) {
76+
return ""
77+
}
78+
return strings.TrimSuffix(filepath.Base(path), ".jsonl")
79+
}
80+
81+
func qwenProviderCapabilities() Capabilities {
82+
return Capabilities{
83+
Source: jsonlFileProviderSourceCapabilities(),
84+
Content: ContentCapabilities{
85+
FirstMessage: CapabilitySupported,
86+
Cwd: CapabilitySupported,
87+
Thinking: CapabilitySupported,
88+
ToolCalls: CapabilitySupported,
89+
ToolResults: CapabilitySupported,
90+
PerMessageTokenUsage: CapabilitySupported,
91+
Model: CapabilitySupported,
92+
},
93+
}
94+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package parser
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestQwenProviderFactoryReplacesLegacyAdapter(t *testing.T) {
15+
factory, ok := ProviderFactoryByType(AgentQwen)
16+
require.True(t, ok)
17+
require.NotNil(t, factory)
18+
19+
provider, ok := NewProvider(AgentQwen, ProviderConfig{
20+
Roots: []string{t.TempDir()},
21+
Machine: "devbox",
22+
})
23+
require.True(t, ok)
24+
require.NotNil(t, provider)
25+
}
26+
27+
func TestQwenProviderSourceMethods(t *testing.T) {
28+
root := t.TempDir()
29+
projectDir := filepath.Join(root, "-Users-alice-code-sample-project")
30+
sourcePath := filepath.Join(projectDir, "chats", "session-123.jsonl")
31+
nonIDPath := filepath.Join(projectDir, "chats", "2025.01.01.jsonl")
32+
writeSourceFile(t, sourcePath, qwenProviderFixture("session-123"))
33+
writeSourceFile(t, nonIDPath, qwenProviderFixture("header-session-id"))
34+
writeSourceFile(t, filepath.Join(projectDir, "notes", "skip.jsonl"), "{}\n")
35+
writeSourceFile(t, filepath.Join(root, "root-session.jsonl"), "{}\n")
36+
writeSourceFile(t, filepath.Join(projectDir, "chats", "nested", "deep.jsonl"), "{}\n")
37+
38+
provider, ok := NewProvider(AgentQwen, ProviderConfig{
39+
Roots: []string{root},
40+
Machine: "devbox",
41+
})
42+
require.True(t, ok)
43+
44+
discovered, err := provider.Discover(context.Background())
45+
require.NoError(t, err)
46+
require.Len(t, discovered, 2)
47+
assert.Equal(t, []string{nonIDPath, sourcePath}, sourceDisplayPaths(discovered))
48+
assert.Equal(t, []string{"sample_project", "sample_project"}, sourceProjects(discovered))
49+
50+
plan, err := provider.WatchPlan(context.Background())
51+
require.NoError(t, err)
52+
require.Len(t, plan.Roots, 1)
53+
assert.Equal(t, root, plan.Roots[0].Path)
54+
assert.True(t, plan.Roots[0].Recursive)
55+
assert.Equal(t, []string{"*.jsonl"}, plan.Roots[0].IncludeGlobs)
56+
57+
found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
58+
FullSessionID: "host~qwen:session-123",
59+
})
60+
require.NoError(t, err)
61+
require.True(t, ok)
62+
assert.Equal(t, sourcePath, found.DisplayPath)
63+
64+
_, ok, err = provider.FindSource(context.Background(), FindSourceRequest{
65+
RawSessionID: "2025.01.01",
66+
})
67+
require.NoError(t, err)
68+
assert.False(t, ok)
69+
70+
found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{
71+
StoredFilePath: nonIDPath,
72+
})
73+
require.NoError(t, err)
74+
require.True(t, ok)
75+
assert.Equal(t, nonIDPath, found.DisplayPath)
76+
77+
require.NoError(t, os.Remove(sourcePath))
78+
changed, err := provider.SourcesForChangedPath(
79+
context.Background(),
80+
ChangedPathRequest{Path: sourcePath, EventKind: "remove", WatchRoot: root},
81+
)
82+
require.NoError(t, err)
83+
require.Len(t, changed, 1)
84+
assert.Equal(t, sourcePath, changed[0].DisplayPath)
85+
}
86+
87+
func TestQwenProviderDiscoversSymlinkedProjectDirectory(t *testing.T) {
88+
root := t.TempDir()
89+
targetDir := t.TempDir()
90+
sourcePath := filepath.Join(root, "-Users-alice-code-sample-project", "chats", "session-123.jsonl")
91+
targetPath := filepath.Join(targetDir, "chats", "session-123.jsonl")
92+
writeSourceFile(t, targetPath, qwenProviderFixture("session-123"))
93+
if err := os.Symlink(targetDir, filepath.Join(root, "-Users-alice-code-sample-project")); err != nil {
94+
t.Skipf("symlink not supported: %v", err)
95+
}
96+
97+
provider, ok := NewProvider(AgentQwen, ProviderConfig{
98+
Roots: []string{root},
99+
Machine: "devbox",
100+
})
101+
require.True(t, ok)
102+
103+
discovered, err := provider.Discover(context.Background())
104+
require.NoError(t, err)
105+
require.Len(t, discovered, 1)
106+
assert.Equal(t, sourcePath, discovered[0].DisplayPath)
107+
108+
found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{
109+
FullSessionID: "host~qwen:session-123",
110+
})
111+
require.NoError(t, err)
112+
require.True(t, ok)
113+
assert.Equal(t, sourcePath, found.DisplayPath)
114+
}
115+
116+
func TestQwenProviderParse(t *testing.T) {
117+
root := t.TempDir()
118+
sourcePath := filepath.Join(root, "-Users-alice-code-sample-project", "chats", "session-123.jsonl")
119+
writeSourceFile(t, sourcePath, qwenProviderFixture("session-123"))
120+
121+
provider, ok := NewProvider(AgentQwen, ProviderConfig{
122+
Roots: []string{root},
123+
Machine: "devbox",
124+
})
125+
require.True(t, ok)
126+
sources, err := provider.Discover(context.Background())
127+
require.NoError(t, err)
128+
require.Len(t, sources, 1)
129+
130+
outcome, err := provider.Parse(context.Background(), ParseRequest{
131+
Source: sources[0],
132+
Fingerprint: SourceFingerprint{Key: sourcePath, Hash: "abc123"},
133+
})
134+
require.NoError(t, err)
135+
require.True(t, outcome.ResultSetComplete)
136+
require.Len(t, outcome.Results, 1)
137+
assert.Equal(t, DataVersionCurrent, outcome.Results[0].DataVersion)
138+
assert.Equal(t, "qwen:session-123", outcome.Results[0].Result.Session.ID)
139+
assert.Equal(t, "sample_project", outcome.Results[0].Result.Session.Project)
140+
assert.Equal(t, "devbox", outcome.Results[0].Result.Session.Machine)
141+
assert.Equal(t, "abc123", outcome.Results[0].Result.Session.File.Hash)
142+
assert.Len(t, outcome.Results[0].Result.Messages, 2)
143+
}
144+
145+
func qwenProviderFixture(sessionID string) string {
146+
return strings.Join([]string{
147+
`{"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"}]}}`,
148+
`{"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}}`,
149+
}, "\n")
150+
}

0 commit comments

Comments
 (0)