From d0b9bc2afb8d8da9865500d8e424c4057149d707 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 21:34:17 -0400 Subject: [PATCH] feat(parser): migrate amp and zencoder providers Amp and Zencoder both use shallow session-file roots, so migrating them together keeps the provider stack moving without introducing another source helper. The concrete providers preserve legacy filename filters, raw/full ID lookup, deleted-path classification, fingerprint propagation, and parse normalization while continuing to compose the shared JSON source mechanics explicitly. fix(parser): preserve JSONL symlink file sources Migrated providers are intended to preserve legacy source discovery while moving behind the provider facade. Several legacy JSON/JSONL discoveries accepted matching symlinked session files and the parsers read through those symlink targets, so the shared source helper needs an explicit opt-in for that source shape instead of treating every symlink as non-regular metadata. This keeps the default helper behavior strict while allowing shallow and directory JSONL providers to opt into the compatibility path they already had before the migration. Validation: go test -tags "fts5" ./internal/parser -run 'Test(Amp|Zencoder)ProviderSourceMethodsFollowSymlinkedSessionFile' -count=1; go test -tags "fts5" ./internal/parser -run 'Test(Amp|Zencoder|DeepSeekTUI)ProviderSourceMethodsFollowSymlinkedSessionFile|Test(CommandCode|Iflow)ProviderDiscoversSymlinkedProjectDirectory|TestGptmeProvider' -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; make test-short; make nilaway; git diff --check test(parser): opt amp zencoder into provider shadow Amp and Zencoder now have concrete facade providers on this branch, so their migration modes should fail closed through the shared shadow-compare harness instead of leaving those implementations additive. Earlier provider opt-ins stay inherited from lower stack branches, and later provider families remain legacy-only until their own branches introduce 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 amp zencoder shadow parity Amp and Zencoder are shadow-compared on this branch, so add source-level migration tests that run ObserveProviderSource and compare provider output to the legacy ParseAmpSession and ParseZencoderSession functions. Validation: go fmt ./...; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... refactor(parser): fold amp zencoder into providers Amp and Zencoder should stop carrying two public parser shapes once their concrete providers exist. Keeping exported parser entrypoints and legacy sync dispatch made this branch additive instead of a real migration. Make both providers authoritative, move parsing behind provider methods, remove source callbacks and engine dispatch, and replace shadow-baseline tests with provider API coverage plus guards that the old symbols stay gone. Validation: go test -tags "fts5" ./internal/parser ./internal/sync ./cmd/agentsview -count=1; go vet ./...; git diff --check fix(parser): preserve amp zencoder file hashes Amp and Zencoder legacy sync stored the source content hash, but the migrated providers did not request hashed source fingerprints. Provider-authoritative writes would therefore clear file_hash when running through the real provider path.\n\nEnable source hashing for both providers and update their provider tests to exercise Fingerprint -> Parse instead of passing manually injected hashes.\n\nValidation: go test -tags "fts5" ./internal/parser -run 'Test(Amp|Zencoder)ProviderParse' -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check --- internal/parser/amp.go | 4 +- internal/parser/amp_provider.go | 63 ++++ internal/parser/amp_test.go | 75 +++-- internal/parser/amp_zencoder_provider_test.go | 286 ++++++++++++++++++ internal/parser/discovery.go | 46 --- internal/parser/discovery_test.go | 96 +++++- internal/parser/provider.go | 4 + internal/parser/provider_migration.go | 4 +- internal/parser/types.go | 32 +- internal/parser/zencoder.go | 56 +--- internal/parser/zencoder_provider.go | 68 +++++ internal/parser/zencoder_test.go | 155 +++++++--- internal/sync/engine.go | 95 ------ 13 files changed, 685 insertions(+), 299 deletions(-) create mode 100644 internal/parser/amp_provider.go create mode 100644 internal/parser/amp_zencoder_provider_test.go create mode 100644 internal/parser/zencoder_provider.go 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 {