diff --git a/internal/parser/amp.go b/internal/parser/amp.go index 625ca9bcd..a1ce5e304 100644 --- a/internal/parser/amp.go +++ b/internal/parser/amp.go @@ -12,9 +12,7 @@ import ( "github.com/tidwall/gjson" ) -// ParseAmpSession parses an Amp thread JSON file. -// Each thread is a single JSON document at ~/.local/share/amp/threads/T-*.json. -func ParseAmpSession( +func parseAmpSession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/amp_provider.go b/internal/parser/amp_provider.go new file mode 100644 index 000000000..e23ed1ab2 --- /dev/null +++ b/internal/parser/amp_provider.go @@ -0,0 +1,63 @@ +package parser + +import ( + "context" + "path/filepath" +) + +// Amp stores each thread 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 newAmpProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + ampProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newAmpSourceSet(cfg.Roots) }, + ) +} + +func newAmpSourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentAmp, roots, + withExtensions(".json"), + withFollowSymlinkFiles(), + withContentHashing(), + withIncludePath(isAmpSourcePath), + withSessionIDFromPath(func(root, path string) string { + return ampThreadIDFromPath(path) + }), + withParseFile(ampParseFile), + ) +} + +func ampParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseAmpSession(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 isAmpSourcePath(root, path string) bool { + return IsAmpThreadFileName(filepath.Base(path)) +} + +func ampProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/amp_test.go b/internal/parser/amp_test.go index 3c33adbe1..c7ba658c5 100644 --- a/internal/parser/amp_test.go +++ b/internal/parser/amp_test.go @@ -1,6 +1,8 @@ package parser import ( + "context" + "path/filepath" "strings" "testing" "time" @@ -15,10 +17,43 @@ func runAmpParserTest( ) (*ParsedSession, []ParsedMessage, error) { t.Helper() path := createTestFile(t, "T-test.json", content) - return ParseAmpSession(path, "local") + return parseAmpTestSession(t, path, "local") } -func TestParseAmpSession_Basic(t *testing.T) { +func parseAmpTestSession( + t *testing.T, + path string, + machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{filepath.Dir(path)}, + Machine: machine, + }) + require.True(t, ok) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: SourceRef{ + Provider: AgentAmp, + Key: path, + DisplayPath: path, + FingerprintKey: path, + Opaque: JSONLSource{ + Root: filepath.Dir(path), + Path: 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 +} + +func TestAmpProviderParsesBasic(t *testing.T) { threadID := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd" content := `{ "v": 1, @@ -43,7 +78,7 @@ func TestParseAmpSession_Basic(t *testing.T) { }` path := createTestFile(t, threadID+".json", content) - sess, msgs, err := ParseAmpSession(path, "local") + sess, msgs, err := parseAmpTestSession(t, path, "local") require.NoError(t, err) require.NotNil(t, sess) @@ -72,7 +107,7 @@ func TestParseAmpSession_Basic(t *testing.T) { assert.Equal(t, 1, msgs[1].Ordinal) } -func TestParseAmpSession_ToolUseAndThinking(t *testing.T) { +func TestAmpProviderParsesToolUseAndThinking(t *testing.T) { content := `{ "v": 1, "id": "T-tooluse", @@ -322,7 +357,7 @@ func TestExtractAmpToolResults(t *testing.T) { } } -func TestParseAmpSession_AmpToolResultSchema(t *testing.T) { +func TestAmpProviderParsesAmpToolResultSchema(t *testing.T) { content := `{ "v": 1, "id": "T-amp-tool-result-schema", @@ -347,7 +382,7 @@ func TestParseAmpSession_AmpToolResultSchema(t *testing.T) { assert.Equal(t, "Here is a complete breakdown", DecodeContent(msgs[1].ToolResults[0].ContentRaw)) } -func TestParseAmpSession_AmpToolResultDict(t *testing.T) { +func TestAmpProviderParsesAmpToolResultDict(t *testing.T) { content := `{ "v": 1, "id": "T-amp-tool-result-dict", @@ -370,7 +405,7 @@ func TestParseAmpSession_AmpToolResultDict(t *testing.T) { assert.Equal(t, "cmd output", DecodeContent(msgs[1].ToolResults[0].ContentRaw)) } -func TestParseAmpSession_NoEnv(t *testing.T) { +func TestAmpProviderParsesNoEnv(t *testing.T) { content := `{ "v": 1, "id": "T-noenv", @@ -389,7 +424,7 @@ func TestParseAmpSession_NoEnv(t *testing.T) { require.Equal(t, 1, len(msgs)) } -func TestParseAmpSession_NoTitle(t *testing.T) { +func TestAmpProviderParsesNoTitle(t *testing.T) { content := `{ "v": 1, "id": "T-notitle", @@ -408,7 +443,7 @@ func TestParseAmpSession_NoTitle(t *testing.T) { assert.Equal(t, "Fix the bug in main.go please.", sess.FirstMessage) } -func TestParseAmpSession_NoMetaTraces(t *testing.T) { +func TestAmpProviderParsesNoMetaTraces(t *testing.T) { content := `{ "v": 1, "id": "T-notraces", @@ -427,7 +462,7 @@ func TestParseAmpSession_NoMetaTraces(t *testing.T) { assertZeroTimestamp(t, sess.EndedAt, "EndedAt") } -func TestParseAmpSession_LastTraceWithoutEndTime(t *testing.T) { +func TestAmpProviderParsesLastTraceWithoutEndTime(t *testing.T) { content := `{ "v": 1, "id": "T-trace-end-missing", @@ -451,7 +486,7 @@ func TestParseAmpSession_LastTraceWithoutEndTime(t *testing.T) { assert.Equal(t, "2024-01-01T00:00:02Z", sess.EndedAt.UTC().Format(time.RFC3339)) } -func TestParseAmpSession_EmptyThread(t *testing.T) { +func TestAmpProviderParsesEmptyThread(t *testing.T) { content := `{ "v": 1, "id": "T-empty", @@ -466,7 +501,7 @@ func TestParseAmpSession_EmptyThread(t *testing.T) { assert.Nil(t, msgs) } -func TestParseAmpSession_FirstMessageTruncation(t *testing.T) { +func TestAmpProviderParsesFirstMessageTruncation(t *testing.T) { longText := strings.Repeat("a", 400) content := `{"v":1,"id":"T-trunc","created":1704067200000,"messages":[` + `{"role":"user","content":[{"type":"text","text":"` + longText + `"}]}]}` @@ -478,7 +513,7 @@ func TestParseAmpSession_FirstMessageTruncation(t *testing.T) { assert.Equal(t, 303, len(sess.FirstMessage)) } -func TestParseAmpSession_InvalidCreated(t *testing.T) { +func TestAmpProviderParsesInvalidCreated(t *testing.T) { t.Run("missing created", func(t *testing.T) { content := `{ "v": 1, @@ -539,9 +574,9 @@ func TestParseAmpSession_InvalidCreated(t *testing.T) { }) } -func TestParseAmpSession_Errors(t *testing.T) { +func TestAmpProviderParsesErrors(t *testing.T) { t.Run("missing file", func(t *testing.T) { - _, _, err := ParseAmpSession("/nonexistent/T-xxx.json", "local") + _, _, err := parseAmpTestSession(t, "/nonexistent/T-xxx.json", "local") assert.Error(t, err) }) @@ -563,13 +598,13 @@ func TestParseAmpSession_Errors(t *testing.T) { t.Run("missing id and invalid filename", func(t *testing.T) { content := `{"v":1,"created":1704067200000,"messages":[]}` path := createTestFile(t, "bad-name.json", content) - _, _, err := ParseAmpSession(path, "local") + _, _, err := parseAmpTestSession(t, path, "local") assert.Error(t, err) assert.Contains(t, err.Error(), "missing or invalid id") }) } -func TestParseAmpSession_MismatchedID(t *testing.T) { +func TestAmpProviderParsesMismatchedID(t *testing.T) { t.Run("invalid JSON id", func(t *testing.T) { content := `{ "v": 1, @@ -581,7 +616,7 @@ func TestParseAmpSession_MismatchedID(t *testing.T) { }` path := createTestFile(t, "T-fallback-uuid.json", content) - sess, _, err := ParseAmpSession(path, "local") + sess, _, err := parseAmpTestSession(t, path, "local") require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "amp:T-fallback-uuid", sess.ID) @@ -600,7 +635,7 @@ func TestParseAmpSession_MismatchedID(t *testing.T) { }` path := createTestFile(t, "bad-name.json", content) - sess, _, err := ParseAmpSession(path, "local") + sess, _, err := parseAmpTestSession(t, path, "local") require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "amp:T-from-json", sess.ID) @@ -620,7 +655,7 @@ func TestParseAmpSession_MismatchedID(t *testing.T) { }` path := createTestFile(t, "T-from-file.json", content) - sess, _, err := ParseAmpSession(path, "local") + sess, _, err := parseAmpTestSession(t, path, "local") require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "amp:T-from-file", sess.ID) diff --git a/internal/parser/amp_zencoder_provider_test.go b/internal/parser/amp_zencoder_provider_test.go new file mode 100644 index 000000000..a8d50c036 --- /dev/null +++ b/internal/parser/amp_zencoder_provider_test.go @@ -0,0 +1,286 @@ +package parser + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAmpProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentAmp) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestAmpProviderSourceMethods(t *testing.T) { + root := t.TempDir() + threadID := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd" + sourcePath := filepath.Join(root, threadID+".json") + writeSourceFile(t, sourcePath, ampProviderFixture(threadID)) + writeSourceFile(t, filepath.Join(root, "T-.json"), "{}\n") + writeSourceFile(t, filepath.Join(root, "notes.json"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", threadID+".json"), "{}\n") + + provider, ok := NewProvider(AgentAmp, 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, AgentAmp, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~amp:" + threadID, + }) + 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 TestAmpProviderSourceMethodsFollowSymlinkedSessionFile(t *testing.T) { + root := t.TempDir() + targetDir := t.TempDir() + threadID := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd" + targetPath := filepath.Join(targetDir, threadID+".json") + sourcePath := filepath.Join(root, threadID+".json") + writeSourceFile(t, targetPath, ampProviderFixture(threadID)) + if err := os.Symlink(targetPath, sourcePath); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentAmp, 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~amp:" + threadID, + }) + 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 TestAmpProviderParse(t *testing.T) { + root := t.TempDir() + threadID := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd" + sourcePath := filepath.Join(root, threadID+".json") + content := ampProviderFixture(threadID) + writeSourceFile(t, sourcePath, content) + + provider, ok := NewProvider(AgentAmp, 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, "amp:"+threadID, outcome.Results[0].Result.Session.ID) + assert.Equal(t, "amp-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 TestZencoderProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentZencoder) + require.True(t, ok) + require.NotNil(t, factory) + + provider, ok := NewProvider(AgentZencoder, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestZencoderProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "abc-def-123.jsonl") + writeSourceFile(t, sourcePath, zencoderProviderFixture("abc-def-123")) + writeSourceFile(t, filepath.Join(root, "notes.txt"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", "abc-def-123.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentZencoder, 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, AgentZencoder, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].DisplayPath) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + FullSessionID: "host~zencoder:abc-def-123", + }) + 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 TestZencoderProviderSourceMethodsFollowSymlinkedSessionFile(t *testing.T) { + root := t.TempDir() + targetDir := t.TempDir() + targetPath := filepath.Join(targetDir, "abc-def-123.jsonl") + sourcePath := filepath.Join(root, "abc-def-123.jsonl") + writeSourceFile(t, targetPath, zencoderProviderFixture("abc-def-123")) + if err := os.Symlink(targetPath, sourcePath); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + provider, ok := NewProvider(AgentZencoder, 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~zencoder:abc-def-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 TestZencoderProviderParse(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "abc-def-123.jsonl") + content := zencoderProviderFixture("abc-def-123") + writeSourceFile(t, sourcePath, content) + + provider, ok := NewProvider(AgentZencoder, 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, "zencoder:abc-def-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, 3) +} + +func ampProviderFixture(threadID string) string { + return `{ + "v": 1, + "id": "` + threadID + `", + "created": 1704067200000, + "title": "Migrate database schema", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Migrate the DB schema."}]}, + {"role": "assistant", "content": [{"type": "text", "text": "Sure, I will help."}]} + ], + "env": {"initial": {"trees": [{"displayName": "amp-project"}]}}, + "meta": {"traces": []} +}` +} + +func zencoderProviderFixture(sessionID string) string { + return strings.Join([]string{ + `{"id":"` + sessionID + `","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}`, + `{"role":"system","content":"Working directory: /Users/alice/code/sample-project"}`, + `{"role":"user","content":[{"type":"text","text":"hello"}]}`, + `{"role":"assistant","content":[{"type":"text","text":"OK."}]}`, + }, "\n") +} diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 0ed066181..799c538c4 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -1318,52 +1318,6 @@ func ResolveGeminiProject( return NormalizeName(dirName) } -// DiscoverAmpSessions finds all thread JSON files under -// the Amp threads directory (~/.local/share/amp/threads/T-*.json). -func DiscoverAmpSessions(threadsDir string) []DiscoveredFile { - if threadsDir == "" { - return nil - } - - entries, err := os.ReadDir(threadsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !IsAmpThreadFileName(name) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(threadsDir, name), - Agent: AgentAmp, - }) - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindAmpSourceFile locates an Amp thread file by its raw -// thread ID (without the "amp:" prefix). -func FindAmpSourceFile(threadsDir, threadID string) string { - if threadsDir == "" || !isValidAmpThreadID(threadID) { - return "" - } - candidate := filepath.Join(threadsDir, threadID+".json") - if _, err := os.Stat(candidate); err == nil { - return candidate - } - return "" -} - // DiscoverCopilotSessions finds all JSONL files under // /session-state/. Supports both bare format // (.jsonl) and directory format (/events.jsonl). diff --git a/internal/parser/discovery_test.go b/internal/parser/discovery_test.go index 1a1b50bb4..72f4c98ba 100644 --- a/internal/parser/discovery_test.go +++ b/internal/parser/discovery_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -57,6 +58,31 @@ func assertDiscoveredFiles(t *testing.T, got []DiscoveredFile, wantFilenames []s } } +func assertSourceRefs(t *testing.T, got []SourceRef, wantFilenames []string, wantAgent AgentType) { + t.Helper() + + want := make(map[string]bool) + for _, f := range wantFilenames { + want[f] = true + } + + gotMap := make(map[string]bool) + for _, f := range got { + base := filepath.Base(f.DisplayPath) + gotMap[base] = true + assert.Equalf(t, wantAgent, f.Provider, "file %q: provider", base) + } + + assert.Equal(t, len(want), len(got), "files total") + + for file := range want { + assert.Truef(t, gotMap[file], "missing expected file: %q", file) + } + for file := range gotMap { + assert.Truef(t, want[file], "got unexpected file: %q", file) + } +} + func TestDiscoverClaudeProjects(t *testing.T) { tests := []struct { name string @@ -179,7 +205,7 @@ func TestDiscoverCodexSessions(t *testing.T) { } } -func TestDiscoverAmpSessions(t *testing.T) { +func TestAmpProviderDiscoversSessions(t *testing.T) { tests := []struct { name string files map[string]string @@ -211,17 +237,27 @@ func TestDiscoverAmpSessions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() setupFileSystem(t, dir, tt.files) - files := DiscoverAmpSessions(dir) - assertDiscoveredFiles( - t, files, tt.wantFiles, AgentAmp, - ) + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) + files, err := provider.Discover(context.Background()) + require.NoError(t, err) + assertSourceRefs(t, files, tt.wantFiles, AgentAmp) }) } t.Run("Nonexistent", func(t *testing.T) { dir := filepath.Join(t.TempDir(), "does-not-exist") - files := DiscoverAmpSessions(dir) - assert.Nil(t, files, "expected nil") + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) + files, err := provider.Discover(context.Background()) + require.NoError(t, err) + assert.Empty(t, files, "expected empty") }) } @@ -291,30 +327,54 @@ func TestFindClaudeSourceFile(t *testing.T) { }) } -func TestFindAmpSourceFile(t *testing.T) { +func TestAmpProviderFindsSourceFile(t *testing.T) { t.Run("Found", func(t *testing.T) { dir := t.TempDir() rel := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd.json" setupFileSystem(t, dir, map[string]string{ rel: "{}", }) - got := FindAmpSourceFile( - dir, "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd", + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) + got, ok, err := provider.FindSource( + context.Background(), + FindSourceRequest{ + RawSessionID: "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd", + }, ) + require.NoError(t, err) + require.True(t, ok) want := filepath.Join(dir, rel) - assert.Equal(t, want, got) + assert.Equal(t, want, got.DisplayPath) }) t.Run("Nonexistent", func(t *testing.T) { dir := t.TempDir() - got := FindAmpSourceFile( - dir, "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd", + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) + _, ok, err := provider.FindSource( + context.Background(), + FindSourceRequest{ + RawSessionID: "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd", + }, ) - assert.Empty(t, got, "expected empty") + require.NoError(t, err) + assert.False(t, ok, "expected empty") }) t.Run("Validation", func(t *testing.T) { dir := t.TempDir() + provider, ok := NewProvider(AgentAmp, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) tests := []string{ "", "../bad", @@ -323,8 +383,12 @@ func TestFindAmpSourceFile(t *testing.T) { "T-", } for _, id := range tests { - got := FindAmpSourceFile(dir, id) - assert.Emptyf(t, got, "FindAmpSourceFile(%q)", id) + _, ok, err := provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: id}, + ) + require.NoError(t, err) + assert.Falsef(t, ok, "Amp provider FindSource(%q)", id) } }) } diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 9032857b2..27d6e88ca 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -347,6 +347,8 @@ func ProviderFactories() []ProviderFactory { func providerFactoryForDef(def AgentDef) ProviderFactory { def = cloneAgentDef(def) switch def.Type { + case AgentAmp: + return newAmpProviderFactory(def) case AgentCommandCode: return newCommandCodeProviderFactory(def) case AgentDeepSeekTUI: @@ -355,6 +357,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newIflowProviderFactory(def) case AgentGptme: return newGptmeProviderFactory(def) + case AgentZencoder: + return newZencoderProviderFactory(def) default: return legacyProviderFactory{def: def} } diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 5e917ad81..6778a3304 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -28,8 +28,8 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentOpenHands: ProviderMigrationLegacyOnly, AgentCursor: ProviderMigrationLegacyOnly, AgentIflow: ProviderMigrationProviderAuthoritative, - AgentAmp: ProviderMigrationLegacyOnly, - AgentZencoder: ProviderMigrationLegacyOnly, + AgentAmp: ProviderMigrationProviderAuthoritative, + AgentZencoder: ProviderMigrationProviderAuthoritative, AgentVSCodeCopilot: ProviderMigrationLegacyOnly, AgentVSCopilot: ProviderMigrationLegacyOnly, AgentPi: ProviderMigrationLegacyOnly, diff --git a/internal/parser/types.go b/internal/parser/types.go index 62dac5dbb..fd673522a 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -232,26 +232,22 @@ var Registry = []AgentDef{ FindSourceFunc: FindCursorSourceFile, }, { - Type: AgentAmp, - DisplayName: "Amp", - EnvVar: "AMP_DIR", - ConfigKey: "amp_dirs", - DefaultDirs: []string{".local/share/amp/threads"}, - IDPrefix: "amp:", - FileBased: true, - DiscoverFunc: DiscoverAmpSessions, - FindSourceFunc: FindAmpSourceFile, + Type: AgentAmp, + DisplayName: "Amp", + EnvVar: "AMP_DIR", + ConfigKey: "amp_dirs", + DefaultDirs: []string{".local/share/amp/threads"}, + IDPrefix: "amp:", + FileBased: true, }, { - Type: AgentZencoder, - DisplayName: "Zencoder", - EnvVar: "ZENCODER_DIR", - ConfigKey: "zencoder_dirs", - DefaultDirs: []string{".zencoder/sessions"}, - IDPrefix: "zencoder:", - FileBased: true, - DiscoverFunc: DiscoverZencoderSessions, - FindSourceFunc: FindZencoderSourceFile, + Type: AgentZencoder, + DisplayName: "Zencoder", + EnvVar: "ZENCODER_DIR", + ConfigKey: "zencoder_dirs", + DefaultDirs: []string{".zencoder/sessions"}, + IDPrefix: "zencoder:", + FileBased: true, }, { Type: AgentIflow, diff --git a/internal/parser/zencoder.go b/internal/parser/zencoder.go index 62e6fcec1..84fe74705 100644 --- a/internal/parser/zencoder.go +++ b/internal/parser/zencoder.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" "regexp" - "sort" "strings" "time" @@ -445,10 +444,7 @@ func zencoderToolResultContentLength( return total } -// ParseZencoderSession parses a Zencoder JSONL session file. -// Returns (nil, nil, nil) if the file doesn't exist or -// contains no user/assistant messages. -func ParseZencoderSession( +func parseZencoderSession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) @@ -567,53 +563,3 @@ func ParseZencoderSession( func IsZencoderSessionFileName(name string) bool { return strings.HasSuffix(name, ".jsonl") } - -// DiscoverZencoderSessions finds all JSONL files under -// the Zencoder sessions directory (~/.zencoder/sessions/*.jsonl). -func DiscoverZencoderSessions( - sessionsDir string, -) []DiscoveredFile { - if sessionsDir == "" { - return nil - } - - entries, err := os.ReadDir(sessionsDir) - if err != nil { - return nil - } - - var files []DiscoveredFile - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !IsZencoderSessionFileName(name) { - continue - } - files = append(files, DiscoveredFile{ - Path: filepath.Join(sessionsDir, name), - Agent: AgentZencoder, - }) - } - - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindZencoderSourceFile locates a Zencoder session file by -// its raw session ID (without the "zencoder:" prefix). -func FindZencoderSourceFile( - sessionsDir, rawID string, -) string { - if sessionsDir == "" || !IsValidSessionID(rawID) { - return "" - } - candidate := filepath.Join(sessionsDir, rawID+".jsonl") - if _, err := os.Stat(candidate); err == nil { - return candidate - } - return "" -} diff --git a/internal/parser/zencoder_provider.go b/internal/parser/zencoder_provider.go new file mode 100644 index 000000000..35888d350 --- /dev/null +++ b/internal/parser/zencoder_provider.go @@ -0,0 +1,68 @@ +package parser + +import ( + "context" + "path/filepath" + "strings" +) + +// Zencoder stores each session as a single JSONL 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 newZencoderProviderFactory(def AgentDef) ProviderFactory { + return newSourceSetFactory( + def, + zencoderProviderCapabilities(), + func(cfg ProviderConfig) SourceSet { return newZencoderSourceSet(cfg.Roots) }, + ) +} + +func newZencoderSourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentZencoder, roots, + withFollowSymlinkFiles(), + withContentHashing(), + withIncludePath(isZencoderSourcePath), + withSessionIDFromPath(zencoderSessionIDFromPath), + withParseFile(zencoderParseFile), + ) +} + +func zencoderParseFile( + _ context.Context, path string, req ParseRequest, +) ([]ParseResult, []string, error) { + sess, msgs, err := parseZencoderSession(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 isZencoderSourcePath(root, path string) bool { + return IsZencoderSessionFileName(filepath.Base(path)) +} + +func zencoderSessionIDFromPath(root, path string) string { + return strings.TrimSuffix(filepath.Base(path), ".jsonl") +} + +func zencoderProviderCapabilities() Capabilities { + return Capabilities{ + Source: jsonlFileProviderSourceCapabilities(), + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Cwd: CapabilitySupported, + Relationships: CapabilitySupported, + Subagents: CapabilitySupported, + Thinking: CapabilitySupported, + ToolCalls: CapabilitySupported, + ToolResults: CapabilitySupported, + }, + } +} diff --git a/internal/parser/zencoder_test.go b/internal/parser/zencoder_test.go index 38b6261b0..749383862 100644 --- a/internal/parser/zencoder_test.go +++ b/internal/parser/zencoder_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "os" "path/filepath" "strings" @@ -16,10 +17,43 @@ func runZencoderParserTest( ) (*ParsedSession, []ParsedMessage, error) { t.Helper() path := createTestFile(t, "test-uuid.jsonl", content) - return ParseZencoderSession(path, "local") + return parseZencoderTestSession(t, path, "local") } -func TestParseZencoderSession_Basic(t *testing.T) { +func parseZencoderTestSession( + t *testing.T, + path string, + machine string, +) (*ParsedSession, []ParsedMessage, error) { + t.Helper() + + provider, ok := NewProvider(AgentZencoder, ProviderConfig{ + Roots: []string{filepath.Dir(path)}, + Machine: machine, + }) + require.True(t, ok) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: SourceRef{ + Provider: AgentZencoder, + Key: path, + DisplayPath: path, + FingerprintKey: path, + Opaque: JSONLSource{ + Root: filepath.Dir(path), + Path: 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 +} + +func TestZencoderProviderParsesBasic(t *testing.T) { header := `{"id":"abc-123","chatId":"chat-1","modelId":"model-1","parentId":"","creationReason":"newChat","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z","version":"1"}` system := `{"role":"system","content":"You are an AI assistant.\n\n# Environment\n\nWorking directory: /home/user/myproject\n\nOS: linux"}` user := `{"role":"user","content":[{"type":"text","text":"Fix the bug.","tag":"user-input"}]}` @@ -70,7 +104,7 @@ func TestParseZencoderSession_Basic(t *testing.T) { assert.Equal(t, RelNone, sess.RelationshipType) } -func TestParseZencoderSession_ToolCallAndReasoning(t *testing.T) { +func TestZencoderProviderParsesToolCallAndReasoning(t *testing.T) { header := `{"id":"tc-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Read the file."}]}` assistant := `{"role":"assistant","content":[` + @@ -105,7 +139,7 @@ func TestParseZencoderSession_ToolCallAndReasoning(t *testing.T) { assert.Equal(t, "tc1", msgs[1].ToolCalls[0].ToolUseID) } -func TestParseZencoderSession_ToolResults(t *testing.T) { +func TestZencoderProviderParsesToolResults(t *testing.T) { header := `{"id":"tr-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Read it."}]}` assistant := `{"role":"assistant","content":[` + @@ -139,7 +173,7 @@ func TestParseZencoderSession_ToolResults(t *testing.T) { "package main") } -func TestParseZencoderSession_UserInputTagFiltering(t *testing.T) { +func TestZencoderProviderParsesUserInputTagFiltering(t *testing.T) { header := `{"id":"tag-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[` + `{"type":"text","text":"system instructions","tag":"instructions"},` + @@ -177,7 +211,7 @@ func TestParseZencoderSession_UserInputTagFiltering(t *testing.T) { assert.Equal(t, "actual user input", sess.FirstMessage) } -func TestParseZencoderSession_DirectContinuation(t *testing.T) { +func TestZencoderProviderParsesDirectContinuation(t *testing.T) { header := `{"id":"child-123","parentId":"parent-456","creationReason":"directContinuation","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Continue."}]}` assistant := `{"role":"assistant","content":[{"type":"text","text":"Continuing."}]}` @@ -194,7 +228,7 @@ func TestParseZencoderSession_DirectContinuation(t *testing.T) { assert.Equal(t, RelContinuation, sess.RelationshipType) } -func TestParseZencoderSession_SummarizedContinuation(t *testing.T) { +func TestZencoderProviderParsesSummarizedContinuation(t *testing.T) { header := `{"id":"child-789","parentId":"parent-012","creationReason":"summarizedContinuation","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Continue."}]}` assistant := `{"role":"assistant","content":[{"type":"text","text":"OK."}]}` @@ -211,7 +245,7 @@ func TestParseZencoderSession_SummarizedContinuation(t *testing.T) { assert.Equal(t, RelContinuation, sess.RelationshipType) } -func TestParseZencoderSession_ProjectExtraction(t *testing.T) { +func TestZencoderProviderParsesProjectExtraction(t *testing.T) { header := `{"id":"proj-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` system := `{"role":"system","content":"You are helpful.\n\nWorking directory: /home/user/workspace/coolproject\n"}` user := `{"role":"user","content":[{"type":"text","text":"hello"}]}` @@ -232,7 +266,7 @@ func TestParseZencoderSession_ProjectExtraction(t *testing.T) { assert.False(t, msgs[1].IsSystem) } -func TestParseZencoderSession_EmptySession(t *testing.T) { +func TestZencoderProviderParsesEmptySession(t *testing.T) { // Header only, no messages. header := `{"id":"empty-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` @@ -242,7 +276,7 @@ func TestParseZencoderSession_EmptySession(t *testing.T) { assert.Nil(t, msgs) } -func TestParseZencoderSession_PermissionSkippedFinishStored(t *testing.T) { +func TestZencoderProviderParsesPermissionSkippedFinishStored(t *testing.T) { header := `{"id":"skip-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Do it."}]}` permission := `{"role":"permission","data":{"allowed":true}}` @@ -268,7 +302,7 @@ func TestParseZencoderSession_PermissionSkippedFinishStored(t *testing.T) { assert.Equal(t, 1, sess.UserMessageCount) } -func TestParseZencoderSession_FirstMessageTruncation(t *testing.T) { +func TestZencoderProviderParsesFirstMessageTruncation(t *testing.T) { header := `{"id":"trunc-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` longText := strings.Repeat("a", 400) user := `{"role":"user","content":[{"type":"text","text":"` + longText + `"}]}` @@ -282,16 +316,14 @@ func TestParseZencoderSession_FirstMessageTruncation(t *testing.T) { assert.Equal(t, 303, len(sess.FirstMessage)) } -func TestParseZencoderSession_MissingFile(t *testing.T) { - sess, msgs, err := ParseZencoderSession( - "/nonexistent/test.jsonl", "local", - ) +func TestZencoderProviderParsesMissingFile(t *testing.T) { + sess, msgs, err := parseZencoderTestSession(t, "/nonexistent/test.jsonl", "local") require.NoError(t, err) assert.Nil(t, sess) assert.Nil(t, msgs) } -func TestParseZencoderSession_FallbackSessionID(t *testing.T) { +func TestZencoderProviderParsesFallbackSessionID(t *testing.T) { // Header with no id field -> falls back to filename. header := `{"createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"hello"}]}` @@ -305,7 +337,7 @@ func TestParseZencoderSession_FallbackSessionID(t *testing.T) { assert.Equal(t, "zencoder:test-uuid", sess.ID) } -func TestDiscoverZencoderSessions(t *testing.T) { +func TestZencoderProviderDiscoversSessions(t *testing.T) { dir := t.TempDir() // Create some session files. @@ -324,39 +356,74 @@ func TestDiscoverZencoderSessions(t *testing.T) { filepath.Join(dir, "subdir"), 0o755, )) - files := DiscoverZencoderSessions(dir) + provider, ok := NewProvider(AgentZencoder, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) + files, err := provider.Discover(context.Background()) + require.NoError(t, err) assert.Equal(t, 2, len(files)) for _, f := range files { - assert.Equal(t, AgentZencoder, f.Agent) - assert.True(t, strings.HasSuffix(f.Path, ".jsonl")) + assert.Equal(t, AgentZencoder, f.Provider) + assert.True(t, strings.HasSuffix(f.DisplayPath, ".jsonl")) } } -func TestDiscoverZencoderSessions_EmptyDir(t *testing.T) { - files := DiscoverZencoderSessions("") - assert.Nil(t, files) +func TestZencoderProviderDiscoversEmptyDir(t *testing.T) { + provider, ok := NewProvider(AgentZencoder, ProviderConfig{ + Roots: []string{""}, + Machine: "local", + }) + require.True(t, ok) + files, err := provider.Discover(context.Background()) + require.NoError(t, err) + assert.Empty(t, files) } -func TestFindZencoderSourceFile(t *testing.T) { +func TestZencoderProviderFindsSourceFile(t *testing.T) { dir := t.TempDir() name := "abc-def-123.jsonl" f, err := os.Create(filepath.Join(dir, name)) require.NoError(t, err) f.Close() - result := FindZencoderSourceFile(dir, "abc-def-123") - assert.Equal(t, filepath.Join(dir, name), result) + provider, ok := NewProvider(AgentZencoder, ProviderConfig{ + Roots: []string{dir}, + Machine: "local", + }) + require.True(t, ok) + found, ok, err := provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "abc-def-123"}, + ) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, filepath.Join(dir, name), found.DisplayPath) // Non-existent ID. - result = FindZencoderSourceFile(dir, "nonexistent") - assert.Empty(t, result) + _, ok, err = provider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "nonexistent"}, + ) + require.NoError(t, err) + assert.False(t, ok) // Empty dir. - result = FindZencoderSourceFile("", "abc-def-123") - assert.Empty(t, result) + emptyProvider, ok := NewProvider(AgentZencoder, ProviderConfig{ + Roots: []string{""}, + Machine: "local", + }) + require.True(t, ok) + _, ok, err = emptyProvider.FindSource( + context.Background(), + FindSourceRequest{RawSessionID: "abc-def-123"}, + ) + require.NoError(t, err) + assert.False(t, ok) } -func TestParseZencoderSession_UserContentWithoutTag(t *testing.T) { +func TestZencoderProviderParsesUserContentWithoutTag(t *testing.T) { header := `{"id":"notag-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"no tag input"}]}` assistant := `{"role":"assistant","content":[{"type":"text","text":"OK."}]}` @@ -373,7 +440,7 @@ func TestParseZencoderSession_UserContentWithoutTag(t *testing.T) { assert.Equal(t, "no tag input", msgs[0].Content) } -func TestParseZencoderSession_NewChatNoRelationship(t *testing.T) { +func TestZencoderProviderParsesNewChatNoRelationship(t *testing.T) { header := `{"id":"new-123","parentId":"some-parent","creationReason":"newChat","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"hello"}]}` @@ -388,7 +455,7 @@ func TestParseZencoderSession_NewChatNoRelationship(t *testing.T) { assert.Equal(t, RelNone, sess.RelationshipType) } -func TestParseZencoderSession_SubagentSessionID(t *testing.T) { +func TestZencoderProviderParsesSubagentSessionID(t *testing.T) { header := `{"id":"parent-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Use subagent."}]}` assistant := `{"role":"assistant","content":[` + @@ -415,7 +482,7 @@ func TestParseZencoderSession_SubagentSessionID(t *testing.T) { ) } -func TestParseZencoderSession_SubagentMultiple(t *testing.T) { +func TestZencoderProviderParsesSubagentMultiple(t *testing.T) { header := `{"id":"parent-456","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Use subagents."}]}` assistant := `{"role":"assistant","content":[` + @@ -447,7 +514,7 @@ func TestParseZencoderSession_SubagentMultiple(t *testing.T) { ) } -func TestParseZencoderSession_NoSessionIDTag(t *testing.T) { +func TestZencoderProviderParsesNoSessionIDTag(t *testing.T) { header := `{"id":"parent-789","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Read file."}]}` assistant := `{"role":"assistant","content":[` + @@ -469,7 +536,7 @@ func TestParseZencoderSession_NoSessionIDTag(t *testing.T) { assert.Empty(t, msgs[1].ToolCalls[0].SubagentSessionID) } -func TestParseZencoderSession_SkillBlocks(t *testing.T) { +func TestZencoderProviderParsesSkillBlocks(t *testing.T) { header := `{"id":"skill-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[` + `{"type":"text","text":"Do the thing.","tag":"user-input"},` + @@ -505,7 +572,7 @@ func TestParseZencoderSession_SkillBlocks(t *testing.T) { assert.Equal(t, "Do the thing.", sess.FirstMessage) } -func TestParseZencoderSession_ToolResultSystemTags(t *testing.T) { +func TestZencoderProviderParsesToolResultSystemTags(t *testing.T) { header := `{"id":"trsys-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` user := `{"role":"user","content":[{"type":"text","text":"Run it."}]}` assistant := `{"role":"assistant","content":[` + @@ -544,7 +611,7 @@ func TestParseZencoderSession_ToolResultSystemTags(t *testing.T) { assert.Contains(t, msgs[3].Content, "Extra context") } -func TestParseZencoderSession_ToolResultTaggedBlocksFilteredFromContentRaw(t *testing.T) { +func TestZencoderProviderParsesToolResultTaggedBlocksFilteredFromContentRaw(t *testing.T) { // Verify that tagged text blocks in tool-result content are // stripped from ContentRaw (to avoid double-rendering) and // emitted as a separate system message instead. @@ -663,7 +730,7 @@ func TestParseZencoderSession_ToolResultTaggedBlocksFilteredFromContentRaw(t *te } } -func TestParseZencoderSession_SystemOnlySession(t *testing.T) { +func TestZencoderProviderParsesSystemOnlySession(t *testing.T) { // A session with only a header and a system message (e.g. // environment banner) should be filtered out as empty. header := `{"id":"sysonly-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` @@ -677,7 +744,7 @@ func TestParseZencoderSession_SystemOnlySession(t *testing.T) { assert.Nil(t, msgs, "system-only session should produce no messages") } -func TestParseZencoderSession_SystemAndFinishOnlySession(t *testing.T) { +func TestZencoderProviderParsesSystemAndFinishOnlySession(t *testing.T) { // A session with system + finish but no real user/assistant // messages should also be filtered out. header := `{"id":"sysfin-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` @@ -692,7 +759,7 @@ func TestParseZencoderSession_SystemAndFinishOnlySession(t *testing.T) { assert.Nil(t, msgs) } -func TestParseZencoderSession_TimestampBoundsFromMessages(t *testing.T) { +func TestZencoderProviderParsesTimestampBoundsFromMessages(t *testing.T) { // When header timestamps are missing, session bounds should // be derived from per-message timestamps. header := `{"id":"bounds-123"}` @@ -714,7 +781,7 @@ func TestParseZencoderSession_TimestampBoundsFromMessages(t *testing.T) { assertTimestamp(t, sess.EndedAt, wantEnd) } -func TestParseZencoderSession_TimestampBoundsStaleHeader(t *testing.T) { +func TestZencoderProviderParsesTimestampBoundsStaleHeader(t *testing.T) { // When header has timestamps but messages have more // recent ones, endedAt should be updated. header := `{"id":"stale-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` @@ -738,7 +805,7 @@ func TestParseZencoderSession_TimestampBoundsStaleHeader(t *testing.T) { assertTimestamp(t, sess.EndedAt, wantEnd) } -func TestParseZencoderSession_MessageTimestamps(t *testing.T) { +func TestZencoderProviderParsesMessageTimestamps(t *testing.T) { header := `{"id":"ts-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:05:00Z"}` system := `{"role":"system","content":"You are an AI.\n\nWorking directory: /home/user/proj","createdAt":"2024-01-01T00:00:01Z"}` user := `{"role":"user","content":[{"type":"text","text":"Hello."}],"createdAt":"2024-01-01T00:00:02Z"}` @@ -777,7 +844,7 @@ func TestParseZencoderSession_MessageTimestamps(t *testing.T) { assert.Equal(t, wantFinish, msgs[4].Timestamp) } -func TestParseZencoderSession_MessageTimestamps_Missing(t *testing.T) { +func TestZencoderProviderParsesMessageTimestamps_Missing(t *testing.T) { header := `{"id":"ts-miss-123","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:01:00Z"}` // Lines without createdAt field. user := `{"role":"user","content":[{"type":"text","text":"No timestamp."}]}` diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 87a2023d7..8cf8c91b6 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1292,38 +1292,6 @@ func (e *Engine) classifyOnePath( } } - // Amp: /T-*.json - for _, ampDir := range e.agentDirs[parser.AgentAmp] { - if ampDir == "" { - continue - } - if rel, ok := isUnder(ampDir, path); ok { - if strings.Count(rel, sep) == 0 && - parser.IsAmpThreadFileName(filepath.Base(rel)) { - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentAmp, - }, true - } - } - } - - // Zencoder: /.jsonl - for _, zenDir := range e.agentDirs[parser.AgentZencoder] { - if zenDir == "" { - continue - } - if rel, ok := isUnder(zenDir, path); ok { - if strings.Count(rel, sep) == 0 && - parser.IsZencoderSessionFileName(filepath.Base(rel)) { - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentZencoder, - }, true - } - } - } - // VSCode Copilot: /workspaceStorage//chatSessions/.{json,jsonl} // or: /globalStorage/emptyWindowChatSessions/.{json,jsonl} for _, vscDir := range e.agentDirs[parser.AgentVSCodeCopilot] { @@ -4558,10 +4526,6 @@ func (e *Engine) processFile( res = e.processOpenHands(file, info) case parser.AgentCursor: res = e.processCursor(file, info) - case parser.AgentAmp: - res = e.processAmp(file, info) - case parser.AgentZencoder: - res = e.processZencoder(file, info) case parser.AgentVSCodeCopilot: res = e.processVSCodeCopilot(file, info) case parser.AgentVSCopilot: @@ -6113,65 +6077,6 @@ func (e *Engine) processGemini( } } -func (e *Engine) processAmp( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - // Fast path: skip by file_path + mtime before parsing. - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseAmpSession( - 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 - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - -func (e *Engine) processZencoder( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseZencoderSession( - 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 - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - func (e *Engine) processVSCodeCopilot( file parser.DiscoveredFile, info os.FileInfo, ) processResult {