Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions cmd/agentsview/parse_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func parseDiffAgentTypes(names []string) ([]parser.AgentType, error) {
strings.Join(parseDiffSupportedAgents(), ", "),
)
}
if !def.FileBased || def.DiscoverFunc == nil {
if !parseDiffAgentSupported(def) {
return nil, fmt.Errorf(
"agent %q is not supported by parse-diff "+
"(no on-disk source to re-parse)",
Expand All @@ -253,18 +253,33 @@ func parseDiffAgentTypes(names []string) ([]parser.AgentType, error) {
return out, nil
}

// parseDiffSupportedAgents lists the agent types parse-diff can
// re-parse: file-based agents with a discovery function.
// parseDiffSupportedAgents lists the agent types parse-diff can re-parse.
func parseDiffSupportedAgents() []string {
var names []string
for _, def := range parser.Registry {
if def.FileBased && def.DiscoverFunc != nil {
if parseDiffAgentSupported(def) {
names = append(names, string(def.Type))
}
}
return names
}

func parseDiffAgentSupported(def parser.AgentDef) bool {
if !def.FileBased {
return false
}
if def.DiscoverFunc != nil {
return true
}
switch parser.ProviderMigrationModes()[def.Type] {
case parser.ProviderMigrationProviderAuthoritative:
_, ok := parser.ProviderFactoryByType(def.Type)
return ok
default:
return false
}
}

// renderParseDiffReport writes the human-readable report. An empty
// archive renders a zero-count summary with no tables. Every value
// that originates in session files or archive rows (IDs, paths,
Expand Down
10 changes: 10 additions & 0 deletions cmd/agentsview/parse_diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ func TestParseDiffAgentTypes(t *testing.T) {
in: []string{"claude"},
want: []string{"claude"},
},
{
name: "provider authoritative agent",
in: []string{"pi"},
want: []string{"pi"},
},
{
name: "provider authoritative shared provider family agent",
in: []string{"omp"},
want: []string{"omp"},
},
{
name: "trims and lowercases",
in: []string{" Claude "},
Expand Down
4 changes: 1 addition & 3 deletions internal/parser/amp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions internal/parser/amp_provider.go
Original file line number Diff line number Diff line change
@@ -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,
},
}
}
75 changes: 55 additions & 20 deletions internal/parser/amp_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package parser

import (
"context"
"path/filepath"
"strings"
"testing"
"time"
Expand All @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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 + `"}]}]}`
Expand All @@ -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,
Expand Down Expand Up @@ -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)
})

Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading