Skip to content

Commit d0b9bc2

Browse files
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
1 parent 633ddc8 commit d0b9bc2

13 files changed

Lines changed: 685 additions & 299 deletions

internal/parser/amp.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import (
1212
"github.com/tidwall/gjson"
1313
)
1414

15-
// ParseAmpSession parses an Amp thread JSON file.
16-
// Each thread is a single JSON document at ~/.local/share/amp/threads/T-*.json.
17-
func ParseAmpSession(
15+
func parseAmpSession(
1816
path, machine string,
1917
) (*ParsedSession, []ParsedMessage, error) {
2018
info, err := os.Stat(path)

internal/parser/amp_provider.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package parser
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
)
7+
8+
// Amp stores each thread as a single JSON file in a directory. It is a
9+
// directory-of-files provider: discovery, watching, change classification,
10+
// lookup, and fingerprinting come from JSONLSourceSet, and the ParseFile option
11+
// makes that source set a full SourceSet so it rides the generic factory.
12+
func newAmpProviderFactory(def AgentDef) ProviderFactory {
13+
return newSourceSetFactory(
14+
def,
15+
ampProviderCapabilities(),
16+
func(cfg ProviderConfig) SourceSet { return newAmpSourceSet(cfg.Roots) },
17+
)
18+
}
19+
20+
func newAmpSourceSet(roots []string) JSONLSourceSet {
21+
return newJSONLSourceSet(AgentAmp, roots,
22+
withExtensions(".json"),
23+
withFollowSymlinkFiles(),
24+
withContentHashing(),
25+
withIncludePath(isAmpSourcePath),
26+
withSessionIDFromPath(func(root, path string) string {
27+
return ampThreadIDFromPath(path)
28+
}),
29+
withParseFile(ampParseFile),
30+
)
31+
}
32+
33+
func ampParseFile(
34+
_ context.Context, path string, req ParseRequest,
35+
) ([]ParseResult, []string, error) {
36+
sess, msgs, err := parseAmpSession(path, req.Machine)
37+
if err != nil {
38+
return nil, nil, err
39+
}
40+
if sess == nil {
41+
return nil, nil, nil
42+
}
43+
if req.Fingerprint.Hash != "" {
44+
sess.File.Hash = req.Fingerprint.Hash
45+
}
46+
return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil
47+
}
48+
49+
func isAmpSourcePath(root, path string) bool {
50+
return IsAmpThreadFileName(filepath.Base(path))
51+
}
52+
53+
func ampProviderCapabilities() Capabilities {
54+
return Capabilities{
55+
Source: jsonlFileProviderSourceCapabilities(),
56+
Content: ContentCapabilities{
57+
FirstMessage: CapabilitySupported,
58+
Thinking: CapabilitySupported,
59+
ToolCalls: CapabilitySupported,
60+
ToolResults: CapabilitySupported,
61+
},
62+
}
63+
}

internal/parser/amp_test.go

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package parser
22

33
import (
4+
"context"
5+
"path/filepath"
46
"strings"
57
"testing"
68
"time"
@@ -15,10 +17,43 @@ func runAmpParserTest(
1517
) (*ParsedSession, []ParsedMessage, error) {
1618
t.Helper()
1719
path := createTestFile(t, "T-test.json", content)
18-
return ParseAmpSession(path, "local")
20+
return parseAmpTestSession(t, path, "local")
1921
}
2022

21-
func TestParseAmpSession_Basic(t *testing.T) {
23+
func parseAmpTestSession(
24+
t *testing.T,
25+
path string,
26+
machine string,
27+
) (*ParsedSession, []ParsedMessage, error) {
28+
t.Helper()
29+
30+
provider, ok := NewProvider(AgentAmp, ProviderConfig{
31+
Roots: []string{filepath.Dir(path)},
32+
Machine: machine,
33+
})
34+
require.True(t, ok)
35+
36+
outcome, err := provider.Parse(context.Background(), ParseRequest{
37+
Source: SourceRef{
38+
Provider: AgentAmp,
39+
Key: path,
40+
DisplayPath: path,
41+
FingerprintKey: path,
42+
Opaque: JSONLSource{
43+
Root: filepath.Dir(path),
44+
Path: path,
45+
},
46+
},
47+
Machine: machine,
48+
})
49+
if err != nil || len(outcome.Results) == 0 {
50+
return nil, nil, err
51+
}
52+
result := outcome.Results[0].Result
53+
return &result.Session, result.Messages, nil
54+
}
55+
56+
func TestAmpProviderParsesBasic(t *testing.T) {
2257
threadID := "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd"
2358
content := `{
2459
"v": 1,
@@ -43,7 +78,7 @@ func TestParseAmpSession_Basic(t *testing.T) {
4378
}`
4479

4580
path := createTestFile(t, threadID+".json", content)
46-
sess, msgs, err := ParseAmpSession(path, "local")
81+
sess, msgs, err := parseAmpTestSession(t, path, "local")
4782
require.NoError(t, err)
4883
require.NotNil(t, sess)
4984

@@ -72,7 +107,7 @@ func TestParseAmpSession_Basic(t *testing.T) {
72107
assert.Equal(t, 1, msgs[1].Ordinal)
73108
}
74109

75-
func TestParseAmpSession_ToolUseAndThinking(t *testing.T) {
110+
func TestAmpProviderParsesToolUseAndThinking(t *testing.T) {
76111
content := `{
77112
"v": 1,
78113
"id": "T-tooluse",
@@ -322,7 +357,7 @@ func TestExtractAmpToolResults(t *testing.T) {
322357
}
323358
}
324359

325-
func TestParseAmpSession_AmpToolResultSchema(t *testing.T) {
360+
func TestAmpProviderParsesAmpToolResultSchema(t *testing.T) {
326361
content := `{
327362
"v": 1,
328363
"id": "T-amp-tool-result-schema",
@@ -347,7 +382,7 @@ func TestParseAmpSession_AmpToolResultSchema(t *testing.T) {
347382
assert.Equal(t, "Here is a complete breakdown", DecodeContent(msgs[1].ToolResults[0].ContentRaw))
348383
}
349384

350-
func TestParseAmpSession_AmpToolResultDict(t *testing.T) {
385+
func TestAmpProviderParsesAmpToolResultDict(t *testing.T) {
351386
content := `{
352387
"v": 1,
353388
"id": "T-amp-tool-result-dict",
@@ -370,7 +405,7 @@ func TestParseAmpSession_AmpToolResultDict(t *testing.T) {
370405
assert.Equal(t, "cmd output", DecodeContent(msgs[1].ToolResults[0].ContentRaw))
371406
}
372407

373-
func TestParseAmpSession_NoEnv(t *testing.T) {
408+
func TestAmpProviderParsesNoEnv(t *testing.T) {
374409
content := `{
375410
"v": 1,
376411
"id": "T-noenv",
@@ -389,7 +424,7 @@ func TestParseAmpSession_NoEnv(t *testing.T) {
389424
require.Equal(t, 1, len(msgs))
390425
}
391426

392-
func TestParseAmpSession_NoTitle(t *testing.T) {
427+
func TestAmpProviderParsesNoTitle(t *testing.T) {
393428
content := `{
394429
"v": 1,
395430
"id": "T-notitle",
@@ -408,7 +443,7 @@ func TestParseAmpSession_NoTitle(t *testing.T) {
408443
assert.Equal(t, "Fix the bug in main.go please.", sess.FirstMessage)
409444
}
410445

411-
func TestParseAmpSession_NoMetaTraces(t *testing.T) {
446+
func TestAmpProviderParsesNoMetaTraces(t *testing.T) {
412447
content := `{
413448
"v": 1,
414449
"id": "T-notraces",
@@ -427,7 +462,7 @@ func TestParseAmpSession_NoMetaTraces(t *testing.T) {
427462
assertZeroTimestamp(t, sess.EndedAt, "EndedAt")
428463
}
429464

430-
func TestParseAmpSession_LastTraceWithoutEndTime(t *testing.T) {
465+
func TestAmpProviderParsesLastTraceWithoutEndTime(t *testing.T) {
431466
content := `{
432467
"v": 1,
433468
"id": "T-trace-end-missing",
@@ -451,7 +486,7 @@ func TestParseAmpSession_LastTraceWithoutEndTime(t *testing.T) {
451486
assert.Equal(t, "2024-01-01T00:00:02Z", sess.EndedAt.UTC().Format(time.RFC3339))
452487
}
453488

454-
func TestParseAmpSession_EmptyThread(t *testing.T) {
489+
func TestAmpProviderParsesEmptyThread(t *testing.T) {
455490
content := `{
456491
"v": 1,
457492
"id": "T-empty",
@@ -466,7 +501,7 @@ func TestParseAmpSession_EmptyThread(t *testing.T) {
466501
assert.Nil(t, msgs)
467502
}
468503

469-
func TestParseAmpSession_FirstMessageTruncation(t *testing.T) {
504+
func TestAmpProviderParsesFirstMessageTruncation(t *testing.T) {
470505
longText := strings.Repeat("a", 400)
471506
content := `{"v":1,"id":"T-trunc","created":1704067200000,"messages":[` +
472507
`{"role":"user","content":[{"type":"text","text":"` + longText + `"}]}]}`
@@ -478,7 +513,7 @@ func TestParseAmpSession_FirstMessageTruncation(t *testing.T) {
478513
assert.Equal(t, 303, len(sess.FirstMessage))
479514
}
480515

481-
func TestParseAmpSession_InvalidCreated(t *testing.T) {
516+
func TestAmpProviderParsesInvalidCreated(t *testing.T) {
482517
t.Run("missing created", func(t *testing.T) {
483518
content := `{
484519
"v": 1,
@@ -539,9 +574,9 @@ func TestParseAmpSession_InvalidCreated(t *testing.T) {
539574
})
540575
}
541576

542-
func TestParseAmpSession_Errors(t *testing.T) {
577+
func TestAmpProviderParsesErrors(t *testing.T) {
543578
t.Run("missing file", func(t *testing.T) {
544-
_, _, err := ParseAmpSession("/nonexistent/T-xxx.json", "local")
579+
_, _, err := parseAmpTestSession(t, "/nonexistent/T-xxx.json", "local")
545580
assert.Error(t, err)
546581
})
547582

@@ -563,13 +598,13 @@ func TestParseAmpSession_Errors(t *testing.T) {
563598
t.Run("missing id and invalid filename", func(t *testing.T) {
564599
content := `{"v":1,"created":1704067200000,"messages":[]}`
565600
path := createTestFile(t, "bad-name.json", content)
566-
_, _, err := ParseAmpSession(path, "local")
601+
_, _, err := parseAmpTestSession(t, path, "local")
567602
assert.Error(t, err)
568603
assert.Contains(t, err.Error(), "missing or invalid id")
569604
})
570605
}
571606

572-
func TestParseAmpSession_MismatchedID(t *testing.T) {
607+
func TestAmpProviderParsesMismatchedID(t *testing.T) {
573608
t.Run("invalid JSON id", func(t *testing.T) {
574609
content := `{
575610
"v": 1,
@@ -581,7 +616,7 @@ func TestParseAmpSession_MismatchedID(t *testing.T) {
581616
}`
582617

583618
path := createTestFile(t, "T-fallback-uuid.json", content)
584-
sess, _, err := ParseAmpSession(path, "local")
619+
sess, _, err := parseAmpTestSession(t, path, "local")
585620
require.NoError(t, err)
586621
require.NotNil(t, sess)
587622
assert.Equal(t, "amp:T-fallback-uuid", sess.ID)
@@ -600,7 +635,7 @@ func TestParseAmpSession_MismatchedID(t *testing.T) {
600635
}`
601636

602637
path := createTestFile(t, "bad-name.json", content)
603-
sess, _, err := ParseAmpSession(path, "local")
638+
sess, _, err := parseAmpTestSession(t, path, "local")
604639
require.NoError(t, err)
605640
require.NotNil(t, sess)
606641
assert.Equal(t, "amp:T-from-json", sess.ID)
@@ -620,7 +655,7 @@ func TestParseAmpSession_MismatchedID(t *testing.T) {
620655
}`
621656

622657
path := createTestFile(t, "T-from-file.json", content)
623-
sess, _, err := ParseAmpSession(path, "local")
658+
sess, _, err := parseAmpTestSession(t, path, "local")
624659
require.NoError(t, err)
625660
require.NotNil(t, sess)
626661
assert.Equal(t, "amp:T-from-file", sess.ID)

0 commit comments

Comments
 (0)