diff --git a/internal/parser/deepseek_tui.go b/internal/parser/deepseek_tui.go index d1fd2314a..534e95230 100644 --- a/internal/parser/deepseek_tui.go +++ b/internal/parser/deepseek_tui.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/tidwall/gjson" @@ -13,47 +12,7 @@ import ( const deepSeekTUIPrefix = "deepseek-tui:" -// DiscoverDeepSeekTUISessions finds DeepSeek TUI / CodeWhale session -// JSON documents under a sessions directory. -func DiscoverDeepSeekTUISessions(root string) []DiscoveredFile { - entries, err := os.ReadDir(root) - if err != nil { - return nil - } - - files := make([]DiscoveredFile, 0) - for _, entry := range entries { - if entry.IsDir() || !isDeepSeekTUISessionFile(entry.Name()) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(root, entry.Name()), - Agent: AgentDeepSeekTUI, - }) - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindDeepSeekTUISourceFile locates a DeepSeek TUI / CodeWhale session -// JSON document by raw session ID. -func FindDeepSeekTUISourceFile(root, rawID string) string { - if !IsValidSessionID(rawID) { - return "" - } - path := filepath.Join(root, rawID+".json") - if info, err := os.Stat(path); err == nil && !info.IsDir() { - return path - } - return "" -} - -// ParseDeepSeekTUISession parses a DeepSeek TUI / CodeWhale saved -// session JSON file. -func ParseDeepSeekTUISession( +func parseDeepSeekTUISession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/deepseek_tui_provider.go b/internal/parser/deepseek_tui_provider.go new file mode 100644 index 000000000..6246c5e09 --- /dev/null +++ b/internal/parser/deepseek_tui_provider.go @@ -0,0 +1,66 @@ +package parser + +import ( + "context" + "path/filepath" +) + +// DeepSeek TUI stores each session as a single JSON file in a 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 newDeepSeekTUIProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + deepSeekTUIProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newDeepSeekTUISourceSet(cfg.Roots) }, + ) +} + +func newDeepSeekTUISourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentDeepSeekTUI, roots, + withExtensions(".json"), + withFollowSymlinkFiles(), + withContentHashing(), + withIncludePath(isDeepSeekTUISourcePath), + withSessionIDFromPath(func(root, path string) string { + return deepSeekTUISessionIDFromPath(path) + }), + withParseFile(deepSeekTUIParseFile), + ) +} + +func deepSeekTUIParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseDeepSeekTUISession(path, 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 isDeepSeekTUISourcePath(root, path string) bool { + return isDeepSeekTUISessionFile(filepath.Base(path)) +} + +func deepSeekTUIProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + SessionName: CapabilitySupported, + Cwd: CapabilitySupported, + Model: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/deepseek_tui_provider_test.go b/internal/parser/deepseek_tui_provider_test.go new file mode 100644 index 000000000..f3ac4f109 --- /dev/null +++ b/internal/parser/deepseek_tui_provider_test.go @@ -0,0 +1,160 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeepSeekTUIProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentDeepSeekTUI) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentDeepSeekTUI, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestDeepSeekTUIProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "session_123.json") + writeSourceFile(t, sourcePath, deepSeekTUIProviderFixture()) + writeSourceFile(t, filepath.Join(root, "latest.json"), "{}\n") + writeSourceFile(t, filepath.Join(root, "offline_queue.json"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", "session_456.json"), "{}\n") + + provider, ok := NewProvider(AgentDeepSeekTUI, 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, AgentDeepSeekTUI, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~deepseek-tui:session_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + found, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + FingerprintKey: sourcePath, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, 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 TestDeepSeekTUIProviderSourceMethodsFollowSymlinkedSessionFile(t *testing.T) { + root := t.TempDir() + targetDir := t.TempDir() + targetPath := filepath.Join(targetDir, "session_123.json") + sourcePath := filepath.Join(root, "session_123.json") + writeSourceFile(t, targetPath, deepSeekTUIProviderFixture()) + if err := os.Symlink(targetPath, sourcePath); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentDeepSeekTUI, 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~deepseek-tui:session_123", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, sourcePath, found.DisplayPath) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].DisplayPath) +} + +func TestDeepSeekTUIProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "session_123.json") + content := deepSeekTUIProviderFixture() + writeSourceFile(t, sourcePath, content) + + provider, ok := NewProvider(AgentDeepSeekTUI, 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) + + fingerprint, err := provider.Fingerprint(context.Background(), sources[0]) + require.NoError(t, err) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: sources[0], + Fingerprint: fingerprint, + }) + 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, "deepseek-tui: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, + fmt.Sprintf("%x", sha256.Sum256([]byte(content))), + outcome.Results[0].Result.Session.File.Hash, + ) + assert.Len(t, outcome.Results[0].Result.Messages, 2) +} + +func deepSeekTUIProviderFixture() string { + return `{ + "metadata": { + "id": "session_123", + "title": "Investigate DeepSeek TUI", + "created_at": "2026-06-01T10:00:00Z", + "updated_at": "2026-06-01T10:02:00Z", + "model": "deepseek-chat", + "workspace": "/Users/alice/code/sample-project" + }, + "messages": [ + {"role": "user", "content": "Inspect server logs", "timestamp": "2026-06-01T10:00:05Z"}, + {"role": "assistant", "content": [{"type": "text", "text": "The server failed during startup."}], "timestamp": "2026-06-01T10:00:10Z"} + ] +}` +} diff --git a/internal/parser/deepseek_tui_test.go b/internal/parser/deepseek_tui_test.go index 40dede71c..285978c5a 100644 --- a/internal/parser/deepseek_tui_test.go +++ b/internal/parser/deepseek_tui_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "testing" @@ -9,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestDiscoverDeepSeekTUISessions(t *testing.T) { +func TestDeepSeekTUIProviderDiscoversSessions(t *testing.T) { t.Parallel() root := t.TempDir() @@ -22,27 +23,57 @@ func TestDiscoverDeepSeekTUISessions(t *testing.T) { require.NoError(t, os.MkdirAll(checkpointDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(checkpointDir, "nested.json"), []byte(`{}`), 0o644)) - files := DiscoverDeepSeekTUISessions(root) + provider, ok := NewProvider(AgentDeepSeekTUI, ProviderConfig{ + Roots: []string{root}, + Machine: "local", + }) + require.True(t, ok) + files, err := provider.Discover(context.Background()) + require.NoError(t, err) require.Len(t, files, 2) - assert.Equal(t, filepath.Join(root, "session_a.json"), files[0].Path) - assert.Equal(t, AgentDeepSeekTUI, files[0].Agent) - assert.Equal(t, filepath.Join(root, "session_b.json"), files[1].Path) - assert.Equal(t, AgentDeepSeekTUI, files[1].Agent) + assert.Equal(t, filepath.Join(root, "session_a.json"), files[0].DisplayPath) + assert.Equal(t, AgentDeepSeekTUI, files[0].Provider) + assert.Equal(t, filepath.Join(root, "session_b.json"), files[1].DisplayPath) + assert.Equal(t, AgentDeepSeekTUI, files[1].Provider) } -func TestFindDeepSeekTUISourceFile(t *testing.T) { +func TestDeepSeekTUIProviderFindsSourceFile(t *testing.T) { t.Parallel() root := t.TempDir() path := filepath.Join(root, "session_123.json") require.NoError(t, os.WriteFile(path, []byte(`{}`), 0o644)) - assert.Equal(t, path, FindDeepSeekTUISourceFile(root, "session_123")) - assert.Empty(t, FindDeepSeekTUISourceFile(root, "missing")) - assert.Empty(t, FindDeepSeekTUISourceFile(root, "../session_123")) + provider, ok := NewProvider(AgentDeepSeekTUI, ProviderConfig{ + Roots: []string{root}, + Machine: "local", + }) + require.True(t, ok) + + found, ok, err := provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "session_123"}, + ) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, path, found.DisplayPath) + + _, ok, err = provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "missing"}, + ) + require.NoError(t, err) + assert.False(t, ok) + + _, ok, err = provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "../session_123"}, + ) + require.NoError(t, err) + assert.False(t, ok) } -func TestParseDeepSeekTUISessionBasic(t *testing.T) { +func TestDeepSeekTUIProviderParsesBasicSession(t *testing.T) { t.Parallel() content := `{ @@ -61,9 +92,9 @@ func TestParseDeepSeekTUISessionBasic(t *testing.T) { {"role": "assistant", "content": [{"type": "text", "text": "The server failed during startup."}], "timestamp": "2026-06-01T10:00:10Z"} ] }` - path := createTestFile(t, "deepseek_tui.json", content) + path := createTestFile(t, "session_123.json", content) - sess, msgs, err := ParseDeepSeekTUISession(path, "local") + sess, msgs, err := parseDeepSeekTUITestSession(t, path, "local") require.NoError(t, err) require.NotNil(t, sess) require.Len(t, msgs, 2) @@ -89,7 +120,7 @@ func TestParseDeepSeekTUISessionBasic(t *testing.T) { assert.Equal(t, "The server failed during startup.", msgs[1].Content) } -func TestParseDeepSeekTUISessionToolUseAndThinking(t *testing.T) { +func TestDeepSeekTUIProviderParsesToolUseAndThinking(t *testing.T) { t.Parallel() content := `{ @@ -106,9 +137,9 @@ func TestParseDeepSeekTUISessionToolUseAndThinking(t *testing.T) { {"role": "assistant", "content": [{"type": "text", "text": "It is a Go file."}]} ] }` - path := createTestFile(t, "deepseek_tui_tools.json", content) + path := createTestFile(t, "session_tools.json", content) - sess, msgs, err := ParseDeepSeekTUISession(path, "local") + sess, msgs, err := parseDeepSeekTUITestSession(t, path, "local") require.NoError(t, err) require.NotNil(t, sess) require.Len(t, msgs, 4) @@ -129,7 +160,7 @@ func TestParseDeepSeekTUISessionToolUseAndThinking(t *testing.T) { assert.Equal(t, "package main", DecodeContent(msgs[2].ToolResults[0].ContentRaw)) } -func TestParseDeepSeekTUISessionObjectToolResult(t *testing.T) { +func TestDeepSeekTUIProviderParsesObjectToolResult(t *testing.T) { t.Parallel() content := `{ @@ -144,9 +175,9 @@ func TestParseDeepSeekTUISessionObjectToolResult(t *testing.T) { ]} ] }` - path := createTestFile(t, "deepseek_tui_obj.json", content) + path := createTestFile(t, "session_obj.json", content) - _, msgs, err := ParseDeepSeekTUISession(path, "local") + _, msgs, err := parseDeepSeekTUITestSession(t, path, "local") require.NoError(t, err) require.Len(t, msgs, 3) @@ -156,7 +187,7 @@ func TestParseDeepSeekTUISessionObjectToolResult(t *testing.T) { assert.Equal(t, "file1.go\nfile2.go", DecodeContent(result.ContentRaw)) } -func TestParseDeepSeekTUISessionEmptyObjectToolResult(t *testing.T) { +func TestDeepSeekTUIProviderParsesEmptyObjectToolResult(t *testing.T) { t.Parallel() content := `{ @@ -171,9 +202,9 @@ func TestParseDeepSeekTUISessionEmptyObjectToolResult(t *testing.T) { ]} ] }` - path := createTestFile(t, "deepseek_tui_empty_obj.json", content) + path := createTestFile(t, "session_empty_obj.json", content) - _, msgs, err := ParseDeepSeekTUISession(path, "local") + _, msgs, err := parseDeepSeekTUITestSession(t, path, "local") require.NoError(t, err) require.Len(t, msgs, 3) @@ -183,16 +214,45 @@ func TestParseDeepSeekTUISessionEmptyObjectToolResult(t *testing.T) { assert.Empty(t, DecodeContent(result.ContentRaw)) } -func TestParseDeepSeekTUISessionSkipsEmpty(t *testing.T) { +func TestDeepSeekTUIProviderSkipsEmptySession(t *testing.T) { t.Parallel() - path := createTestFile(t, "deepseek_tui_empty.json", `{ + path := createTestFile(t, "empty_session.json", `{ "metadata": {"id": "empty_session"}, "messages": [] }`) - sess, msgs, err := ParseDeepSeekTUISession(path, "local") + sess, msgs, err := parseDeepSeekTUITestSession(t, path, "local") require.NoError(t, err) assert.Nil(t, sess) assert.Nil(t, msgs) } + +func parseDeepSeekTUITestSession( + t *testing.T, + path string, + machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + + provider, ok := NewProvider(AgentDeepSeekTUI, ProviderConfig{ + Roots: []string{filepath.Dir(path)}, + Machine: machine, + }) + require.True(t, ok) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: SourceRef{ + Provider: AgentDeepSeekTUI, + Key: path, + DisplayPath: path, + FingerprintKey: path, + }, + Machine: machine, + }) + if err != nil || len(outcome.Results) == 0 { + return nil, nil, err + } + result := outcome.Results[0].Result + return &result.Session, result.Messages, nil +} diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 7426f70ee..9032857b2 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -349,6 +349,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { switch def.Type { case AgentCommandCode: return newCommandCodeProviderFactory(def) + case AgentDeepSeekTUI: + return newDeepSeekTUIProviderFactory(def) case AgentIflow: return newIflowProviderFactory(def) case AgentGptme: diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 513a64d87..5e917ad81 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -35,7 +35,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentPi: ProviderMigrationLegacyOnly, AgentQwen: ProviderMigrationLegacyOnly, AgentCommandCode: ProviderMigrationProviderAuthoritative, - AgentDeepSeekTUI: ProviderMigrationLegacyOnly, + AgentDeepSeekTUI: ProviderMigrationProviderAuthoritative, AgentOpenClaw: ProviderMigrationLegacyOnly, AgentQClaw: ProviderMigrationLegacyOnly, AgentKimi: ProviderMigrationLegacyOnly, diff --git a/internal/parser/types.go b/internal/parser/types.go index 6d382f959..62dac5dbb 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -362,10 +362,8 @@ var Registry = []AgentDef{ ".codewhale/sessions", ".deepseek/sessions", }, - IDPrefix: "deepseek-tui:", - FileBased: true, - DiscoverFunc: DiscoverDeepSeekTUISessions, - FindSourceFunc: FindDeepSeekTUISourceFile, + IDPrefix: "deepseek-tui:", + FileBased: true, }, { Type: AgentOpenClaw, diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index 3861c296a..18c04d68d 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -571,8 +571,8 @@ func TestDeepSeekTUIRegistryEntry(t *testing.T) { def, ok := AgentByType(AgentDeepSeekTUI) require.True(t, ok, "AgentDeepSeekTUI missing from Registry") require.True(t, def.FileBased, "DeepSeek TUI FileBased") - require.NotNil(t, def.DiscoverFunc, "DeepSeek TUI DiscoverFunc") - require.NotNil(t, def.FindSourceFunc, "DeepSeek TUI FindSourceFunc") + assert.Nil(t, def.DiscoverFunc, "DeepSeek TUI DiscoverFunc") + assert.Nil(t, def.FindSourceFunc, "DeepSeek TUI FindSourceFunc") assert.Equal(t, "DeepSeek TUI", def.DisplayName) assert.Equal(t, "DEEPSEEK_TUI_SESSIONS_DIR", def.EnvVar) assert.Equal(t, "deepseek_tui_sessions_dirs", def.ConfigKey) diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 732546e39..87a2023d7 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1308,30 +1308,6 @@ func (e *Engine) classifyOnePath( } } - // DeepSeek TUI / CodeWhale: /.json - for _, dsDir := range e.agentDirs[parser.AgentDeepSeekTUI] { - if dsDir == "" { - continue - } - if rel, ok := isUnder(dsDir, path); ok { - if strings.Count(rel, sep) != 0 { - continue - } - name := filepath.Base(rel) - if name == "latest.json" || name == "offline_queue.json" { - continue - } - sessionID, ok := strings.CutSuffix(name, ".json") - if !ok || !parser.IsValidSessionID(sessionID) { - continue - } - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentDeepSeekTUI, - }, true - } - } - // Zencoder: /.jsonl for _, zenDir := range e.agentDirs[parser.AgentZencoder] { if zenDir == "" { @@ -4584,8 +4560,6 @@ func (e *Engine) processFile( res = e.processCursor(file, info) case parser.AgentAmp: res = e.processAmp(file, info) - case parser.AgentDeepSeekTUI: - res = e.processDeepSeekTUI(file, info) case parser.AgentZencoder: res = e.processZencoder(file, info) case parser.AgentVSCodeCopilot: @@ -6169,38 +6143,6 @@ func (e *Engine) processAmp( } } -func (e *Engine) processDeepSeekTUI( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseDeepSeekTUISession( - file.Path, 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 - } - inode, device := getFileIdentity(info) - sess.File.Inode = inode - sess.File.Device = device - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - func (e *Engine) processZencoder( file parser.DiscoveredFile, info os.FileInfo, ) processResult { diff --git a/internal/sync/engine_test.go b/internal/sync/engine_test.go index edfb00892..52ffe1a1f 100644 --- a/internal/sync/engine_test.go +++ b/internal/sync/engine_test.go @@ -3108,6 +3108,9 @@ func TestEngine_ClassifyPathsDeepSeekTUISession(t *testing.T) { require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files) assert.Equal(t, sessionPath, files[0].Path) assert.Equal(t, parser.AgentDeepSeekTUI, files[0].Agent) + assert.True(t, files[0].ProviderProcess) + require.NotNil(t, files[0].ProviderSource) + assert.Equal(t, sessionPath, files[0].ProviderSource.DisplayPath) bogus := []string{ filepath.Join(deepSeekDir, "stray.jsonl"),